From caa349d3ab54714231b09a34c4b7158e8964c28f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:35:57 +0900 Subject: [PATCH 001/359] feat(app-server): add CodexAppServerKit --- Docs/architecture.md | 69 +- Package.swift | 26 +- README.md | 27 + .../AppServerClient.swift | 64 +- .../AppServerProcessTransport.swift | 180 +- .../CodexAppServerKit/AppServerRequests.swift | 1884 +++++++++++++++++ .../CodexAppServerKit/CodexAppServer.swift | 353 +++ .../CodexAppServerNotificationRouter.swift | 668 ++++++ .../CodexAppServerKit/CodexDomainTypes.swift | 1083 ++++++++++ .../CodexThreadOperations.swift | 214 ++ .../CodexTurnSequences.swift | 343 +++ .../JSONRPC.swift | 0 Sources/CodexAppServerKit/README.md | 201 ++ .../AppServerCodexReviewBackend.swift | 38 +- .../AppServerRequests.swift | 1263 +---------- .../AppServerReviewControl.swift | 1 + Sources/CodexReviewHost/CodexReviewHost.swift | 11 +- .../LiveCodexReviewStoreBackend.swift | 71 +- Sources/CodexReviewTesting/TestSupport.swift | 5 +- .../ArchitectureFenceTests.swift | 68 +- .../CodexAppServerKitTests.swift | 450 ++++ .../AppServerClientTests.swift | 12 +- .../CodexReviewHostTests.swift | 11 +- 23 files changed, 5639 insertions(+), 1403 deletions(-) rename Sources/{CodexReviewAppServer => CodexAppServerKit}/AppServerClient.swift (76%) rename Sources/{CodexReviewAppServer => CodexAppServerKit}/AppServerProcessTransport.swift (88%) create mode 100644 Sources/CodexAppServerKit/AppServerRequests.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServer.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift create mode 100644 Sources/CodexAppServerKit/CodexDomainTypes.swift create mode 100644 Sources/CodexAppServerKit/CodexThreadOperations.swift create mode 100644 Sources/CodexAppServerKit/CodexTurnSequences.swift rename Sources/{CodexReviewAppServer => CodexAppServerKit}/JSONRPC.swift (100%) create mode 100644 Sources/CodexAppServerKit/README.md create mode 100644 Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index 11272bab..d952282c 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -1,9 +1,9 @@ # CodexReviewKit Architecture CodexReviewKit provides ReviewMonitor, a native macOS app for running and -observing Codex review. The package is organized so app-server protocol bytes, -semantic review events, observable timeline state, UI rendering, and MCP -responses each have a clear owner. +observing Codex review. The package is organized so the generic app-server +client, review-specific app-server behavior, semantic review events, observable +timeline state, UI rendering, and MCP responses each have a clear owner. The important architectural invariant is that review progress flows in one direction: @@ -26,8 +26,9 @@ have been converted into domain events. | `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 | +| `CodexAppServerKit` | Generic `codex app-server` process transport, JSON-RPC client, typed request DTOs, and Swift domain API for threads, turns, prompts, models, accounts, and login | | `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 | +| `CodexReviewAppServer` | Review-specific `review/start` orchestration, ReviewMonitor 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 | | `CodexReviewHost` | Runtime composition for ReviewMonitor | @@ -45,15 +46,23 @@ runtime together; lower targets do not import the host. review item kinds, timeline item content, and `ReviewDomainEvent` values that describe review progress independently of any transport, UI, or MCP protocol. +`CodexAppServerKit` owns the generic app-server connection boundary. It starts +the local `codex app-server` process, performs JSON-RPC framing, sends +`initialize`/`initialized`, retries overload responses, preserves unknown +notifications, and exposes `CodexAppServer` as the Swift-facing container for +threads, turns, prompts, messages, transcripts, log entries, models, accounts, +and login flows. It must not import ReviewMonitor targets. + `CodexReviewAppServerWire` owns raw app-server notification shapes. Its job is -to decode wire payloads and expose domain events. It may depend on +to decode review-specific 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. +`CodexReviewAppServer` owns ReviewMonitor's `review/start` runtime behavior. It +uses the generic app-server boundary for transport and common DTOs, converts +review notifications through `CodexReviewAppServerWire`, and hands +application-facing review events to the store. Review 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 @@ -93,12 +102,16 @@ flowchart TB WireDTO["Raw app-server DTOs"] end - subgraph AppServer["CodexReviewAppServer"] + subgraph Kit["CodexAppServerKit"] + DomainAPI["CodexAppServer"] Client["JSON-RPC client"] - Runtime["Review runtime"] Process["codex app-server"] end + subgraph AppServer["CodexReviewAppServer"] + Runtime["Review runtime"] + end + subgraph MCP["MCP"] MCPAdapter["CodexReviewMCPAdapter"] MCPServer["CodexReviewMCPServer"] @@ -123,10 +136,11 @@ flowchart TB PublicStore --> UI Renderer --> UI MCPAdapter --> MCPServer + DomainAPI --> Client + Client --> Process + Runtime --> DomainAPI Runtime --> WireDTO Runtime --> PublicStore - Client --> Process - Runtime --> Client Composition --> Runtime Composition --> MCPServer Composition --> PublicStore @@ -172,22 +186,32 @@ 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. +- `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`, `CodexThread`, + `CodexTurn`, `CodexPrompt`, thread event streams, messages, transcript/log + values, progress values, model values, account values, and login handles. - Same-thread mutating requests are serialized. - 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 + events. - Cancellation is represented by typed control/cleanup requests, not by closing the transport. +`CodexReviewAppServer` adds ReviewMonitor-specific `review/start`, review +notification conversion, cleanup, recovery, and UI-facing orchestration on top +of that generic boundary. + +The intended ownership for review logs is that `CodexAppServerKit` supplies +generic app-server thread events and log entries, while ReviewMonitor-specific +targets adapt those values into `ReviewDomainEvent`, `ReviewTimeline`, and +legacy review log projections. + Fake and live tests use the same transport protocol. ## MCP Boundary @@ -239,9 +263,10 @@ Default tests are deterministic and do not start a live `codex app-server`. | --- | --- | --- | | `CodexReviewDomainTests` | Domain timelines and events | Semantic timeline mutation and terminal state | | `CodexReviewApplicationTests` | Domain timelines and ObservationBridge awaiters | Use-case observation behavior | +| `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, and domain result aggregation | | `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 | +| `CodexReviewAppServerTests` | Fake JSON-RPC transport | Review-specific request serialization, notification buffering, interrupt/cleanup, and recovery | | `CodexReviewMCPAdapterTests` | Domain timeline projections | MCP adapter snapshots | | `CodexReviewMCPServerTests` | Fake review store | MCP protocol conversion and response shape | | `CodexReviewHostTests` | Fake runtime dependencies | Composition, startup, shutdown | diff --git a/Package.swift b/Package.swift index fc776fd0..3d795c9e 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,10 @@ let package = Package( name: "CodexReviewHost", targets: ["CodexReviewHost"] ), + .library( + name: "CodexAppServerKit", + targets: ["CodexAppServerKit"] + ), .library( name: "ReviewUI", targets: ["ReviewUI"] @@ -58,6 +62,12 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .target( + name: "CodexAppServerKit", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .target( name: "CodexReviewAppServerWire", dependencies: [ @@ -70,6 +80,7 @@ let package = Package( .target( name: "CodexReviewAppServer", dependencies: [ + "CodexAppServerKit", "CodexReview", "CodexReviewAppServerWire", "CodexReviewDomain", @@ -105,6 +116,7 @@ let package = Package( .target( name: "CodexReviewHost", dependencies: [ + "CodexAppServerKit", "CodexReview", "CodexReviewAppServer", "CodexReviewMCPServer", @@ -116,6 +128,7 @@ let package = Package( .target( name: "CodexReviewTesting", dependencies: [ + "CodexAppServerKit", "CodexReview", "CodexReviewAppServer", ], @@ -151,6 +164,15 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .testTarget( + name: "CodexAppServerKitTests", + dependencies: [ + "CodexAppServerKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .testTarget( name: "CodexReviewDomainTests", dependencies: [ @@ -196,7 +218,7 @@ let package = Package( ), .testTarget( name: "CodexReviewAppServerTests", - dependencies: ["CodexReviewAppServer", "CodexReviewDomain", "CodexReviewTesting"], + dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewDomain", "CodexReviewTesting"], swiftSettings: [ .swiftLanguageMode(.v6), ] @@ -214,7 +236,7 @@ let package = Package( ), .testTarget( name: "CodexReviewHostTests", - dependencies: ["CodexReviewAppServer", "CodexReviewHost", "CodexReviewTesting"], + dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewHost", "CodexReviewTesting"], swiftSettings: [ .swiftLanguageMode(.v6), ] diff --git a/README.md b/README.md index 44d27bce..77ad7acd 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,33 @@ visible. - `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, +threads, turns, prompts, 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](Sources/CodexAppServerKit/README.md) for the +standalone SDK surface, including thread-level streams for messages, +transcripts, log entries, and progress events. + ## Timeout Setup Long reviews can exceed the default MCP client timeout. `codex mcp add` does diff --git a/Sources/CodexReviewAppServer/AppServerClient.swift b/Sources/CodexAppServerKit/AppServerClient.swift similarity index 76% rename from Sources/CodexReviewAppServer/AppServerClient.swift rename to Sources/CodexAppServerKit/AppServerClient.swift index e3ba083c..416a7187 100644 --- a/Sources/CodexReviewAppServer/AppServerClient.swift +++ b/Sources/CodexAppServerKit/AppServerClient.swift @@ -23,8 +23,11 @@ package actor AppServerClient { 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) } + 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 @@ -60,16 +63,21 @@ package actor AppServerClient { 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) - )) + 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 { + package func send(_ request: Request) async throws + -> Request.Response + { try await send( method: Request.method, params: request.params, @@ -89,28 +97,39 @@ package actor AppServerClient { var retryAttempt = 0 while true { let requestID = await self.allocateRequestID() - logger.debug("JSON-RPC request \(requestID, privacy: .public) -> \(method, privacy: .public)") + 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 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)") + 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) + let delay = overloadRetryDelay(retryAttempt) else { - logger.error("JSON-RPC request \(requestID, privacy: .public) failed for \(method, privacy: .public): \(error.localizedDescription, privacy: .public)") + 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)") + 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)") + logger.error( + "JSON-RPC request \(requestID, privacy: .public) failed for \(method, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) throw error } } @@ -123,10 +142,11 @@ package actor AppServerClient { ) 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 - )) + try await transport.notify( + .init( + method: method, + params: encodedParams + )) } package func notificationStream() async -> AsyncThrowingStream { diff --git a/Sources/CodexReviewAppServer/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift similarity index 88% rename from Sources/CodexReviewAppServer/AppServerProcessTransport.swift rename to Sources/CodexAppServerKit/AppServerProcessTransport.swift index f94f3694..46632a80 100644 --- a/Sources/CodexReviewAppServer/AppServerProcessTransport.swift +++ b/Sources/CodexAppServerKit/AppServerProcessTransport.swift @@ -18,10 +18,13 @@ package actor AppServerProcessTransport: JSONRPC.Transport { environment: [String: String] = ProcessInfo.processInfo.environment, codexHomeURL: URL? = nil ) { - let resolvedCodexHomeURL = codexHomeURL ?? AppServerCodexHome.url(environment: environment) - let resolvedExecutable = executable ?? CodexAppServerExecutable.resolveExecutable( - environment: environment - ) + 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") @@ -32,15 +35,18 @@ package actor AppServerProcessTransport: JSONRPC.Transport { ) } self.executable = resolvedExecutable - self.arguments = arguments ?? CodexAppServerExecutable.appServerArguments( - supportsSessionSource: supportsSessionSource - ) + self.arguments = + arguments + ?? CodexAppServerExecutable.appServerArguments( + supportsSessionSource: supportsSessionSource + ) self.environment = AppServerCodexHome.environment( environment, codexHomeURL: resolvedCodexHomeURL ) self.codexHomeURL = resolvedCodexHomeURL - self.threadStartPermissionStrategy = supportsSessionSource + self.threadStartPermissionStrategy = + supportsSessionSource ? .modernPermissions : .legacySandbox } @@ -58,7 +64,8 @@ package actor AppServerProcessTransport: JSONRPC.Transport { private let stderrEvents: AppServerPipeReadEventSource private var framer = JSONRPC.Framer() private var pending: [Int: PendingResponse] = [:] - private var notificationContinuations: [UUID: AsyncThrowingStream.Continuation] = [:] + private var notificationContinuations: + [UUID: AsyncThrowingStream.Continuation] = [:] private var stderrLogFilter = AppServerStderrLogFilter() private var closed = false @@ -93,9 +100,13 @@ package actor AppServerProcessTransport: JSONRPC.Transport { ) 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)") + 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) @@ -163,7 +174,9 @@ package actor AppServerProcessTransport: JSONRPC.Transport { stderrEvents.cancel() try? stdin.fileHandleForWriting.close() if terminateProcess { - logger.info("Terminating codex app-server pid \(self.process.processIdentifier, privacy: .public)") + logger.info( + "Terminating codex app-server pid \(self.process.processIdentifier, privacy: .public)" + ) await process.terminateAndWait() } finishAll(throwing: JSONRPC.Error.closed) @@ -237,7 +250,9 @@ package actor AppServerProcessTransport: JSONRPC.Transport { ) try stdin.fileHandleForWriting.write(contentsOf: response) } catch { - logger.error("Failed to reject unsupported app-server request \(method, privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error( + "Failed to reject unsupported app-server request \(method, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) } } @@ -248,10 +263,11 @@ package actor AppServerProcessTransport: JSONRPC.Transport { 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 - )) + pendingResponse.continuation.resume( + throwing: JSONRPC.Error.responseError( + code: code, + message: message + )) return } let result = object["result"] ?? [:] @@ -271,13 +287,14 @@ package actor AppServerProcessTransport: JSONRPC.Transport { } 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]) + 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 } @@ -353,10 +370,12 @@ package struct AppServerStderrLogFilter: Sendable { 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 [ + .init( + level: .error, + message: "emitted \(data.count) undecodable bytes" + ) + ] } return append(text) } @@ -378,8 +397,9 @@ package struct AppServerStderrLogFilter: Sendable { events.append(contentsOf: processLine(line)) let nextIndex = bufferedText.index(after: index) if bufferedText[index] == "\r", - nextIndex < bufferedText.endIndex, - bufferedText[nextIndex] == "\n" { + nextIndex < bufferedText.endIndex, + bufferedText[nextIndex] == "\n" + { lineStart = bufferedText.index(after: nextIndex) index = lineStart } else { @@ -464,23 +484,22 @@ package struct AppServerStderrLogFilter: Sendable { 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+"#, + 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: ") + 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: ") + return trimmed.contains("codex_core::tools::router: error=") + || trimmed.hasPrefix("Wall time: ") || trimmed.hasPrefix("Exit code: ") } } @@ -572,21 +591,24 @@ private final class AppServerSpawnedProcess: @unchecked Sendable { 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 - )) + 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, @@ -601,7 +623,8 @@ private final class AppServerSpawnedProcess: @unchecked Sendable { try check(posix_spawnattr_setpgroup(&attributes, 0)) let argv = [executable] + arguments - let envp = environment + let envp = + environment .sorted { $0.key < $1.key } .map { "\($0.key)=\($0.value)" } @@ -609,14 +632,15 @@ private final class AppServerSpawnedProcess: @unchecked Sendable { 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 check( + posix_spawn( + &processIdentifier, + executablePointer, + &fileActions, + &attributes, + argvPointers, + envPointers + )) } } } @@ -642,7 +666,10 @@ private final class AppServerSpawnedProcess: @unchecked Sendable { return } signalProcessTree(SIGTERM, trackedProcessIDs: trackedProcessIDs) - guard await waitUntilExit(timeout: graceDuration, trackedProcessIDs: trackedProcessIDs) == false else { + guard + await waitUntilExit(timeout: graceDuration, trackedProcessIDs: trackedProcessIDs) + == false + else { return } signalProcessTree(SIGKILL, trackedProcessIDs: trackedProcessIDs) @@ -813,18 +840,20 @@ package enum AppServerCodexHome { environment: [String: String] = ProcessInfo.processInfo.environment, homeDirectoryForCurrentUser: URL = FileManager.default.homeDirectoryForCurrentUser ) -> URL { - if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), - codexHome.isEmpty == false + 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 + home.isEmpty == false { return URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".codex_review", isDirectory: true) } - return homeDirectoryForCurrentUser + return + homeDirectoryForCurrentUser .appendingPathComponent(".codex_review", isDirectory: true) } @@ -871,7 +900,9 @@ package enum CodexAppServerExecutable { package static let fileBackedAuthConfiguration = #"cli_auth_credentials_store="file""# - package static func resolve(environment: [String: String] = ProcessInfo.processInfo.environment) -> Command { + package static func resolve(environment: [String: String] = ProcessInfo.processInfo.environment) + -> Command + { let executable = resolveExecutable(environment: environment) return .init( executable: executable, @@ -882,10 +913,12 @@ package enum CodexAppServerExecutable { 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" + let requestedCommand = + [ + environment["CODEX_APP_SERVER_CODEX_EXECUTABLE"], + environment["CODEX_REVIEW_CODEX_EXECUTABLE"], + environment["CODEX_EXECUTABLE"], + ].compactMap(\.self).first ?? "codex" if let candidate = findExecutable( requestedCommand, @@ -930,7 +963,8 @@ package enum CodexAppServerExecutable { return nil } if trimmedCommand.contains("/") { - return FileManager.default.isExecutableFile(atPath: trimmedCommand) ? trimmedCommand : nil + return FileManager.default.isExecutableFile(atPath: trimmedCommand) + ? trimmedCommand : nil } for directory in pathSearchDirectories(environment: environment) { let candidate = URL(fileURLWithPath: directory, isDirectory: true) diff --git a/Sources/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift new file mode 100644 index 00000000..0ecf7a60 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerRequests.swift @@ -0,0 +1,1884 @@ +import Foundation + +package enum AppServerAPI { + package enum Initialize {} + package enum Thread { + package enum Start {} + package enum Resume {} + package enum Fork {} + package enum List {} + package enum Read {} + package enum Archive {} + package enum Unarchive {} + package enum Name { + package enum Set {} + } + package enum Compact { + package enum Start {} + } + package enum Rollback {} + package enum Delete {} + package enum Unsubscribe {} + package enum BackgroundTerminals { + package enum Clean {} + } + } + package enum Turn { + package enum Start {} + package enum Steer {} + package enum Interrupt {} + } + package enum Config { + package enum Read {} + package enum BatchWrite {} + } + package enum Model { + package enum List {} + } + package enum Auth { + package enum Read {} + } + package enum Account { + package enum Read {} + package enum Logout {} + package enum RateLimits { + package enum Read {} + } + package enum Login { + package enum Start {} + package enum Complete {} + package enum Cancel {} + } + } +} + +extension AppServerAPI { + package enum RequestScope: Hashable, Sendable { + case thread(String) + } +} + +extension AppServerAPI.Thread.Start { + package enum PermissionStrategy: Equatable, Sendable { + case modernPermissions + case legacySandbox + } +} + +extension AppServerAPI { + package protocol Request: Sendable { + associatedtype Params: Encodable & Sendable + associatedtype Response: Decodable & Sendable + + static var method: String { get } + var params: Params { get } + var scope: AppServerAPI.RequestScope? { get } + } +} + +extension AppServerAPI.Request { + package var scope: AppServerAPI.RequestScope? { nil } +} + +package enum AppServerJSONValue: Codable, Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AppServerJSONValue]) + case object([String: AppServerJSONValue]) + case null + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([AppServerJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: AppServerJSONValue].self)) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +extension AppServerAPI.Initialize { + package struct ClientInfo: Codable, Equatable, Sendable { + package var name: String + package var title: String? + package var version: String + + package init(name: String, title: String? = nil, version: String) { + self.name = name + self.title = title + self.version = version + } + } +} + +extension AppServerAPI.Initialize { + package struct Capabilities: Codable, Equatable, Sendable { + package var experimentalAPI: Bool + + enum CodingKeys: String, CodingKey { + case experimentalAPI = "experimentalApi" + } + + package init(experimentalAPI: Bool = true) { + self.experimentalAPI = experimentalAPI + } + } +} + +extension AppServerAPI.Initialize { + package struct Params: Codable, Equatable, Sendable { + package var clientInfo: AppServerAPI.Initialize.ClientInfo + package var capabilities: AppServerAPI.Initialize.Capabilities + + enum CodingKeys: String, CodingKey { + case clientInfo + case capabilities + } + + package init(clientName: String, clientVersion: String) { + self.clientInfo = .init(name: clientName, version: clientVersion) + self.capabilities = .init() + } + } +} + +extension AppServerAPI.Initialize { + package struct Response: Codable, Equatable, Sendable { + package var codexHome: String? + package var userAgent: String? + + package init(codexHome: String? = nil, userAgent: String? = nil) { + self.codexHome = codexHome + self.userAgent = userAgent + } + } +} + +extension AppServerAPI.Initialize { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Initialize.Response + + package static let method = "initialize" + package var params: AppServerAPI.Initialize.Params + + package init(params: AppServerAPI.Initialize.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String? + package var cwd: String? + package var model: String? + package var modelProvider: String? + package var ephemeral: Bool? + package var baseInstructions: String? + package var developerInstructions: String? + package var approvalPolicy: String? + package var approvalsReviewer: String? + package var sandbox: String? + package var serviceName: String? + package var serviceTier: String? + package var personality: String? + package var config: [String: AppServerJSONValue]? + package var permissions: AppServerAPI.Thread.Start.Permissions? + // Session start source drives lifecycle hooks; thread source is analytics classification. + package var sessionStartSource: AppServerAPI.Thread.Start.Source? + package var threadSource: AppServerAPI.Thread.Source? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case cwd + case model + case modelProvider + case ephemeral + case baseInstructions + case developerInstructions + case approvalPolicy + case approvalsReviewer + case sandbox + case serviceName + case serviceTier + case personality + case config + case permissions + case sessionStartSource + case threadSource + } + + package init( + threadID: String? = nil, + cwd: String? = nil, + model: String? = nil, + modelProvider: String? = nil, + ephemeral: Bool? = nil, + baseInstructions: String? = nil, + developerInstructions: String? = nil, + approvalPolicy: String? = nil, + approvalsReviewer: String? = nil, + sandbox: String? = nil, + serviceName: String? = nil, + serviceTier: String? = nil, + personality: String? = nil, + config: [String: AppServerJSONValue]? = nil, + permissions: AppServerAPI.Thread.Start.Permissions? = nil, + sessionStartSource: AppServerAPI.Thread.Start.Source? = nil, + threadSource: AppServerAPI.Thread.Source? = nil + ) { + self.threadID = threadID + self.cwd = cwd + self.model = model + self.modelProvider = modelProvider + self.ephemeral = ephemeral + self.baseInstructions = baseInstructions + self.developerInstructions = developerInstructions + self.approvalPolicy = approvalPolicy + self.approvalsReviewer = approvalsReviewer + self.sandbox = sandbox + self.serviceName = serviceName + self.serviceTier = serviceTier + self.personality = personality + self.config = config + self.permissions = permissions + self.sessionStartSource = sessionStartSource + self.threadSource = threadSource + } + } +} + +extension AppServerAPI.Thread.Start { + package enum Source: String, Codable, Equatable, Sendable { + case startup + case clear + } +} + +extension AppServerAPI.Thread { + package enum Source: String, Codable, Equatable, Sendable { + case user + case subagent + case memoryConsolidation = "memory_consolidation" + } +} + +extension AppServerAPI.Thread.Start { + package enum Permissions: Codable, Equatable, Sendable { + case profileID(String) + case profileSelection(AppServerAPI.Thread.Start.PermissionProfileSelection) + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let profileID = try? container.decode(String.self) { + self = .profileID(profileID) + return + } + if let profileSelection = try? container.decode( + AppServerAPI.Thread.Start.PermissionProfileSelection.self) + { + self = .profileSelection(profileSelection) + return + } + throw DecodingError.typeMismatch( + AppServerAPI.Thread.Start.Permissions.self, + .init( + codingPath: decoder.codingPath, + debugDescription: + "Expected a permissions profile ID or profile selection object." + ) + ) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .profileID(let profileID): + try container.encode(profileID) + case .profileSelection(let profileSelection): + try container.encode(profileSelection) + } + } + } +} + +extension AppServerAPI.Thread.Start { + package struct PermissionProfileSelection: Codable, Equatable, Sendable { + package var type: String + package var id: String + + package init(id: String, type: String = "profile") { + self.type = type + self.id = id + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Response: Codable, Equatable, Sendable { + package var threadID: String + package var model: String? + + enum CodingKeys: String, CodingKey { + case thread + case model + } + + private struct Thread: Codable, Equatable, Sendable { + var id: String + } + + package init(threadID: String, model: String? = nil) { + self.threadID = threadID + self.model = model + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.threadID = try container.decode(Thread.self, forKey: .thread).id + self.model = try container.decodeIfPresent(String.self, forKey: .model) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Thread(id: threadID), forKey: .thread) + try container.encodeIfPresent(model, forKey: .model) + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Start.Response + + package static let method = "thread/start" + package var params: AppServerAPI.Thread.Start.Params + + package init(params: AppServerAPI.Thread.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread { + package struct Snapshot: Codable, Equatable, Sendable { + package var id: String + package var cwd: String? + package var name: String? + package var preview: String? + package var modelProvider: String? + package var createdAt: Int? + package var updatedAt: Int? + package var ephemeral: Bool? + package var turns: [AppServerAPI.Turn.Payload]? + + enum CodingKeys: String, CodingKey { + case id + case cwd + case name + case preview + case modelProvider + case createdAt + case updatedAt + case ephemeral + case turns + } + + package init( + id: String, + cwd: String? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + createdAt: Int? = nil, + updatedAt: Int? = nil, + ephemeral: Bool? = nil, + turns: [AppServerAPI.Turn.Payload]? = nil + ) { + self.id = id + self.cwd = cwd + self.name = name + self.preview = preview + self.modelProvider = modelProvider + self.createdAt = createdAt + self.updatedAt = updatedAt + self.ephemeral = ephemeral + self.turns = turns + } + } +} + +extension AppServerAPI.Thread.Resume { + package typealias Params = AppServerAPI.Thread.Start.Params + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Resume.Response + + package static let method = "thread/resume" + package var params: AppServerAPI.Thread.Resume.Params + package var scope: AppServerAPI.RequestScope? { + params.threadID.map(AppServerAPI.RequestScope.thread) + } + + package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { + var scopedParams = params + scopedParams.threadID = threadID + self.params = scopedParams + } + } +} + +extension AppServerAPI.Thread.Fork { + package typealias Params = AppServerAPI.Thread.Start.Params + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Fork.Response + + package static let method = "thread/fork" + package var params: AppServerAPI.Thread.Fork.Params + package var scope: AppServerAPI.RequestScope? { + params.threadID.map(AppServerAPI.RequestScope.thread) + } + + package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { + var scopedParams = params + scopedParams.threadID = threadID + self.params = scopedParams + } + } +} + +extension AppServerAPI.Turn { + package struct Payload: Codable, Equatable, Sendable { + package var id: String + package var status: String? + package var error: AppServerAPI.Turn.Error? + + package init(id: String, status: String? = nil, error: AppServerAPI.Turn.Error? = nil) { + self.id = id + self.status = status + self.error = error + } + } +} + +extension AppServerAPI.Thread.List { + package struct Params: Codable, Equatable, Sendable { + package var archived: Bool? + package var cursor: String? + package var cwd: AppServerAPI.Thread.List.CWDFilter? + package var limit: Int? + package var modelProviders: [String]? + package var searchTerm: String? + package var sortDirection: String? + package var sortKey: String? + package var sourceKinds: [String]? + package var useStateDbOnly: Bool? + + package init( + archived: Bool? = nil, + cursor: String? = nil, + cwd: AppServerAPI.Thread.List.CWDFilter? = nil, + limit: Int? = nil, + modelProviders: [String]? = nil, + searchTerm: String? = nil, + sortDirection: String? = nil, + sortKey: String? = nil, + sourceKinds: [String]? = nil, + useStateDbOnly: Bool? = nil + ) { + self.archived = archived + self.cursor = cursor + self.cwd = cwd + self.limit = limit + self.modelProviders = modelProviders + self.searchTerm = searchTerm + self.sortDirection = sortDirection + self.sortKey = sortKey + self.sourceKinds = sourceKinds + self.useStateDbOnly = useStateDbOnly + } + } + + package enum CWDFilter: Codable, Equatable, Sendable { + case path(String) + case paths([String]) + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let path = try? container.decode(String.self) { + self = .path(path) + } else { + self = .paths(try container.decode([String].self)) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .path(let path): + try container.encode(path) + case .paths(let paths): + try container.encode(paths) + } + } + } + + package struct Response: Codable, Equatable, Sendable { + package var data: [AppServerAPI.Thread.Snapshot] + package var nextCursor: String? + package var backwardsCursor: String? + + package init( + data: [AppServerAPI.Thread.Snapshot], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.data = data + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.List.Response + + package static let method = "thread/list" + package var params: AppServerAPI.Thread.List.Params + + package init(params: AppServerAPI.Thread.List.Params = .init()) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Read { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var includeTurns: Bool? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case includeTurns + } + + package init(threadID: String, includeTurns: Bool? = nil) { + self.threadID = threadID + self.includeTurns = includeTurns + } + } + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Read.Response + + package static let method = "thread/read" + package var params: AppServerAPI.Thread.Read.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Read.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Archive { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Archive.Response + + package static let method = "thread/archive" + package var params: AppServerAPI.Thread.Archive.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Archive.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Unarchive { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Unarchive.Response + + package static let method = "thread/unarchive" + package var params: AppServerAPI.Thread.Unarchive.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Unarchive.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Name.Set { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var name: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case name + } + + package init(threadID: String, name: String) { + self.threadID = threadID + self.name = name + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Name.Set.Response + + package static let method = "thread/name/set" + package var params: AppServerAPI.Thread.Name.Set.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Name.Set.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Compact.Start { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Compact.Start.Response + + package static let method = "thread/compact/start" + package var params: AppServerAPI.Thread.Compact.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Compact.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn { + package struct Error: Codable, Equatable, Sendable { + package var message: String + + package init(message: String) { + self.message = message + } + } +} + +extension AppServerAPI { + package enum UserInput: Codable, Equatable, Sendable { + case text(String) + case image(url: String) + case localImage(path: String) + case skill(name: String, path: String) + case mention(name: String, path: String) + + private enum CodingKeys: String, CodingKey { + case type + case text + case url + case path + case name + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "text": + self = .text(try container.decode(String.self, forKey: .text)) + case "image": + self = .image(url: try container.decode(String.self, forKey: .url)) + case "localImage": + self = .localImage(path: try container.decode(String.self, forKey: .path)) + case "skill": + self = .skill( + name: try container.decode(String.self, forKey: .name), + path: try container.decode(String.self, forKey: .path) + ) + case "mention": + self = .mention( + name: try container.decode(String.self, forKey: .name), + path: try container.decode(String.self, forKey: .path) + ) + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported app-server input type: \(type)" + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let url): + try container.encode("image", forKey: .type) + try container.encode(url, forKey: .url) + case .localImage(let path): + try container.encode("localImage", forKey: .type) + try container.encode(path, forKey: .path) + case .skill(let name, let path): + try container.encode("skill", forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + case .mention(let name, let path): + try container.encode("mention", forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + } + } + } +} + +extension AppServerAPI.Turn { + package enum SandboxPolicy: Codable, Equatable, Sendable { + case readOnly(networkAccess: Bool) + case workspaceWrite( + writableRoots: [String], + networkAccess: Bool, + excludeTmpdirEnvVar: Bool, + excludeSlashTmp: Bool + ) + case dangerFullAccess + + private enum CodingKeys: String, CodingKey { + case type + case writableRoots + case networkAccess + case excludeTmpdirEnvVar + case excludeSlashTmp + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "readOnly": + self = .readOnly( + networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) + ?? false) + case "workspaceWrite": + self = .workspaceWrite( + writableRoots: try container.decodeIfPresent( + [String].self, forKey: .writableRoots) ?? [], + networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) + ?? false, + excludeTmpdirEnvVar: try container.decodeIfPresent( + Bool.self, forKey: .excludeTmpdirEnvVar) ?? false, + excludeSlashTmp: try container.decodeIfPresent( + Bool.self, forKey: .excludeSlashTmp) ?? false + ) + case "dangerFullAccess": + self = .dangerFullAccess + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported sandbox policy type: \(type)" + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .readOnly(let networkAccess): + try container.encode("readOnly", forKey: .type) + try container.encode(networkAccess, forKey: .networkAccess) + case .workspaceWrite( + let writableRoots, let networkAccess, let excludeTmpdirEnvVar, let excludeSlashTmp): + try container.encode("workspaceWrite", forKey: .type) + try container.encode(writableRoots, forKey: .writableRoots) + try container.encode(networkAccess, forKey: .networkAccess) + try container.encode(excludeTmpdirEnvVar, forKey: .excludeTmpdirEnvVar) + try container.encode(excludeSlashTmp, forKey: .excludeSlashTmp) + case .dangerFullAccess: + try container.encode("dangerFullAccess", forKey: .type) + } + } + } +} + +extension AppServerAPI.Turn.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var input: [AppServerAPI.UserInput] + package var approvalPolicy: String? + package var approvalsReviewer: String? + package var clientUserMessageID: String? + package var cwd: String? + package var effort: String? + package var model: String? + package var outputSchema: AppServerJSONValue? + package var personality: String? + package var sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? + package var serviceTier: String? + package var summary: String? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case input + case approvalPolicy + case approvalsReviewer + case clientUserMessageID = "clientUserMessageId" + case cwd + case effort + case model + case outputSchema + case personality + case sandboxPolicy + case serviceTier + case summary + } + + package init( + threadID: String, + input: [AppServerAPI.UserInput], + approvalPolicy: String? = nil, + approvalsReviewer: String? = nil, + clientUserMessageID: String? = nil, + cwd: String? = nil, + effort: String? = nil, + model: String? = nil, + outputSchema: AppServerJSONValue? = nil, + personality: String? = nil, + sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? = nil, + serviceTier: String? = nil, + summary: String? = nil + ) { + self.threadID = threadID + self.input = input + self.approvalPolicy = approvalPolicy + self.approvalsReviewer = approvalsReviewer + self.clientUserMessageID = clientUserMessageID + self.cwd = cwd + self.effort = effort + self.model = model + self.outputSchema = outputSchema + self.personality = personality + self.sandboxPolicy = sandboxPolicy + self.serviceTier = serviceTier + self.summary = summary + } + } + + package struct Response: Codable, Equatable, Sendable { + package var turn: AppServerAPI.Turn.Payload + + package init(turn: AppServerAPI.Turn.Payload) { + self.turn = turn + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Turn.Start.Response + + package static let method = "turn/start" + package var params: AppServerAPI.Turn.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Turn.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn.Steer { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var expectedTurnID: String + package var input: [AppServerAPI.UserInput] + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case expectedTurnID = "expectedTurnId" + case input + } + + package init(threadID: String, expectedTurnID: String, input: [AppServerAPI.UserInput]) { + self.threadID = threadID + self.expectedTurnID = expectedTurnID + self.input = input + } + } + + package struct Response: Codable, Equatable, Sendable { + package var turnID: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + } + + package init(turnID: String) { + self.turnID = turnID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Turn.Steer.Response + + package static let method = "turn/steer" + package var params: AppServerAPI.Turn.Steer.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Turn.Steer.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn.Interrupt { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var turnID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + } + + package init(threadID: String, turnID: String) { + self.threadID = threadID + self.turnID = turnID + } + } +} + +extension AppServerAPI.Turn.Interrupt { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "turn/interrupt" + package var params: AppServerAPI.Turn.Interrupt.Params + + package init(params: AppServerAPI.Turn.Interrupt.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Rollback { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var numTurns: Int + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case numTurns + } + + package init(threadID: String, numTurns: Int) { + self.threadID = threadID + self.numTurns = numTurns + } + } +} + +extension AppServerAPI.Thread.Rollback { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/rollback" + package var params: AppServerAPI.Thread.Rollback.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Rollback.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Delete { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.Delete { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/delete" + package var params: AppServerAPI.Thread.Delete.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Delete.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package enum Status: String, Codable, Equatable, Sendable { + case notLoaded + case notSubscribed + case unsubscribed + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Response: Codable, Equatable, Sendable { + package var status: AppServerAPI.Thread.Unsubscribe.Status + + package init(status: AppServerAPI.Thread.Unsubscribe.Status) { + self.status = status + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Unsubscribe.Response + + package static let method = "thread/unsubscribe" + package var params: AppServerAPI.Thread.Unsubscribe.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Unsubscribe.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.BackgroundTerminals.Clean { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.BackgroundTerminals.Clean { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/backgroundTerminals/clean" + package var params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Config.Read { + package struct Response: Codable, Equatable, Sendable { + package var config: AppServerAPI.Config.Snapshot + + package init(config: AppServerAPI.Config.Snapshot) { + self.config = config + } + } +} + +extension AppServerAPI.Config { + package struct Snapshot: Codable, Equatable, Sendable { + package var model: String? + package var reviewModel: String? + package var modelReasoningEffort: String? + package var serviceTier: String? + + enum CodingKeys: String, CodingKey { + case model + case reviewModel = "review_model" + case modelReasoningEffort = "model_reasoning_effort" + case serviceTier = "service_tier" + } + + package init( + model: String? = nil, + reviewModel: String? = nil, + modelReasoningEffort: String? = nil, + serviceTier: String? = nil + ) { + self.model = model + self.reviewModel = reviewModel + self.modelReasoningEffort = modelReasoningEffort + self.serviceTier = serviceTier + } + } +} + +extension AppServerAPI.Config.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Config.Read.Response + + package static let method = "config/read" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} + +extension AppServerAPI.Config { + package enum Value: Encodable, Equatable, Sendable { + case string(String) + case null + + package func encode(to encoder: Encoder) throws { + switch self { + case .string(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } + } +} + +extension AppServerAPI.Config { + package enum MergeStrategy: String, Codable, Equatable, Sendable { + case replace + case upsert + } +} + +extension AppServerAPI.Config { + package struct Edit: Encodable, Equatable, Sendable { + package var keyPath: String + package var value: AppServerAPI.Config.Value + package var mergeStrategy: AppServerAPI.Config.MergeStrategy + + package init( + keyPath: String, + value: AppServerAPI.Config.Value, + mergeStrategy: AppServerAPI.Config.MergeStrategy = .replace + ) { + self.keyPath = keyPath + self.value = value + self.mergeStrategy = mergeStrategy + } + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Params: Encodable, Equatable, Sendable { + package var edits: [AppServerAPI.Config.Edit] + package var filePath: String? + package var expectedVersion: String? + package var reloadUserConfig: Bool + + package init( + edits: [AppServerAPI.Config.Edit], + filePath: String? = nil, + expectedVersion: String? = nil, + reloadUserConfig: Bool = true + ) { + self.edits = edits + self.filePath = filePath + self.expectedVersion = expectedVersion + self.reloadUserConfig = reloadUserConfig + } + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Response: Decodable, Equatable, Sendable { + package var status: String + package var version: String? + package var filePath: String? + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Config.BatchWrite.Response + + package static let method = "config/batchWrite" + package var params: AppServerAPI.Config.BatchWrite.Params + + package init(params: AppServerAPI.Config.BatchWrite.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Model.List { + package struct Params: Codable, Equatable, Sendable { + package var cursor: String? + package var limit: Int? + package var includeHidden: Bool? + + package init( + cursor: String? = nil, + limit: Int? = nil, + includeHidden: Bool? = nil + ) { + self.cursor = cursor + self.limit = limit + self.includeHidden = includeHidden + } + } +} + +extension AppServerAPI.Model.List { + package struct Response: Codable, Equatable, Sendable { + package var data: [CodexModel] + package var nextCursor: String? + + package init( + data: [CodexModel], + nextCursor: String? = nil + ) { + self.data = data + self.nextCursor = nextCursor + } + } +} + +extension AppServerAPI.Model.List { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Model.List.Response + + package static let method = "model/list" + package var params: AppServerAPI.Model.List.Params + + package init(params: AppServerAPI.Model.List.Params = .init(includeHidden: true)) { + self.params = params + } + } +} + +extension AppServerAPI.Auth.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Read.Response + + package static let method = "account/read" + package var params: AppServerAPI.Account.Read.Params + + package init() { + self.params = .init(refreshToken: false) + } + } +} + +extension AppServerAPI.Account.Read { + package struct Params: Codable, Equatable, Sendable { + package var refreshToken: Bool + + package init(refreshToken: Bool) { + self.refreshToken = refreshToken + } + } +} + +extension AppServerAPI.Account.Read { + package struct Response: Codable, Equatable, Sendable { + package var account: AppServerAPI.Account.Snapshot? + package var requiresOpenAIAuth: Bool + + enum CodingKeys: String, CodingKey { + case account + case requiresOpenAIAuth = "requiresOpenaiAuth" + } + + package init( + account: AppServerAPI.Account.Snapshot? = nil, requiresOpenAIAuth: Bool = false + ) { + self.account = account + self.requiresOpenAIAuth = requiresOpenAIAuth + } + } +} + +extension AppServerAPI.Account.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Read.Response + + package static let method = "account/read" + package var params: AppServerAPI.Account.Read.Params + + package init(params: AppServerAPI.Account.Read.Params = .init(refreshToken: false)) { + self.params = params + } + } +} + +extension AppServerAPI.Account.RateLimits.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.RateLimits.Response + + package static let method = "account/rateLimits/read" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Response: Codable, Equatable, Sendable { + package var rateLimits: AppServerAPI.Account.RateLimits.Snapshot + package var rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? + + enum CodingKeys: String, CodingKey { + case rateLimits + case rateLimitsByLimitID = "rateLimitsByLimitId" + } + + package init( + rateLimits: AppServerAPI.Account.RateLimits.Snapshot, + rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? = nil + ) { + self.rateLimits = rateLimits + self.rateLimitsByLimitID = rateLimitsByLimitID + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Snapshot: Codable, Equatable, Sendable { + package var limitID: String? + package var primary: AppServerAPI.Account.RateLimits.Window? + package var secondary: AppServerAPI.Account.RateLimits.Window? + package var planType: String? + + enum CodingKeys: String, CodingKey { + case limitID = "limitId" + case primary + case secondary + case planType + } + + package init( + limitID: String? = nil, + primary: AppServerAPI.Account.RateLimits.Window? = nil, + secondary: AppServerAPI.Account.RateLimits.Window? = nil, + planType: String? = nil + ) { + self.limitID = limitID + self.primary = primary + self.secondary = secondary + self.planType = planType + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Window: Codable, Equatable, Sendable { + package var usedPercent: Int + package var windowDurationMins: Int? + package var resetsAt: Int64? + + package init( + usedPercent: Int, + windowDurationMins: Int? = nil, + resetsAt: Int64? = nil + ) { + self.usedPercent = usedPercent + self.windowDurationMins = windowDurationMins + self.resetsAt = resetsAt + } + } +} + +extension AppServerAPI.Account.RateLimits.Response { + package var codexRateLimitWindows: + [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] + { + Self.rateLimitWindows(from: codexSnapshot) + } + + package var codexPlanType: String? { + codexSnapshot?.planType + } + + private var codexSnapshot: AppServerAPI.Account.RateLimits.Snapshot? { + if let codexSnapshot = rateLimitsByLimitID?["codex"] { + return codexSnapshot + } + if let codexSnapshot = rateLimitsByLimitID?.first(where: { limitID, snapshot in + Self.isCodexRateLimit(limitID) || Self.isCodexRateLimit(snapshot.limitID) + })?.value { + return codexSnapshot + } + if Self.isCodexRateLimit(rateLimits.limitID) { + return rateLimits + } + return nil + } + + private static func rateLimitWindows( + from snapshot: AppServerAPI.Account.RateLimits.Snapshot? + ) -> [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] { + [snapshot?.primary, snapshot?.secondary].compactMap { window in + guard let window, + let duration = window.windowDurationMins + else { + return nil + } + return ( + windowDurationMinutes: duration, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } + } + + package static func isCodexRateLimit(_ limitID: String?) -> Bool { + let trimmedLimitID = limitID?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedLimitID = + if let trimmedLimitID, trimmedLimitID.isEmpty == false { + trimmedLimitID + } else { + "codex" + } + return normalizedLimitID == "codex" || normalizedLimitID.hasPrefix("codex_") + } +} + +extension AppServerAPI.Account { + package struct Snapshot: Codable, Equatable, Sendable { + package enum Kind: String, Codable, Equatable, Sendable { + case chatGPT = "chatgpt" + case apiKey + case amazonBedrock + } + + package var id: String + package var kind: Kind + package var label: String + package var planType: String? + + private enum CodingKeys: String, CodingKey { + case type + case email + case planType + } + + package init( + kind: Kind, + id: String, + label: String, + planType: String? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.planType = planType + } + + package init(email: String, planType: String) { + self.init( + kind: .chatGPT, + id: Self.normalizedAccountID(email), + label: email, + planType: planType + ) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .type) + switch kind { + case .apiKey: + self.init(kind: .apiKey, id: "api-key", label: "API Key") + case .chatGPT: + let email = try container.decode(String.self, forKey: .email) + let normalizedEmail = Self.normalizedAccountID(email) + guard normalizedEmail.isEmpty == false else { + throw DecodingError.dataCorruptedError( + forKey: .email, + in: container, + debugDescription: "ChatGPT account email must not be empty." + ) + } + self.init( + kind: .chatGPT, + id: normalizedEmail, + label: email, + planType: try container.decodeIfPresent(String.self, forKey: .planType) + ) + case .amazonBedrock: + self.init(kind: .amazonBedrock, id: "amazon-bedrock", label: "Amazon Bedrock") + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .type) + switch kind { + case .apiKey, .amazonBedrock: + break + case .chatGPT: + try container.encode(label, forKey: .email) + try container.encodeIfPresent(planType, forKey: .planType) + } + } + + private static func normalizedAccountID(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + } +} + +extension AppServerAPI.Account.Login { + package struct Params: Codable, Equatable, Sendable { + package var type: String + package var apiKey: String? + package var codexStreamlinedLogin: Bool + package var nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication? + + package init( + type: String = "chatgpt", + apiKey: String? = nil, + codexStreamlinedLogin: Bool = true, + nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication? = nil + ) { + self.type = type + self.apiKey = apiKey + self.codexStreamlinedLogin = codexStreamlinedLogin + self.nativeWebAuthentication = nativeWebAuthentication + } + } +} + +extension AppServerAPI.Account.Login { + package struct NativeWebAuthentication: Codable, Equatable, Sendable { + package var callbackURLScheme: String + + enum CodingKeys: String, CodingKey { + case callbackURLScheme = "callbackUrlScheme" + } + + package init(callbackURLScheme: String) { + self.callbackURLScheme = callbackURLScheme + } + } +} + +extension AppServerAPI.Account.Login { + package 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) + } + } + } +} + +extension AppServerAPI.Account.Login.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Login.Response + + package static let method = "account/login/start" + package var params: AppServerAPI.Account.Login.Params + + package init(params: AppServerAPI.Account.Login.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Account.Login.Complete { + package 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 + } + } +} + +extension AppServerAPI.Account.Login.Complete { + package struct Response: Codable, Equatable, Sendable { + package init() {} + } +} + +extension AppServerAPI.Account.Login.Complete { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Login.Complete.Response + + package static let method = "account/login/complete" + package var params: AppServerAPI.Account.Login.Complete.Params + + package init(params: AppServerAPI.Account.Login.Complete.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Params: Codable, Equatable, Sendable { + package var loginID: String + + enum CodingKeys: String, CodingKey { + case loginID = "loginId" + } + + package init(loginID: String) { + self.loginID = loginID + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Response: Codable, Equatable, Sendable { + package var status: String + + package init(status: String = "canceled") { + self.status = status + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Login.Cancel.Response + + package static let method = "account/login/cancel" + package var params: AppServerAPI.Account.Login.Cancel.Params + + package init(params: AppServerAPI.Account.Login.Cancel.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Account.Logout { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "account/logout" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift new file mode 100644 index 00000000..61543378 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -0,0 +1,353 @@ +import Foundation + +public actor CodexAppServer { + public struct Configuration: Sendable { + public var executable: String? + public var arguments: [String]? + public var environment: [String: String] + public var codexHomeURL: URL? + public var clientName: String + public var clientVersion: String + + public init( + executable: String? = nil, + arguments: [String]? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + codexHomeURL: URL? = nil, + clientName: String = "CodexAppServerKit", + clientVersion: String = "1" + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.codexHomeURL = codexHomeURL + self.clientName = clientName + self.clientVersion = clientVersion + } + + public static let `default` = Configuration() + } + + private let client: AppServerClient + private let router: CodexAppServerNotificationRouter + + public init(configuration: Configuration = .default) async throws { + let transportConfiguration = AppServerProcessTransport.Configuration( + executable: configuration.executable, + arguments: configuration.arguments, + environment: configuration.environment, + codexHomeURL: configuration.codexHomeURL + ?? Self.defaultCodexHomeURL(environment: configuration.environment) + ) + let client = AppServerClient( + transport: try AppServerProcessTransport(configuration: transportConfiguration)) + _ = try await client.initialize( + clientName: configuration.clientName, + clientVersion: configuration.clientVersion + ) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + self.client = client + self.router = router + } + + package init(client: AppServerClient, router: CodexAppServerNotificationRouter) { + self.client = client + self.router = router + } + + public func close() async { + await router.stop() + await client.close() + } + + public func startThread( + in workspace: URL, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init() + ) async throws -> CodexThread { + let approvalMode = options.approvalMode ?? .autoReview + let response = try await client.send( + AppServerAPI.Thread.Start.Request( + params: .init( + cwd: workspace.path, + model: options.model, + modelProvider: options.modelProvider, + ephemeral: options.ephemeral, + baseInstructions: instructions?.base, + developerInstructions: instructions?.developer, + approvalPolicy: approvalMode.approvalPolicy, + approvalsReviewer: approvalMode.approvalsReviewer, + sandbox: options.sandbox?.threadSandboxValue, + serviceTier: options.serviceTier + ) + )) + return CodexThread( + id: .init(rawValue: response.threadID), + workspace: workspace, + model: response.model ?? options.model, + client: client, + router: router + ) + } + + public func resumeThread( + _ id: CodexThread.ID, + options: CodexThread.ResumeOptions = .init() + ) async throws -> CodexThread { + let response = try await client.send( + AppServerAPI.Thread.Resume.Request( + threadID: id.rawValue, + params: threadStartParams(options: options) + )) + return thread(from: response.thread) + } + + public func forkThread( + _ id: CodexThread.ID, + options: CodexThread.Options = .init() + ) async throws -> CodexThread { + let response = try await client.send( + AppServerAPI.Thread.Fork.Request( + threadID: id.rawValue, + params: threadStartParams(options: options) + )) + return thread(from: response.thread) + } + + public func unarchiveThread(_ id: CodexThread.ID) async throws -> CodexThread { + let response = try await client.send( + AppServerAPI.Thread.Unarchive.Request( + params: .init(threadID: id.rawValue) + )) + return thread(from: response.thread) + } + + public func deleteThread(_ id: CodexThread.ID) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: id.rawValue) + )) + } + + public func listThreads(_ query: CodexThreadQuery = .init()) async throws -> CodexThreadPage { + let response = try await client.send( + AppServerAPI.Thread.List.Request( + params: .init( + archived: query.archived, + cursor: query.cursor, + cwd: query.workspace.map { .path($0.path) }, + limit: query.limit, + searchTerm: query.searchTerm + ))) + return .init( + threads: response.data.map(Self.threadSnapshot), + nextCursor: response.nextCursor, + backwardsCursor: response.backwardsCursor + ) + } + + public func models(includeHidden: Bool = false) async throws -> [CodexModel] { + var cursor: String? + var models: [CodexModel] = [] + repeat { + let response = try await client.send( + AppServerAPI.Model.List.Request( + params: .init(cursor: cursor, includeHidden: includeHidden) + )) + models.append(contentsOf: response.data) + cursor = response.nextCursor + } while cursor != nil + return models + } + + public func account(refreshToken: Bool = false) async throws -> CodexAccount? { + let response = try await client.send( + AppServerAPI.Account.Read.Request(params: .init(refreshToken: refreshToken)) + ) + return response.account.map(Self.account) + } + + public func configuration() async throws -> CodexConfiguration { + let response = try await client.send(AppServerAPI.Config.Read.Request()) + return .init( + model: response.config.model, + reviewModel: response.config.reviewModel, + reasoningEffort: response.config.modelReasoningEffort, + serviceTier: response.config.serviceTier + ) + } + + public func rateLimits() async throws -> CodexRateLimits { + let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) + return .init( + planType: response.codexPlanType, + windows: response.codexRateLimitWindows.map { + .init( + windowDurationMinutes: $0.windowDurationMinutes, + usedPercent: $0.usedPercent, + resetsAt: $0.resetsAt + ) + } + ) + } + + @discardableResult + public func loginAPIKey(_ apiKey: String) async throws -> CodexLoginHandle { + let response = try await client.send( + AppServerAPI.Account.Login.Start.Request( + params: .init(type: "apiKey", apiKey: apiKey) + )) + return try Self.loginHandle(from: response) + } + + public func loginChatGPT(callbackURLScheme: String? = nil) async throws -> CodexLoginHandle { + let response = try await client.send( + AppServerAPI.Account.Login.Start.Request( + params: .init( + type: "chatgpt", + nativeWebAuthentication: callbackURLScheme.map { + .init(callbackURLScheme: $0) + } + ) + )) + return try Self.loginHandle(from: response) + } + + public func loginChatGPTDeviceCode() async throws -> CodexLoginHandle { + let response = try await client.send( + AppServerAPI.Account.Login.Start.Request( + params: .init(type: "chatgptDeviceCode") + )) + return try Self.loginHandle(from: response) + } + + public func cancelLogin(_ handle: CodexLoginHandle) async throws { + guard let id = handle.id else { + return + } + try await cancelLogin(id: id) + } + + public func cancelLogin(id: CodexLoginHandle.ID) async throws { + let _: AppServerAPI.Account.Login.Cancel.Response = try await client.send( + AppServerAPI.Account.Login.Cancel.Request(params: .init(loginID: id.rawValue)) + ) + } + + public func completeLogin(_ handle: CodexLoginHandle, callbackURL: URL) async throws { + guard let id = handle.id else { + return + } + try await completeLogin(id: id, callbackURL: callbackURL) + } + + public func completeLogin(id: CodexLoginHandle.ID, callbackURL: URL) async throws { + let _: AppServerAPI.Account.Login.Complete.Response = try await client.send( + AppServerAPI.Account.Login.Complete.Request( + params: .init( + loginID: id.rawValue, + callbackURL: callbackURL.absoluteString + )) + ) + } + + public func logout() async throws { + let _: EmptyResponse = try await client.send(AppServerAPI.Account.Logout.Request()) + } + + private func threadStartParams(options: CodexThread.Options) -> AppServerAPI.Thread.Start.Params + { + .init( + model: options.model, + modelProvider: options.modelProvider, + ephemeral: options.ephemeral, + approvalPolicy: options.approvalMode?.approvalPolicy, + approvalsReviewer: options.approvalMode?.approvalsReviewer, + sandbox: options.sandbox?.threadSandboxValue, + serviceTier: options.serviceTier + ) + } + + private func thread(from snapshot: AppServerAPI.Thread.Snapshot) -> CodexThread { + CodexThread( + id: .init(rawValue: snapshot.id), + workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + client: client, + router: router + ) + } + + package nonisolated static func threadSnapshot( + from snapshot: AppServerAPI.Thread.Snapshot + ) -> CodexThreadSnapshot { + .init( + id: .init(rawValue: snapshot.id), + workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + name: snapshot.name, + preview: snapshot.preview, + turns: (snapshot.turns ?? []).map { + CodexTurnSnapshot( + id: .init(rawValue: $0.id), + status: $0.status.map(CodexTurnStatus.init(rawValue:)), + errorMessage: $0.error?.message + ) + } + ) + } + + private nonisolated static func account(from snapshot: AppServerAPI.Account.Snapshot) + -> CodexAccount + { + .init( + id: snapshot.id, + kind: .init(rawValue: snapshot.kind.rawValue) ?? .chatGPT, + label: snapshot.label, + planType: snapshot.planType + ) + } + + private nonisolated static func loginHandle( + from response: AppServerAPI.Account.Login.Response + ) throws -> CodexLoginHandle { + switch response { + case .apiKey: + return .apiKey + case .chatgpt(let loginID, let authURL, _): + guard let url = URL(string: authURL) else { + throw CodexAppServerError.jsonRPC( + code: -32602, message: "Invalid ChatGPT authentication URL.") + } + return .chatGPT(id: .init(rawValue: loginID), authenticationURL: url) + case .chatgptDeviceCode(let loginID, let verificationURL, let userCode): + guard let url = URL(string: verificationURL) else { + throw CodexAppServerError.jsonRPC( + code: -32602, message: "Invalid ChatGPT device-code verification URL.") + } + return .chatGPTDeviceCode( + id: .init(rawValue: loginID), + verificationURL: url, + userCode: userCode + ) + case .chatgptAuthTokens: + return .apiKey + } + } + + private nonisolated static func defaultCodexHomeURL(environment: [String: String]) -> 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", isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + } +} diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift new file mode 100644 index 00000000..25ac8d6a --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -0,0 +1,668 @@ +import Foundation + +package actor CodexAppServerNotificationRouter { + private struct Subscriber { + var continuation: AsyncThrowingStream.Continuation + } + + private struct NotificationContext { + var threadID: CodexThread.ID? + var turnID: CodexTurn.ID? + } + + private let client: AppServerClient + private var routerTask: Task? + private var threadIDByTurnID: [CodexTurn.ID: CodexThread.ID] = [:] + private var turnHistoryByTurnID: [CodexTurn.ID: [CodexTurnEvent]] = [:] + private var threadHistoryByThreadID: [CodexThread.ID: [CodexThreadEvent]] = [:] + private var turnSubscribersByTurnID: [CodexTurn.ID: [UUID: Subscriber]] = [:] + private var threadSubscribersByThreadID: + [CodexThread.ID: [UUID: Subscriber]] = [:] + private let decoder = JSONDecoder() + + package init(client: AppServerClient) { + self.client = client + } + + package func start() async { + guard routerTask == nil else { + return + } + let notifications = await client.notificationStream() + routerTask = Task { + do { + for try await notification in notifications { + self.route(notification) + } + } catch { + self.finishAll(throwing: error) + } + } + } + + package func events(for turnID: CodexTurn.ID) -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let subscriptionID = UUID() + Task { + self.addTurnSubscriber( + subscriptionID, turnID: turnID, continuation: continuation) + } + continuation.onTermination = { _ in + Task { await self.removeTurnSubscriber(subscriptionID, turnID: turnID) } + } + } + } + + package func events(for threadID: CodexThread.ID) -> AsyncThrowingStream< + CodexThreadEvent, Error + > { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let subscriptionID = UUID() + Task { + self.addThreadSubscriber( + subscriptionID, threadID: threadID, continuation: continuation) + } + continuation.onTermination = { _ in + Task { await self.removeThreadSubscriber(subscriptionID, threadID: threadID) } + } + } + } + + package func stop() { + routerTask?.cancel() + routerTask = nil + finishAll(throwing: JSONRPC.Error.closed) + } + + private func route(_ notification: JSONRPC.Notification) { + var context = Self.context(from: notification.params) + if let threadID = context.threadID, let turnID = context.turnID { + threadIDByTurnID[turnID] = threadID + } else if let turnID = context.turnID, let threadID = threadIDByTurnID[turnID] { + context.threadID = threadID + } + + if let threadID = context.threadID { + let event = decodeThreadEvent(notification: notification, context: context) + threadHistoryByThreadID[threadID, default: []].append(event) + if let subscribers = threadSubscribersByThreadID[threadID] { + for subscriber in subscribers.values { + subscriber.continuation.yield(event) + } + } + if case .closed = event { + finishThreadSubscribers(threadID: threadID) + } + } + + if let turnID = context.turnID { + let event = decodeTurnEvent(notification: notification, context: context) + turnHistoryByTurnID[turnID, default: []].append(event) + if let subscribers = turnSubscribersByTurnID[turnID] { + for subscriber in subscribers.values { + subscriber.continuation.yield(event) + } + } + if Self.isTerminalTurnEvent(event) { + finishTurnSubscribers(turnID: turnID) + } + } + } + + private func addTurnSubscriber( + _ subscriptionID: UUID, + turnID: CodexTurn.ID, + continuation: AsyncThrowingStream.Continuation + ) { + let history = turnHistoryByTurnID[turnID] ?? [] + for event in history { + continuation.yield(event) + } + if history.contains(where: Self.isTerminalTurnEvent) { + continuation.finish() + return + } + turnSubscribersByTurnID[turnID, default: [:]][subscriptionID] = .init( + continuation: continuation) + } + + private func addThreadSubscriber( + _ subscriptionID: UUID, + threadID: CodexThread.ID, + continuation: AsyncThrowingStream.Continuation + ) { + let history = threadHistoryByThreadID[threadID] ?? [] + for event in history { + continuation.yield(event) + } + if history.contains(where: Self.isTerminalThreadEvent) { + continuation.finish() + return + } + threadSubscribersByThreadID[threadID, default: [:]][subscriptionID] = .init( + continuation: continuation) + } + + private nonisolated static func isTerminalTurnEvent(_ event: CodexTurnEvent) -> Bool { + switch event { + case .completed, .failed: + true + case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, + .tokenUsageUpdated, .unknown: + false + } + } + + private nonisolated static func isTerminalThreadEvent(_ event: CodexThreadEvent) -> Bool { + if case .closed = event { + return true + } + return false + } + + private func decodeTurnEvent( + notification: JSONRPC.Notification, + context: NotificationContext + ) -> CodexTurnEvent { + let raw = CodexRawNotification( + method: notification.method, + params: notification.params, + threadID: context.threadID, + turnID: context.turnID + ) + switch notification.method { + case "turn/started": + return .started(context.turnID ?? .init(rawValue: "")) + case "turn/completed": + return .completed(turnResult(from: notification.params, context: context)) + case "turn/failed", "turn/cancelled": + return .failed(turnFailureMessage(from: notification.params) ?? notification.method) + case "item/started": + if let item = item(from: notification.params) { + return .itemStarted(item) + } + return .unknown(raw) + case "item/updated": + if let item = item(from: notification.params) { + return .itemUpdated(item) + } + return .unknown(raw) + case "item/completed": + if let item = item(from: notification.params) { + return .itemCompleted(item) + } + return .unknown(raw) + case "item/agentMessage/delta": + if let delta = messageDelta(from: notification.params) { + return .messageDelta(delta) + } + return .unknown(raw) + case "thread/tokenUsage/updated": + if let usage = tokenUsage(from: notification.params) { + return .tokenUsageUpdated(usage) + } + return .unknown(raw) + default: + return .unknown(raw) + } + } + + private func decodeThreadEvent( + notification: JSONRPC.Notification, + context: NotificationContext + ) -> CodexThreadEvent { + let raw = CodexRawNotification( + method: notification.method, + params: notification.params, + threadID: context.threadID, + turnID: context.turnID + ) + switch notification.method { + case "turn/started": + return .turnStarted(context.turnID ?? .init(rawValue: "")) + case "turn/completed": + return .turnCompleted(turnResult(from: notification.params, context: context)) + case "turn/failed", "turn/cancelled": + return .turnFailed( + turnID: context.turnID, + message: turnFailureMessage(from: notification.params) ?? notification.method + ) + case "item/started": + if let item = item(from: notification.params) { + return item.message.map { .message($0, turnID: context.turnID) } + ?? .itemStarted(item, turnID: context.turnID) + } + return .unknown(raw) + case "item/updated": + if let item = item(from: notification.params) { + return item.message.map { .message($0, turnID: context.turnID) } + ?? .itemUpdated(item, turnID: context.turnID) + } + return .unknown(raw) + case "item/completed": + if let item = item(from: notification.params) { + return item.message.map { .message($0, turnID: context.turnID) } + ?? .itemCompleted(item, turnID: context.turnID) + } + return .unknown(raw) + case "item/agentMessage/delta": + if let delta = messageDelta(from: notification.params) { + return .messageDelta(delta, turnID: context.turnID) + } + return .unknown(raw) + case "thread/tokenUsage/updated": + if let usage = tokenUsage(from: notification.params) { + return .tokenUsageUpdated(usage, turnID: context.turnID) + } + return .unknown(raw) + case "thread/status/changed": + if let status = threadStatus(from: notification.params) { + return .statusChanged(status) + } + return .unknown(raw) + case "thread/closed": + return .closed + default: + return .unknown(raw) + } + } + + private func removeTurnSubscriber(_ subscriptionID: UUID, turnID: CodexTurn.ID) { + turnSubscribersByTurnID[turnID]?.removeValue(forKey: subscriptionID) + if turnSubscribersByTurnID[turnID]?.isEmpty == true { + turnSubscribersByTurnID.removeValue(forKey: turnID) + } + } + + private func removeThreadSubscriber(_ subscriptionID: UUID, threadID: CodexThread.ID) { + threadSubscribersByThreadID[threadID]?.removeValue(forKey: subscriptionID) + if threadSubscribersByThreadID[threadID]?.isEmpty == true { + threadSubscribersByThreadID.removeValue(forKey: threadID) + } + } + + private func finishTurnSubscribers(turnID: CodexTurn.ID) { + let subscribers = + turnSubscribersByTurnID.removeValue(forKey: turnID).map { + Array($0.values) + } ?? [] + for subscriber in subscribers { + subscriber.continuation.finish() + } + } + + private func finishThreadSubscribers(threadID: CodexThread.ID) { + let subscribers = + threadSubscribersByThreadID.removeValue(forKey: threadID).map { + Array($0.values) + } ?? [] + for subscriber in subscribers { + subscriber.continuation.finish() + } + } + + private func finishAll(throwing error: Error) { + let turnSubscribers = turnSubscribersByTurnID.values.flatMap(\.values) + let threadSubscribers = threadSubscribersByThreadID.values.flatMap(\.values) + turnSubscribersByTurnID.removeAll() + threadSubscribersByThreadID.removeAll() + for subscriber in turnSubscribers { + subscriber.continuation.finish(throwing: error) + } + for subscriber in threadSubscribers { + subscriber.continuation.finish(throwing: error) + } + } + + private func item(from data: Data) -> CodexThreadItem? { + guard let payload = try? decoder.decode(ItemPayload.self, from: data) else { + return nil + } + return payload.item.threadItem + } + + private func messageDelta(from data: Data) -> CodexMessageDelta? { + guard let payload = try? decoder.decode(AgentMessageDeltaPayload.self, from: data) else { + return nil + } + let text = payload.delta ?? payload.text ?? "" + guard text.isEmpty == false else { + return nil + } + return .init( + text: text, + itemID: payload.itemID, + phase: payload.phase.map(CodexMessagePhase.init(rawValue:)) + ) + } + + private func tokenUsage(from data: Data) -> CodexTokenUsage? { + guard let payload = try? decoder.decode(TokenUsagePayload.self, from: data) else { + return nil + } + return payload.tokenUsage.codexUsage + } + + private func threadStatus(from data: Data) -> CodexThreadStatus? { + if let payload = try? decoder.decode(ThreadStatusPayload.self, from: data), + let type = payload.status?.type + { + return .init(rawValue: type) + } + return nil + } + + private func turnResult(from data: Data, context: NotificationContext) -> CodexTurnResult { + let payload = try? decoder.decode(TurnCompletedPayload.self, from: data) + let turn = payload?.turn + let status = turn?.status.map(CodexTurnStatus.init(rawValue:)) + return .init( + id: turn.map { .init(rawValue: $0.id) } ?? context.turnID ?? .init(rawValue: ""), + status: status, + errorMessage: turn?.error?.message + ) + } + + private func turnFailureMessage(from data: Data) -> String? { + if let payload = try? decoder.decode(TurnCompletedPayload.self, from: data) { + return payload.turn.error?.message + } + if let payload = try? decoder.decode(ErrorPayload.self, from: data) { + return payload.error?.message ?? payload.message + } + return nil + } + + private static func context(from data: Data) -> NotificationContext { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .init() + } + let threadID = + stringValue(named: "threadId", in: object) + ?? objectValue(named: "thread", in: object).flatMap { stringValue(named: "id", in: $0) } + let turnID = + stringValue(named: "turnId", in: object) + ?? objectValue(named: "turn", in: object).flatMap { stringValue(named: "id", in: $0) } + return .init( + threadID: threadID.map(CodexThread.ID.init(rawValue:)), + turnID: turnID.map(CodexTurn.ID.init(rawValue:)) + ) + } + + private static func stringValue(named name: String, in object: [String: Any]) -> String? { + object[name] as? String + } + + private static func objectValue(named name: String, in object: [String: Any]) -> [String: Any]? + { + object[name] as? [String: Any] + } +} + +private struct TurnCompletedPayload: Decodable { + var turn: AppServerAPI.Turn.Payload +} + +private struct ErrorPayload: Decodable { + struct ErrorBody: Decodable { + var message: String? + } + + var message: String? + var error: ErrorBody? +} + +private struct AgentMessageDeltaPayload: Decodable { + var itemID: String? + var delta: String? + var text: String? + var phase: String? + + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case delta + case text + case phase + } +} + +private struct ItemPayload: Decodable { + var item: RawThreadItem +} + +private struct ThreadStatusPayload: Decodable { + struct Status: Decodable { + var type: String? + } + + var status: Status? +} + +private struct TokenUsagePayload: Decodable { + var tokenUsage: RawTokenUsage +} + +private struct RawTokenUsage: Decodable { + var total: RawTokenUsageBreakdown? + var modelContextWindow: Int? + + var codexUsage: CodexTokenUsage { + .init( + inputTokens: total?.inputTokens, + outputTokens: total?.outputTokens, + totalTokens: total?.totalTokens, + cachedInputTokens: total?.cachedInputTokens, + reasoningOutputTokens: total?.reasoningOutputTokens, + modelContextWindow: modelContextWindow + ) + } +} + +private struct RawTokenUsageBreakdown: Decodable { + var cachedInputTokens: Int? + var inputTokens: Int? + var outputTokens: Int? + var reasoningOutputTokens: Int? + var totalTokens: Int? +} + +private struct RawThreadItem: Decodable { + var id: String? + var type: String? + var kind: String? + var text: String? + var review: String? + var phase: String? + var command: String? + var cwd: String? + var aggregatedOutput: String? + var output: String? + var exitCode: Int? + var status: String? + var path: String? + var namespace: String? + var server: String? + var tool: String? + var name: String? + var query: String? + var prompt: String? + var summary: [String]? + var content: [String]? + var arguments: AppServerJSONValue? + var input: AppServerJSONValue? + var result: AppServerJSONValue? + var error: AppServerJSONValue? + var rawValue: AppServerJSONValue? + + enum CodingKeys: String, CodingKey { + case id + case type + case kind + case text + case review + case phase + case command + case cwd + case aggregatedOutput + case output + case exitCode + case status + case path + case namespace + case server + case tool + case name + case query + case prompt + case summary + case content + case arguments + case input + case result + case error + } + + init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeStringIfPresent(forKey: .id) + type = try container.decodeStringIfPresent(forKey: .type) + kind = try container.decodeStringIfPresent(forKey: .kind) + text = try container.decodeStringIfPresent(forKey: .text) + review = try container.decodeStringIfPresent(forKey: .review) + phase = try container.decodeStringIfPresent(forKey: .phase) + command = try container.decodeStringIfPresent(forKey: .command) + cwd = try container.decodeStringIfPresent(forKey: .cwd) + aggregatedOutput = try container.decodeStringIfPresent(forKey: .aggregatedOutput) + output = try container.decodeStringIfPresent(forKey: .output) + exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) + status = try container.decodeStringIfPresent(forKey: .status) + path = try container.decodeStringIfPresent(forKey: .path) + namespace = try container.decodeStringIfPresent(forKey: .namespace) + server = try container.decodeStringIfPresent(forKey: .server) + tool = try container.decodeStringIfPresent(forKey: .tool) + name = try container.decodeStringIfPresent(forKey: .name) + query = try container.decodeStringIfPresent(forKey: .query) + prompt = try container.decodeStringIfPresent(forKey: .prompt) + summary = try? container.decodeIfPresent([String].self, forKey: .summary) + content = try? container.decodeIfPresent([String].self, forKey: .content) + arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) + input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) + result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) + error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) + } + + var threadItem: CodexThreadItem { + let rawType = type ?? kind ?? "unknown" + let kind = CodexThreadItem.Kind(rawValue: rawType) + let itemID = id ?? UUID().uuidString + return .init( + id: itemID, + kind: kind, + content: content(kind: kind, id: itemID, rawType: rawType) + ) + } + + private func content( + kind: CodexThreadItem.Kind, + id: String, + rawType: String + ) -> CodexThreadItem.Content { + switch kind { + case .userMessage: + return .message(.init(id: id, role: .user, text: messageText)) + case .agentMessage: + return .message( + .init( + id: id, + role: .assistant, + phase: phase.map(CodexMessagePhase.init(rawValue:)), + text: messageText + )) + case .plan: + return .plan(messageText) + case .reasoning: + return .reasoning( + summary?.joined(separator: "\n") ?? content?.joined(separator: "\n") ?? messageText) + case .commandExecution: + return .command( + .init( + command: command ?? "", + cwd: cwd, + output: aggregatedOutput ?? output ?? text, + exitCode: exitCode, + status: status.map(CodexTurnStatus.init(rawValue:)) + )) + case .fileChange: + return .fileChange( + .init( + path: path, + output: aggregatedOutput ?? output ?? text, + status: status.map(CodexTurnStatus.init(rawValue:)) + )) + case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .subAgentActivity, + .webSearch, .imageView, .sleep, .imageGeneration: + return .toolCall( + .init( + namespace: namespace, + server: server, + name: tool ?? name ?? query ?? path, + arguments: arguments?.displayText ?? input?.displayText, + result: result?.displayText ?? text, + error: error?.displayText, + status: status.map(CodexTurnStatus.init(rawValue:)) + )) + case .contextCompaction: + return .contextCompaction(status ?? text) + case .diagnostic, .error: + return .diagnostic(messageText) + case .unknown: + return .unknown(.init(rawType: rawType, text: messageText, payload: rawPayload)) + } + } + + private var messageText: String { + text ?? review ?? content?.joined(separator: "\n") ?? "" + } + + private var rawPayload: Data? { + rawValue.flatMap { try? JSONEncoder().encode($0) } + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeStringIfPresent(forKey key: Key) throws -> String? { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + if let bool = try? decode(Bool.self, forKey: key) { + return bool ? "true" : "false" + } + return nil + } +} + +extension AppServerJSONValue { + fileprivate var displayText: String? { + switch self { + case .string(let value): + value + case .int(let value): + String(value) + case .double(let value): + String(value) + case .bool(let value): + value ? "true" : "false" + case .array, .object: + (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } + case .null: + nil + } + } +} diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift new file mode 100644 index 00000000..f307892d --- /dev/null +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -0,0 +1,1083 @@ +import Foundation + +public struct CodexPrompt: ExpressibleByStringLiteral, Equatable, Sendable { + public var parts: [Part] + + public init(parts: [Part]) { + self.parts = parts + } + + public init(stringLiteral value: String) { + self.parts = [.text(value)] + } + + public init(_ text: String) { + self.parts = [.text(text)] + } + + public enum Part: Equatable, Sendable { + case text(String) + case imageURL(URL) + case localImage(URL) + case skill(name: String, path: URL) + case mention(name: String, path: URL) + } +} + +public struct CodexInstructions: Equatable, Sendable { + public var base: String? + public var developer: String? + + public init(base: String? = nil, developer: String? = nil) { + self.base = base + self.developer = developer + } + + public static func base(_ text: String) -> Self { + .init(base: text) + } + + public static func developer(_ text: String) -> Self { + .init(developer: text) + } +} + +public enum CodexSandbox: String, Codable, Equatable, Sendable { + case readOnly + case workspaceWrite + case fullAccess + + package var threadSandboxValue: String { + switch self { + case .readOnly: + "read-only" + case .workspaceWrite: + "workspace-write" + case .fullAccess: + "danger-full-access" + } + } + + package var turnSandboxPolicy: AppServerAPI.Turn.SandboxPolicy { + switch self { + case .readOnly: + .readOnly(networkAccess: false) + case .workspaceWrite: + .workspaceWrite( + writableRoots: [], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ) + case .fullAccess: + .dangerFullAccess + } + } +} + +public enum CodexApprovalMode: String, Codable, Equatable, Sendable { + case autoReview + case denyAll + + package var approvalPolicy: String { + switch self { + case .autoReview: + "on-request" + case .denyAll: + "never" + } + } + + package var approvalsReviewer: String? { + switch self { + case .autoReview: + "auto_review" + case .denyAll: + nil + } + } +} + +public struct CodexThread: Identifiable, Sendable { + public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + } + + public struct Options: Equatable, Sendable { + public var model: String? + public var modelProvider: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var serviceTier: String? + public var ephemeral: Bool? + + public init( + model: String? = nil, + modelProvider: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + serviceTier: String? = nil, + ephemeral: Bool? = nil + ) { + self.model = model + self.modelProvider = modelProvider + self.approvalMode = approvalMode + self.sandbox = sandbox + self.serviceTier = serviceTier + self.ephemeral = ephemeral + } + } + + public typealias ResumeOptions = Options + + public let id: ID + public let workspace: URL? + public let model: String? + + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + + package init( + id: ID, + workspace: URL? = nil, + model: String? = nil, + client: AppServerClient, + router: CodexAppServerNotificationRouter + ) { + self.id = id + self.workspace = workspace + self.model = model + self.client = client + self.router = router + } +} + +public struct CodexTurn: Identifiable, Sendable { + public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + } + + public struct Options: Equatable, Sendable { + public var model: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var cwd: URL? + public var effort: String? + public var serviceTier: String? + public var summary: String? + + public init( + model: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + cwd: URL? = nil, + effort: String? = nil, + serviceTier: String? = nil, + summary: String? = nil + ) { + self.model = model + self.approvalMode = approvalMode + self.sandbox = sandbox + self.cwd = cwd + self.effort = effort + self.serviceTier = serviceTier + self.summary = summary + } + } + + public let id: ID + public let threadID: CodexThread.ID + + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + + package init( + id: ID, + threadID: CodexThread.ID, + client: AppServerClient, + router: CodexAppServerNotificationRouter + ) { + self.id = id + self.threadID = threadID + self.client = client + self.router = router + } +} + +public struct CodexThreadSnapshot: Identifiable, Equatable, Sendable { + public var id: CodexThread.ID + public var workspace: URL? + public var name: String? + public var preview: String? + public var turns: [CodexTurnSnapshot] + + public init( + id: CodexThread.ID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + turns: [CodexTurnSnapshot] = [] + ) { + self.id = id + self.workspace = workspace + self.name = name + self.preview = preview + self.turns = turns + } +} + +public struct CodexTurnSnapshot: Identifiable, Equatable, Sendable { + public var id: CodexTurn.ID + public var status: CodexTurnStatus? + public var errorMessage: String? + + public init( + id: CodexTurn.ID, + status: CodexTurnStatus? = nil, + errorMessage: String? = nil + ) { + self.id = id + self.status = status + self.errorMessage = errorMessage + } +} + +public struct CodexThreadQuery: Equatable, Sendable { + public var archived: Bool? + public var cursor: String? + public var workspace: URL? + public var limit: Int? + public var searchTerm: String? + + public init( + archived: Bool? = nil, + cursor: String? = nil, + workspace: URL? = nil, + limit: Int? = nil, + searchTerm: String? = nil + ) { + self.archived = archived + self.cursor = cursor + self.workspace = workspace + self.limit = limit + self.searchTerm = searchTerm + } +} + +public struct CodexThreadPage: Equatable, Sendable { + public var threads: [CodexThreadSnapshot] + public var nextCursor: String? + public var backwardsCursor: String? + + public init( + threads: [CodexThreadSnapshot], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.threads = threads + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } +} + +public struct CodexTranscript: Equatable, Sendable { + public var items: [CodexThreadItem] + + public init(items: [CodexThreadItem] = []) { + self.items = items + } + + public var messages: [CodexMessage] { + items.compactMap(\.message) + } + + public var finalAnswer: String? { + var fallback: String? + for message in messages.reversed() where message.role == .assistant { + if message.phase == .finalAnswer { + return message.text + } + if message.phase == nil, fallback == nil { + fallback = message.text + } + } + return fallback + } +} + +public struct CodexThreadItem: Identifiable, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case userMessage + case agentMessage + case plan + case reasoning + case commandExecution + case fileChange + case mcpToolCall + case dynamicToolCall + case collabAgentToolCall + case subAgentActivity + case webSearch + case imageView + case sleep + case imageGeneration + case contextCompaction + case diagnostic + case error + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "userMessage": + self = .userMessage + case "agentMessage": + self = .agentMessage + case "plan": + self = .plan + case "reasoning": + self = .reasoning + case "commandExecution": + self = .commandExecution + case "fileChange": + self = .fileChange + case "mcpToolCall": + self = .mcpToolCall + case "dynamicToolCall": + self = .dynamicToolCall + case "collabAgentToolCall": + self = .collabAgentToolCall + case "subAgentActivity": + self = .subAgentActivity + case "webSearch": + self = .webSearch + case "imageView": + self = .imageView + case "sleep": + self = .sleep + case "imageGeneration": + self = .imageGeneration + case "contextCompaction": + self = .contextCompaction + case "diagnostic": + self = .diagnostic + case "error": + self = .error + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .userMessage: + "userMessage" + case .agentMessage: + "agentMessage" + case .plan: + "plan" + case .reasoning: + "reasoning" + case .commandExecution: + "commandExecution" + case .fileChange: + "fileChange" + case .mcpToolCall: + "mcpToolCall" + case .dynamicToolCall: + "dynamicToolCall" + case .collabAgentToolCall: + "collabAgentToolCall" + case .subAgentActivity: + "subAgentActivity" + case .webSearch: + "webSearch" + case .imageView: + "imageView" + case .sleep: + "sleep" + case .imageGeneration: + "imageGeneration" + case .contextCompaction: + "contextCompaction" + case .diagnostic: + "diagnostic" + case .error: + "error" + case .unknown(let rawValue): + rawValue + } + } + } + + public enum Content: Equatable, Sendable { + case message(CodexMessage) + case plan(String) + case reasoning(String) + case command(CodexCommand) + case fileChange(CodexFileChange) + case toolCall(CodexToolCall) + case contextCompaction(String?) + case diagnostic(String) + case log(String) + case unknown(CodexRawItem) + } + + public var id: String + public var kind: Kind + public var content: Content + + public init( + id: String, + kind: Kind, + content: Content + ) { + self.id = id + self.kind = kind + self.content = content + } + + public var text: String? { + switch content { + case .message(let message): + message.text + case .plan(let text), .reasoning(let text), .diagnostic(let text), .log(let text): + text + case .command(let command): + command.output ?? command.command + case .fileChange(let fileChange): + fileChange.output ?? fileChange.path + case .toolCall(let toolCall): + toolCall.result ?? toolCall.error ?? toolCall.name + case .contextCompaction(let text): + text + case .unknown(let raw): + raw.text + } + } + + public var message: CodexMessage? { + if case .message(let message) = content { + return message + } + return nil + } +} + +public struct CodexMessage: Identifiable, Equatable, Sendable { + public enum Role: Equatable, Sendable { + case user + case assistant + case system + case tool + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "user": + self = .user + case "assistant", "agent": + self = .assistant + case "system": + self = .system + case "tool": + self = .tool + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .user: + "user" + case .assistant: + "assistant" + case .system: + "system" + case .tool: + "tool" + case .unknown(let rawValue): + rawValue + } + } + } + + public var id: String + public var role: Role + public var phase: CodexMessagePhase? + public var text: String + + public init( + id: String, + role: Role, + phase: CodexMessagePhase? = nil, + text: String + ) { + self.id = id + self.role = role + self.phase = phase + self.text = text + } +} + +public enum CodexMessagePhase: Equatable, Sendable { + case commentary + case finalAnswer + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "commentary": + self = .commentary + case "final_answer", "finalAnswer": + self = .finalAnswer + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .commentary: + "commentary" + case .finalAnswer: + "final_answer" + case .unknown(let rawValue): + rawValue + } + } +} + +public struct CodexCommand: Equatable, Sendable { + public var command: String + public var cwd: String? + public var output: String? + public var exitCode: Int? + public var status: CodexTurnStatus? + + public init( + command: String, + cwd: String? = nil, + output: String? = nil, + exitCode: Int? = nil, + status: CodexTurnStatus? = nil + ) { + self.command = command + self.cwd = cwd + self.output = output + self.exitCode = exitCode + self.status = status + } +} + +public struct CodexFileChange: Equatable, Sendable { + public var path: String? + public var output: String? + public var status: CodexTurnStatus? + + public init(path: String? = nil, output: String? = nil, status: CodexTurnStatus? = nil) { + self.path = path + self.output = output + self.status = status + } +} + +public struct CodexToolCall: Equatable, Sendable { + public var namespace: String? + public var server: String? + public var name: String? + public var arguments: String? + public var result: String? + public var error: String? + public var status: CodexTurnStatus? + + public init( + namespace: String? = nil, + server: String? = nil, + name: String? = nil, + arguments: String? = nil, + result: String? = nil, + error: String? = nil, + status: CodexTurnStatus? = nil + ) { + self.namespace = namespace + self.server = server + self.name = name + self.arguments = arguments + self.result = result + self.error = error + self.status = status + } +} + +public struct CodexRawItem: Equatable, Sendable { + public var rawType: String + public var text: String? + public var payload: Data? + + public init(rawType: String, text: String? = nil, payload: Data? = nil) { + self.rawType = rawType + self.text = text + self.payload = payload + } +} + +public enum CodexTurnStatus: Equatable, Sendable { + case running + case completed + case failed + case interrupted + case cancelled + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "running", "inProgress", "started": + self = .running + case "completed", "succeeded", "success": + self = .completed + case "failed", "failure", "error": + self = .failed + case "interrupted": + self = .interrupted + case "cancelled", "canceled", "aborted": + self = .cancelled + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .running: + "running" + case .completed: + "completed" + case .failed: + "failed" + case .interrupted: + "interrupted" + case .cancelled: + "cancelled" + case .unknown(let rawValue): + rawValue + } + } + + public var isFailure: Bool { + switch self { + case .failed, .interrupted, .cancelled: + true + case .running, .completed, .unknown: + false + } + } +} + +public struct CodexTurnResult: Identifiable, Equatable, Sendable { + public var id: CodexTurn.ID + public var status: CodexTurnStatus? + public var errorMessage: String? + public var finalAnswer: String? + public var transcript: CodexTranscript + public var usage: CodexTokenUsage? + + public init( + id: CodexTurn.ID, + status: CodexTurnStatus? = nil, + errorMessage: String? = nil, + finalAnswer: String? = nil, + transcript: CodexTranscript = .init(), + usage: CodexTokenUsage? = nil + ) { + self.id = id + self.status = status + self.errorMessage = errorMessage + self.finalAnswer = finalAnswer + self.transcript = transcript + self.usage = usage + } +} + +public struct CodexTokenUsage: Equatable, Sendable { + public var inputTokens: Int? + public var outputTokens: Int? + public var totalTokens: Int? + public var cachedInputTokens: Int? + public var reasoningOutputTokens: Int? + public var modelContextWindow: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + totalTokens: Int? = nil, + cachedInputTokens: Int? = nil, + reasoningOutputTokens: Int? = nil, + modelContextWindow: Int? = nil + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.cachedInputTokens = cachedInputTokens + self.reasoningOutputTokens = reasoningOutputTokens + self.modelContextWindow = modelContextWindow + } +} + +public struct CodexMessageDelta: Equatable, Sendable { + public var text: String + public var itemID: String? + public var phase: CodexMessagePhase? + + public init(text: String, itemID: String? = nil, phase: CodexMessagePhase? = nil) { + self.text = text + self.itemID = itemID + self.phase = phase + } +} + +public enum CodexTurnEvent: Equatable, Sendable { + case started(CodexTurn.ID) + case itemStarted(CodexThreadItem) + case itemUpdated(CodexThreadItem) + case itemCompleted(CodexThreadItem) + case messageDelta(CodexMessageDelta) + case tokenUsageUpdated(CodexTokenUsage) + case completed(CodexTurnResult) + case failed(String) + case unknown(CodexRawNotification) +} + +public enum CodexThreadEvent: Equatable, Sendable { + case turnStarted(CodexTurn.ID) + case turnCompleted(CodexTurnResult) + case turnFailed(turnID: CodexTurn.ID?, message: String) + case itemStarted(CodexThreadItem, turnID: CodexTurn.ID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurn.ID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurn.ID?) + case message(CodexMessage, turnID: CodexTurn.ID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurn.ID?) + case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurn.ID?) + case statusChanged(CodexThreadStatus) + case closed + case unknown(CodexRawNotification) +} + +public struct CodexThreadLogEntry: Identifiable, Equatable, Sendable { + public enum Phase: Equatable, Sendable { + case started + case updated + case completed + case delta + } + + public var id: String + public var turnID: CodexTurn.ID? + public var phase: Phase + public var item: CodexThreadItem? + public var messageDelta: CodexMessageDelta? + + public init( + id: String, + turnID: CodexTurn.ID? = nil, + phase: Phase, + item: CodexThreadItem? = nil, + messageDelta: CodexMessageDelta? = nil + ) { + self.id = id + self.turnID = turnID + self.phase = phase + self.item = item + self.messageDelta = messageDelta + } +} + +public enum CodexThreadStatus: Equatable, Sendable { + case running + case closed + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "running", "loaded": + self = .running + case "closed", "notLoaded": + self = .closed + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .running: + "running" + case .closed: + "closed" + case .unknown(let rawValue): + rawValue + } + } +} + +public struct CodexTurnProgress: Equatable, Sendable { + public enum Phase: Equatable, Sendable { + case running + case completed + case failed(CodexAppServerError) + } + + public var phase: Phase + public var transcript: CodexTranscript + public var result: CodexTurnResult? + + public init( + phase: Phase, + transcript: CodexTranscript = .init(), + result: CodexTurnResult? = nil + ) { + self.phase = phase + self.transcript = transcript + self.result = result + } +} + +public struct CodexRawNotification: Equatable, Sendable { + public var method: String + public var params: Data + public var threadID: CodexThread.ID? + public var turnID: CodexTurn.ID? + + public init( + method: String, + params: Data, + threadID: CodexThread.ID? = nil, + turnID: CodexTurn.ID? = nil + ) { + self.method = method + self.params = params + self.threadID = threadID + self.turnID = turnID + } +} + +public struct CodexConfiguration: Equatable, Sendable { + public var model: String? + public var reviewModel: String? + public var reasoningEffort: String? + public var serviceTier: String? + + public init( + model: String? = nil, + reviewModel: String? = nil, + reasoningEffort: String? = nil, + serviceTier: String? = nil + ) { + self.model = model + self.reviewModel = reviewModel + self.reasoningEffort = reasoningEffort + self.serviceTier = serviceTier + } +} + +public struct CodexRateLimits: Equatable, Sendable { + public var planType: String? + public var windows: [CodexRateLimitWindow] + + public init(planType: String? = nil, windows: [CodexRateLimitWindow] = []) { + self.planType = planType + self.windows = windows + } +} + +public struct CodexRateLimitWindow: Equatable, Sendable { + public var windowDurationMinutes: Int + public var usedPercent: Int + public var resetsAt: Date? + + public init(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date? = nil) { + self.windowDurationMinutes = windowDurationMinutes + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +public struct CodexModel: Codable, Identifiable, Equatable, Sendable { + public struct ReasoningOption: Codable, Equatable, Sendable { + public var reasoningEffort: String + public var description: String + + public init(reasoningEffort: String, description: String) { + self.reasoningEffort = reasoningEffort + self.description = description + } + } + + private struct ServiceTier: Decodable { + let id: String + } + + public var id: String + public var model: String + public var displayName: String + public var hidden: Bool + public var supportedReasoningEfforts: [ReasoningOption] + public var defaultReasoningEffort: String? + public var supportedServiceTiers: [String] + public var isDefault: Bool + + private enum CodingKeys: String, CodingKey { + case id + case model + case displayName + case hidden + case supportedReasoningEfforts + case defaultReasoningEffort + case supportedServiceTiers = "additionalSpeedTiers" + case serviceTiers + case isDefault + } + + public init( + id: String, + model: String, + displayName: String, + hidden: Bool = false, + supportedReasoningEfforts: [ReasoningOption] = [], + defaultReasoningEffort: String? = nil, + supportedServiceTiers: [String] = [], + isDefault: Bool = false + ) { + self.id = id + self.model = model + self.displayName = displayName + self.hidden = hidden + self.supportedReasoningEfforts = supportedReasoningEfforts + self.defaultReasoningEffort = defaultReasoningEffort + self.supportedServiceTiers = supportedServiceTiers + self.isDefault = isDefault + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.model = try container.decode(String.self, forKey: .model) + self.displayName = try container.decode(String.self, forKey: .displayName) + self.hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) ?? false + self.supportedReasoningEfforts = + try container.decodeIfPresent( + [ReasoningOption].self, + forKey: .supportedReasoningEfforts + ) ?? [] + self.defaultReasoningEffort = try container.decodeIfPresent( + String.self, forKey: .defaultReasoningEffort) + let additionalSpeedTiers = + try container.decodeIfPresent([String].self, forKey: .supportedServiceTiers) ?? [] + let serviceTierIDs = + try container.decodeIfPresent([ServiceTier].self, forKey: .serviceTiers)?.map(\.id) + ?? [] + self.supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTierIDs)).sorted() + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(model, forKey: .model) + try container.encode(displayName, forKey: .displayName) + try container.encode(hidden, forKey: .hidden) + try container.encode(supportedReasoningEfforts, forKey: .supportedReasoningEfforts) + try container.encodeIfPresent(defaultReasoningEffort, forKey: .defaultReasoningEffort) + try container.encode(supportedServiceTiers, forKey: .supportedServiceTiers) + try container.encode(isDefault, forKey: .isDefault) + } +} + +public struct CodexAccount: Identifiable, Equatable, Sendable { + public enum Kind: String, Codable, Equatable, Sendable { + case chatGPT = "chatgpt" + case apiKey + case amazonBedrock + } + + public var id: String + public var kind: Kind + public var label: String + public var planType: String? + + public init(id: String, kind: Kind, label: String, planType: String? = nil) { + self.id = id + self.kind = kind + self.label = label + self.planType = planType + } +} + +public enum CodexLoginHandle: Equatable, Sendable { + public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + } + + case apiKey + case chatGPT(id: ID, authenticationURL: URL) + case chatGPTDeviceCode(id: ID, verificationURL: URL, userCode: String) + + public var id: ID? { + switch self { + case .apiKey: + nil + case .chatGPT(let id, _), .chatGPTDeviceCode(let id, _, _): + id + } + } +} + +public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { + case transportClosed + case jsonRPC(code: Int, message: String) + case serverBusy(String) + case retryLimitExceeded + case malformedNotification(String) + case turnFailed(String) + + public var errorDescription: String? { + switch self { + case .transportClosed: + "The Codex app-server connection is closed." + case .jsonRPC(_, let message): + message + case .serverBusy(let message): + message + case .retryLimitExceeded: + "The Codex app-server remained busy after all retry attempts." + case .malformedNotification(let message): + message + case .turnFailed(let message): + message + } + } +} diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift new file mode 100644 index 00000000..86617242 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -0,0 +1,214 @@ +import Foundation + +extension CodexThread { + public var events: CodexThreadEventSequence { + .init { + AsyncThrowingStream { continuation in + Task { + let stream = await router.events(for: id) + do { + for try await event in stream { + continuation.yield(event) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + } + + public var messages: CodexThreadMessageSequence { + .init(events: events) + } + + public var transcript: CodexThreadTranscriptSequence { + .init(events: events) + } + + public var logEntries: CodexThreadLogSequence { + .init(events: events) + } + + public func respond( + to prompt: CodexPrompt, + options: CodexTurn.Options = .init() + ) async throws -> CodexTurnResult { + let turn = try await startTurn(prompt, options: options) + return try await turn.result() + } + + public func respond( + to prompt: String, + options: CodexTurn.Options = .init() + ) async throws -> CodexTurnResult { + try await respond(to: CodexPrompt(prompt), options: options) + } + + public func startTurn( + _ prompt: CodexPrompt, + options: CodexTurn.Options = .init() + ) async throws -> CodexTurn { + let response = try await client.send( + AppServerAPI.Turn.Start.Request( + params: .init( + threadID: id.rawValue, + input: prompt.appServerInput, + approvalPolicy: options.approvalMode?.approvalPolicy, + approvalsReviewer: options.approvalMode?.approvalsReviewer, + cwd: options.cwd?.path, + effort: options.effort, + model: options.model, + sandboxPolicy: options.sandbox?.turnSandboxPolicy, + serviceTier: options.serviceTier, + summary: options.summary + ) + )) + return CodexTurn( + id: .init(rawValue: response.turn.id), + threadID: id, + client: client, + router: router + ) + } + + public func startTurn( + _ prompt: String, + options: CodexTurn.Options = .init() + ) async throws -> CodexTurn { + try await startTurn(CodexPrompt(prompt), options: options) + } + + public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot { + let response = try await client.send( + AppServerAPI.Thread.Read.Request( + params: .init(threadID: id.rawValue, includeTurns: includeTurns) + )) + return .init( + id: .init(rawValue: response.thread.id), + workspace: response.thread.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + name: response.thread.name, + preview: response.thread.preview, + turns: (response.thread.turns ?? []).map { + CodexTurnSnapshot( + id: .init(rawValue: $0.id), + status: $0.status.map(CodexTurnStatus.init(rawValue:)), + errorMessage: $0.error?.message + ) + } + ) + } + + public func rename(to name: String) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Name.Set.Request( + params: .init(threadID: id.rawValue, name: name) + )) + } + + public func compact() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Compact.Start.Request( + params: .init(threadID: id.rawValue) + )) + } + + public func archive() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Archive.Request( + params: .init(threadID: id.rawValue) + )) + } + + public func unarchive() async throws -> CodexThreadSnapshot { + let response = try await client.send( + AppServerAPI.Thread.Unarchive.Request( + params: .init(threadID: id.rawValue) + )) + return CodexAppServer.threadSnapshot(from: response.thread) + } + + public func rollback(turnCount: Int = 1) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Rollback.Request( + params: .init(threadID: id.rawValue, numTurns: turnCount) + )) + } + + public func delete() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: id.rawValue) + )) + } +} + +extension CodexTurn { + public var events: CodexTurnEventSequence { + .init { + AsyncThrowingStream { continuation in + Task { + let stream = await router.events(for: id) + do { + for try await event in stream { + continuation.yield(event) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + } + + public var progress: CodexTurnProgressSequence { + .init(events: events) + } + + public func result() async throws -> CodexTurnResult { + try await CodexTurnResultCollector.collect(from: events) + } + + public func steer(with prompt: CodexPrompt) async throws { + let _: AppServerAPI.Turn.Steer.Response = try await client.send( + AppServerAPI.Turn.Steer.Request( + params: .init( + threadID: threadID.rawValue, + expectedTurnID: id.rawValue, + input: prompt.appServerInput + ) + )) + } + + public func steer(with prompt: String) async throws { + try await steer(with: CodexPrompt(prompt)) + } + + public func interrupt() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Turn.Interrupt.Request( + params: .init(threadID: threadID.rawValue, turnID: id.rawValue) + )) + } +} + +extension CodexPrompt { + package var appServerInput: [AppServerAPI.UserInput] { + parts.map { part in + switch part { + case .text(let text): + .text(text) + case .imageURL(let url): + .image(url: url.absoluteString) + case .localImage(let url): + .localImage(path: url.path) + case .skill(let name, let path): + .skill(name: name, path: path.path) + case .mention(let name, let path): + .mention(name: name, path: path.path) + } + } + } +} diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift new file mode 100644 index 00000000..a8a17d59 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -0,0 +1,343 @@ +import Foundation + +public struct CodexThreadEventSequence: AsyncSequence, Sendable { + public typealias Element = CodexThreadEvent + + private let makeStream: @Sendable () -> AsyncThrowingStream + + package init( + makeStream: @escaping @Sendable () -> AsyncThrowingStream + ) { + self.makeStream = makeStream + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + makeStream().makeAsyncIterator() + } +} + +public struct CodexThreadMessageSequence: AsyncSequence, Sendable { + public typealias Element = CodexMessage + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexMessage? { + while let event = try await events.next() { + switch event { + case .message(let message, _): + return message + case .itemStarted(let item, _), .itemUpdated(let item, _), + .itemCompleted(let item, _): + if let message = item.message { + return message + } + case .messageDelta(let delta, _): + return .init( + id: delta.itemID ?? UUID().uuidString, + role: .assistant, + phase: delta.phase, + text: delta.text + ) + case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, + .statusChanged, .closed, .unknown: + continue + } + } + return nil + } + } +} + +public struct CodexThreadTranscriptSequence: AsyncSequence, Sendable { + public typealias Element = CodexTranscript + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + private var accumulator = CodexTranscriptAccumulator() + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexTranscript? { + while let event = try await events.next() { + if accumulator.apply(event) { + return accumulator.transcript + } + } + return nil + } + } +} + +public struct CodexThreadLogSequence: AsyncSequence, Sendable { + public typealias Element = CodexThreadLogEntry + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexThreadLogEntry? { + while let event = try await events.next() { + switch event { + case .itemStarted(let item, let turnID): + return .init(id: item.id, turnID: turnID, phase: .started, item: item) + case .itemUpdated(let item, let turnID): + return .init(id: item.id, turnID: turnID, phase: .updated, item: item) + case .itemCompleted(let item, let turnID): + return .init(id: item.id, turnID: turnID, phase: .completed, item: item) + case .message(let message, let turnID): + let item = CodexThreadItem( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + return .init(id: message.id, turnID: turnID, phase: .completed, item: item) + case .messageDelta(let delta, let turnID): + return .init( + id: delta.itemID ?? UUID().uuidString, + turnID: turnID, + phase: .delta, + messageDelta: delta + ) + case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, + .statusChanged, .closed, .unknown: + continue + } + } + return nil + } + } +} + +public struct CodexTurnEventSequence: AsyncSequence, Sendable { + public typealias Element = CodexTurnEvent + + private let makeStream: @Sendable () -> AsyncThrowingStream + + package init(makeStream: @escaping @Sendable () -> AsyncThrowingStream) { + self.makeStream = makeStream + } + + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + makeStream().makeAsyncIterator() + } +} + +public struct CodexTurnProgressSequence: AsyncSequence, Sendable { + public typealias Element = CodexTurnProgress + + private let events: CodexTurnEventSequence + + package init(events: CodexTurnEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + private var accumulator = CodexTranscriptAccumulator() + private var usage: CodexTokenUsage? + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexTurnProgress? { + guard let event = try await events.next() else { + return nil + } + switch event { + case .started, .unknown: + return .init(phase: .running, transcript: accumulator.transcript) + case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta: + _ = accumulator.apply(event) + return .init(phase: .running, transcript: accumulator.transcript) + case .tokenUsageUpdated(let newUsage): + usage = newUsage + return .init(phase: .running, transcript: accumulator.transcript) + case .completed(var result): + result = finalizedResult(result) + if let errorMessage = result.errorMessage { + return .init( + phase: .failed(.turnFailed(errorMessage)), + transcript: result.transcript, + result: result + ) + } + if result.status?.isFailure == true { + return .init( + phase: .failed(.turnFailed(result.status?.rawValue ?? "Turn failed.")), + transcript: result.transcript, + result: result + ) + } + return .init(phase: .completed, transcript: result.transcript, result: result) + case .failed(let message): + return .init( + phase: .failed(.turnFailed(message)), + transcript: accumulator.transcript + ) + } + } + + private func finalizedResult(_ result: CodexTurnResult) -> CodexTurnResult { + var result = result + if result.finalAnswer == nil { + result.finalAnswer = accumulator.transcript.finalAnswer + } + if result.transcript.items.isEmpty { + result.transcript = accumulator.transcript + } + if result.usage == nil { + result.usage = usage + } + return result + } + } +} + +package struct CodexTurnResultCollector { + static func collect(from events: CodexTurnEventSequence) async throws -> CodexTurnResult { + var accumulator = CodexTranscriptAccumulator() + var usage: CodexTokenUsage? + for try await event in events { + switch event { + case .started, .unknown: + continue + case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta: + _ = accumulator.apply(event) + case .tokenUsageUpdated(let newUsage): + usage = newUsage + case .completed(var result): + if result.finalAnswer == nil { + result.finalAnswer = accumulator.transcript.finalAnswer + } + if result.transcript.items.isEmpty { + result.transcript = accumulator.transcript + } + if result.usage == nil { + result.usage = usage + } + if let message = result.errorMessage { + throw CodexAppServerError.turnFailed(message) + } + if result.status?.isFailure == true { + throw CodexAppServerError.turnFailed(result.status?.rawValue ?? "Turn failed.") + } + return result + case .failed(let message): + throw CodexAppServerError.turnFailed(message) + } + } + throw CodexAppServerError.transportClosed + } +} + +private struct CodexTranscriptAccumulator { + private var items: [CodexThreadItem] = [] + private var itemIndexesByID: [String: Int] = [:] + private var messageDeltaTextByItemID: [String: String] = [:] + + var transcript: CodexTranscript { + .init(items: items) + } + + mutating func apply(_ event: CodexTurnEvent) -> Bool { + switch event { + case .itemStarted(let item), .itemUpdated(let item), .itemCompleted(let item): + upsert(item) + return true + case .messageDelta(let delta): + append(delta) + return true + case .started, .tokenUsageUpdated, .completed, .failed, .unknown: + return false + } + } + + mutating func apply(_ event: CodexThreadEvent) -> Bool { + switch event { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + upsert(item) + return true + case .message(let message, _): + upsert( + .init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + )) + return true + case .messageDelta(let delta, _): + append(delta) + return true + case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, + .closed, .unknown: + return false + } + } + + private mutating func upsert(_ item: CodexThreadItem) { + if let index = itemIndexesByID[item.id] { + items[index] = item + } else { + itemIndexesByID[item.id] = items.count + items.append(item) + } + } + + private mutating func append(_ delta: CodexMessageDelta) { + let itemID = delta.itemID ?? "agent-message-delta" + let text = (messageDeltaTextByItemID[itemID] ?? "") + delta.text + messageDeltaTextByItemID[itemID] = text + let message = CodexMessage( + id: itemID, + role: .assistant, + phase: delta.phase, + text: text + ) + upsert(.init(id: itemID, kind: .agentMessage, content: .message(message))) + } +} diff --git a/Sources/CodexReviewAppServer/JSONRPC.swift b/Sources/CodexAppServerKit/JSONRPC.swift similarity index 100% rename from Sources/CodexReviewAppServer/JSONRPC.swift rename to Sources/CodexAppServerKit/JSONRPC.swift diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md new file mode 100644 index 00000000..c5788513 --- /dev/null +++ b/Sources/CodexAppServerKit/README.md @@ -0,0 +1,201 @@ +# CodexAppServerKit + +CodexAppServerKit is a Swift library for working with a local +`codex app-server` process from macOS apps and tools. + +The package hides JSON-RPC framing and app-server DTOs behind Swift domain +types. Callers work with an app-server container, threads, turns, prompts, +messages, transcript items, log entries, models, accounts, and login handles. + +## Container + +Create one `CodexAppServer` for the lifetime of the app-server connection: + +```swift +import CodexAppServerKit + +let appServer = try await CodexAppServer() +let thread = try await appServer.startThread(in: workspaceURL) + +let result = try await thread.respond(to: "Review this workspace.") +print(result.finalAnswer ?? "") + +await appServer.close() +``` + +`CodexAppServer()` uses the local `codex` executable over stdio. It performs +`initialize` / `initialized`, manages the process transport, routes +notifications, retries app-server overload responses, and preserves schema-new +notifications as unknown domain events. + +## Threads + +`CodexThread` is the long-lived handle for a Codex conversation in a workspace. +It supports high-level commands: + +```swift +let thread = try await appServer.startThread( + in: workspaceURL, + instructions: .init(developer: "Keep responses concise."), + options: .init(model: "gpt-5", approvalMode: .autoReview) +) + +let turn = try await thread.startTurn("Run the checks.") +try await turn.steer(with: "Focus on failing tests.") +let result = try await turn.result() +``` + +Thread management is exposed without requiring raw request DTOs: + +```swift +let snapshot = try await thread.read(includeTurns: true) +try await thread.rename(to: "Release review") +try await thread.compact() +try await thread.archive() +let restored = try await thread.unarchive() +try await thread.rollback(turnCount: 1) +try await thread.delete() +``` + +## Streaming + +Threads expose async sequences for chat, transcript, and log-style consumers. +This is the API surface intended for higher-level products that need to render +Codex output continuously. + +```swift +for try await message in thread.messages { + print(message.text) +} +``` + +```swift +for try await transcript in thread.transcript { + render(transcript.items) +} +``` + +```swift +for try await entry in thread.logEntries { + switch entry.item?.content { + case .message(let message): + renderMessage(message) + case .command(let command): + renderCommand(command.command, output: command.output) + case .toolCall(let tool): + renderToolCall(tool.name, result: tool.result, error: tool.error) + case .fileChange(let fileChange): + renderFileChange(fileChange.path, output: fileChange.output) + default: + break + } +} +``` + +`CodexThread.events` is the full thread event stream. It includes turn +lifecycle, item lifecycle, message deltas, token usage, thread status, and +unknown notifications: + +```swift +for try await event in thread.events { + switch event { + case .tokenUsageUpdated(let usage, _): + updateUsage(usage.totalTokens) + case .unknown(let raw): + logUnknownNotification(raw.method) + default: + break + } +} +``` + +This lets CodexReviewKit and similar clients build review logs from +CodexAppServerKit domain events instead of parsing JSON-RPC notifications or +string logs directly. + +## Turns + +`CodexTurn` is a handle for one in-flight turn. It has turn-scoped streams and +result aggregation: + +```swift +let turn = try await thread.startTurn("Summarize the changes.") + +for try await progress in turn.progress { + render(progress.transcript) +} + +let result = try await turn.result() +``` + +`CodexThread.respond(to:)` is the convenience form for starting a turn, +consuming its events, and returning the final `CodexTurnResult`. + +Final answers are derived from assistant messages whose phase is +`.finalAnswer`. If no final-answer phase is present, the last normal assistant +message is used as a fallback. + +## Prompts + +`CodexPrompt` accepts text and structured parts: + +```swift +let prompt: CodexPrompt = .init(parts: [ + .text("Explain this screenshot."), + .localImage(screenshotURL), + .mention(name: "repo", path: workspaceURL), +]) +``` + +String literals are supported for simple prompts: + +```swift +try await thread.respond(to: "What changed?") +``` + +## Models, Account, And Login + +```swift +let models = try await appServer.models() +let account = try await appServer.account(refreshToken: true) +let configuration = try await appServer.configuration() +let rateLimits = try await appServer.rateLimits() +``` + +Login flows return typed handles: + +```swift +let handle = try await appServer.loginChatGPT(callbackURLScheme: "myapp") +try await appServer.completeLogin(handle, callbackURL: callbackURL) +``` + +API key and device-code login are also available: + +```swift +try await appServer.loginAPIKey(apiKey) +let deviceCode = try await appServer.loginChatGPTDeviceCode() +``` + +## Boundary + +Public users should not need to import or call JSON-RPC or `AppServerAPI` +request DTOs. Those remain package-level implementation details. + +The public boundary is: + +- `CodexAppServer` +- `CodexThread` +- `CodexTurn` +- `CodexPrompt` +- `CodexTranscript` +- `CodexThreadItem` +- `CodexThreadEvent` +- `CodexTurnEvent` +- `CodexTurnProgress` +- `CodexTurnResult` +- `CodexModel` +- `CodexAccount` +- `CodexLoginHandle` + +Unknown notifications and unknown item kinds are preserved so clients can keep +running when app-server adds new schema. diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 4e2d823e..d8173f9b 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1,4 +1,5 @@ import Foundation +import CodexAppServerKit import CodexReview import CodexReviewAppServerWire import CodexReviewDomain @@ -770,7 +771,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { let response = try await client.send(AppServerAPI.Model.List.Request( params: .init(cursor: cursor, includeHidden: true) )) - models.append(contentsOf: response.data) + models.append(contentsOf: response.data.map(\.reviewModelCatalogItem)) cursor = response.nextCursor?.nilIfEmpty } while cursor != nil return models @@ -1794,13 +1795,40 @@ private extension AppServerCodexReviewBackend { private extension AppServerAPI.Account.Snapshot { 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 CodexModel { + var reviewModelCatalogItem: CodexReviewSettings.ModelCatalogItem { + let reasoningOptions = supportedReasoningEfforts.compactMap { option in + CodexReviewSettings.ReasoningEffort(rawValue: option.reasoningEffort).map { + CodexReviewSettings.ReasoningOption(reasoningEffort: $0, description: option.description) + } + } + let defaultReasoningEffort = defaultReasoningEffort + .flatMap(CodexReviewSettings.ReasoningEffort.init(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 ) } } diff --git a/Sources/CodexReviewAppServer/AppServerRequests.swift b/Sources/CodexReviewAppServer/AppServerRequests.swift index 281e3550..f80a9af2 100644 --- a/Sources/CodexReviewAppServer/AppServerRequests.swift +++ b/Sources/CodexReviewAppServer/AppServerRequests.swift @@ -1,1245 +1,86 @@ -import Foundation +import CodexAppServerKit import CodexReview +import Foundation -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 {} - } - } +extension AppServerAPI { 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 - } -} } +extension AppServerAPI.Review.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var target: CodexReviewAPI.Target + package var delivery: AppServerAPI.Review.Start.Delivery -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 + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case target + case delivery } - 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 init( + threadID: String, + target: CodexReviewAPI.Target, + delivery: AppServerAPI.Review.Start.Delivery = .inline + ) { + self.threadID = threadID + self.target = target + self.delivery = delivery } } } -} - - -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" +extension AppServerAPI.Review.Start { + package enum Delivery: String, Codable, Equatable, Sendable { + case inline + case detached } - - package init(threadID: String) { - self.threadID = threadID - } -} } +extension AppServerAPI.Review.Start { + package struct Response: Codable, Equatable, Sendable { + package var turnID: String + package var reviewThreadID: String? -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() + enum CodingKeys: String, CodingKey { + case turn + case reviewThreadID = "reviewThreadId" } - } -} -} - - -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 + package init(turnID: String, reviewThreadID: String? = nil) { + self.turnID = turnID + self.reviewThreadID = reviewThreadID } - if let codexSnapshot = rateLimitsByLimitID?.first(where: { limitID, snapshot in - Self.isCodexRateLimit(limitID) || Self.isCodexRateLimit(snapshot.limitID) - })?.value { - return codexSnapshot - } - if Self.isCodexRateLimit(rateLimits.limitID) { - return rateLimits - } - return nil - } - private static func rateLimitWindows( - from snapshot: AppServerAPI.Account.RateLimits.Snapshot? - ) -> [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] { - [snapshot?.primary, snapshot?.secondary].compactMap { window in - guard let window, - let duration = window.windowDurationMins - else { - return nil - } - return ( - windowDurationMinutes: duration, - usedPercent: window.usedPercent, - resetsAt: window.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } - ) + package 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) } - } - - 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 - ) + 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) } } - - 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 - } -} } +extension AppServerAPI.Review.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Review.Start.Response -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 static let method = "review/start" + package var params: AppServerAPI.Review.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) } - } - 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 init(params: AppServerAPI.Review.Start.Params) { + self.params = params } } } -} - - -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 index 1e8f60a9..31b976d1 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -1,4 +1,5 @@ import Foundation +import CodexAppServerKit import CodexReview package struct AppServerReviewInterruption: Equatable, Sendable { diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift index 0b2ab211..a922adb9 100644 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ b/Sources/CodexReviewHost/CodexReviewHost.swift @@ -1,8 +1,11 @@ import Foundation +import CodexAppServerKit import CodexReview import CodexReviewAppServer import CodexReviewMCPServer +private typealias ReviewCodexAccount = CodexReview.CodexAccount + @MainActor package final class CodexReviewHost { package let store: CodexReviewStore @@ -278,13 +281,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 -> ReviewCodexAccount? in let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) - let accountKey = CodexAccount.normalizedEmail(account.id.rawValue) + let accountKey = ReviewCodexAccount.normalizedEmail(account.id.rawValue) guard label.isEmpty == false, accountKey.isEmpty == false else { return nil } - return CodexAccount( + return ReviewCodexAccount( accountKey: accountKey, email: label, planType: account.planType, @@ -293,7 +296,7 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend { ) } let activeAccountKey = snapshot.activeAccountID - .map { CodexAccount.normalizedEmail($0.rawValue) } + .map { ReviewCodexAccount.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..86ded327 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,10 +1,13 @@ import AppKit import Foundation import OSLog +import CodexAppServerKit import CodexReview import CodexReviewAppServer import CodexReviewMCPServer +private typealias ReviewCodexAccount = CodexReview.CodexAccount + private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void @@ -1079,7 +1082,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, authSourceCodexHomeURL: URL? = nil - ) -> CodexAccount? { + ) -> ReviewCodexAccount? { guard let activeAccountID = snapshot.activeAccountID?.rawValue, let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), let account = Self.monitorAccount(from: backendAccount) @@ -1093,7 +1096,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return nil } var persistedAccounts = auth.persistedAccounts - let persistedAccount: CodexAccount + let persistedAccount: ReviewCodexAccount if let index = persistedAccounts.firstIndex(where: { $0.accountKey == account.accountKey }) { persistedAccounts[index].updateEmail(account.email) persistedAccounts[index].updateKind(account.kind, capabilities: account.capabilities) @@ -1405,7 +1408,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: ReviewCodexAccount, auth: CodexReviewAuthModel) async { guard account.capabilities.supportsRateLimitRefresh else { return } @@ -1422,7 +1425,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func refreshSavedAccountRateLimits(for account: CodexAccount) async { + private func refreshSavedAccountRateLimits(for account: ReviewCodexAccount) async { let temporaryCodexHomeURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-rate-limits-\(UUID().uuidString)", isDirectory: true) do { @@ -1467,7 +1470,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func refreshRateLimits( - for account: CodexAccount, + for account: ReviewCodexAccount, using backend: AppServerCodexReviewBackend?, source: String ) async -> Bool { @@ -1503,14 +1506,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func validateRateLimitBackendAccount( - _ account: CodexAccount, + _ account: ReviewCodexAccount, 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 = ReviewCodexAccount.normalizedEmail(activeAccountID) guard actualAccountKey == account.accountKey else { let actualEmail = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID })?.label ?? activeAccountID @@ -1521,10 +1524,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func recordRateLimitRefreshFailure( _ error: any Error, - account: CodexAccount + account: ReviewCodexAccount ) { let message = error.localizedDescription - if CodexAccount.requiresReauthentication(errorMessage: message) { + if ReviewCodexAccount.requiresReauthentication(errorMessage: message) { account.markRateLimitReauthenticationRequired( fetchedAt: Date(), error: message @@ -1575,7 +1578,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func applyRateLimits( windows: [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)], planType: String?, - to account: CodexAccount + to account: ReviewCodexAccount ) { account.updateRateLimits(windows) if let planType { @@ -1586,7 +1589,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func persistRefreshedSharedAuth( from sourceCodexHomeURL: URL?, - for account: CodexAccount + for account: ReviewCodexAccount ) { guard let sourceCodexHomeURL else { return @@ -1635,13 +1638,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) -> ReviewCodexAccount? { let label = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines) - let accountKey = CodexAccount.normalizedEmail(snapshot.id.rawValue) + let accountKey = ReviewCodexAccount.normalizedEmail(snapshot.id.rawValue) guard label.isEmpty == false, accountKey.isEmpty == false else { return nil } - return CodexAccount( + return ReviewCodexAccount( accountKey: accountKey, email: label, planType: snapshot.planType, @@ -1683,7 +1686,7 @@ private enum LoginActivation: Equatable, Sendable { func resolvedActiveAccountKey( authenticatedAccountKey: String, - persistedAccounts: [CodexAccount] + persistedAccounts: [ReviewCodexAccount] ) -> String? { switch self { case .activateAuthenticatedAccount: @@ -1810,9 +1813,9 @@ private enum CodexReviewAccountRegistry { static func legacyDefault(accountKey: String?, email: String) -> Self { let normalizedAccountKey = accountKey - .map(CodexAccount.normalizedEmail) + .map(ReviewCodexAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } - switch normalizedAccountKey ?? CodexAccount.normalizedEmail(email) { + switch normalizedAccountKey ?? ReviewCodexAccount.normalizedEmail(email) { case "api-key": return .apiKey case "amazon-bedrock": @@ -1833,11 +1836,11 @@ private enum CodexReviewAccountRegistry { } } - static func load(codexHomeURL: URL) -> (accounts: [CodexAccount], activeAccountKey: String?) { + static func load(codexHomeURL: URL) -> (accounts: [ReviewCodexAccount], activeAccountKey: String?) { let registry = loadRegistry(codexHomeURL: codexHomeURL) let accounts = registry.accounts.compactMap(makeAccount(from:)) let activeAccountKey = registry.activeAccountKey - .map(CodexAccount.normalizedEmail) + .map(ReviewCodexAccount.normalizedEmail) .flatMap { activeAccountKey in accounts.contains(where: { $0.accountKey == activeAccountKey }) ? activeAccountKey : nil } @@ -1846,7 +1849,7 @@ private enum CodexReviewAccountRegistry { } static func saveAccounts( - _ accounts: [CodexAccount], + _ accounts: [ReviewCodexAccount], activeAccountKey: String?, codexHomeURL: URL ) throws { @@ -1855,7 +1858,7 @@ private enum CodexReviewAccountRegistry { normalizedAccountKey(from: entry).map { ($0, entry) } }) let normalizedActiveAccountKey = activeAccountKey - .map(CodexAccount.normalizedEmail) + .map(ReviewCodexAccount.normalizedEmail) .flatMap { accountKey in accounts.contains(where: { $0.accountKey == accountKey }) ? accountKey : nil } @@ -1896,10 +1899,10 @@ private enum CodexReviewAccountRegistry { static func activateAccount( _ accountKey: String, - accounts: [CodexAccount], + accounts: [ReviewCodexAccount], codexHomeURL: URL ) throws { - let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) let savedAuthURL = savedAccountAuthURL( accountKey: normalizedAccountKey, codexHomeURL: codexHomeURL @@ -1916,7 +1919,7 @@ private enum CodexReviewAccountRegistry { } static func updateCachedRateLimits( - from account: CodexAccount, + from account: ReviewCodexAccount, codexHomeURL: URL ) throws { var registry = loadRegistry(codexHomeURL: codexHomeURL) @@ -1939,7 +1942,7 @@ private enum CodexReviewAccountRegistry { } static func saveSharedAuth( - for account: CodexAccount, + for account: ReviewCodexAccount, codexHomeURL: URL ) throws { try saveSharedAuth( @@ -1951,7 +1954,7 @@ private enum CodexReviewAccountRegistry { static func saveSharedAuth( from sourceCodexHomeURL: URL, - for account: CodexAccount, + for account: ReviewCodexAccount, codexHomeURL: URL ) throws { let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL) @@ -1988,7 +1991,7 @@ private enum CodexReviewAccountRegistry { from sourceCodexHomeURL: URL, to destinationCodexHomeURL: URL ) throws -> Bool { - let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) let sourceURL = savedAccountAuthURL( accountKey: normalizedAccountKey, codexHomeURL: sourceCodexHomeURL @@ -2003,17 +2006,17 @@ private enum CodexReviewAccountRegistry { return true } - private static func makeAccount(from entry: Entry) -> CodexAccount? { + private static func makeAccount(from entry: Entry) -> ReviewCodexAccount? { let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) guard email.isEmpty == false else { return nil } - let normalizedEmail = CodexAccount.normalizedEmail(email) + let normalizedEmail = ReviewCodexAccount.normalizedEmail(email) let accountKey = entry.accountKey - .map(CodexAccount.normalizedEmail) + .map(ReviewCodexAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } ?? normalizedEmail - let account = CodexAccount( + let account = ReviewCodexAccount( accountKey: accountKey, email: email, planType: entry.planType, @@ -2079,9 +2082,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 = ReviewCodexAccount.normalizedEmail(email) return entry.accountKey - .map(CodexAccount.normalizedEmail) + .map(ReviewCodexAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } ?? (normalizedEmail.isEmpty ? nil : normalizedEmail) } @@ -2110,7 +2113,7 @@ private enum CodexReviewAccountRegistry { } private static func pathComponent(forAccountKey accountKey: String) -> String { - let normalizedAccountKey = CodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) switch normalizedAccountKey { case ".": return "%2E" diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 2cc7413a..f679fee9 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -1,4 +1,5 @@ import Foundation +import CodexAppServerKit import CodexReview import CodexReviewAppServer @@ -720,12 +721,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 -> CodexReview.CodexAccount? in let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) guard label.isEmpty == false else { return nil } - return CodexAccount(email: label) + return CodexReview.CodexAccount(email: label) } auth.applyPersistedAccountStates( accounts.map(savedAccountPayload(from:)), diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index e4a7d9fc..677e5d99 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -19,7 +19,7 @@ struct ArchitectureFenceTests { "ReviewServiceRuntimeTestSupport", "ReviewAppServerAdapter", ] - ), + ) ]) Self.expectNoViolations(violations) @@ -59,16 +59,23 @@ struct ArchitectureFenceTests { 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() { + for (offset, line) in text.split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + { let lineText = String(line) - guard forbiddenAssignments.contains(where: { - let range = NSRange(lineText.startIndex.. String? { let trimmed = statement.trimmingCharacters(in: .whitespaces) guard trimmed.isEmpty == false, - trimmed.hasPrefix("//") == false + trimmed.hasPrefix("//") == false else { return nil } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift new file mode 100644 index 00000000..b8cf1591 --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -0,0 +1,450 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit + +@Suite("CodexAppServerKit") +struct CodexAppServerKitTests { + @Test func initializeSendsHandshakeAndInitializedNotification() 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(clientName: "TestClient", clientVersion: "1") + + #expect(response.codexHome == "/tmp/codex") + #expect(await transport.recordedRequests().map(\.method) == ["initialize"]) + #expect(await transport.recordedNotifications().map(\.method) == ["initialized"]) + let params = try #require(await transport.recordedRequests().first?.params) + let decoded = try JSONDecoder().decode(AppServerAPI.Initialize.Params.self, from: params) + #expect(decoded.clientInfo.name == "TestClient") + #expect(decoded.clientInfo.version == "1") + } + + @Test func appServerStartThreadSerializesDomainOptions() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), + for: "thread/start" + ) + let client = AppServerClient(transport: transport) + let server = CodexAppServer( + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let thread = try await server.startThread( + in: workspace, + instructions: .init(base: "Base", developer: "Developer"), + options: .init(model: "gpt-5", sandbox: .workspaceWrite, ephemeral: true) + ) + + #expect(thread.id == "thread-1") + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "thread/start") + let params = try JSONDecoder().decode( + AppServerAPI.Thread.Start.Params.self, from: request.params) + #expect(params.cwd == workspace.path) + #expect(params.model == "gpt-5") + #expect(params.ephemeral == true) + #expect(params.baseInstructions == "Base") + #expect(params.developerInstructions == "Developer") + #expect(params.approvalPolicy == "on-request") + #expect(params.approvalsReviewer == "auto_review") + #expect(params.sandbox == "workspace-write") + } + + @Test func promptPartsEncodeToAppServerInputItems() { + let prompt = CodexPrompt(parts: [ + .text("Describe these files."), + .imageURL(URL(string: "https://example.test/diagram.png")!), + .localImage(URL(fileURLWithPath: "/tmp/screenshot.png")), + .skill(name: "checks", path: URL(fileURLWithPath: "/tmp/skills/checks")), + .mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")), + ]) + + #expect( + prompt.appServerInput == [ + .text("Describe these files."), + .image(url: "https://example.test/diagram.png"), + .localImage(path: "/tmp/screenshot.png"), + .skill(name: "checks", path: "/tmp/skills/checks"), + .mention(name: "repo", path: "/tmp/repo"), + ]) + } + + @Test func clientRetriesOverloadedRequestsThenSucceeds() async throws { + let transport = FakeJSONRPCTransport() + await transport.enqueueFailure( + .responseError(code: -32001, message: "server busy"), for: "ping") + try await transport.enqueue(EmptyResponse(), for: "ping") + let client = AppServerClient( + transport: transport, + overloadRetryDelay: { $0 == 0 ? .zero : nil }, + retrySleep: { _ in } + ) + + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + + #expect(await transport.recordedRequests().map(\.method) == ["ping", "ping"]) + } + + @Test func turnResultReplaysEarlyNotificationsAndKeepsUnknownEvents() async throws { + let transport = FakeJSONRPCTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "future/notification", + params: TurnIDParams(turnID: "turn-1") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", delta: "Done") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + let turn = CodexTurn( + id: "turn-1", + threadID: "thread-1", + client: client, + router: router + ) + + let events = try await collect(turn.events) + #expect( + events.contains { + if case .unknown(let raw) = $0 { + raw.method == "future/notification" + } else { + false + } + }) + let result = try await CodexTurnResultCollector.collect( + from: .init { + AsyncThrowingStream { continuation in + for event in events { + continuation.yield(event) + } + continuation.finish() + } + }) + #expect(result.id == "turn-1") + #expect(result.status == .completed) + #expect(result.finalAnswer == "Done") + } + + @Test func threadStreamsReplayMessagesTranscriptLogsAndUsage() async throws { + let transport = FakeJSONRPCTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "message-1", + type: "agentMessage", + text: "Interim", + phase: "commentary" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "message-2", + type: "agentMessage", + text: "Final", + phase: "final_answer" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "passed", + status: "completed" + ) + ) + ) + try await transport.emitServerNotification( + method: "thread/tokenUsage/updated", + params: TokenUsageParams( + threadID: "thread-1", + turnID: "turn-1", + tokenUsage: .init( + total: .init(inputTokens: 1, outputTokens: 2, totalTokens: 3) + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let messages = try await collect(thread.messages) + #expect(messages.map(\.text) == ["Interim", "Final"]) + #expect(messages.last?.phase == .finalAnswer) + + let transcripts = try await collect(thread.transcript) + #expect(transcripts.last?.finalAnswer == "Final") + #expect(transcripts.last?.items.count == 3) + + let logs = try await collect(thread.logEntries) + #expect(logs.contains { $0.item?.kind == .commandExecution }) + #expect(logs.contains { $0.item?.text == "Final" }) + + let events = try await collect(thread.events) + #expect( + events.contains { + if case .tokenUsageUpdated(let usage, let turnID) = $0 { + turnID == "turn-1" && usage.totalTokens == 3 + } else { + false + } + }) + } +} + +private func collect( + _ sequence: Sequence +) async throws -> [Sequence.Element] { + var elements: [Sequence.Element] = [] + for try await element in sequence { + elements.append(element) + } + return elements +} + +private actor FakeJSONRPCTransport: JSONRPC.Transport { + 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 notificationContinuations: + [AsyncThrowingStream.Continuation] = [] + private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] + private var closed = false + + func enqueue(_ response: Response, for method: String) throws { + responses[method, default: []].append(.success(try JSONEncoder().encode(response))) + } + + func enqueueFailure(_ error: JSONRPC.Error, for method: String) { + responses[method, default: []].append(.failure(error)) + } + + func send(_ request: JSONRPC.Request) async throws -> Data { + guard closed == false else { + throw JSONRPC.Error.closed + } + requests.append(request) + let queued = + responses[request.method, default: []].isEmpty + ? nil + : responses[request.method, default: []].removeFirst() + switch queued { + case .success(let data): + return data + case .failure(let error): + throw error + case nil: + return try JSONEncoder().encode(EmptyResponse()) + } + } + + func notify(_ notification: JSONRPC.Notification) async throws { + notifications.append(notification) + } + + func notificationStream() -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + notificationContinuations.append(continuation) + resumeNotificationStreamCountWaiters() + } + } + + func close() async { + closed = true + for continuation in notificationContinuations { + continuation.finish() + } + notificationContinuations.removeAll() + } + + func recordedRequests() -> [JSONRPC.Request] { + requests + } + + func recordedNotifications() -> [JSONRPC.Notification] { + notifications + } + + func waitForNotificationStreamCount(_ count: Int) async { + if notificationContinuations.count >= count { + return + } + await withCheckedContinuation { continuation in + if notificationContinuations.count >= count { + continuation.resume() + } else { + notificationStreamCountWaiters.append((count, continuation)) + } + } + } + + func emitServerNotification( + method: String, + params: Params + ) throws { + let notification = JSONRPC.Notification( + method: method, + params: try JSONEncoder().encode(params) + ) + for continuation in notificationContinuations { + continuation.yield(notification) + } + } + + private func resumeNotificationStreamCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in notificationStreamCountWaiters { + if notificationContinuations.count >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + notificationStreamCountWaiters = remaining + } +} + +private struct TurnIDParams: Encodable, Sendable { + var turnID: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + } +} + +private struct ThreadIDParams: Encodable, Sendable { + var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +private struct TurnDeltaParams: Encodable, Sendable { + var turnID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + case delta + } +} + +private struct TurnCompletedParams: Encodable, Sendable { + var turn: AppServerAPI.Turn.Payload +} + +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? + var command: String? + var aggregatedOutput: String? + var status: String? + + init( + id: String, + type: String, + text: String? = nil, + phase: String? = nil, + command: String? = nil, + aggregatedOutput: String? = nil, + status: String? = nil + ) { + self.id = id + self.type = type + self.text = text + self.phase = phase + self.command = command + self.aggregatedOutput = aggregatedOutput + self.status = status + } + } +} + +private struct TokenUsageParams: Encodable, Sendable { + var threadID: String + var turnID: String + var tokenUsage: TokenUsage + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case tokenUsage + } + + struct TokenUsage: Encodable, Sendable { + var total: Breakdown + var modelContextWindow: Int? + } + + struct Breakdown: Encodable, Sendable { + var cachedInputTokens: Int = 0 + var inputTokens: Int + var outputTokens: Int + var reasoningOutputTokens: Int = 0 + var totalTokens: Int + } +} diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 119f1fc5..48507b01 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -1,6 +1,7 @@ import Darwin import Foundation import Testing +import CodexAppServerKit @testable import CodexReviewAppServer import CodexReview import CodexReviewDomain @@ -686,7 +687,6 @@ struct AppServerClientTests { #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 { @@ -703,11 +703,9 @@ struct AppServerClientTests { #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 { @@ -5815,15 +5813,15 @@ private func enqueueInitialize(_ transport: FakeJSONRPCTransport) async throws { private func makeModelCatalogItem( model: String, isDefault: Bool = false -) -> CodexReviewSettings.ModelCatalogItem { +) -> CodexModel { .init( id: model, model: model, displayName: model, hidden: false, - supportedReasoningEfforts: [.init(reasoningEffort: .medium, description: "Balanced")], - defaultReasoningEffort: .medium, - supportedServiceTiers: [.fast], + supportedReasoningEfforts: [.init(reasoningEffort: "medium", description: "Balanced")], + defaultReasoningEffort: "medium", + supportedServiceTiers: ["fast"], isDefault: isDefault ) } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index ffed52d7..2f2122ea 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -2,6 +2,7 @@ import Foundation import AppKit import AuthenticationServices import Testing +import CodexAppServerKit import CodexReview import CodexReviewAppServer import CodexReviewHost @@ -1041,7 +1042,7 @@ struct CodexReviewHostTests { ) await waitUntil { store.jobs.first?.core.run.turnID == "turn-first" } - try await store.switchAccount(CodexAccount(email: "second@example.com")) + try await store.switchAccount(CodexReview.CodexAccount(email: "second@example.com")) let result = try await reviewRead await secondTransport.waitForRequestCount(2) await firstTransport.waitForRequestCount(8) @@ -1165,7 +1166,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(CodexReview.CodexAccount(email: "second@example.com")) } #expect(store.auth.selectedAccount?.accountKey == "first@example.com") @@ -1678,7 +1679,7 @@ struct CodexReviewHostTests { @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 = CodexReview.CodexAccount(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( @@ -1701,8 +1702,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 = CodexReview.CodexAccount(email: ".") + let dotDotAccount = CodexReview.CodexAccount(email: "..") let dotDirectoryURL = accountsURL.appendingPathComponent("%2E", isDirectory: true) let dotDotDirectoryURL = accountsURL.appendingPathComponent("%2E%2E", isDirectory: true) try FileManager.default.createDirectory(at: dotDirectoryURL, withIntermediateDirectories: true) From d2e8bc2cfbed61e8baf01fec29723d6f2a29bb2b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:19:10 +0900 Subject: [PATCH 002/359] feat(app-server): refine CodexAppServerKit domain API --- Docs/architecture.md | 19 +- README.md | 6 +- .../CodexAppServerKit/CodexAppServer.swift | 24 +- .../CodexAppServerNotificationRouter.swift | 55 +- .../CodexAppServerKit/CodexDomainTypes.swift | 493 +++++++++++++++--- .../CodexThreadOperations.swift | 120 +++-- .../CodexTurnSequences.swift | 22 +- Sources/CodexAppServerKit/README.md | 133 +++-- .../CodexAppServerKitTests.swift | 245 ++++++++- 9 files changed, 894 insertions(+), 223 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index d952282c..4fe2ee67 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -146,10 +146,9 @@ flowchart TB Composition --> PublicStore ``` -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 create reverse imports from Domain, Wire, Application, +Rendering, UI, or MCP adapter targets back into runtime/protocol owners. ## Observation Ownership @@ -192,10 +191,14 @@ the store. - `initialize` and `initialized` run once per connection. - `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`, `CodexThread`, - `CodexTurn`, `CodexPrompt`, thread event streams, messages, transcript/log - values, progress values, model values, account values, and login handles. -- Same-thread mutating requests are serialized. +- The public Kit API is expressed as `CodexAppServer`, `CodexThreadID`, + `CodexTurnID`, `CodexThread`, `CodexPrompt`, Foundation Models-style + `respond` / `streamResponse` / `CodexResponseStream.collect()` calls, + Codex-specific response stream controls, thread event streams, messages, + transcript/log values, model values, account values, and login handles. +- 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. - Notifications are routed by turn ID, early turn notifications are replayed to later consumers, and schema-new notifications are preserved as unknown domain diff --git a/README.md b/README.md index 77ad7acd..c416b974 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ visible. `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, -threads, turns, prompts, transcripts, models, accounts, and login flows. +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: @@ -64,7 +65,8 @@ conversion. See [CodexAppServerKit README](Sources/CodexAppServerKit/README.md) for the standalone SDK surface, including thread-level streams for messages, -transcripts, log entries, and progress events. +transcripts, log entries, and in-flight response controls such as steer, queue, +and interrupt. ## Timeout Setup diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 61543378..b2ed6eda 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -92,7 +92,7 @@ public actor CodexAppServer { } public func resumeThread( - _ id: CodexThread.ID, + _ id: CodexThreadID, options: CodexThread.ResumeOptions = .init() ) async throws -> CodexThread { let response = try await client.send( @@ -104,7 +104,7 @@ public actor CodexAppServer { } public func forkThread( - _ id: CodexThread.ID, + _ id: CodexThreadID, options: CodexThread.Options = .init() ) async throws -> CodexThread { let response = try await client.send( @@ -115,7 +115,7 @@ public actor CodexAppServer { return thread(from: response.thread) } - public func unarchiveThread(_ id: CodexThread.ID) async throws -> CodexThread { + public func unarchiveThread(_ id: CodexThreadID) async throws -> CodexThread { let response = try await client.send( AppServerAPI.Thread.Unarchive.Request( params: .init(threadID: id.rawValue) @@ -123,7 +123,7 @@ public actor CodexAppServer { return thread(from: response.thread) } - public func deleteThread(_ id: CodexThread.ID) async throws { + public func deleteThread(_ id: CodexThreadID) async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Delete.Request( params: .init(threadID: id.rawValue) @@ -256,8 +256,7 @@ public actor CodexAppServer { let _: EmptyResponse = try await client.send(AppServerAPI.Account.Logout.Request()) } - private func threadStartParams(options: CodexThread.Options) -> AppServerAPI.Thread.Start.Params - { + private func threadStartParams(options: CodexThread.Options) -> AppServerAPI.Thread.Start.Params { .init( model: options.model, modelProvider: options.modelProvider, @@ -296,9 +295,7 @@ public actor CodexAppServer { ) } - private nonisolated static func account(from snapshot: AppServerAPI.Account.Snapshot) - -> CodexAccount - { + private nonisolated static func account(from snapshot: AppServerAPI.Account.Snapshot) -> CodexAccount { .init( id: snapshot.id, kind: .init(rawValue: snapshot.kind.rawValue) ?? .chatGPT, @@ -335,15 +332,12 @@ public actor CodexAppServer { } private nonisolated static func defaultCodexHomeURL(environment: [String: String]) -> URL { - if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters( - in: .whitespacesAndNewlines), - codexHome.isEmpty == false - { + 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 - { + home.isEmpty == false { return URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".codex", isDirectory: true) } diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 25ac8d6a..676dec53 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -6,18 +6,18 @@ package actor CodexAppServerNotificationRouter { } private struct NotificationContext { - var threadID: CodexThread.ID? - var turnID: CodexTurn.ID? + var threadID: CodexThreadID? + var turnID: CodexTurnID? } private let client: AppServerClient private var routerTask: Task? - private var threadIDByTurnID: [CodexTurn.ID: CodexThread.ID] = [:] - private var turnHistoryByTurnID: [CodexTurn.ID: [CodexTurnEvent]] = [:] - private var threadHistoryByThreadID: [CodexThread.ID: [CodexThreadEvent]] = [:] - private var turnSubscribersByTurnID: [CodexTurn.ID: [UUID: Subscriber]] = [:] - private var threadSubscribersByThreadID: - [CodexThread.ID: [UUID: Subscriber]] = [:] + private var threadIDByTurnID: [CodexTurnID: CodexThreadID] = [:] + private var turnHistoryByTurnID: [CodexTurnID: [CodexTurnEvent]] = [:] + private var threadHistoryByThreadID: [CodexThreadID: [CodexThreadEvent]] = [:] + private var turnSubscribersByTurnID: [CodexTurnID: [UUID: Subscriber]] = [:] + private var threadSubscribersByThreadID: [CodexThreadID: [UUID: Subscriber]] = + [:] private let decoder = JSONDecoder() package init(client: AppServerClient) { @@ -40,7 +40,7 @@ package actor CodexAppServerNotificationRouter { } } - package func events(for turnID: CodexTurn.ID) -> AsyncThrowingStream { + package func events(for turnID: CodexTurnID) -> AsyncThrowingStream { AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let subscriptionID = UUID() Task { @@ -53,7 +53,7 @@ package actor CodexAppServerNotificationRouter { } } - package func events(for threadID: CodexThread.ID) -> AsyncThrowingStream< + package func events(for threadID: CodexThreadID) -> AsyncThrowingStream< CodexThreadEvent, Error > { AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in @@ -111,7 +111,7 @@ package actor CodexAppServerNotificationRouter { private func addTurnSubscriber( _ subscriptionID: UUID, - turnID: CodexTurn.ID, + turnID: CodexTurnID, continuation: AsyncThrowingStream.Continuation ) { let history = turnHistoryByTurnID[turnID] ?? [] @@ -128,7 +128,7 @@ package actor CodexAppServerNotificationRouter { private func addThreadSubscriber( _ subscriptionID: UUID, - threadID: CodexThread.ID, + threadID: CodexThreadID, continuation: AsyncThrowingStream.Continuation ) { let history = threadHistoryByThreadID[threadID] ?? [] @@ -267,21 +267,21 @@ package actor CodexAppServerNotificationRouter { } } - private func removeTurnSubscriber(_ subscriptionID: UUID, turnID: CodexTurn.ID) { + private func removeTurnSubscriber(_ subscriptionID: UUID, turnID: CodexTurnID) { turnSubscribersByTurnID[turnID]?.removeValue(forKey: subscriptionID) if turnSubscribersByTurnID[turnID]?.isEmpty == true { turnSubscribersByTurnID.removeValue(forKey: turnID) } } - private func removeThreadSubscriber(_ subscriptionID: UUID, threadID: CodexThread.ID) { + private func removeThreadSubscriber(_ subscriptionID: UUID, threadID: CodexThreadID) { threadSubscribersByThreadID[threadID]?.removeValue(forKey: subscriptionID) if threadSubscribersByThreadID[threadID]?.isEmpty == true { threadSubscribersByThreadID.removeValue(forKey: threadID) } } - private func finishTurnSubscribers(turnID: CodexTurn.ID) { + private func finishTurnSubscribers(turnID: CodexTurnID) { let subscribers = turnSubscribersByTurnID.removeValue(forKey: turnID).map { Array($0.values) @@ -291,7 +291,7 @@ package actor CodexAppServerNotificationRouter { } } - private func finishThreadSubscribers(threadID: CodexThread.ID) { + private func finishThreadSubscribers(threadID: CodexThreadID) { let subscribers = threadSubscribersByThreadID.removeValue(forKey: threadID).map { Array($0.values) @@ -344,20 +344,21 @@ package actor CodexAppServerNotificationRouter { } private func threadStatus(from data: Data) -> CodexThreadStatus? { - if let payload = try? decoder.decode(ThreadStatusPayload.self, from: data), + guard + let payload = try? decoder.decode(ThreadStatusPayload.self, from: data), let type = payload.status?.type - { - return .init(rawValue: type) + else { + return nil } - return nil + return .init(rawValue: type) } - private func turnResult(from data: Data, context: NotificationContext) -> CodexTurnResult { + private func turnResult(from data: Data, context: NotificationContext) -> CodexResponse { let payload = try? decoder.decode(TurnCompletedPayload.self, from: data) let turn = payload?.turn let status = turn?.status.map(CodexTurnStatus.init(rawValue:)) return .init( - id: turn.map { .init(rawValue: $0.id) } ?? context.turnID ?? .init(rawValue: ""), + turnID: turn.map { .init(rawValue: $0.id) } ?? context.turnID ?? .init(rawValue: ""), status: status, errorMessage: turn?.error?.message ) @@ -384,8 +385,8 @@ package actor CodexAppServerNotificationRouter { stringValue(named: "turnId", in: object) ?? objectValue(named: "turn", in: object).flatMap { stringValue(named: "id", in: $0) } return .init( - threadID: threadID.map(CodexThread.ID.init(rawValue:)), - turnID: turnID.map(CodexTurn.ID.init(rawValue:)) + threadID: threadID.map(CodexThreadID.init(rawValue:)), + turnID: turnID.map(CodexTurnID.init(rawValue:)) ) } @@ -393,8 +394,7 @@ package actor CodexAppServerNotificationRouter { object[name] as? String } - private static func objectValue(named name: String, in object: [String: Any]) -> [String: Any]? - { + private static func objectValue(named name: String, in object: [String: Any]) -> [String: Any]? { object[name] as? [String: Any] } } @@ -559,7 +559,8 @@ private struct RawThreadItem: Decodable { return .init( id: itemID, kind: kind, - content: content(kind: kind, id: itemID, rawType: rawType) + content: content(kind: kind, id: itemID, rawType: rawType), + rawPayload: rawPayload ) } diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index f307892d..2dddf907 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -7,6 +7,10 @@ public struct CodexPrompt: ExpressibleByStringLiteral, Equatable, Sendable { self.parts = parts } + public init(@CodexPromptBuilder _ content: () throws -> CodexPrompt) rethrows { + self = try content() + } + public init(stringLiteral value: String) { self.parts = [.text(value)] } @@ -24,6 +28,45 @@ public struct CodexPrompt: ExpressibleByStringLiteral, Equatable, Sendable { } } +@resultBuilder +public enum CodexPromptBuilder { + public static func buildBlock(_ components: CodexPrompt...) -> CodexPrompt { + .init(parts: components.flatMap(\.parts)) + } + + public static func buildExpression(_ expression: CodexPrompt) -> CodexPrompt { + expression + } + + public static func buildExpression(_ expression: CodexPrompt.Part) -> CodexPrompt { + .init(parts: [expression]) + } + + public static func buildExpression(_ expression: String) -> CodexPrompt { + .init(expression) + } + + public static func buildOptional(_ component: CodexPrompt?) -> CodexPrompt { + component ?? .init(parts: []) + } + + public static func buildEither(first component: CodexPrompt) -> CodexPrompt { + component + } + + public static func buildEither(second component: CodexPrompt) -> CodexPrompt { + component + } + + public static func buildArray(_ components: [CodexPrompt]) -> CodexPrompt { + .init(parts: components.flatMap(\.parts)) + } + + public static func buildLimitedAvailability(_ component: CodexPrompt) -> CodexPrompt { + component + } +} + public struct CodexInstructions: Equatable, Sendable { public var base: String? public var developer: String? @@ -33,6 +76,14 @@ public struct CodexInstructions: Equatable, Sendable { self.developer = developer } + public init(_ developer: String) { + self.init(developer: developer) + } + + public init(@CodexInstructionsBuilder _ developer: () throws -> String) rethrows { + self.init(developer: try developer()) + } + public static func base(_ text: String) -> Self { .init(base: text) } @@ -42,6 +93,37 @@ public struct CodexInstructions: Equatable, Sendable { } } +@resultBuilder +public enum CodexInstructionsBuilder { + public static func buildBlock(_ components: String...) -> String { + components.filter { $0.isEmpty == false }.joined(separator: "\n") + } + + public static func buildExpression(_ expression: String) -> String { + expression + } + + public static func buildOptional(_ component: String?) -> String { + component ?? "" + } + + public static func buildEither(first component: String) -> String { + component + } + + public static func buildEither(second component: String) -> String { + component + } + + public static func buildArray(_ components: [String]) -> String { + components.filter { $0.isEmpty == false }.joined(separator: "\n") + } + + public static func buildLimitedAvailability(_ component: String) -> String { + component + } +} + public enum CodexSandbox: String, Codable, Equatable, Sendable { case readOnly case workspaceWrite @@ -98,19 +180,70 @@ public enum CodexApprovalMode: String, Codable, Equatable, Sendable { } } -public struct CodexThread: Identifiable, Sendable { - public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String +public struct CodexGenerationOptions: Equatable, Sendable { + public var model: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var cwd: URL? + public var effort: String? + public var serviceTier: String? + public var summary: String? + public var transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy - public init(rawValue: String) { - self.rawValue = rawValue - } + public init( + model: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + cwd: URL? = nil, + effort: String? = nil, + serviceTier: String? = nil, + summary: String? = nil, + transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript + ) { + self.model = model + self.approvalMode = approvalMode + self.sandbox = sandbox + self.cwd = cwd + self.effort = effort + self.serviceTier = serviceTier + self.summary = summary + self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy + } +} - public init(stringLiteral value: String) { - self.rawValue = value - } +public struct CodexTranscriptErrorHandlingPolicy: Equatable, Sendable { + private enum Kind: Equatable, Sendable { + case preserveTranscript + case revertTranscript + } + + private var kind: Kind + + private init(kind: Kind) { + self.kind = kind } + public static let preserveTranscript = Self(kind: .preserveTranscript) + public static let revertTranscript = Self(kind: .revertTranscript) + + package var shouldRevertTranscript: Bool { + kind == .revertTranscript + } +} + +public struct CodexThreadID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } +} + +public struct CodexThread: Identifiable, Sendable { public struct Options: Equatable, Sendable { public var model: String? public var modelProvider: String? @@ -138,7 +271,7 @@ public struct CodexThread: Identifiable, Sendable { public typealias ResumeOptions = Options - public let id: ID + public let id: CodexThreadID public let workspace: URL? public let model: String? @@ -146,7 +279,7 @@ public struct CodexThread: Identifiable, Sendable { package let router: CodexAppServerNotificationRouter package init( - id: ID, + id: CodexThreadID, workspace: URL? = nil, model: String? = nil, client: AppServerClient, @@ -160,56 +293,28 @@ public struct CodexThread: Identifiable, Sendable { } } -public struct CodexTurn: Identifiable, Sendable { - public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } +public struct CodexTurnID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String - public init(stringLiteral value: String) { - self.rawValue = value - } + public init(rawValue: String) { + self.rawValue = rawValue } - public struct Options: Equatable, Sendable { - public var model: String? - public var approvalMode: CodexApprovalMode? - public var sandbox: CodexSandbox? - public var cwd: URL? - public var effort: String? - public var serviceTier: String? - public var summary: String? - - public init( - model: String? = nil, - approvalMode: CodexApprovalMode? = nil, - sandbox: CodexSandbox? = nil, - cwd: URL? = nil, - effort: String? = nil, - serviceTier: String? = nil, - summary: String? = nil - ) { - self.model = model - self.approvalMode = approvalMode - self.sandbox = sandbox - self.cwd = cwd - self.effort = effort - self.serviceTier = serviceTier - self.summary = summary - } + public init(stringLiteral value: String) { + self.rawValue = value } +} - public let id: ID - public let threadID: CodexThread.ID +package struct CodexTurn: Identifiable, Sendable { + package let id: CodexTurnID + package let threadID: CodexThreadID package let client: AppServerClient package let router: CodexAppServerNotificationRouter package init( - id: ID, - threadID: CodexThread.ID, + id: CodexTurnID, + threadID: CodexThreadID, client: AppServerClient, router: CodexAppServerNotificationRouter ) { @@ -221,14 +326,14 @@ public struct CodexTurn: Identifiable, Sendable { } public struct CodexThreadSnapshot: Identifiable, Equatable, Sendable { - public var id: CodexThread.ID + public var id: CodexThreadID public var workspace: URL? public var name: String? public var preview: String? public var turns: [CodexTurnSnapshot] public init( - id: CodexThread.ID, + id: CodexThreadID, workspace: URL? = nil, name: String? = nil, preview: String? = nil, @@ -243,12 +348,12 @@ public struct CodexThreadSnapshot: Identifiable, Equatable, Sendable { } public struct CodexTurnSnapshot: Identifiable, Equatable, Sendable { - public var id: CodexTurn.ID + public var id: CodexTurnID public var status: CodexTurnStatus? public var errorMessage: String? public init( - id: CodexTurn.ID, + id: CodexTurnID, status: CodexTurnStatus? = nil, errorMessage: String? = nil ) { @@ -319,6 +424,10 @@ public struct CodexTranscript: Equatable, Sendable { } return fallback } + + public var responseText: String? { + messages.reversed().first { $0.role == .assistant }?.text + } } public struct CodexThreadItem: Identifiable, Equatable, Sendable { @@ -441,15 +550,18 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { public var id: String public var kind: Kind public var content: Content + public var rawPayload: Data? public init( id: String, kind: Kind, - content: Content + content: Content, + rawPayload: Data? = nil ) { self.id = id self.kind = kind self.content = content + self.rawPayload = rawPayload } public var text: String? { @@ -690,23 +802,27 @@ public enum CodexTurnStatus: Equatable, Sendable { } } -public struct CodexTurnResult: Identifiable, Equatable, Sendable { - public var id: CodexTurn.ID +public struct CodexResponse: Identifiable, Equatable, Sendable { + public var turnID: CodexTurnID public var status: CodexTurnStatus? public var errorMessage: String? public var finalAnswer: String? public var transcript: CodexTranscript public var usage: CodexTokenUsage? + public var id: CodexTurnID { + turnID + } + public init( - id: CodexTurn.ID, + turnID: CodexTurnID, status: CodexTurnStatus? = nil, errorMessage: String? = nil, finalAnswer: String? = nil, transcript: CodexTranscript = .init(), usage: CodexTokenUsage? = nil ) { - self.id = id + self.turnID = turnID self.status = status self.errorMessage = errorMessage self.finalAnswer = finalAnswer @@ -715,6 +831,213 @@ public struct CodexTurnResult: Identifiable, Equatable, Sendable { } } +public struct CodexResponseStream: AsyncSequence, Sendable { + public enum SubmissionMode: Equatable, Sendable { + case queueAfterCurrentResponse + case interruptCurrentResponse + } + + public struct Snapshot: Equatable, Sendable { + public var turnID: CodexTurnID + public var content: String? + public var transcript: CodexTranscript + public var usage: CodexTokenUsage? + public var response: CodexResponse? + + public init( + turnID: CodexTurnID, + content: String? = nil, + transcript: CodexTranscript = .init(), + usage: CodexTokenUsage? = nil, + response: CodexResponse? = nil + ) { + self.turnID = turnID + self.content = content + self.transcript = transcript + self.usage = usage + self.response = response + } + } + + private let turn: CodexTurn + private let transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy + + package init( + turn: CodexTurn, + transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy + ) { + self.turn = turn + self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy + } + + public func makeAsyncIterator() -> Iterator { + Iterator( + turn: turn, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, + progress: turn.progress.makeAsyncIterator() + ) + } + + public func collect() async throws -> CodexResponse { + try await withTaskCancellationHandler { + do { + return try await turn.result() + } catch { + try await handleFailure() + throw error + } + } onCancel: { + let turn = turn + Task { + try? await turn.interrupt() + } + } + } + + public func interrupt() async throws { + try await turn.interrupt() + } + + public func steer(with prompt: CodexPrompt) async throws { + try await turn.steer(with: prompt) + } + + public func steer(with prompt: String) async throws { + try await steer(with: CodexPrompt(prompt)) + } + + public func steer(@CodexPromptBuilder prompt: () throws -> CodexPrompt) async throws { + try await steer(with: try prompt()) + } + + public func submit( + _ prompt: CodexPrompt, + mode: SubmissionMode, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + switch mode { + case .queueAfterCurrentResponse: + _ = try await collect() + return try await startFollowUp(to: prompt, options: options) + case .interruptCurrentResponse: + try await interrupt() + try await waitForInterruptedResponse() + return try await startFollowUp(to: prompt, options: options) + } + } + + public func submit( + _ prompt: String, + mode: SubmissionMode, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + try await submit(CodexPrompt(prompt), mode: mode, options: options) + } + + public func submit( + mode: SubmissionMode, + options: CodexGenerationOptions = .init(), + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexResponseStream { + try await submit(try prompt(), mode: mode, options: options) + } + + private func startFollowUp( + to prompt: CodexPrompt, + options: CodexGenerationOptions + ) async throws -> CodexResponseStream { + let turn = try await startCodexTurn( + threadID: turn.threadID, + prompt: prompt, + options: options, + client: turn.client, + router: turn.router + ) + return .init( + turn: turn, + transcriptErrorHandlingPolicy: options.transcriptErrorHandlingPolicy + ) + } + + private func waitForInterruptedResponse() async throws { + for try await event in turn.events { + switch event { + case .completed(let response): + if let message = response.errorMessage { + throw CodexAppServerError.turnFailed(message) + } + switch response.status { + case .interrupted, .cancelled: + return + case .failed: + throw CodexAppServerError.turnFailed(CodexTurnStatus.failed.rawValue) + case .running, .completed, .unknown, nil: + return + } + case .failed(let message): + throw CodexAppServerError.turnFailed(message) + case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, + .tokenUsageUpdated, .unknown: + continue + } + } + throw CodexAppServerError.transportClosed + } + + private func handleFailure() async throws { + guard transcriptErrorHandlingPolicy.shouldRevertTranscript else { + return + } + let _: EmptyResponse = try await turn.client.send( + AppServerAPI.Thread.Rollback.Request( + params: .init(threadID: turn.threadID.rawValue, numTurns: 1) + )) + } + + public struct Iterator: AsyncIteratorProtocol { + private let turn: CodexTurn + private let transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy + private var progress: CodexTurnProgressSequence.Iterator + + fileprivate init( + turn: CodexTurn, + transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy, + progress: CodexTurnProgressSequence.Iterator + ) { + self.turn = turn + self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy + self.progress = progress + } + + public mutating func next() async throws -> Snapshot? { + guard let progress = try await progress.next() else { + return nil + } + if case .failed(let error) = progress.phase { + try await handleFailure() + throw error + } + return Snapshot( + turnID: turn.id, + content: progress.result?.finalAnswer ?? progress.transcript.responseText, + transcript: progress.transcript, + usage: progress.result?.usage, + response: progress.result + ) + } + + private func handleFailure() async throws { + guard transcriptErrorHandlingPolicy.shouldRevertTranscript else { + return + } + let _: EmptyResponse = try await turn.client.send( + AppServerAPI.Thread.Rollback.Request( + params: .init(threadID: turn.threadID.rawValue, numTurns: 1) + )) + } + } +} + public struct CodexTokenUsage: Equatable, Sendable { public var inputTokens: Int? public var outputTokens: Int? @@ -752,28 +1075,28 @@ public struct CodexMessageDelta: Equatable, Sendable { } } -public enum CodexTurnEvent: Equatable, Sendable { - case started(CodexTurn.ID) +package enum CodexTurnEvent: Equatable, Sendable { + case started(CodexTurnID) case itemStarted(CodexThreadItem) case itemUpdated(CodexThreadItem) case itemCompleted(CodexThreadItem) case messageDelta(CodexMessageDelta) case tokenUsageUpdated(CodexTokenUsage) - case completed(CodexTurnResult) + case completed(CodexResponse) case failed(String) case unknown(CodexRawNotification) } public enum CodexThreadEvent: Equatable, Sendable { - case turnStarted(CodexTurn.ID) - case turnCompleted(CodexTurnResult) - case turnFailed(turnID: CodexTurn.ID?, message: String) - case itemStarted(CodexThreadItem, turnID: CodexTurn.ID?) - case itemUpdated(CodexThreadItem, turnID: CodexTurn.ID?) - case itemCompleted(CodexThreadItem, turnID: CodexTurn.ID?) - case message(CodexMessage, turnID: CodexTurn.ID?) - case messageDelta(CodexMessageDelta, turnID: CodexTurn.ID?) - case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurn.ID?) + case turnStarted(CodexTurnID) + case turnCompleted(CodexResponse) + case turnFailed(turnID: CodexTurnID?, message: String) + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case message(CodexMessage, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) + case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) case statusChanged(CodexThreadStatus) case closed case unknown(CodexRawNotification) @@ -788,14 +1111,14 @@ public struct CodexThreadLogEntry: Identifiable, Equatable, Sendable { } public var id: String - public var turnID: CodexTurn.ID? + public var turnID: CodexTurnID? public var phase: Phase public var item: CodexThreadItem? public var messageDelta: CodexMessageDelta? public init( id: String, - turnID: CodexTurn.ID? = nil, + turnID: CodexTurnID? = nil, phase: Phase, item: CodexThreadItem? = nil, messageDelta: CodexMessageDelta? = nil @@ -836,21 +1159,21 @@ public enum CodexThreadStatus: Equatable, Sendable { } } -public struct CodexTurnProgress: Equatable, Sendable { - public enum Phase: Equatable, Sendable { +package struct CodexTurnProgress: Equatable, Sendable { + package enum Phase: Equatable, Sendable { case running case completed case failed(CodexAppServerError) } - public var phase: Phase - public var transcript: CodexTranscript - public var result: CodexTurnResult? + package var phase: Phase + package var transcript: CodexTranscript + package var result: CodexResponse? - public init( + package init( phase: Phase, transcript: CodexTranscript = .init(), - result: CodexTurnResult? = nil + result: CodexResponse? = nil ) { self.phase = phase self.transcript = transcript @@ -861,14 +1184,14 @@ public struct CodexTurnProgress: Equatable, Sendable { public struct CodexRawNotification: Equatable, Sendable { public var method: String public var params: Data - public var threadID: CodexThread.ID? - public var turnID: CodexTurn.ID? + public var threadID: CodexThreadID? + public var turnID: CodexTurnID? public init( method: String, params: Data, - threadID: CodexThread.ID? = nil, - turnID: CodexTurn.ID? = nil + threadID: CodexThreadID? = nil, + turnID: CodexTurnID? = nil ) { self.method = method self.params = params diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 86617242..340807bf 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -23,7 +23,7 @@ extension CodexThread { .init(events: events) } - public var transcript: CodexThreadTranscriptSequence { + public var transcriptUpdates: CodexThreadTranscriptSequence { .init(events: events) } @@ -33,53 +33,63 @@ extension CodexThread { public func respond( to prompt: CodexPrompt, - options: CodexTurn.Options = .init() - ) async throws -> CodexTurnResult { - let turn = try await startTurn(prompt, options: options) - return try await turn.result() + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponse { + try await streamResponse(to: prompt, options: options).collect() } public func respond( to prompt: String, - options: CodexTurn.Options = .init() - ) async throws -> CodexTurnResult { + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponse { try await respond(to: CodexPrompt(prompt), options: options) } - public func startTurn( + public func respond( + options: CodexGenerationOptions = .init(), + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexResponse { + try await respond(to: try prompt(), options: options) + } + + public func streamResponse( + to prompt: CodexPrompt, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + let turn = try await startTurn(prompt, options: options) + return .init( + turn: turn, + transcriptErrorHandlingPolicy: options.transcriptErrorHandlingPolicy + ) + } + + public func streamResponse( + to prompt: String, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + try await streamResponse(to: CodexPrompt(prompt), options: options) + } + + public func streamResponse( + options: CodexGenerationOptions = .init(), + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexResponseStream { + try await streamResponse(to: try prompt(), options: options) + } + + package func startTurn( _ prompt: CodexPrompt, - options: CodexTurn.Options = .init() + options: CodexGenerationOptions = .init() ) async throws -> CodexTurn { - let response = try await client.send( - AppServerAPI.Turn.Start.Request( - params: .init( - threadID: id.rawValue, - input: prompt.appServerInput, - approvalPolicy: options.approvalMode?.approvalPolicy, - approvalsReviewer: options.approvalMode?.approvalsReviewer, - cwd: options.cwd?.path, - effort: options.effort, - model: options.model, - sandboxPolicy: options.sandbox?.turnSandboxPolicy, - serviceTier: options.serviceTier, - summary: options.summary - ) - )) - return CodexTurn( - id: .init(rawValue: response.turn.id), + try await startCodexTurn( threadID: id, + prompt: prompt, + options: options, client: client, router: router ) } - public func startTurn( - _ prompt: String, - options: CodexTurn.Options = .init() - ) async throws -> CodexTurn { - try await startTurn(CodexPrompt(prompt), options: options) - } - public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot { let response = try await client.send( AppServerAPI.Thread.Read.Request( @@ -144,8 +154,38 @@ extension CodexThread { } } +package func startCodexTurn( + threadID: CodexThreadID, + prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + client: AppServerClient, + router: CodexAppServerNotificationRouter +) async throws -> CodexTurn { + let response = try await client.send( + AppServerAPI.Turn.Start.Request( + params: .init( + threadID: threadID.rawValue, + input: prompt.appServerInput, + approvalPolicy: options.approvalMode?.approvalPolicy, + approvalsReviewer: options.approvalMode?.approvalsReviewer, + cwd: options.cwd?.path, + effort: options.effort, + model: options.model, + sandboxPolicy: options.sandbox?.turnSandboxPolicy, + serviceTier: options.serviceTier, + summary: options.summary + ) + )) + return CodexTurn( + id: .init(rawValue: response.turn.id), + threadID: threadID, + client: client, + router: router + ) +} + extension CodexTurn { - public var events: CodexTurnEventSequence { + package var events: CodexTurnEventSequence { .init { AsyncThrowingStream { continuation in Task { @@ -163,15 +203,15 @@ extension CodexTurn { } } - public var progress: CodexTurnProgressSequence { + package var progress: CodexTurnProgressSequence { .init(events: events) } - public func result() async throws -> CodexTurnResult { - try await CodexTurnResultCollector.collect(from: events) + package func result() async throws -> CodexResponse { + try await CodexResponseCollector.collect(from: events) } - public func steer(with prompt: CodexPrompt) async throws { + package func steer(with prompt: CodexPrompt) async throws { let _: AppServerAPI.Turn.Steer.Response = try await client.send( AppServerAPI.Turn.Steer.Request( params: .init( @@ -182,11 +222,11 @@ extension CodexTurn { )) } - public func steer(with prompt: String) async throws { + package func steer(with prompt: String) async throws { try await steer(with: CodexPrompt(prompt)) } - public func interrupt() async throws { + package func interrupt() async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Turn.Interrupt.Request( params: .init(threadID: threadID.rawValue, turnID: id.rawValue) diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index a8a17d59..c0954507 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -148,8 +148,8 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { } } -public struct CodexTurnEventSequence: AsyncSequence, Sendable { - public typealias Element = CodexTurnEvent +package struct CodexTurnEventSequence: AsyncSequence, Sendable { + package typealias Element = CodexTurnEvent private let makeStream: @Sendable () -> AsyncThrowingStream @@ -157,13 +157,13 @@ public struct CodexTurnEventSequence: AsyncSequence, Sendable { self.makeStream = makeStream } - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + package func makeAsyncIterator() -> AsyncThrowingStream.Iterator { makeStream().makeAsyncIterator() } } -public struct CodexTurnProgressSequence: AsyncSequence, Sendable { - public typealias Element = CodexTurnProgress +package struct CodexTurnProgressSequence: AsyncSequence, Sendable { + package typealias Element = CodexTurnProgress private let events: CodexTurnEventSequence @@ -171,11 +171,11 @@ public struct CodexTurnProgressSequence: AsyncSequence, Sendable { self.events = events } - public func makeAsyncIterator() -> Iterator { + package func makeAsyncIterator() -> Iterator { Iterator(events: events.makeAsyncIterator()) } - public struct Iterator: AsyncIteratorProtocol { + package struct Iterator: AsyncIteratorProtocol { private var events: AsyncThrowingStream.Iterator private var accumulator = CodexTranscriptAccumulator() private var usage: CodexTokenUsage? @@ -184,7 +184,7 @@ public struct CodexTurnProgressSequence: AsyncSequence, Sendable { self.events = events } - public mutating func next() async throws -> CodexTurnProgress? { + package mutating func next() async throws -> CodexTurnProgress? { guard let event = try await events.next() else { return nil } @@ -222,7 +222,7 @@ public struct CodexTurnProgressSequence: AsyncSequence, Sendable { } } - private func finalizedResult(_ result: CodexTurnResult) -> CodexTurnResult { + private func finalizedResult(_ result: CodexResponse) -> CodexResponse { var result = result if result.finalAnswer == nil { result.finalAnswer = accumulator.transcript.finalAnswer @@ -238,8 +238,8 @@ public struct CodexTurnProgressSequence: AsyncSequence, Sendable { } } -package struct CodexTurnResultCollector { - static func collect(from events: CodexTurnEventSequence) async throws -> CodexTurnResult { +package struct CodexResponseCollector { + static func collect(from events: CodexTurnEventSequence) async throws -> CodexResponse { var accumulator = CodexTranscriptAccumulator() var usage: CodexTokenUsage? for try await event in events { diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index c5788513..cc350778 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -4,8 +4,9 @@ CodexAppServerKit is a Swift library for working with a local `codex app-server` process from macOS apps and tools. The package hides JSON-RPC framing and app-server DTOs behind Swift domain -types. Callers work with an app-server container, threads, turns, prompts, -messages, transcript items, log entries, models, accounts, and login handles. +types. Callers work with an app-server container, sessions, prompts, +responses, response streams, transcript items, log entries, models, accounts, +and login handles. ## Container @@ -17,8 +18,8 @@ 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.") -print(result.finalAnswer ?? "") +let response = try await thread.respond(to: "Review this workspace.") +print(response.finalAnswer ?? "") await appServer.close() ``` @@ -30,8 +31,9 @@ notifications as unknown domain events. ## Threads -`CodexThread` is the long-lived handle for a Codex conversation in a workspace. -It supports high-level commands: +`CodexThread` is the long-lived session handle for a Codex conversation in a +workspace. It mirrors the Foundation Models session style: use `respond` for a +single final response, or `streamResponse` when the UI needs partial snapshots. ```swift let thread = try await appServer.startThread( @@ -40,9 +42,10 @@ let thread = try await appServer.startThread( options: .init(model: "gpt-5", approvalMode: .autoReview) ) -let turn = try await thread.startTurn("Run the checks.") -try await turn.steer(with: "Focus on failing tests.") -let result = try await turn.result() +let response = try await thread.respond { + "Run the checks." + "Focus on failing tests." +} ``` Thread management is exposed without requiring raw request DTOs: @@ -59,9 +62,68 @@ try await thread.delete() ## Streaming -Threads expose async sequences for chat, transcript, and log-style consumers. -This is the API surface intended for higher-level products that need to render -Codex output continuously. +Use `streamResponse` for the Foundation Models-style streaming surface. The +stream yields snapshots of the accumulated response state and can be collected +into the final `CodexResponse`. + +```swift +let stream = try await thread.streamResponse(to: "Summarize the changes.") + +for try await snapshot in stream { + render(snapshot.transcript.items) +} + +let response = try await stream.collect() +``` + +Codex also supports explicit interruption for an in-flight response. Foundation +Models normally relies on task cancellation, but app-server has real +`turn/steer` and `turn/interrupt` control paths, so `CodexResponseStream` +exposes them directly: + +```swift +let stream = try await thread.streamResponse(to: "Run the slow checks.") +try await stream.steer(with: "Prefer the smallest fix.") +try await stream.interrupt() +``` + +If the task awaiting `stream.collect()` is cancelled, the stream also sends the +same interrupt request to app-server. + +When a UI needs to accept another prompt while a response is in flight, submit +it with an explicit follow-up mode: + +```swift +let next = try await stream.submit( + "Now update the tests.", + mode: .queueAfterCurrentResponse +) + +let urgent = try await stream.submit( + "Stop and try the shorter path.", + mode: .interruptCurrentResponse +) +``` + +Use `steer(with:)` when the new input should modify the current turn. +`.queueAfterCurrentResponse` waits for the current response to finish before +starting the next turn. `.interruptCurrentResponse` sends `turn/interrupt`, +waits for app-server's terminal event, and then starts the next turn in the +same thread. + +`CodexGenerationOptions` includes `transcriptErrorHandlingPolicy`, matching the +Foundation Models policy shape: + +```swift +let stream = try await thread.streamResponse( + to: "Try the risky change.", + options: .init(transcriptErrorHandlingPolicy: .revertTranscript) +) +``` + +Threads also expose async sequences for chat, transcript updates, and log-style +consumers. This is the API surface intended for higher-level products that need +to render Codex output continuously outside a single response stream. ```swift for try await message in thread.messages { @@ -70,7 +132,7 @@ for try await message in thread.messages { ``` ```swift -for try await transcript in thread.transcript { +for try await transcript in thread.transcriptUpdates { render(transcript.items) } ``` @@ -113,23 +175,15 @@ This lets CodexReviewKit and similar clients build review logs from CodexAppServerKit domain events instead of parsing JSON-RPC notifications or string logs directly. -## Turns +Known `CodexThreadItem` values keep their high-level `content` projection and +the original `rawPayload`. Use the raw payload when a product needs +full-fidelity rendering for app-server fields that the current Kit version does +not yet model directly. -`CodexTurn` is a handle for one in-flight turn. It has turn-scoped streams and -result aggregation: +## Responses -```swift -let turn = try await thread.startTurn("Summarize the changes.") - -for try await progress in turn.progress { - render(progress.transcript) -} - -let result = try await turn.result() -``` - -`CodexThread.respond(to:)` is the convenience form for starting a turn, -consuming its events, and returning the final `CodexTurnResult`. +`CodexResponse` is the final result from `respond` or `ResponseStream.collect()`. +It carries the final answer, transcript, status, token usage, and `turnID`. Final answers are derived from assistant messages whose phase is `.finalAnswer`. If no final-answer phase is present, the last normal assistant @@ -153,6 +207,19 @@ String literals are supported for simple prompts: try await thread.respond(to: "What changed?") ``` +For dynamic prompts, use the result-builder initializer or the builder overloads +on `respond` and `streamResponse`: + +```swift +let response = try await thread.respond { + "Explain this screenshot." + CodexPrompt.Part.localImage(screenshotURL) + if includeRepository { + CodexPrompt.Part.mention(name: "repo", path: workspaceURL) + } +} +``` + ## Models, Account, And Login ```swift @@ -184,15 +251,17 @@ request DTOs. Those remain package-level implementation details. The public boundary is: - `CodexAppServer` +- `CodexThreadID` +- `CodexTurnID` - `CodexThread` -- `CodexTurn` +- `CodexResponse` +- `CodexResponseStream` +- `CodexGenerationOptions` +- `CodexTranscriptErrorHandlingPolicy` - `CodexPrompt` - `CodexTranscript` - `CodexThreadItem` - `CodexThreadEvent` -- `CodexTurnEvent` -- `CodexTurnProgress` -- `CodexTurnResult` - `CodexModel` - `CodexAccount` - `CodexLoginHandle` diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index b8cf1591..7374b3a0 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -129,7 +129,7 @@ struct CodexAppServerKitTests { false } }) - let result = try await CodexTurnResultCollector.collect( + let result = try await CodexResponseCollector.collect( from: .init { AsyncThrowingStream { continuation in for event in events { @@ -138,7 +138,7 @@ struct CodexAppServerKitTests { continuation.finish() } }) - #expect(result.id == "turn-1") + #expect(result.turnID == "turn-1") #expect(result.status == .completed) #expect(result.finalAnswer == "Done") } @@ -217,7 +217,7 @@ struct CodexAppServerKitTests { #expect(messages.map(\.text) == ["Interim", "Final"]) #expect(messages.last?.phase == .finalAnswer) - let transcripts = try await collect(thread.transcript) + let transcripts = try await collect(thread.transcriptUpdates) #expect(transcripts.last?.finalAnswer == "Final") #expect(transcripts.last?.items.count == 3) @@ -235,6 +235,218 @@ struct CodexAppServerKitTests { } }) } + + @Test func responseStreamYieldsSnapshotsAndCollectsFinalResponse() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse { + "Summarize this." + CodexPrompt.Part.mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")) + } + + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", delta: "Final") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + #expect(first?.turnID == "turn-1") + #expect(first?.content == "Final") + + let response = try await stream.collect() + #expect(response.turnID == "turn-1") + #expect(response.finalAnswer == "Final") + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, from: request.params) + #expect( + params.input == [ + .text("Summarize this."), + .mention(name: "repo", path: "/tmp/repo"), + ]) + } + + @Test func responseStreamInterruptSendsTurnInterrupt() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + try await stream.interrupt() + + #expect( + await transport.recordedRequests().map(\.method) == [ + "turn/start", + "turn/interrupt", + ]) + let request = try #require(await transport.recordedRequests().last) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Interrupt.Params.self, from: request.params) + #expect(params.threadID == "thread-1") + #expect(params.turnID == "turn-1") + } + + @Test func responseStreamSteerSubmitsInputToCurrentTurn() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue( + AppServerAPI.Turn.Steer.Response(turnID: "turn-1"), + for: "turn/steer" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + try await stream.steer(with: "Prefer the smallest fix.") + + #expect( + await transport.recordedRequests().map(\.method) == [ + "turn/start", + "turn/steer", + ]) + let request = try #require(await transport.recordedRequests().last) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Steer.Params.self, from: request.params) + #expect(params.threadID == "thread-1") + #expect(params.expectedTurnID == "turn-1") + #expect(params.input == [.text("Prefer the smallest fix.")]) + } + + @Test func responseStreamQueueStartsFollowUpAfterCurrentResponse() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-2", status: "running")), + for: "turn/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse(to: "First request.") + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + + _ = try await stream.submit( + "Second request.", + mode: .queueAfterCurrentResponse, + options: .init(model: "gpt-5") + ) + + #expect( + await transport.recordedRequests().map(\.method) == [ + "turn/start", + "turn/start", + ]) + let request = try #require(await transport.recordedRequests().last) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, from: request.params) + #expect(params.threadID == "thread-1") + #expect(params.input == [.text("Second request.")]) + #expect(params.model == "gpt-5") + } + + @Test func responseStreamInterruptStartsFollowUpAfterServerTerminalEvent() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-2", status: "running")), + for: "turn/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse(to: "Long request.") + let followUpTask = Task { + try await stream.submit( + "Use the shorter path.", + mode: .interruptCurrentResponse + ) + } + await transport.waitForRequestCount(2) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "interrupted")) + ) + _ = try await followUpTask.value + + #expect( + await transport.recordedRequests().map(\.method) == [ + "turn/start", + "turn/interrupt", + "turn/start", + ]) + let interruptRequest = try #require(await transport.recordedRequests().dropLast().last) + let interruptParams = try JSONDecoder().decode( + AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params) + #expect(interruptParams.threadID == "thread-1") + #expect(interruptParams.turnID == "turn-1") + + let followUpRequest = try #require(await transport.recordedRequests().last) + let followUpParams = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, from: followUpRequest.params) + #expect(followUpParams.threadID == "thread-1") + #expect(followUpParams.input == [.text("Use the shorter path.")]) + } } private func collect( @@ -259,6 +471,7 @@ private actor FakeJSONRPCTransport: JSONRPC.Transport { private var notificationContinuations: [AsyncThrowingStream.Continuation] = [] private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] + private var requestCountWaiters: [(Int, CheckedContinuation)] = [] private var closed = false func enqueue(_ response: Response, for method: String) throws { @@ -274,6 +487,7 @@ private actor FakeJSONRPCTransport: JSONRPC.Transport { throw JSONRPC.Error.closed } requests.append(request) + resumeRequestCountWaiters() let queued = responses[request.method, default: []].isEmpty ? nil @@ -315,6 +529,19 @@ private actor FakeJSONRPCTransport: JSONRPC.Transport { notifications } + 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)) + } + } + } + func waitForNotificationStreamCount(_ count: Int) async { if notificationContinuations.count >= count { return @@ -352,6 +579,18 @@ private actor FakeJSONRPCTransport: JSONRPC.Transport { } notificationStreamCountWaiters = remaining } + + 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 struct TurnIDParams: Encodable, Sendable { From 791db9221ba8c43669c23c88df0fe4b0db80ef41 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:23:12 +0900 Subject: [PATCH 003/359] docs(app-server): document public CodexAppServer APIs --- .../CodexAppServerKit/CodexAppServer.swift | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index b2ed6eda..f8e109a4 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -1,14 +1,49 @@ import Foundation +/// A live connection to a Codex app-server process. +/// +/// `CodexAppServer` owns the app-server transport, performs the initial +/// JSON-RPC handshake, and routes server notifications to thread, turn, and +/// login domain objects. public actor CodexAppServer { + /// Options for launching and identifying a Codex app-server process. public struct Configuration: Sendable { + /// The `codex` executable path or command name. + /// + /// Set this when the executable is not available through the process + /// environment. When `nil`, the default transport command is used. public var executable: String? + + /// Command-line arguments passed to the app-server executable. + /// + /// When `nil`, the transport uses the default arguments for starting + /// `codex app-server`. public var arguments: [String]? + + /// Environment variables supplied to the app-server process. public var environment: [String: String] + + /// The Codex home directory used by the app-server process. + /// + /// When `nil`, the value is derived from `CODEX_HOME` or the user's + /// home directory in the supplied environment. public var codexHomeURL: URL? + + /// The client name sent in the app-server `initialize` request. public var clientName: String + + /// The client version sent in the app-server `initialize` request. public var clientVersion: String + /// Creates a configuration for launching a Codex app-server process. + /// + /// - Parameters: + /// - executable: The `codex` executable path or command name. + /// - arguments: Command-line arguments for the app-server process. + /// - environment: Environment variables for the app-server process. + /// - codexHomeURL: Codex home directory, or `nil` to infer it. + /// - clientName: Client name sent during app-server initialization. + /// - clientVersion: Client version sent during app-server initialization. public init( executable: String? = nil, arguments: [String]? = nil, @@ -25,12 +60,20 @@ public actor CodexAppServer { self.clientVersion = clientVersion } + /// A configuration that uses the local `codex` executable and current environment. public static let `default` = Configuration() } private let client: AppServerClient private let router: CodexAppServerNotificationRouter + /// Starts a Codex app-server process and initializes the client session. + /// + /// The initializer completes after the app-server has accepted the + /// `initialize` request and notification routing is ready. + /// + /// - Parameter configuration: Process and client identity configuration. + /// - Throws: A transport, JSON-RPC, or app-server initialization error. public init(configuration: Configuration = .default) async throws { let transportConfiguration = AppServerProcessTransport.Configuration( executable: configuration.executable, @@ -56,11 +99,23 @@ public actor CodexAppServer { self.router = router } + /// Closes the app-server connection and stops notification routing. + /// + /// Call this when the container is no longer needed. Closing is idempotent + /// from the perspective of public callers. public func close() async { await router.stop() await client.close() } + /// Creates a new Codex thread in a workspace. + /// + /// - Parameters: + /// - workspace: The workspace directory for the thread. + /// - instructions: Optional base and developer instructions. + /// - options: Thread creation options, including model, approval, and sandbox settings. + /// - Returns: A domain handle for the created thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func startThread( in workspace: URL, instructions: CodexInstructions? = nil, @@ -91,6 +146,13 @@ public actor CodexAppServer { ) } + /// Resumes an existing Codex thread. + /// + /// - Parameters: + /// - id: The thread identifier to resume. + /// - options: Resume options that may override the stored thread context. + /// - Returns: A domain handle for the resumed thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func resumeThread( _ id: CodexThreadID, options: CodexThread.ResumeOptions = .init() @@ -103,6 +165,13 @@ public actor CodexAppServer { return thread(from: response.thread) } + /// Forks an existing Codex thread into a new thread. + /// + /// - Parameters: + /// - id: The source thread identifier. + /// - options: Options for the forked thread. + /// - Returns: A domain handle for the forked thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func forkThread( _ id: CodexThreadID, options: CodexThread.Options = .init() @@ -115,6 +184,11 @@ public actor CodexAppServer { return thread(from: response.thread) } + /// Restores an archived Codex thread. + /// + /// - Parameter id: The archived thread identifier. + /// - Returns: A domain handle for the restored thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func unarchiveThread(_ id: CodexThreadID) async throws -> CodexThread { let response = try await client.send( AppServerAPI.Thread.Unarchive.Request( @@ -123,6 +197,10 @@ public actor CodexAppServer { return thread(from: response.thread) } + /// Permanently deletes a Codex thread. + /// + /// - Parameter id: The thread identifier to delete. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func deleteThread(_ id: CodexThreadID) async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Delete.Request( @@ -130,6 +208,11 @@ public actor CodexAppServer { )) } + /// Lists Codex threads visible to the app-server account. + /// + /// - Parameter query: Paging and filtering options. + /// - Returns: A page of thread snapshots. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func listThreads(_ query: CodexThreadQuery = .init()) async throws -> CodexThreadPage { let response = try await client.send( AppServerAPI.Thread.List.Request( @@ -147,6 +230,11 @@ public actor CodexAppServer { ) } + /// Lists available Codex models. + /// + /// - Parameter includeHidden: Whether hidden models should be included. + /// - Returns: The complete model list across all app-server result pages. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func models(includeHidden: Bool = false) async throws -> [CodexModel] { var cursor: String? var models: [CodexModel] = [] @@ -161,6 +249,11 @@ public actor CodexAppServer { return models } + /// Reads the active Codex account. + /// + /// - Parameter refreshToken: Whether the app-server should refresh token state before returning. + /// - Returns: The active account, or `nil` when no account is signed in. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func account(refreshToken: Bool = false) async throws -> CodexAccount? { let response = try await client.send( AppServerAPI.Account.Read.Request(params: .init(refreshToken: refreshToken)) @@ -168,6 +261,10 @@ public actor CodexAppServer { return response.account.map(Self.account) } + /// Reads the app-server configuration visible to Codex clients. + /// + /// - Returns: Model, reasoning, review model, and service-tier settings. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func configuration() async throws -> CodexConfiguration { let response = try await client.send(AppServerAPI.Config.Read.Request()) return .init( @@ -178,6 +275,10 @@ public actor CodexAppServer { ) } + /// Reads Codex account rate-limit information. + /// + /// - Returns: Current plan type and rate-limit windows reported by the app-server. + /// - Throws: A transport, JSON-RPC, or app-server request error. public func rateLimits() async throws -> CodexRateLimits { let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) return .init( @@ -192,6 +293,11 @@ public actor CodexAppServer { ) } + /// Starts an API-key login flow. + /// + /// - Parameter apiKey: The OpenAI API key to register with Codex. + /// - Returns: The login handle reported by the app-server. + /// - Throws: A transport, JSON-RPC, or app-server login error. @discardableResult public func loginAPIKey(_ apiKey: String) async throws -> CodexLoginHandle { let response = try await client.send( @@ -201,6 +307,11 @@ public actor CodexAppServer { return try Self.loginHandle(from: response) } + /// Starts a ChatGPT browser login flow. + /// + /// - Parameter callbackURLScheme: Optional native callback URL scheme for completing login. + /// - Returns: A login handle containing the next browser or callback step. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func loginChatGPT(callbackURLScheme: String? = nil) async throws -> CodexLoginHandle { let response = try await client.send( AppServerAPI.Account.Login.Start.Request( @@ -214,6 +325,10 @@ public actor CodexAppServer { return try Self.loginHandle(from: response) } + /// Starts a ChatGPT device-code login flow. + /// + /// - Returns: A login handle containing device-code instructions. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func loginChatGPTDeviceCode() async throws -> CodexLoginHandle { let response = try await client.send( AppServerAPI.Account.Login.Start.Request( @@ -222,6 +337,12 @@ public actor CodexAppServer { return try Self.loginHandle(from: response) } + /// Cancels a pending login flow. + /// + /// Handles without an app-server login identifier are treated as already complete. + /// + /// - Parameter handle: The login handle returned from a login-start method. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func cancelLogin(_ handle: CodexLoginHandle) async throws { guard let id = handle.id else { return @@ -229,12 +350,24 @@ public actor CodexAppServer { try await cancelLogin(id: id) } + /// Cancels a pending login flow by identifier. + /// + /// - Parameter id: The app-server login identifier. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func cancelLogin(id: CodexLoginHandle.ID) async throws { let _: AppServerAPI.Account.Login.Cancel.Response = try await client.send( AppServerAPI.Account.Login.Cancel.Request(params: .init(loginID: id.rawValue)) ) } + /// Completes a native ChatGPT browser login flow. + /// + /// Handles without an app-server login identifier are treated as already complete. + /// + /// - Parameters: + /// - handle: The login handle returned from `loginChatGPT(callbackURLScheme:)`. + /// - callbackURL: The callback URL received by the client application. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func completeLogin(_ handle: CodexLoginHandle, callbackURL: URL) async throws { guard let id = handle.id else { return @@ -242,6 +375,12 @@ public actor CodexAppServer { try await completeLogin(id: id, callbackURL: callbackURL) } + /// Completes a native ChatGPT browser login flow by identifier. + /// + /// - Parameters: + /// - id: The app-server login identifier. + /// - callbackURL: The callback URL received by the client application. + /// - Throws: A transport, JSON-RPC, or app-server login error. public func completeLogin(id: CodexLoginHandle.ID, callbackURL: URL) async throws { let _: AppServerAPI.Account.Login.Complete.Response = try await client.send( AppServerAPI.Account.Login.Complete.Request( @@ -252,6 +391,9 @@ public actor CodexAppServer { ) } + /// Logs out of the active Codex account. + /// + /// - Throws: A transport, JSON-RPC, or app-server request error. public func logout() async throws { let _: EmptyResponse = try await client.send(AppServerAPI.Account.Logout.Request()) } From 6f1a34aaeb6b89512ce97a88d42f39501c183291 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:18 +0900 Subject: [PATCH 004/359] refactor(host): route ReviewMonitor runtime through CodexAppServer --- .../CodexAppServerKit/CodexAppServer.swift | 37 +++- .../AppServerCodexReviewBackend.swift | 5 + Sources/CodexReviewHost/CodexReviewHost.swift | 17 -- .../LiveCodexReviewStoreBackend.swift | 170 ++++++++---------- 4 files changed, 120 insertions(+), 109 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index f8e109a4..c9dbe530 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -66,6 +66,15 @@ public actor CodexAppServer { private let client: AppServerClient private let router: CodexAppServerNotificationRouter + private let reviewThreadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy + + package nonisolated var appServerClient: AppServerClient { + client + } + + package nonisolated var threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy { + reviewThreadStartPermissionStrategy + } /// Starts a Codex app-server process and initializes the client session. /// @@ -92,11 +101,33 @@ public actor CodexAppServer { await router.start() self.client = client self.router = router + self.reviewThreadStartPermissionStrategy = transportConfiguration.threadStartPermissionStrategy + } + + package init( + client: AppServerClient, + router: CodexAppServerNotificationRouter, + threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + ) { + self.client = client + self.router = router + self.reviewThreadStartPermissionStrategy = threadStartPermissionStrategy } - package init(client: AppServerClient, router: CodexAppServerNotificationRouter) { + package init( + transport: any JSONRPC.Transport, + threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + ) async throws { + let client = AppServerClient(transport: transport) + _ = try await client.initialize( + clientName: Configuration.default.clientName, + clientVersion: Configuration.default.clientVersion + ) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() self.client = client self.router = router + self.reviewThreadStartPermissionStrategy = threadStartPermissionStrategy } /// Closes the app-server connection and stops notification routing. @@ -108,6 +139,10 @@ public actor CodexAppServer { await client.close() } + package func notificationStream() async -> AsyncThrowingStream { + await client.notificationStream() + } + /// Creates a new Codex thread in a workspace. /// /// - Parameters: diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index d8173f9b..914911be 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -39,6 +39,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var notificationRouterMetrics = AppServerNotificationRouterMetrics() private var reviewStartRequestsInFlight = 0 + package init(appServer: CodexAppServer) { + self.client = appServer.appServerClient + self.threadStartPermissionStrategy = appServer.threadStartPermissionStrategy + } + package init( client: AppServerClient, threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift index a922adb9..6509eaa6 100644 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ b/Sources/CodexReviewHost/CodexReviewHost.swift @@ -1,7 +1,5 @@ import Foundation -import CodexAppServerKit import CodexReview -import CodexReviewAppServer import CodexReviewMCPServer private typealias ReviewCodexAccount = CodexReview.CodexAccount @@ -31,21 +29,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 diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 86ded327..ba880591 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -69,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 } } @@ -176,10 +176,12 @@ public extension CodexReviewStore { mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, shutdownCleanupTimeout: shutdownCleanupTimeout, appServerRuntimeFactory: { codexHomeURL in - let client = AppServerClient(transport: try await transportFactory(codexHomeURL)) + let appServer = try await CodexAppServer( + transport: try await transportFactory(codexHomeURL) + ) return .init( - client: client, - backend: AppServerCodexReviewBackend(client: client) + appServer: appServer, + backend: AppServerCodexReviewBackend(appServer: appServer) ) } ), @@ -198,12 +200,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let seed: CodexReviewStoreSeed - private var client: AppServerClient? + private var appServer: CodexAppServer? 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 @@ -272,7 +274,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } var isActive: Bool { - client != nil + appServer != nil } var handlesActiveReviewStopCleanup: Bool { @@ -371,25 +373,16 @@ 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( + try await CodexAppServer(configuration: .init( executable: codexExecutablePath, codexHomeURL: codexHomeURL - ) - let transport = try AppServerProcessTransport(configuration: configuration) - return AppServerProcessRuntime( - transport: transport, - threadStartPermissionStrategy: configuration.threadStartPermissionStrategy - ) + )) }.value - let client = AppServerClient(transport: processRuntime.transport) return .init( - client: client, - backend: AppServerCodexReviewBackend( - client: client, - threadStartPermissionStrategy: processRuntime.threadStartPermissionStrategy - ) + appServer: appServer, + backend: AppServerCodexReviewBackend(appServer: appServer) ) } } @@ -409,19 +402,19 @@ 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 + startedAppServer = appServer + self.appServer = appServer self.appServerBackend = backend - observeAuthNotifications(client: client, backend: backend, store: store) + observeAuthNotifications(appServer: appServer, backend: backend, store: store) if let mcpHTTPServerFactory { let mcpHTTPServer = mcpHTTPServerFactory(store, mcpHTTPServerConfiguration) try await mcpHTTPServer.start() @@ -437,8 +430,8 @@ 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 self.appServerBackend = nil self.mcpHTTPServer = nil authNotificationTask?.cancel() @@ -495,10 +488,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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 @@ -513,14 +506,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { timeoutWarning: "Timed out cleaning active reviews before stopping runtime" ) } - self.client = nil + self.appServer = nil 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") } @@ -624,8 +617,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 { @@ -636,7 +629,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 { @@ -645,7 +638,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 { @@ -777,14 +770,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") @@ -793,7 +786,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") @@ -802,12 +795,12 @@ 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 @@ -830,14 +823,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( @@ -865,8 +858,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 @@ -877,7 +870,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, @@ -909,12 +902,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { callbackURL: callbackURL.absoluteString )) let activation = loginActivation - 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 @@ -936,7 +929,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } } - await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL) + await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) } catch is CancellationError { await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth) } catch CodexReviewNativeAuthenticationError.cancelled { @@ -946,18 +939,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, @@ -982,11 +975,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 @@ -996,7 +989,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 @@ -1013,7 +1006,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 { @@ -1025,14 +1018,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 { @@ -1140,7 +1133,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func observeAuthNotifications( - client: AppServerClient, + appServer: CodexAppServer, backend: AppServerCodexReviewBackend, store: CodexReviewStore ) { @@ -1149,7 +1142,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let self, let store else { return } - let stream = await client.notificationStream() + let stream = await appServer.notificationStream() do { for try await notification in stream { await self.handleAuthNotification( @@ -1171,7 +1164,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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)" @@ -1184,16 +1177,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { timeoutWarning: "Timed out cleaning active reviews after runtime failure" ) } - let failedClient = client + let failedAppServer = appServer let failedMCPHTTPServer = mcpHTTPServer - client = nil + appServer = nil appServerBackend = nil mcpHTTPServer = nil authNotificationTask = nil store.transitionToFailed(message) await failedMCPHTTPServer?.stop() await cleanupLoginRuntime(loginCleanup) - await failedClient?.close() + await failedAppServer?.close() } private func handleAuthNotification( @@ -1214,7 +1207,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func observeLoginNotifications( - client: AppServerClient, + appServer: CodexAppServer, backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel ) { @@ -1223,7 +1216,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let self, let auth else { return } - let stream = await client.notificationStream() + let stream = await appServer.notificationStream() do { for try await notification in stream where notification.method == "account/login/completed" @@ -1271,7 +1264,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } loginChallenge = nil - let loginClient = loginClient + let loginAppServer = loginAppServer let loginCodexHomeURL = loginCodexHomeURL let activeAuthenticationSession = activeAuthenticationSession self.activeAuthenticationSession = nil @@ -1286,11 +1279,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) self.loginBackend = nil isWaitingForLoginAccountUpdate = false - self.loginClient = nil + self.loginAppServer = nil self.loginCodexHomeURL = nil loginNotificationTask?.cancel() loginNotificationTask = nil - await closeIsolatedLoginRuntime(client: loginClient, codexHomeURL: loginCodexHomeURL) + await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) return } isWaitingForLoginAccountUpdate = true @@ -1317,7 +1310,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 { @@ -1351,12 +1344,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( @@ -1455,10 +1448,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) @@ -1537,15 +1530,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) } @@ -1553,8 +1546,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 @@ -1564,7 +1557,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginNotificationTask?.cancel() loginNotificationTask = nil return .init( - client: loginClient, + appServer: loginAppServer, codexHomeURL: loginCodexHomeURL, authenticationSession: activeAuthenticationSession ) @@ -1572,7 +1565,7 @@ 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( @@ -1663,18 +1656,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { @MainActor private struct AppServerRuntime: Sendable { - var client: AppServerClient + var appServer: CodexAppServer 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 From c9dc53c0dada78aea85f304df14c71c0403b9592 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:45:00 +0900 Subject: [PATCH 005/359] refactor(architecture): move review wire vocabulary out of domain --- Package.swift | 1 - .../CodexAppServerKit/AppServerClient.swift | 4 +- .../AppServerProcessTransport.swift | 6 +- Sources/CodexAppServerKit/README.md | 2 +- .../AppServerReviewEventKind.swift | 25 ++++++++ .../AppServerWireEvent.swift | 44 +++++++------- Sources/CodexReviewDomain/ReviewKinds.swift | 23 -------- .../ArchitectureFenceTests.swift | 59 +++++++++++++++++++ .../ReviewTimelineTests.swift | 1 - 9 files changed, 112 insertions(+), 53 deletions(-) create mode 100644 Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift diff --git a/Package.swift b/Package.swift index 3d795c9e..c4d52bb7 100644 --- a/Package.swift +++ b/Package.swift @@ -92,7 +92,6 @@ let package = Package( .target( name: "CodexReviewMCPAdapter", dependencies: [ - "CodexReview", "CodexReviewDomain", ], swiftSettings: [ diff --git a/Sources/CodexAppServerKit/AppServerClient.swift b/Sources/CodexAppServerKit/AppServerClient.swift index 416a7187..b9ae2678 100644 --- a/Sources/CodexAppServerKit/AppServerClient.swift +++ b/Sources/CodexAppServerKit/AppServerClient.swift @@ -1,7 +1,7 @@ import Foundation import OSLog -private let logger = Logger(subsystem: "CodexReviewKit", category: "app-server-client") +private let logger = Logger(subsystem: "CodexAppServerKit", category: "app-server-client") package actor AppServerClient { private static let appServerOverloadedErrorCode = -32001 @@ -35,7 +35,7 @@ package actor AppServerClient { } package func initialize( - clientName: String = "CodexReviewKit", + clientName: String = "CodexAppServerKit", clientVersion: String = "2" ) async throws -> AppServerAPI.Initialize.Response { if let initializationResponse { diff --git a/Sources/CodexAppServerKit/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift index 46632a80..283c71dd 100644 --- a/Sources/CodexAppServerKit/AppServerProcessTransport.swift +++ b/Sources/CodexAppServerKit/AppServerProcessTransport.swift @@ -2,7 +2,7 @@ import Darwin import Foundation import OSLog -private let logger = Logger(subsystem: "CodexReviewKit", category: "app-server-transport") +private let logger = Logger(subsystem: "CodexAppServerKit", category: "app-server-transport") package actor AppServerProcessTransport: JSONRPC.Transport { package struct Configuration: Sendable { @@ -92,11 +92,11 @@ package actor AppServerProcessTransport: JSONRPC.Transport { self.stderr = stderr let stdoutEvents = AppServerPipeReadEventSource( fileHandle: stdout.fileHandleForReading, - label: "com.lynnpd.CodexReviewKit.app-server.stdout" + label: "com.lynnpd.CodexAppServerKit.app-server.stdout" ) let stderrEvents = AppServerPipeReadEventSource( fileHandle: stderr.fileHandleForReading, - label: "com.lynnpd.CodexReviewKit.app-server.stderr" + label: "com.lynnpd.CodexAppServerKit.app-server.stderr" ) self.stdoutEvents = stdoutEvents self.stderrEvents = stderrEvents diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index cc350778..cb3b7ab3 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -171,7 +171,7 @@ for try await event in thread.events { } ``` -This lets CodexReviewKit and similar clients build review logs from +This lets review clients build logs from CodexAppServerKit domain events instead of parsing JSON-RPC notifications or string logs directly. diff --git a/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift b/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift new file mode 100644 index 00000000..b2f762e5 --- /dev/null +++ b/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift @@ -0,0 +1,25 @@ +import Foundation +import CodexReviewDomain + +public struct AppServerReviewEventKind: 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" +} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift index cc5aceb1..4f05a9a6 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift @@ -2,13 +2,13 @@ import Foundation import CodexReviewDomain public struct AppServerWireEvent: Equatable, Sendable { - public var kind: ReviewWireEventKind + public var kind: AppServerReviewEventKind public var itemKind: ReviewItemKind? public var itemID: ReviewTimelineItem.ID? public var timestamp: Date? public init( - kind: ReviewWireEventKind, + kind: AppServerReviewEventKind, itemKind: ReviewItemKind? = nil, itemID: ReviewTimelineItem.ID? = nil, timestamp: Date? = nil @@ -126,7 +126,7 @@ public indirect enum AppServerWireJSONValue: Decodable, Equatable, Sendable { } public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { - public var method: ReviewWireEventKind + public var method: AppServerReviewEventKind public var payload: Payload public var rawPayload: AppServerWireJSONValue? @@ -135,7 +135,7 @@ public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { } public init( - method: ReviewWireEventKind, + method: AppServerReviewEventKind, payload: Payload = Payload(), rawPayload: AppServerWireJSONValue? = nil ) { @@ -152,7 +152,7 @@ public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { 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.method = AppServerReviewEventKind(rawValue: rawMethod) self.rawPayload = container.contains(.payload) ? try container.decode(AppServerWireJSONValue.self, forKey: .payload) : nil @@ -501,7 +501,7 @@ public extension AppServerWireReviewNotification { } } - func itemStartedEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func itemStartedEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let item else { return [] } @@ -518,7 +518,7 @@ public extension AppServerWireReviewNotification { return [.itemStarted(seed(for: item, phase: phase))] } - func itemUpdateEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func itemUpdateEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { if let item { let phase = item.phase(default: .running) if item.family == .reasoning { @@ -537,7 +537,7 @@ public extension AppServerWireReviewNotification { return unknownEvent(method: method) } - func itemCompletionEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func itemCompletionEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let item else { return [] } @@ -579,7 +579,7 @@ public extension AppServerWireReviewNotification { )] } - func toolProgressEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func toolProgressEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let message = message?.nilIfEmpty else { return unknownEvent(method: method) } @@ -596,7 +596,7 @@ public extension AppServerWireReviewNotification { ))] } - func fileChangeUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func fileChangeUpdateEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { let changesOutput = changes.map(\.summaryText).joined(separator: "\n").nilIfEmpty let changePath = changes.compactMap { $0.path?.nilIfEmpty }.first let updateItemID = item?.path?.nilIfEmpty == nil @@ -611,7 +611,7 @@ public extension AppServerWireReviewNotification { ))] } - func diffUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func diffUpdateEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let diff = diff?.nilIfEmpty else { return unknownEvent(method: method) } @@ -624,7 +624,7 @@ public extension AppServerWireReviewNotification { ))] } - func planUpdateEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func planUpdateEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { let markdown = plan.compactMap { step -> String? in switch (step.status.nilIfEmpty, step.step.nilIfEmpty) { case let (status?, step?): @@ -649,7 +649,7 @@ public extension AppServerWireReviewNotification { ))] } - func contextCompactionEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func contextCompactionEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { [.itemCompleted(ReviewTimelineItemSeed( id: .init(rawValue: itemID ?? resolvedTurnID.map { "contextCompaction:\($0)" } ?? syntheticItemID(method: method.rawValue)), kind: .contextCompaction, @@ -659,7 +659,7 @@ public extension AppServerWireReviewNotification { ))] } - func threadStatusEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func threadStatusEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { switch normalizedStatus(status?.type) { case "notloaded", "closed": return [.reviewFailed(terminalMessage ?? status?.type ?? "")] @@ -676,7 +676,7 @@ public extension AppServerWireReviewNotification { } } - func errorEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func errorEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let message = diagnosticMessage else { return [.reviewFailed("")] } @@ -687,14 +687,14 @@ public extension AppServerWireReviewNotification { return [.itemUpdated(diagnostic), .reviewFailed(message)] } - func diagnosticEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func diagnosticEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let message = diagnosticMessage else { return unknownEvent(method: method) } return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] } - func modelReroutedEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func modelReroutedEvents(method: AppServerReviewEventKind) -> [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 { @@ -703,7 +703,7 @@ public extension AppServerWireReviewNotification { return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] } - func modelVerificationEvents(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func modelVerificationEvents(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { let message = diagnosticMessage ?? verifications.joined(separator: "\n").nilIfEmpty guard let message else { return unknownEvent(method: method) @@ -711,7 +711,7 @@ public extension AppServerWireReviewNotification { return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] } - func messageEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func messageEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { guard let message = message?.nilIfEmpty else { return unknownEvent(method: method) } @@ -724,7 +724,7 @@ public extension AppServerWireReviewNotification { ))] } - func unknownEvent(method: ReviewWireEventKind) -> [ReviewDomainEvent] { + func unknownEvent(method: AppServerReviewEventKind) -> [ReviewDomainEvent] { [.itemUpdated(ReviewTimelineItemSeed( id: .init(rawValue: itemID ?? syntheticItemID(method: method.rawValue)), kind: ReviewItemKind(rawValue: method.rawValue), @@ -816,7 +816,7 @@ public extension AppServerWireReviewNotification { } private func diagnosticSeed( - method: ReviewWireEventKind, + method: AppServerReviewEventKind, message: String, phase: ReviewItemPhase ) -> ReviewTimelineItemSeed { @@ -1417,7 +1417,7 @@ private enum TerminalDisposition { case cancelled } -private extension ReviewWireEventKind { +private extension AppServerReviewEventKind { static let turnCompleted: Self = "turn/completed" static let turnFailed: Self = "turn/failed" static let turnCancelled: Self = "turn/cancelled" diff --git a/Sources/CodexReviewDomain/ReviewKinds.swift b/Sources/CodexReviewDomain/ReviewKinds.swift index f77b35b6..966c42dd 100644 --- a/Sources/CodexReviewDomain/ReviewKinds.swift +++ b/Sources/CodexReviewDomain/ReviewKinds.swift @@ -24,29 +24,6 @@ public struct ReviewItemKind: ReviewStringIdentifier, CustomStringConvertible { 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 { diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index 677e5d99..997df95e 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -41,6 +41,41 @@ struct ArchitectureFenceTests { Self.expectNoViolations(violations) } + @Test func codexReviewDomainDoesNotOwnAppServerWireVocabulary() throws { + let repo = Self.repositoryRoot + let domainSources = repo.appending(path: "Sources/CodexReviewDomain") + let forbiddenFragments = [ + "AppServer", + "JSONRPC", + "WireEvent", + "turn/", + "item/", + ] + + let violations = try Self.sourceFragmentViolations( + in: domainSources, + relativeRoot: "Sources/CodexReviewDomain", + forbiddenFragments: forbiddenFragments + ) + + Self.expectNoViolations(violations) + } + + @Test func codexAppServerKitDoesNotUseReviewMonitorProductNames() throws { + let repo = Self.repositoryRoot + let kitSources = repo.appending(path: "Sources/CodexAppServerKit") + let violations = try Self.sourceFragmentViolations( + in: kitSources, + relativeRoot: "Sources/CodexAppServerKit", + forbiddenFragments: [ + "CodexReviewKit", + "ReviewMonitor", + ] + ) + + Self.expectNoViolations(violations) + } + @Test func reviewUIDoesNotMutateStoreOwnedJobStateDirectly() throws { let repo = Self.repositoryRoot let checkedRoots = [ @@ -278,6 +313,30 @@ struct ArchitectureFenceTests { return violations } + private static func sourceFragmentViolations( + in root: URL, + relativeRoot: String, + forbiddenFragments: [String] + ) throws -> [String] { + var violations: [String] = [] + 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 lineText = String(line) + guard let fragment = forbiddenFragments.first(where: { lineText.contains($0) }) else { + continue + } + violations.append( + "\(relativeRoot)/\(file):\(offset + 1): contains \(fragment)" + ) + } + } + return violations + } + private static func importedModules(from line: Substring) -> [String] { line.split(separator: ";").compactMap(importedModule(fromImportStatement:)) } diff --git a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift b/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift index 49b8f08d..b6987baf 100644 --- a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift +++ b/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift @@ -9,7 +9,6 @@ struct ReviewTimelineTests { 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""#) From fa84e27b0ea1b1316f5272d1b720de351a1fd309 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:51:56 +0900 Subject: [PATCH 006/359] refactor(app-server): decode review notifications in wire target --- .../AppServerCodexReviewBackend.swift | 431 ++---------------- .../AppServerWireEvent.swift | 19 +- .../AppServerClientTests.swift | 2 +- 3 files changed, 57 insertions(+), 395 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 914911be..6254b9f5 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -719,10 +719,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { notificationRouterMetrics.ignored += 1 return } - guard let payload = try? JSONDecoder().decode(TurnNotificationPayload.self, from: notification.params) else { + guard let wireNotification = try? AppServerWireReviewNotification( + method: notification.method, + paramsData: notification.params + ) else { notificationRouterMetrics.ignored += 1 return } + let payload = wireNotification.payload notificationRouterMetrics.decoded += 1 if let turnID = payload.resolvedTurnID, abandonedTurnIDs.contains(turnID) { @@ -733,9 +737,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewNotificationSequence += 1 let routed = AppServerRoutedReviewNotification( sequence: reviewNotificationSequence, - method: notification.method, - params: notification.params, - payload: payload + wireNotification: wireNotification ) if let threadID = payload.threadID { guard let session = reviewEventSession(forThreadID: threadID) else { @@ -808,9 +810,15 @@ package struct AppServerReviewEventSessionMetrics: Equatable, Sendable { private struct AppServerRoutedReviewNotification: Sendable { var sequence: Int - var method: String - var params: Data - var payload: TurnNotificationPayload + var wireNotification: AppServerWireReviewNotification + + var method: String { + wireNotification.rawMethod + } + + var payload: TurnNotificationPayload { + wireNotification.payload + } } private struct DecodedReviewNotification { @@ -1309,7 +1317,7 @@ private actor AppServerReviewEventSession { return false } let startsNewCommand = notification.method == "item/started" - && notification.payload.item?.type == "commandExecution" + && notification.payload.item?.type == .commandExecution let reachesModelProgress = decoded.events.contains(where: Self.isCommandProgressBoundary(_:)) guard startsNewCommand || reachesModelProgress else { return false @@ -1321,7 +1329,7 @@ private actor AppServerReviewEventSession { "process/outputDelta", "item/commandExecution/terminalInteraction": return false - case "item/completed" where notification.payload.item?.type == "commandExecution": + case "item/completed" where notification.payload.item?.type == .commandExecution: return false case "turn/completed", "turn/failed", "turn/cancelled", "thread/closed": return false @@ -1875,154 +1883,9 @@ private extension AppServerAPI.Account.Login.Response { } } -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) - } -} - -private struct AppServerThreadStatus: Decodable, Sendable { - var type: String -} +private typealias TurnNotificationPayload = AppServerWireReviewNotification.Payload +private typealias AppServerCommandAction = AppServerWireReviewNotification.CommandAction +private typealias AppServerThreadItem = AppServerWireReviewNotification.Item private let appServerContextCompactionStartedText = "Automatically compacting context" private let appServerContextCompactionCompletedText = "Context automatically compacted" @@ -2045,7 +1908,7 @@ private func decodeReviewNotification( )] case "item/started": if let item = payload.item, - item.type == "commandExecution" { + item.type == .commandExecution { let lifecycle = AppServerCommandLifecycle( item: item, startedAt: payload.startedAt, @@ -2060,7 +1923,7 @@ private func decodeReviewNotification( events = payload.item?.updatedEvents() ?? [] case "item/completed": if let item = payload.item, - item.type == "commandExecution" { + item.type == .commandExecution { let previous = commandLifecycleByItemID[item.id] let lifecycle = AppServerCommandLifecycle( item: item, @@ -2186,7 +2049,7 @@ private func decodeReviewNotification( default: events = [.completed( summary: payload.message ?? "Succeeded.", - result: payload.result + result: payload.result?.nonNullText )] } case "error": @@ -2256,8 +2119,8 @@ private func decodeReviewNotification( 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", + startsReviewMode: notification.method == "item/started" && payload.item?.type.rawValue == "enteredReviewMode", + finishesReviewMode: notification.method == "item/completed" && payload.item?.type.rawValue == "exitedReviewMode", hasDirectTimelineEvents: directEvents.isEmpty == false ) } @@ -2266,9 +2129,7 @@ private func directTimelineDomainEvents( for notification: AppServerRoutedReviewNotification, fallbackReviewThreadID: String ) -> [ReviewDomainEvent] { - guard let wireNotification = try? wireReviewNotification(from: notification) else { - return [] - } + let wireNotification = notification.wireNotification return wireNotification .domainEvents(fallbackReviewThreadID: .init(rawValue: fallbackReviewThreadID)) .filter { wireNotification.allowsDirectTimelineEvent($0) } @@ -2386,21 +2247,6 @@ private extension ReviewTimelineItemSeed { } } -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", @@ -2595,30 +2441,7 @@ private extension TurnNotificationPayload { } } -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) - } - +private extension AppServerCommandAction { var metadataAction: ReviewLogEntry.Metadata.CommandAction { .init( kind: metadataKind, @@ -2630,7 +2453,7 @@ private struct AppServerCommandAction: Decodable, Sendable { } private var metadataKind: ReviewLogEntry.Metadata.CommandAction.Kind { - switch type { + switch kind.rawValue { case "read": .read case "listFiles": @@ -2775,92 +2598,12 @@ private extension Dictionary where Key == String, Value == AppServerCommandLifec } } -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) - } - +private extension AppServerThreadItem { func startedEvents( startedAt: Date?, lifecycle: AppServerCommandLifecycle? ) -> [CodexReviewBackendModel.Review.Event] { - switch type { + switch type.rawValue { case "userMessage": return [] case "enteredReviewMode": @@ -2915,7 +2658,7 @@ private struct AppServerThreadItem: Decodable, Sendable { } func updatedEvents() -> [CodexReviewBackendModel.Review.Event] { - switch type { + switch type.rawValue { case "agentMessage": return text.map { [logEntry( @@ -2987,7 +2730,7 @@ private struct AppServerThreadItem: Decodable, Sendable { completedAt: Date?, lifecycle: AppServerCommandLifecycle? ) -> [CodexReviewBackendModel.Review.Event] { - switch type { + switch type.rawValue { case "userMessage": return [] case "agentMessage": @@ -3143,10 +2886,10 @@ private struct AppServerThreadItem: Decodable, Sendable { 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" + let isCommandExecution = type == .commandExecution + let isLifecycleItem = isCommandExecution || type == .contextCompaction return .init( - sourceType: type, + sourceType: type.rawValue, title: title?.nilIfEmpty, status: resolvedStatus, detail: detail?.nilIfEmpty, @@ -3239,7 +2982,7 @@ private struct AppServerThreadItem: Decodable, Sendable { [namespace, server, tool] .compactMap { $0?.nilIfEmpty } .joined(separator: ".") - .nilIfEmpty ?? type + .nilIfEmpty ?? type.rawValue } private var resultSuffix: String { @@ -3257,7 +3000,7 @@ private struct AppServerThreadItem: Decodable, Sendable { } private func reasoningCompletionEvents(replacesGroup: Bool) -> [CodexReviewBackendModel.Review.Event] { - let summaryEvents = (summary ?? []).enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in + let summaryEvents = summary.enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in guard text.isEmpty == false else { return nil } @@ -3268,7 +3011,7 @@ private struct AppServerThreadItem: Decodable, Sendable { replacesGroup: replacesGroup ) } - let rawEvents = (content ?? []).enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in + let rawEvents = content.enumerated().compactMap { index, text -> CodexReviewBackendModel.Review.Event? in guard text.isEmpty == false else { return nil } @@ -3283,102 +3026,8 @@ private struct AppServerThreadItem: Decodable, Sendable { } } -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)) - } - } - +private extension AppServerWireJSONValue { 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 + nonNullText } } diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift index 4f05a9a6..b07933cc 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift @@ -144,6 +144,19 @@ public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { self.rawPayload = rawPayload } + public init(method: String, paramsData: Data) throws { + let paramsObject = try JSONSerialization.jsonObject( + with: paramsData, + options: [.fragmentsAllowed] + ) + let envelope: [String: Any] = [ + "method": method, + "params": paramsObject, + ] + let data = try JSONSerialization.data(withJSONObject: envelope) + self = try JSONDecoder().decode(Self.self, from: data) + } + public enum CodingKeys: String, CodingKey { case method case payload = "params" @@ -440,7 +453,7 @@ public extension AppServerWireReviewNotification { self.item = try? container.decodeIfPresent(Item.self, forKey: .item) } - var resolvedTurnID: String? { + public var resolvedTurnID: String? { turn?.id.nilIfEmpty ?? turnID } @@ -807,11 +820,11 @@ public extension AppServerWireReviewNotification { } } - private var startedAt: Date? { + public var startedAt: Date? { startedAtMs.map(Self.date(millisecondsSince1970:)) } - private var completedAt: Date? { + public var completedAt: Date? { completedAtMs.map(Self.date(millisecondsSince1970:)) } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 48507b01..bc877f25 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -651,7 +651,7 @@ struct AppServerClientTests { 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["name"] as? String == "CodexAppServerKit") #expect(clientInfo["version"] as? String == "2") let capabilities = try #require(params["capabilities"] as? [String: Any]) #expect(capabilities["experimentalApi"] as? Bool == true) From 25d657da82ab977104bb3c8568043b324d63f592 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:00:47 +0900 Subject: [PATCH 007/359] refactor(host): route account events through Kit --- .../CodexAppServerKit/CodexAppServer.swift | 101 +++++++++- .../CodexAppServerKit/CodexDomainTypes.swift | 51 +++++ .../AppServerCodexReviewBackend.swift | 5 +- .../LiveCodexReviewStoreBackend.swift | 178 +++++++----------- .../ArchitectureFenceTests.swift | 19 ++ 5 files changed, 235 insertions(+), 119 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index c9dbe530..d632244d 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -143,6 +143,36 @@ public actor CodexAppServer { await client.notificationStream() } + /// Returns account-related app-server notifications as typed domain events. + /// + /// The stream includes login completion, account update, and Codex + /// rate-limit update notifications. Notifications with newer account + /// methods are preserved as `.unknown`; malformed known notifications are + /// reported as `.malformed` without terminating the stream. + /// + /// - Returns: A stream of account domain events. + public func accountEvents() async -> AsyncThrowingStream { + let notifications = await client.notificationStream() + return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + do { + for try await notification in notifications { + guard let event = Self.accountEvent(from: notification) else { + continue + } + continuation.yield(event) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + /// Creates a new Codex thread in a workspace. /// /// - Parameters: @@ -316,16 +346,7 @@ public actor CodexAppServer { /// - Throws: A transport, JSON-RPC, or app-server request error. public func rateLimits() async throws -> CodexRateLimits { let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) - return .init( - planType: response.codexPlanType, - windows: response.codexRateLimitWindows.map { - .init( - windowDurationMinutes: $0.windowDurationMinutes, - usedPercent: $0.usedPercent, - resetsAt: $0.resetsAt - ) - } - ) + return .init(appServer: response) } /// Starts an API-key login flow. @@ -508,6 +529,50 @@ public actor CodexAppServer { } } + private nonisolated static func accountEvent( + from notification: JSONRPC.Notification + ) -> CodexAccountEvent? { + switch notification.method { + case "account/login/completed": + do { + let payload = try JSONDecoder().decode( + AppServerAccountLoginCompletedNotification.self, + from: notification.params + ) + return .loginCompleted(.init( + loginID: payload.loginID.map(CodexLoginHandle.ID.init(rawValue:)), + success: payload.success, + error: payload.error + )) + } catch { + return .malformed(method: notification.method, message: error.localizedDescription) + } + case "account/updated": + return .accountUpdated + case "account/rateLimits/updated": + do { + let payload = try JSONDecoder().decode( + AppServerAccountRateLimitsUpdatedNotification.self, + from: notification.params + ) + guard AppServerAPI.Account.RateLimits.Response + .isCodexRateLimit(payload.rateLimits.limitID) + else { + return nil + } + return .rateLimitsUpdated(.init( + appServer: .init(rateLimits: payload.rateLimits) + )) + } catch { + return .malformed(method: notification.method, message: error.localizedDescription) + } + case let method where method.hasPrefix("account/"): + return .unknown(.init(method: notification.method, params: notification.params)) + default: + return nil + } + } + private nonisolated static func defaultCodexHomeURL(environment: [String: String]) -> URL { if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), codexHome.isEmpty == false { @@ -522,3 +587,19 @@ public actor CodexAppServer { .appendingPathComponent(".codex", isDirectory: true) } } + +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 AppServerAccountRateLimitsUpdatedNotification: Decodable, Equatable, Sendable { + var rateLimits: AppServerAPI.Account.RateLimits.Snapshot +} diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 2dddf907..a20453ef 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -1241,6 +1241,21 @@ public struct CodexRateLimitWindow: Equatable, Sendable { } } +package extension CodexRateLimits { + init(appServer response: AppServerAPI.Account.RateLimits.Response) { + self.init( + planType: response.codexPlanType, + windows: response.codexRateLimitWindows.map { + .init( + windowDurationMinutes: $0.windowDurationMinutes, + usedPercent: $0.usedPercent, + resetsAt: $0.resetsAt + ) + } + ) + } +} + public struct CodexModel: Codable, Identifiable, Equatable, Sendable { public struct ReasoningOption: Codable, Equatable, Sendable { public var reasoningEffort: String @@ -1352,6 +1367,42 @@ public struct CodexAccount: Identifiable, Equatable, Sendable { } } +/// The result of an app-server account login completion notification. +public struct CodexLoginCompletion: Equatable, Sendable { + /// The app-server login identifier, when the notification is scoped to a login flow. + public var loginID: CodexLoginHandle.ID? + + /// Whether the login completed successfully. + public var success: Bool + + /// The server-provided failure message when `success` is false. + public var error: String? + + public init(loginID: CodexLoginHandle.ID? = nil, success: Bool, error: String? = nil) { + self.loginID = loginID + self.success = success + self.error = error + } +} + +/// A typed account-related notification emitted by Codex app-server. +public enum CodexAccountEvent: Equatable, Sendable { + /// A login flow reached a terminal state. + case loginCompleted(CodexLoginCompletion) + + /// The active account changed or was refreshed. + case accountUpdated + + /// Account rate-limit information changed. + case rateLimitsUpdated(CodexRateLimits) + + /// A known account notification arrived with a shape this SDK could not decode. + case malformed(method: String, message: String) + + /// A notification outside the current account event surface. + case unknown(CodexRawNotification) +} + public enum CodexLoginHandle: Equatable, Sendable { public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { public var rawValue: String diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 6254b9f5..b81a560f 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -85,9 +85,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return .init(accounts: [account], activeAccountID: account.id) } - package func readRateLimits() async throws -> AppServerAPI.Account.RateLimits.Response { + package func readRateLimits() async throws -> CodexRateLimits { _ = try await client.initialize() - return try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) + let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) + return .init(appServer: response) } package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index ba880591..64a1a38f 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1142,11 +1142,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let self, let store else { return } - let stream = await appServer.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 ) @@ -1190,18 +1190,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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 } } @@ -1216,13 +1218,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let self, let auth else { return } - let stream = await appServer.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 { @@ -1232,65 +1231,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 loginAppServer = loginAppServer - 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.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") - } 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( @@ -1365,33 +1357,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 { @@ -1478,11 +1457,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 @@ -1569,12 +1544,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func applyRateLimits( - windows: [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)], - planType: String?, + _ rateLimits: CodexRateLimits, to account: ReviewCodexAccount ) { - 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) @@ -1691,22 +1671,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 { diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index 997df95e..8a9b6f85 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -76,6 +76,25 @@ struct ArchitectureFenceTests { Self.expectNoViolations(violations) } + @Test func codexReviewHostDoesNotDecodeRawAppServerAccountNotifications() throws { + let repo = Self.repositoryRoot + let hostSources = repo.appending(path: "Sources/CodexReviewHost") + let violations = try Self.sourceFragmentViolations( + in: hostSources, + relativeRoot: "Sources/CodexReviewHost", + forbiddenFragments: [ + "JSONRPC.Notification", + "AppServerAPI.Account", + "AppServerAccountLoginCompletedNotification", + "AppServerAccountRateLimitsUpdatedPayload", + "account/login/completed", + "account/rateLimits/updated", + ] + ) + + Self.expectNoViolations(violations) + } + @Test func reviewUIDoesNotMutateStoreOwnedJobStateDirectly() throws { let repo = Self.repositoryRoot let checkedRoots = [ From 0fbe3cac8817233bd17e0e72018bc51f8882d663 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:05:15 +0900 Subject: [PATCH 008/359] refactor(app-server): keep review requests out of Kit namespace --- .../AppServerCodexReviewBackend.swift | 8 +- .../AppServerRequests.swift | 26 ++- .../ArchitectureFenceTests.swift | 24 +++ .../AppServerClientTests.swift | 164 +++++++++--------- .../CodexReviewHostTests.swift | 10 +- 5 files changed, 127 insertions(+), 105 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index b81a560f..8ea3ee5a 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -156,10 +156,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { registerReviewEventSession(session, for: provisionalRun) control.recordThreadStarted(threadID: thread.threadID) - let review: AppServerAPI.Review.Start.Response + let review: AppServerReviewStart.Response reviewStartRequestsInFlight += 1 do { - review = try await client.send(AppServerAPI.Review.Start.Request( + review = try await client.send(AppServerReviewStart.Request( params: .init(threadID: thread.threadID, target: request.request.target) )) } catch { @@ -356,10 +356,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) registerReviewEventSession(session, for: provisionalRun) - let review: AppServerAPI.Review.Start.Response + let review: AppServerReviewStart.Response reviewStartRequestsInFlight += 1 do { - review = try await client.send(AppServerAPI.Review.Start.Request( + review = try await client.send(AppServerReviewStart.Request( params: .init(threadID: interruptedRun.threadID, target: request.request.target) )) } catch { diff --git a/Sources/CodexReviewAppServer/AppServerRequests.swift b/Sources/CodexReviewAppServer/AppServerRequests.swift index f80a9af2..65ccdbcc 100644 --- a/Sources/CodexReviewAppServer/AppServerRequests.swift +++ b/Sources/CodexReviewAppServer/AppServerRequests.swift @@ -2,17 +2,15 @@ import CodexAppServerKit import CodexReview import Foundation -extension AppServerAPI { - package enum Review { - package enum Start {} - } +package enum AppServerReviewStart { + package static let method = "review/start" } -extension AppServerAPI.Review.Start { +extension AppServerReviewStart { package struct Params: Codable, Equatable, Sendable { package var threadID: String package var target: CodexReviewAPI.Target - package var delivery: AppServerAPI.Review.Start.Delivery + package var delivery: AppServerReviewStart.Delivery enum CodingKeys: String, CodingKey { case threadID = "threadId" @@ -23,7 +21,7 @@ extension AppServerAPI.Review.Start { package init( threadID: String, target: CodexReviewAPI.Target, - delivery: AppServerAPI.Review.Start.Delivery = .inline + delivery: AppServerReviewStart.Delivery = .inline ) { self.threadID = threadID self.target = target @@ -32,14 +30,14 @@ extension AppServerAPI.Review.Start { } } -extension AppServerAPI.Review.Start { +extension AppServerReviewStart { package enum Delivery: String, Codable, Equatable, Sendable { case inline case detached } } -extension AppServerAPI.Review.Start { +extension AppServerReviewStart { package struct Response: Codable, Equatable, Sendable { package var turnID: String package var reviewThreadID: String? @@ -69,17 +67,17 @@ extension AppServerAPI.Review.Start { } } -extension AppServerAPI.Review.Start { +extension AppServerReviewStart { package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Review.Start.Response + package typealias Response = AppServerReviewStart.Response - package static let method = "review/start" - package var params: AppServerAPI.Review.Start.Params + package static let method = AppServerReviewStart.method + package var params: AppServerReviewStart.Params package var scope: AppServerAPI.RequestScope? { .thread(params.threadID) } - package init(params: AppServerAPI.Review.Start.Params) { + package init(params: AppServerReviewStart.Params) { self.params = params } } diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index 8a9b6f85..08fd4520 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -95,6 +95,30 @@ struct ArchitectureFenceTests { Self.expectNoViolations(violations) } + @Test func codexAppServerAPINamespaceStaysOwnedByKit() throws { + let repo = Self.repositoryRoot + let checkedRoots = [ + "Sources/CodexReviewAppServer", + "Sources/CodexReviewHost", + "Sources/CodexReviewMCPAdapter", + "Sources/CodexReviewMCPServer", + ] + var violations: [String] = [] + for checkedRoot in checkedRoots { + let root = repo.appending(path: checkedRoot) + violations += try Self.sourceFragmentViolations( + in: root, + relativeRoot: checkedRoot, + forbiddenFragments: [ + "extension AppServerAPI {", + "AppServerAPI.Review", + ] + ) + } + + Self.expectNoViolations(violations) + } + @Test func reviewUIDoesNotMutateStoreOwnedJobStateDirectly() throws { let repo = Self.repositoryRoot let checkedRoots = [ diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index bc877f25..09bbacc8 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -498,16 +498,16 @@ struct AppServerClientTests { @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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.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( + async let first: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) - async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( + async let second: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) await transport.waitForRequestCount(1) @@ -519,16 +519,16 @@ struct AppServerClientTests { @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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.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( + async let first: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) - async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( + async let second: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( params: .init(threadID: "thread-2", target: .uncommittedChanges) )) await transport.waitForRequestCount(2) @@ -921,10 +921,10 @@ struct AppServerClientTests { @Test func reviewTargetEncodesAppServerTaggedShape() async throws { let transport = FakeJSONRPCTransport() - try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.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( + let _: AppServerReviewStart.Response = try await client.send(AppServerReviewStart.Request( params: .init(threadID: "thread-1", target: .baseBranch("main")) )) @@ -941,7 +941,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) _ = try await backend.startReview(.init( @@ -969,7 +969,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend( client: .init(transport: transport), threadStartPermissionStrategy: .legacySandbox @@ -1003,7 +1003,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) _ = try await backend.startReview(.init( @@ -1054,7 +1054,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) _ = try await backend.startReview(.init( @@ -1087,7 +1087,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) _ = try await backend.startReview(.init( @@ -1119,7 +1119,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1171,7 +1171,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -1208,7 +1208,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -1230,7 +1230,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1263,7 +1263,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1331,7 +1331,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1420,7 +1420,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1489,7 +1489,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1525,7 +1525,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1572,7 +1572,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1615,7 +1615,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1668,7 +1668,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1722,7 +1722,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1762,7 +1762,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1797,7 +1797,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1825,7 +1825,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1871,7 +1871,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1909,7 +1909,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -1971,7 +1971,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -2037,7 +2037,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -2117,7 +2117,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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) @@ -2160,7 +2160,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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) @@ -2203,8 +2203,8 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) async let first = backend.startReview(.init( @@ -2228,8 +2228,8 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let firstRun = try await backend.startReview(.init( @@ -2275,8 +2275,8 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let firstRun = try await backend.startReview(.init( @@ -2314,8 +2314,8 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let firstRun = try await backend.startReview(.init( @@ -2346,7 +2346,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -2372,7 +2372,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -2394,7 +2394,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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", @@ -2432,7 +2432,7 @@ struct AppServerClientTests { #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) + let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) #expect(restartParams.threadID == "thread-1") #expect(restartParams.target == .baseBranch("main")) } @@ -2441,10 +2441,10 @@ struct AppServerClientTests { 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(AppServerReviewStart.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") + try await transport.enqueue(AppServerReviewStart.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", @@ -2476,7 +2476,7 @@ struct AppServerClientTests { #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) + let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) #expect(restartParams.threadID == "parent-thread") #expect(restartParams.target == .baseBranch("main")) } @@ -2485,10 +2485,10 @@ struct AppServerClientTests { 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(AppServerReviewStart.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") + try await transport.enqueue(AppServerReviewStart.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", @@ -2543,7 +2543,7 @@ struct AppServerClientTests { #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) + let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) #expect(restartParams.threadID == "parent-thread") #expect(restartParams.target == .baseBranch("main")) } @@ -2553,7 +2553,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = CodexReviewBackendModel.Review.Run( threadID: "thread-1", @@ -2583,7 +2583,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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", @@ -2630,7 +2630,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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", @@ -2908,7 +2908,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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)) @@ -2950,7 +2950,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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)) @@ -3018,7 +3018,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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)) @@ -3088,7 +3088,7 @@ struct AppServerClientTests { ) 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") + try await transport.enqueue(AppServerReviewStart.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)) @@ -3146,7 +3146,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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)) @@ -3238,7 +3238,7 @@ struct AppServerClientTests { 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") + try await transport.enqueue(AppServerReviewStart.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", @@ -3275,7 +3275,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3332,7 +3332,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3396,7 +3396,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3481,7 +3481,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3505,7 +3505,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3544,7 +3544,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3568,7 +3568,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -3596,7 +3596,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -3662,7 +3662,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -3744,7 +3744,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3795,7 +3795,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -3840,7 +3840,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3901,7 +3901,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -3968,7 +3968,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.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)) @@ -4025,7 +4025,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -4391,7 +4391,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -5544,7 +5544,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( @@ -5596,7 +5596,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) let run = try await backend.startReview(.init( diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 2f2122ea..c92604bc 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1003,7 +1003,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-first", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-first"), for: "review/start") + try await firstTransport.enqueue(AppServerReviewStart.Response(turnID: "turn-first"), for: "review/start") try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt") let secondTransport = FakeJSONRPCTransport() @@ -1083,7 +1083,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-active", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-active"), for: "review/start") + try await firstTransport.enqueue(AppServerReviewStart.Response(turnID: "turn-active"), for: "review/start") try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt") try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") @@ -1188,7 +1188,7 @@ struct CodexReviewHostTests { ) try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") 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(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1252,7 +1252,7 @@ struct CodexReviewHostTests { ) try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") 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(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, @@ -1302,7 +1302,7 @@ struct CodexReviewHostTests { for: "thread/start" ) try await transport.enqueue( - AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), + AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), for: "review/start" ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") From 2697a74d1de4084d308febe10a5e87d746fb74c5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:11:55 +0900 Subject: [PATCH 009/359] feat(app-server-kit): expose review sessions --- .../CodexAppServerKit/AppServerRequests.swift | 73 ++++++++ .../CodexAppServerKit/CodexDomainTypes.swift | 124 +++++++++++++ .../CodexThreadOperations.swift | 40 +++++ Sources/CodexAppServerKit/README.md | 29 ++++ .../AppServerCodexReviewBackend.swift | 18 +- .../AppServerRequests.swift | 84 --------- .../CodexReviewTarget+AppServer.swift | 18 ++ .../ArchitectureFenceTests.swift | 2 +- .../CodexAppServerKitTests.swift | 60 +++++++ .../AppServerClientTests.swift | 164 +++++++++--------- .../CodexReviewHostTests.swift | 10 +- 11 files changed, 444 insertions(+), 178 deletions(-) delete mode 100644 Sources/CodexReviewAppServer/AppServerRequests.swift create mode 100644 Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift diff --git a/Sources/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift index 0ecf7a60..8f1cf0bf 100644 --- a/Sources/CodexAppServerKit/AppServerRequests.swift +++ b/Sources/CodexAppServerKit/AppServerRequests.swift @@ -23,6 +23,9 @@ package enum AppServerAPI { package enum Clean {} } } + package enum Review { + package enum Start {} + } package enum Turn { package enum Start {} package enum Steer {} @@ -65,6 +68,76 @@ extension AppServerAPI.Thread.Start { } } +extension AppServerAPI.Review.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var target: CodexReviewTarget + package var delivery: CodexReviewDelivery + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case target + case delivery + } + + package init( + threadID: String, + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline + ) { + self.threadID = threadID + self.target = target + self.delivery = delivery + } + } +} + +extension AppServerAPI.Review.Start { + package struct Response: Codable, Equatable, Sendable { + package var 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) + } + } +} + +extension AppServerAPI.Review.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Review.Start.Response + + package static let method = "review/start" + package var params: AppServerAPI.Review.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Review.Start.Params) { + self.params = params + } + } +} + extension AppServerAPI { package protocol Request: Sendable { associatedtype Params: Encodable & Sendable diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index a20453ef..53d70434 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -293,6 +293,130 @@ public struct CodexThread: Identifiable, Sendable { } } +/// The target that `codex app-server` should review. +public enum CodexReviewTarget: Codable, Hashable, Sendable { + case uncommittedChanges + case baseBranch(String) + case commit(sha: String, title: String? = nil) + case custom(instructions: String) + + private enum CodingKeys: String, CodingKey { + case type + case branch + case sha + case title + case instructions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "uncommittedChanges": + self = .uncommittedChanges + case "baseBranch": + self = .baseBranch(try container.decode(String.self, forKey: .branch)) + case "commit": + self = .commit( + sha: try container.decode(String.self, forKey: .sha), + title: try container.decodeIfPresent(String.self, forKey: .title) + ) + case "custom": + self = .custom(instructions: try container.decode(String.self, forKey: .instructions)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown review target type: \(type)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .uncommittedChanges: + try container.encode("uncommittedChanges", forKey: .type) + case .baseBranch(let branch): + try container.encode("baseBranch", forKey: .type) + try container.encode(branch, forKey: .branch) + case .commit(let sha, let title): + try container.encode("commit", forKey: .type) + try container.encode(sha, forKey: .sha) + try container.encodeIfPresent(title, forKey: .title) + case .custom(let instructions): + try container.encode("custom", forKey: .type) + try container.encode(instructions, forKey: .instructions) + } + } +} + +/// How `review/start` should deliver review work. +public enum CodexReviewDelivery: String, Codable, Equatable, Sendable { + case inline + case detached +} + +/// A review run started by `codex app-server`. +public struct CodexReviewSession: Identifiable, Sendable { + public var id: CodexTurnID { + turnID + } + + public let threadID: CodexThreadID + public let turnID: CodexTurnID + public let reviewThreadID: CodexThreadID + public let response: CodexResponseStream + + private let eventThread: CodexThread + + package init( + threadID: CodexThreadID, + turnID: CodexTurnID, + reviewThreadID: CodexThreadID, + response: CodexResponseStream, + eventThread: CodexThread + ) { + self.threadID = threadID + self.turnID = turnID + self.reviewThreadID = reviewThreadID + self.response = response + self.eventThread = eventThread + } + + public var events: CodexThreadEventSequence { + eventThread.events + } + + public var messages: CodexThreadMessageSequence { + eventThread.messages + } + + public var transcriptUpdates: CodexThreadTranscriptSequence { + eventThread.transcriptUpdates + } + + public var logEntries: CodexThreadLogSequence { + eventThread.logEntries + } + + public func collect() async throws -> CodexResponse { + try await response.collect() + } + + public func interrupt() async throws { + try await response.interrupt() + } + + public func steer(with prompt: CodexPrompt) async throws { + try await response.steer(with: prompt) + } + + public func steer(with prompt: String) async throws { + try await response.steer(with: prompt) + } +} + public struct CodexTurnID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { public var rawValue: String diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 340807bf..05b08b16 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -77,6 +77,46 @@ extension CodexThread { try await streamResponse(to: try prompt(), options: options) } + /// Starts a Codex code review in this thread. + /// + /// The returned session exposes both the response stream for the review + /// turn and thread-level event streams, including log entries. When the + /// app-server uses a detached review thread, those event streams are bound + /// to that review thread. + public func startReview( + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline, + transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript + ) async throws -> CodexReviewSession { + let response = try await client.send(AppServerAPI.Review.Start.Request( + params: .init(threadID: id.rawValue, target: target, delivery: delivery) + )) + let reviewThreadID = response.reviewThreadID.map(CodexThreadID.init(rawValue:)) ?? id + let turn = CodexTurn( + id: .init(rawValue: response.turnID), + threadID: reviewThreadID, + client: client, + router: router + ) + let eventThread = CodexThread( + id: reviewThreadID, + workspace: workspace, + model: model, + client: client, + router: router + ) + return .init( + threadID: id, + turnID: turn.id, + reviewThreadID: reviewThreadID, + response: .init( + turn: turn, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy + ), + eventThread: eventThread + ) + } + package func startTurn( _ prompt: CodexPrompt, options: CodexGenerationOptions = .init() diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index cb3b7ab3..8aeea74d 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -180,6 +180,33 @@ the original `rawPayload`. Use the raw payload when a product needs full-fidelity rendering for app-server fields that the current Kit version does not yet model directly. +## Reviews + +`review/start` is part of the app-server surface, so CodexAppServerKit exposes +it as a thread operation. A review session provides the review turn response and +the review thread event streams. If app-server detaches the review into a +separate thread, `logEntries`, `events`, and `transcriptUpdates` are bound to +that review thread automatically. + +```swift +let review = try await thread.startReview(target: .baseBranch("main")) + +for try await entry in review.logEntries { + renderReviewLog(entry) +} + +let response = try await review.collect() +print(response.finalAnswer ?? "") +``` + +Review targets are Swift domain values: + +```swift +try await thread.startReview(target: .uncommittedChanges) +try await thread.startReview(target: .commit(sha: sha, title: title)) +try await thread.startReview(target: .custom(instructions: instructions)) +``` + ## Responses `CodexResponse` is the final result from `respond` or `ResponseStream.collect()`. @@ -254,6 +281,8 @@ The public boundary is: - `CodexThreadID` - `CodexTurnID` - `CodexThread` +- `CodexReviewTarget` +- `CodexReviewSession` - `CodexResponse` - `CodexResponseStream` - `CodexGenerationOptions` diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 8ea3ee5a..ed93f813 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -156,11 +156,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { registerReviewEventSession(session, for: provisionalRun) control.recordThreadStarted(threadID: thread.threadID) - let review: AppServerReviewStart.Response + let review: AppServerAPI.Review.Start.Response reviewStartRequestsInFlight += 1 do { - review = try await client.send(AppServerReviewStart.Request( - params: .init(threadID: thread.threadID, target: request.request.target) + review = try await client.send(AppServerAPI.Review.Start.Request( + params: .init( + threadID: thread.threadID, + target: request.request.target.appServerReviewTarget + ) )) } catch { reviewStartRequestsInFlight -= 1 @@ -356,11 +359,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) registerReviewEventSession(session, for: provisionalRun) - let review: AppServerReviewStart.Response + let review: AppServerAPI.Review.Start.Response reviewStartRequestsInFlight += 1 do { - review = try await client.send(AppServerReviewStart.Request( - params: .init(threadID: interruptedRun.threadID, target: request.request.target) + review = try await client.send(AppServerAPI.Review.Start.Request( + params: .init( + threadID: interruptedRun.threadID, + target: request.request.target.appServerReviewTarget + ) )) } catch { reviewStartRequestsInFlight -= 1 diff --git a/Sources/CodexReviewAppServer/AppServerRequests.swift b/Sources/CodexReviewAppServer/AppServerRequests.swift deleted file mode 100644 index 65ccdbcc..00000000 --- a/Sources/CodexReviewAppServer/AppServerRequests.swift +++ /dev/null @@ -1,84 +0,0 @@ -import CodexAppServerKit -import CodexReview -import Foundation - -package enum AppServerReviewStart { - package static let method = "review/start" -} - -extension AppServerReviewStart { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var target: CodexReviewAPI.Target - package var delivery: AppServerReviewStart.Delivery - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case target - case delivery - } - - package init( - threadID: String, - target: CodexReviewAPI.Target, - delivery: AppServerReviewStart.Delivery = .inline - ) { - self.threadID = threadID - self.target = target - self.delivery = delivery - } - } -} - -extension AppServerReviewStart { - package enum Delivery: String, Codable, Equatable, Sendable { - case inline - case detached - } -} - -extension AppServerReviewStart { - package 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) - } - } -} - -extension AppServerReviewStart { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerReviewStart.Response - - package static let method = AppServerReviewStart.method - package var params: AppServerReviewStart.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerReviewStart.Params) { - self.params = params - } - } -} diff --git a/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift new file mode 100644 index 00000000..a741cdc4 --- /dev/null +++ b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift @@ -0,0 +1,18 @@ +import CodexAppServerKit +import CodexReview +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/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index 08fd4520..370a5e98 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -111,7 +111,7 @@ struct ArchitectureFenceTests { relativeRoot: checkedRoot, forbiddenFragments: [ "extension AppServerAPI {", - "AppServerAPI.Review", + "AppServerReviewStart", ] ) } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 7374b3a0..a3b7114a 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -56,6 +56,66 @@ struct CodexAppServerKitTests { #expect(params.sandbox == "workspace-write") } + @Test func threadStartReviewSerializesTargetAndStreamsReviewThreadLogs() async throws { + let transport = FakeJSONRPCTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + + #expect(review.threadID == "thread-1") + #expect(review.turnID == "turn-review") + #expect(review.reviewThreadID == "thread-review") + + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "review/start") + let params = try JSONDecoder().decode( + AppServerAPI.Review.Start.Params.self, + from: request.params + ) + #expect(params.threadID == "thread-1") + #expect(params.target == .baseBranch("main")) + #expect(params.delivery == .detached) + + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "passed", + status: "completed" + ) + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let logs = try await collect(review.logEntries) + #expect(logs.count == 1) + #expect(logs.first?.turnID == "turn-review") + #expect(logs.first?.item?.kind == .commandExecution) + #expect(logs.first?.item?.text == "passed") + } + @Test func promptPartsEncodeToAppServerInputItems() { let prompt = CodexPrompt(parts: [ .text("Describe these files."), diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 09bbacc8..bc877f25 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -498,16 +498,16 @@ struct AppServerClientTests { @Test func sameThreadReviewRequestsDoNotOverlap() async throws { let transport = FakeJSONRPCTransport() - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2"), for: "review/start") + 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: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( + async let first: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) - async let second: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( + async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) await transport.waitForRequestCount(1) @@ -519,16 +519,16 @@ struct AppServerClientTests { @Test func differentThreadReviewRequestsCanOverlap() async throws { let transport = FakeJSONRPCTransport() - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2"), for: "review/start") + 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: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( + async let first: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( params: .init(threadID: "thread-1", target: .uncommittedChanges) )) - async let second: AppServerReviewStart.Response = client.send(AppServerReviewStart.Request( + async let second: AppServerAPI.Review.Start.Response = client.send(AppServerAPI.Review.Start.Request( params: .init(threadID: "thread-2", target: .uncommittedChanges) )) await transport.waitForRequestCount(2) @@ -921,10 +921,10 @@ struct AppServerClientTests { @Test func reviewTargetEncodesAppServerTaggedShape() async throws { let transport = FakeJSONRPCTransport() - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") let client = AppServerClient(transport: transport) - let _: AppServerReviewStart.Response = try await client.send(AppServerReviewStart.Request( + let _: AppServerAPI.Review.Start.Response = try await client.send(AppServerAPI.Review.Start.Request( params: .init(threadID: "thread-1", target: .baseBranch("main")) )) @@ -941,7 +941,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -969,7 +969,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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 @@ -1003,7 +1003,7 @@ struct AppServerClientTests { for: "thread/start" ) try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1054,7 +1054,7 @@ struct AppServerClientTests { for: "thread/start" ) try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1087,7 +1087,7 @@ struct AppServerClientTests { for: "thread/start" ) try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1119,7 +1119,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1171,7 +1171,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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)) @@ -1208,7 +1208,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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)) @@ -1230,7 +1230,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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( @@ -1263,7 +1263,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/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( @@ -1331,7 +1331,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread"), for: "review/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( @@ -1420,7 +1420,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1489,7 +1489,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1525,7 +1525,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1572,7 +1572,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1615,7 +1615,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1668,7 +1668,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1722,7 +1722,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1762,7 +1762,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1797,7 +1797,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1825,7 +1825,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1871,7 +1871,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1909,7 +1909,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -1971,7 +1971,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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)) @@ -2037,7 +2037,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/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( @@ -2117,7 +2117,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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) @@ -2160,7 +2160,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/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) @@ -2203,8 +2203,8 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/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( @@ -2228,8 +2228,8 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/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( @@ -2275,8 +2275,8 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/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( @@ -2314,8 +2314,8 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-2"), for: "review/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( @@ -2346,7 +2346,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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)) @@ -2372,7 +2372,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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)) @@ -2394,7 +2394,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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", @@ -2432,7 +2432,7 @@ struct AppServerClientTests { #expect(rollbackParams.threadID == "thread-1") #expect(rollbackParams.numTurns == 1) let restart = try #require(requests.first { $0.method == "review/start" }) - let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) + let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params) #expect(restartParams.threadID == "thread-1") #expect(restartParams.target == .baseBranch("main")) } @@ -2441,10 +2441,10 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "review-thread"), for: "review/start") + 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", @@ -2476,7 +2476,7 @@ struct AppServerClientTests { #expect(rollbackParams.threadID == "review-thread") #expect(rollbackParams.numTurns == 1) let restart = try #require(requests.last { $0.method == "review/start" }) - let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) + let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params) #expect(restartParams.threadID == "parent-thread") #expect(restartParams.target == .baseBranch("main")) } @@ -2485,10 +2485,10 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "review-thread"), for: "review/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(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "review-thread"), for: "review/start") + 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", @@ -2543,7 +2543,7 @@ struct AppServerClientTests { #expect(rollbackParams.threadID == "review-thread") #expect(rollbackParams.numTurns == 1) let restart = try #require(requests.last { $0.method == "review/start" }) - let restartParams = try JSONDecoder().decode(AppServerReviewStart.Params.self, from: restart.params) + let restartParams = try JSONDecoder().decode(AppServerAPI.Review.Start.Params.self, from: restart.params) #expect(restartParams.threadID == "parent-thread") #expect(restartParams.target == .baseBranch("main")) } @@ -2553,7 +2553,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2"), for: "review/start") + 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", @@ -2583,7 +2583,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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", @@ -2630,7 +2630,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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", @@ -2908,7 +2908,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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)) @@ -2950,7 +2950,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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)) @@ -3018,7 +3018,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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)) @@ -3088,7 +3088,7 @@ struct AppServerClientTests { ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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)) @@ -3146,7 +3146,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "thread-1"), for: "review/start") + 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)) @@ -3238,7 +3238,7 @@ struct AppServerClientTests { try await enqueueInitialize(transport) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/rollback") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-2", reviewThreadID: "review-thread-2"), for: "review/start") + 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", @@ -3275,7 +3275,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/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( @@ -3332,7 +3332,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/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( @@ -3396,7 +3396,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-response", reviewThreadID: "thread-1"), for: "review/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( @@ -3481,7 +3481,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -3505,7 +3505,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -3544,7 +3544,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -3568,7 +3568,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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)) @@ -3596,7 +3596,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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)) @@ -3662,7 +3662,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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)) @@ -3744,7 +3744,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -3795,7 +3795,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/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)) @@ -3840,7 +3840,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/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( @@ -3901,7 +3901,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-old", reviewThreadID: "thread-1"), for: "review/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( @@ -3968,7 +3968,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "review-turn", reviewThreadID: "thread-1"), for: "review/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)) @@ -4025,7 +4025,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -4391,7 +4391,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -5544,7 +5544,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( @@ -5596,7 +5596,7 @@ struct AppServerClientTests { 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(AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "thread-1"), for: "review/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( diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index c92604bc..2f2122ea 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1003,7 +1003,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-first", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerReviewStart.Response(turnID: "turn-first"), for: "review/start") + 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() @@ -1083,7 +1083,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-active", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerReviewStart.Response(turnID: "turn-active"), for: "review/start") + try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-active"), for: "review/start") try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt") try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") @@ -1188,7 +1188,7 @@ struct CodexReviewHostTests { ) try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1252,7 +1252,7 @@ struct CodexReviewHostTests { ) try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerReviewStart.Response(turnID: "turn-1"), for: "review/start") + try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, @@ -1302,7 +1302,7 @@ struct CodexReviewHostTests { for: "thread/start" ) try await transport.enqueue( - AppServerReviewStart.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), + AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), for: "review/start" ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") From e8aaab7c83a82745102785b9eac68df180ad7558 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:16:28 +0900 Subject: [PATCH 010/359] refactor(app-server): classify review notifications in wire target --- .../AppServerCodexReviewBackend.swift | 63 ++---------------- .../AppServerWireEvent.swift | 66 +++++++++++++++++++ .../AppServerWireEventTests.swift | 35 ++++++++++ 3 files changed, 106 insertions(+), 58 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index ed93f813..40ebf354 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -722,7 +722,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private func routeReviewNotification(_ notification: JSONRPC.Notification) async { notificationRouterMetrics.received += 1 - guard isReviewNotificationMethod(notification.method) else { + let method = AppServerReviewEventKind(rawValue: notification.method) + guard method.isReviewNotificationMethod else { notificationRouterMetrics.ignored += 1 return } @@ -756,7 +757,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } notificationRouterMetrics.routed += 1 await session.receive(routed) - } else if isThreadlessBroadcastMethod(notification.method) { + } else if wireNotification.method.isThreadlessReviewBroadcast { let sessions = Array(reviewEventSessionsByAttemptID.values) guard sessions.isEmpty == false else { notificationRouterMetrics.ignored += 1 @@ -2126,8 +2127,8 @@ private func decodeReviewNotification( return .init( events: orderedEvents, turnID: payload.resolvedTurnID, - startsReviewMode: notification.method == "item/started" && payload.item?.type.rawValue == "enteredReviewMode", - finishesReviewMode: notification.method == "item/completed" && payload.item?.type.rawValue == "exitedReviewMode", + startsReviewMode: notification.wireNotification.startsReviewMode, + finishesReviewMode: notification.wireNotification.finishesReviewMode, hasDirectTimelineEvents: directEvents.isEmpty == false ) } @@ -2254,60 +2255,6 @@ private extension ReviewTimelineItemSeed { } } -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)" } diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift index b07933cc..6dc0293b 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift @@ -281,6 +281,72 @@ public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { } } +public extension AppServerReviewEventKind { + var isReviewNotificationMethod: Bool { + switch self { + case .threadClosed, + .threadStatusChanged, + .turnStarted, + .turnCompleted, + .turnFailed, + .turnCancelled, + .turnAborted, + .turnDiffUpdated, + .turnPlanUpdated, + .itemStarted, + .itemUpdated, + .itemCompleted, + .autoApprovalReviewStarted, + .autoApprovalReviewCompleted, + .agentMessageDelta, + .planDelta, + .reasoningSummaryTextDelta, + .reasoningSummaryPartAdded, + .reasoningTextDelta, + .commandExecutionOutputDelta, + .commandExecutionTerminalInteraction, + .commandExecOutputDelta, + .processOutputDelta, + .fileChangeOutputDelta, + .fileChangePatchUpdated, + .mcpToolCallProgress, + .agentMessage, + .log, + .error, + .modelRerouted, + .modelVerification, + .threadCompacted, + .warning, + .guardianWarning, + .deprecationNotice, + .configWarning, + .diagnostic: + true + default: + false + } + } + + var isThreadlessReviewBroadcast: Bool { + switch self { + case .warning, .deprecationNotice, .configWarning, .error: + true + default: + false + } + } +} + +public extension AppServerWireReviewNotification { + var startsReviewMode: Bool { + method == .itemStarted && payload.item?.type.rawValue == "enteredReviewMode" + } + + var finishesReviewMode: Bool { + method == .itemCompleted && payload.item?.type.rawValue == "exitedReviewMode" + } +} + public extension AppServerWireReviewNotification { struct Payload: Decodable, Equatable, Sendable { public var threadID: String? diff --git a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift index 0b49c0c8..56e5aec1 100644 --- a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift +++ b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift @@ -5,6 +5,41 @@ import CodexReviewDomain @Suite("app-server wire events") struct AppServerWireEventTests { + @Test func classifiesReviewNotificationsAndReviewModeMarkers() throws { + #expect(AppServerReviewEventKind(rawValue: "turn/started").isReviewNotificationMethod) + #expect(AppServerReviewEventKind(rawValue: "warning").isThreadlessReviewBroadcast) + #expect(AppServerReviewEventKind(rawValue: "future/event").isReviewNotificationMethod == false) + #expect(AppServerReviewEventKind(rawValue: "diagnostic").isThreadlessReviewBroadcast == false) + + let entered = try decodeNotification(""" + { + "method": "item/started", + "params": { + "item": { + "id": "review-mode", + "type": "enteredReviewMode" + } + } + } + """) + #expect(entered.startsReviewMode) + #expect(entered.finishesReviewMode == false) + + let exited = try decodeNotification(""" + { + "method": "item/completed", + "params": { + "item": { + "id": "review-mode", + "type": "exitedReviewMode" + } + } + } + """) + #expect(exited.finishesReviewMode) + #expect(exited.startsReviewMode == false) + } + @Test func preservesUnknownNotificationRawPayloadAndEmitsUnknownContent() throws { let notification = try decodeNotification(""" { From 6784031249155ba47ac18e33cec1f6e83039fec6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:19:24 +0900 Subject: [PATCH 011/359] docs(app-server-kit): document review streams --- .../CodexAppServerKit/CodexDomainTypes.swift | 34 +++++++++++ .../CodexThreadOperations.swift | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 53d70434..0d9c16d4 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -295,9 +295,18 @@ public struct CodexThread: Identifiable, Sendable { /// The target that `codex app-server` should review. public enum CodexReviewTarget: Codable, Hashable, Sendable { + /// Review the current uncommitted working tree changes. case uncommittedChanges + + /// Review changes relative to a base branch. case baseBranch(String) + + /// Review a specific commit. + /// + /// `title` is optional metadata that the app-server may use for display. case commit(sha: String, title: String? = nil) + + /// Review using custom app-server instructions. case custom(instructions: String) private enum CodingKeys: String, CodingKey { @@ -353,19 +362,33 @@ public enum CodexReviewTarget: Codable, Hashable, Sendable { /// How `review/start` should deliver review work. public enum CodexReviewDelivery: String, Codable, Equatable, Sendable { + /// Run the review in the current thread. case inline + + /// Let the app-server create a detached review thread when supported. case detached } /// A review run started by `codex app-server`. public struct CodexReviewSession: Identifiable, Sendable { + /// The response turn identifier, used as the stable session identity. public var id: CodexTurnID { turnID } + /// The thread where `startReview(target:delivery:transcriptErrorHandlingPolicy:)` was called. public let threadID: CodexThreadID + + /// The app-server turn that is producing the review response. public let turnID: CodexTurnID + + /// The thread that emits review events and logs. + /// + /// This equals `threadID` for inline reviews and may differ for detached + /// reviews. public let reviewThreadID: CodexThreadID + + /// The live response stream for the review turn. public let response: CodexResponseStream private let eventThread: CodexThread @@ -384,34 +407,45 @@ public struct CodexReviewSession: Identifiable, Sendable { self.eventThread = eventThread } + /// Thread-scoped events for the review thread. public var events: CodexThreadEventSequence { eventThread.events } + /// Agent messages emitted by the review thread. public var messages: CodexThreadMessageSequence { eventThread.messages } + /// Incremental transcript snapshots for the review thread. public var transcriptUpdates: CodexThreadTranscriptSequence { eventThread.transcriptUpdates } + /// Log-oriented review entries. + /// + /// ReviewMonitor-style logs can be built from this sequence without + /// depending on raw JSON-RPC notifications. public var logEntries: CodexThreadLogSequence { eventThread.logEntries } + /// Collects the review response until the turn finishes. public func collect() async throws -> CodexResponse { try await response.collect() } + /// Interrupts the running review turn. public func interrupt() async throws { try await response.interrupt() } + /// Sends additional input to the running review turn. public func steer(with prompt: CodexPrompt) async throws { try await response.steer(with: prompt) } + /// Sends additional text input to the running review turn. public func steer(with prompt: String) async throws { try await response.steer(with: prompt) } diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 05b08b16..5400e1d0 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -1,6 +1,11 @@ import Foundation extension CodexThread { + /// Thread-scoped events emitted by the app-server. + /// + /// The sequence replays buffered events for this thread before yielding + /// live notifications. It finishes when the app-server reports the thread + /// as closed or when the app-server connection closes. public var events: CodexThreadEventSequence { .init { AsyncThrowingStream { continuation in @@ -19,18 +24,36 @@ extension CodexThread { } } + /// Agent messages emitted by this thread. + /// + /// This sequence is derived from `events` and is useful when callers only + /// need the conversational output rather than every item lifecycle event. public var messages: CodexThreadMessageSequence { .init(events: events) } + /// Incremental transcript snapshots derived from this thread's events. public var transcriptUpdates: CodexThreadTranscriptSequence { .init(events: events) } + /// Log-oriented item events for this thread. + /// + /// This includes command, tool, file-change, diagnostic, and unknown + /// app-server items that are useful for review logs or progress views. public var logEntries: CodexThreadLogSequence { .init(events: events) } + /// Sends a prompt to the thread and waits for the final response. + /// + /// Use `streamResponse(to:options:)` when the caller needs incremental + /// progress, transcript updates, steering, or interruption. + /// + /// - Parameters: + /// - prompt: The structured prompt to send. + /// - options: Per-turn generation options. + /// - Returns: The completed response collected from app-server events. public func respond( to prompt: CodexPrompt, options: CodexGenerationOptions = .init() @@ -38,6 +61,7 @@ extension CodexThread { try await streamResponse(to: prompt, options: options).collect() } + /// Sends a text prompt to the thread and waits for the final response. public func respond( to prompt: String, options: CodexGenerationOptions = .init() @@ -45,6 +69,7 @@ extension CodexThread { try await respond(to: CodexPrompt(prompt), options: options) } + /// Builds a structured prompt, sends it to the thread, and waits for the final response. public func respond( options: CodexGenerationOptions = .init(), @CodexPromptBuilder prompt: () throws -> CodexPrompt @@ -52,6 +77,16 @@ extension CodexThread { try await respond(to: try prompt(), options: options) } + /// Sends a prompt and returns a live response stream. + /// + /// The returned stream exposes events, progress, transcript updates, the + /// collected result, and Codex-specific controls such as steer and + /// interrupt. + /// + /// - Parameters: + /// - prompt: The structured prompt to send. + /// - options: Per-turn generation options. + /// - Returns: A live response stream for the started turn. public func streamResponse( to prompt: CodexPrompt, options: CodexGenerationOptions = .init() @@ -63,6 +98,7 @@ extension CodexThread { ) } + /// Sends a text prompt and returns a live response stream. public func streamResponse( to prompt: String, options: CodexGenerationOptions = .init() @@ -70,6 +106,7 @@ extension CodexThread { try await streamResponse(to: CodexPrompt(prompt), options: options) } + /// Builds a structured prompt, sends it, and returns a live response stream. public func streamResponse( options: CodexGenerationOptions = .init(), @CodexPromptBuilder prompt: () throws -> CodexPrompt @@ -83,6 +120,12 @@ extension CodexThread { /// turn and thread-level event streams, including log entries. When the /// app-server uses a detached review thread, those event streams are bound /// to that review thread. + /// + /// - Parameters: + /// - target: The repository changes or custom instructions to review. + /// - delivery: Whether the app-server should run the review inline or in a detached review thread. + /// - transcriptErrorHandlingPolicy: How collection should treat transcript errors. + /// - Returns: A live review session. public func startReview( target: CodexReviewTarget, delivery: CodexReviewDelivery = .inline, @@ -130,6 +173,10 @@ extension CodexThread { ) } + /// Reads the current thread snapshot. + /// + /// - Parameter includeTurns: Whether to include turn summaries in the snapshot. + /// - Returns: The current app-server snapshot for this thread. public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot { let response = try await client.send( AppServerAPI.Thread.Read.Request( @@ -150,6 +197,9 @@ extension CodexThread { ) } + /// Renames this thread. + /// + /// - Parameter name: The new user-visible thread name. public func rename(to name: String) async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Name.Set.Request( @@ -157,6 +207,7 @@ extension CodexThread { )) } + /// Starts app-server context compaction for this thread. public func compact() async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Compact.Start.Request( @@ -164,6 +215,7 @@ extension CodexThread { )) } + /// Archives this thread. public func archive() async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Archive.Request( @@ -171,6 +223,9 @@ extension CodexThread { )) } + /// Restores this thread from the archive. + /// + /// - Returns: The restored thread snapshot. public func unarchive() async throws -> CodexThreadSnapshot { let response = try await client.send( AppServerAPI.Thread.Unarchive.Request( @@ -179,6 +234,9 @@ extension CodexThread { return CodexAppServer.threadSnapshot(from: response.thread) } + /// Rolls this thread back by the specified number of turns. + /// + /// - Parameter turnCount: The number of latest turns to remove. public func rollback(turnCount: Int = 1) async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Rollback.Request( @@ -186,6 +244,7 @@ extension CodexThread { )) } + /// Permanently deletes this thread. public func delete() async throws { let _: EmptyResponse = try await client.send( AppServerAPI.Thread.Delete.Request( From 2d3aab45d174d463c82fc51b02c7f1d31e124daa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:35:33 +0900 Subject: [PATCH 012/359] feat(app-server-kit): add in-memory test runtime --- Package.swift | 14 + .../CodexAppServerKit/CodexAppServer.swift | 10 + .../CodexAppServerKit/CodexDomainTypes.swift | 4 +- Sources/CodexAppServerKit/README.md | 53 ++ .../CodexAppServerTestRuntime.swift | 570 ++++++++++++++++++ .../CodexAppServerKitTests.swift | 200 ++---- 6 files changed, 702 insertions(+), 149 deletions(-) create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift diff --git a/Package.swift b/Package.swift index c4d52bb7..449616c6 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,10 @@ let package = Package( name: "CodexAppServerKit", targets: ["CodexAppServerKit"] ), + .library( + name: "CodexAppServerKitTesting", + targets: ["CodexAppServerKitTesting"] + ), .library( name: "ReviewUI", targets: ["ReviewUI"] @@ -68,6 +72,15 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .target( + name: "CodexAppServerKitTesting", + dependencies: [ + "CodexAppServerKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .target( name: "CodexReviewAppServerWire", dependencies: [ @@ -167,6 +180,7 @@ let package = Package( name: "CodexAppServerKitTests", dependencies: [ "CodexAppServerKit", + "CodexAppServerKitTesting", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index d632244d..5e4287eb 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -130,6 +130,16 @@ public actor CodexAppServer { self.reviewThreadStartPermissionStrategy = threadStartPermissionStrategy } + package static func testing( + transport: any JSONRPC.Transport, + threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + ) async throws -> CodexAppServer { + try await CodexAppServer( + transport: transport, + threadStartPermissionStrategy: threadStartPermissionStrategy + ) + } + /// Closes the app-server connection and stops notification routing. /// /// Call this when the container is no longer needed. Closing is idempotent diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 0d9c16d4..ed83f38d 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -424,8 +424,8 @@ public struct CodexReviewSession: Identifiable, Sendable { /// Log-oriented review entries. /// - /// ReviewMonitor-style logs can be built from this sequence without - /// depending on raw JSON-RPC notifications. + /// Review log views can be built from this sequence without depending on + /// raw JSON-RPC notifications. public var logEntries: CodexThreadLogSequence { eventThread.logEntries } diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index 8aeea74d..1a0536c9 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -270,6 +270,59 @@ try await appServer.loginAPIKey(apiKey) let deviceCode = try await appServer.loginChatGPTDeviceCode() ``` +## Testing + +Use `CodexAppServerKitTesting` to exercise `CodexAppServer` without launching a +real `codex app-server` process. The test runtime uses an in-memory transport, +enqueues the startup `initialize` response, records requests, and lets tests +emit server notifications explicitly. + +```swift +import CodexAppServerKit +import CodexAppServerKitTesting +import Testing + +@Test func startsThread() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart( + threadID: "thread-test", + model: "gpt-5" + ) + + let thread = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + options: .init(model: "gpt-5") + ) + + #expect(thread.id.rawValue == "thread-test") + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "thread/start", + ]) +} +``` + +For concurrency-sensitive tests, hold a request with +`CodexAppServerTestGate` and release it explicitly. This avoids depending on +sleep duration or repeated `Task.yield()` calls. + +```swift +try await runtime.transport.enqueueTurnStart(turnID: "turn-1") +try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + +let stream = try await thread.streamResponse(to: "Run checks.") +let gate = CodexAppServerTestGate() +await runtime.transport.holdNext(method: "turn/interrupt", gate: gate) + +let interruptTask = Task { + try await stream.interrupt() +} + +await runtime.transport.waitForRequest(method: "turn/interrupt") +await gate.open() +try await interruptTask.value +``` + ## Boundary Public users should not need to import or call JSON-RPC or `AppServerAPI` diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift new file mode 100644 index 00000000..3e2327a3 --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift @@ -0,0 +1,570 @@ +import Foundation +import CodexAppServerKit + +/// A Codex app-server test runtime backed by an in-memory transport. +/// +/// This type does not launch `codex` or any external process. Tests enqueue +/// responses and emit notifications through ``transport`` while exercising the +/// same public ``CodexAppServer`` API that production code uses. +public struct CodexAppServerTestRuntime: Sendable { + /// The app-server domain container under test. + public var server: CodexAppServer + + /// The in-memory transport used by ``server``. + public var transport: CodexAppServerTestTransport + + /// Creates a runtime from an already initialized app-server container and transport. + public init(server: CodexAppServer, transport: CodexAppServerTestTransport) { + self.server = server + self.transport = transport + } + + /// Creates a test runtime without launching a real app-server process. + /// + /// The runtime automatically enqueues the `initialize` response required by + /// ``CodexAppServer`` startup. + /// + /// - Parameters: + /// - transport: The in-memory transport to use. + /// - codexHome: Optional Codex home path returned by initialization. + /// - userAgent: Optional user-agent returned by initialization. + /// - Returns: A started test runtime. + public static func start( + transport: CodexAppServerTestTransport = CodexAppServerTestTransport(), + codexHome: String? = nil, + userAgent: String? = nil + ) async throws -> CodexAppServerTestRuntime { + try await transport.enqueueInitialize(codexHome: codexHome, userAgent: userAgent) + let server = try await CodexAppServer.testing(transport: transport) + return .init(server: server, transport: transport) + } +} + +/// A recorded JSON-RPC request sent by ``CodexAppServer``. +public struct CodexAppServerRecordedRequest: Equatable, Sendable { + /// The JSON-RPC request identifier. + public var id: Int + + /// The app-server method name. + public var method: String + + /// The encoded request parameters. + public var params: Data + + /// Creates a recorded request value. + public init(id: Int, method: String, params: Data) { + self.id = id + self.method = method + self.params = params + } + + /// Decodes the request parameters as `Value`. + public func decodeParams(_ type: Value.Type = Value.self) throws -> Value { + try JSONDecoder().decode(type, from: params) + } +} + +/// A recorded JSON-RPC notification sent by ``CodexAppServer``. +public struct CodexAppServerRecordedNotification: Equatable, Sendable { + /// The app-server notification method name. + public var method: String + + /// The encoded notification parameters. + public var params: Data + + /// Creates a recorded notification value. + public init(method: String, params: Data) { + self.method = method + self.params = params + } + + /// Decodes the notification parameters as `Value`. + public func decodeParams(_ type: Value.Type = Value.self) throws -> Value { + try JSONDecoder().decode(type, from: params) + } +} + +/// A deterministic gate for app-server concurrency tests. +/// +/// Use this to hold a request at a known point and release it explicitly, +/// instead of depending on sleeps or repeated `Task.yield()` calls. +public actor CodexAppServerTestGate { + private var isOpen = false + private var waiters: [UUID: CheckedContinuation] = [:] + + /// Creates a closed gate. + public init() {} + + /// Suspends until the gate opens, or until the waiting task is cancelled. + public func wait() async { + if isOpen { + return + } + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if isOpen || Task.isCancelled { + continuation.resume() + } else { + waiters[waiterID] = continuation + } + } + } onCancel: { + Task { + await self.cancelWaiter(id: waiterID) + } + } + } + + /// Suspends until the gate opens, ignoring task cancellation while waiting. + public func waitIgnoringCancellation() async { + if isOpen { + return + } + let waiterID = UUID() + await withCheckedContinuation { continuation in + if isOpen { + continuation.resume() + } else { + waiters[waiterID] = continuation + } + } + } + + /// Opens the gate and resumes all suspended waiters. + public 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() + } +} + +/// An in-memory app-server transport for tests. +public actor CodexAppServerTestTransport { + private struct RequestGate: Sendable { + var gate: CodexAppServerTestGate + 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 requestMethodWaiters: [(String, Int, CheckedContinuation)] = [] + private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] + private var closed = false + + /// Creates an in-memory app-server transport. + public init() {} + + /// Enqueues a raw Encodable response for `method`. + public func enqueue( + _ response: Response, + for method: String + ) throws { + responses[method, default: []].append(.success(try JSONEncoder().encode(response))) + } + + /// Enqueues a JSON string response for `method`. + public func enqueueJSON(_ json: String, for method: String) throws { + responses[method, default: []].append(.success(Data(json.utf8))) + } + + /// Enqueues a JSON-RPC response error for `method`. + public func enqueueFailure(code: Int, message: String, for method: String) { + responses[method, default: []].append(.failure(.responseError( + code: code, + message: message + ))) + } + + /// Enqueues an empty JSON object response for `method`. + public func enqueueEmpty(for method: String) throws { + try enqueue(EmptyResponse(), for: method) + } + + /// Enqueues a thread-start response. + public func enqueueThreadStart(threadID: String, model: String? = nil) throws { + try enqueue( + AppServerAPI.Thread.Start.Response(threadID: threadID, model: model), + for: "thread/start" + ) + } + + /// Enqueues a turn-start response. + public func enqueueTurnStart(turnID: String, status: String? = nil) throws { + try enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: turnID, status: status)), + for: "turn/start" + ) + } + + /// Enqueues a review-start response. + public func enqueueReviewStart(turnID: String, reviewThreadID: String? = nil) throws { + try enqueue( + AppServerAPI.Review.Start.Response(turnID: turnID, reviewThreadID: reviewThreadID), + for: "review/start" + ) + } + + /// Enqueues a model-list response. + public func enqueueModels(_ models: [CodexModel], nextCursor: String? = nil) throws { + try enqueue( + AppServerAPI.Model.List.Response(data: models, nextCursor: nextCursor), + for: "model/list" + ) + } + + /// Enqueues an account-read response. + public func enqueueAccount(_ account: CodexAccount?, requiresOpenAIAuth: Bool = false) throws { + let snapshot = account.map { + AppServerAPI.Account.Snapshot( + kind: .init(rawValue: $0.kind.rawValue) ?? .chatGPT, + id: $0.id, + label: $0.label, + planType: $0.planType + ) + } + try enqueue( + AppServerAPI.Account.Read.Response( + account: snapshot, + requiresOpenAIAuth: requiresOpenAIAuth + ), + for: "account/read" + ) + } + + /// Enqueues a config-read response. + public func enqueueConfiguration(_ configuration: CodexConfiguration) throws { + try enqueue( + AppServerAPI.Config.Read.Response(config: .init( + model: configuration.model, + reviewModel: configuration.reviewModel, + modelReasoningEffort: configuration.reasoningEffort, + serviceTier: configuration.serviceTier + )), + for: "config/read" + ) + } + + /// Enqueues an account rate-limit response. + public func enqueueRateLimits(_ rateLimits: CodexRateLimits) throws { + let windows = rateLimits.windows + let primary = windows.first.map(Self.window) + let secondary = windows.dropFirst().first.map(Self.window) + try enqueue( + AppServerAPI.Account.RateLimits.Response(rateLimits: .init( + limitID: "codex", + primary: primary, + secondary: secondary, + planType: rateLimits.planType + )), + for: "account/rateLimits/read" + ) + } + + /// Enqueues a ChatGPT browser login response. + public func enqueueChatGPTLogin( + loginID: String, + authenticationURL: URL, + callbackURLScheme: String? = nil + ) throws { + try enqueue( + AppServerAPI.Account.Login.Response.chatgpt( + loginID: loginID, + authURL: authenticationURL.absoluteString, + nativeWebAuthentication: callbackURLScheme.map { + .init(callbackURLScheme: $0) + } + ), + for: "account/login/start" + ) + } + + /// Enqueues a ChatGPT device-code login response. + public func enqueueChatGPTDeviceCodeLogin( + loginID: String, + verificationURL: URL, + userCode: String + ) throws { + try enqueue( + AppServerAPI.Account.Login.Response.chatgptDeviceCode( + loginID: loginID, + verificationURL: verificationURL.absoluteString, + userCode: userCode + ), + for: "account/login/start" + ) + } + + /// Enqueues an API-key login response. + public func enqueueAPIKeyLogin() throws { + try enqueue(AppServerAPI.Account.Login.Response.apiKey, for: "account/login/start") + } + + /// Holds every request for `method` until `gate` opens. + public func hold(method: String, gate: CodexAppServerTestGate) { + gatesByMethod[method] = .init(gate: gate, ignoresCancellation: false) + } + + /// Holds the next request for `method` until `gate` opens. + public func holdNext(method: String, gate: CodexAppServerTestGate) { + oneShotGatesByMethod[method, default: []].append(.init( + gate: gate, + ignoresCancellation: false + )) + } + + /// Holds the next request for `method` and ignores task cancellation while waiting. + public func holdNextIgnoringCancellation(method: String, gate: CodexAppServerTestGate) { + oneShotGatesByMethod[method, default: []].append(.init( + gate: gate, + ignoresCancellation: true + )) + } + + /// Returns all requests sent so far. + public func recordedRequests() -> [CodexAppServerRecordedRequest] { + requests.map { .init(id: $0.id, method: $0.method, params: $0.params) } + } + + /// Returns all requests sent so far for `method`. + public func recordedRequests(method: String) -> [CodexAppServerRecordedRequest] { + recordedRequests().filter { $0.method == method } + } + + /// Returns all client notifications sent so far. + public func recordedNotifications() -> [CodexAppServerRecordedNotification] { + notifications.map { .init(method: $0.method, params: $0.params) } + } + + /// Suspends until at least `count` requests have been sent. + public func waitForRequestCount(_ count: Int) async { + if requests.count >= count { + return + } + await withCheckedContinuation { continuation in + if requests.count >= count { + continuation.resume() + } else { + requestCountWaiters.append((count, continuation)) + } + } + } + + /// Suspends until at least `count` requests for `method` have been sent. + public func waitForRequest(method: String, count: Int = 1) async { + if requests.filter({ $0.method == method }).count >= count { + return + } + await withCheckedContinuation { continuation in + if requests.filter({ $0.method == method }).count >= count { + continuation.resume() + } else { + requestMethodWaiters.append((method, count, continuation)) + } + } + } + + /// Suspends until at least `count` notification stream consumers are attached. + public 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)) + } + } + } + + /// Returns the maximum number of in-flight requests observed for `method`. + public func maxActiveCount(for method: String) -> Int { + maxActiveByMethod[method] ?? 0 + } + + /// Emits a server notification to all attached app-server notification streams. + public 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) + } + } + + /// Emits a server notification from a raw JSON object string. + public func emitServerNotificationJSON(method: String, json: String) { + let notification = JSONRPC.Notification( + method: method, + params: Data(json.utf8) + ) + for continuation in serverNotificationContinuations { + continuation.yield(notification) + } + } + + /// Finishes all attached notification streams with `error`. + public func finishNotificationStreams(throwing error: any Error) { + for continuation in serverNotificationContinuations { + continuation.finish(throwing: error) + } + serverNotificationContinuations.removeAll() + } + + package func enqueueInitialize(codexHome: String?, userAgent: String?) throws { + try enqueue( + AppServerAPI.Initialize.Response(codexHome: codexHome, userAgent: userAgent), + for: "initialize" + ) + } + + private func dequeueResponse(for method: String) -> QueuedResponse? { + guard var queued = responses[method], queued.isEmpty == false else { + return nil + } + let response = queued.removeFirst() + responses[method] = queued + return response + } + + private func dequeueOneShotGate(for method: String) -> RequestGate? { + guard var gates = oneShotGatesByMethod[method], gates.isEmpty == false else { + return nil + } + let gate = gates.removeFirst() + oneShotGatesByMethod[method] = gates + return gate + } + + private func resumeRequestCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in requestCountWaiters { + if requests.count >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + requestCountWaiters = remaining + } + + private func resumeRequestMethodWaiters() { + var remaining: [(String, Int, CheckedContinuation)] = [] + for waiter in requestMethodWaiters { + let count = requests.filter { $0.method == waiter.0 }.count + if count >= waiter.1 { + waiter.2.resume() + } else { + remaining.append(waiter) + } + } + requestMethodWaiters = remaining + } + + private func resumeNotificationStreamCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in notificationStreamCountWaiters { + if serverNotificationContinuations.count >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + notificationStreamCountWaiters = remaining + } + + private static func window( + from window: CodexRateLimitWindow + ) -> AppServerAPI.Account.RateLimits.Window { + .init( + usedPercent: window.usedPercent, + windowDurationMins: window.windowDurationMinutes, + resetsAt: window.resetsAt.map { Int64($0.timeIntervalSince1970 * 1000) } + ) + } +} + +extension CodexAppServerTestTransport: JSONRPC.Transport { + package func send(_ request: JSONRPC.Request) async throws -> Data { + guard closed == false else { + throw JSONRPC.Error.closed + } + requests.append(request) + resumeRequestCountWaiters() + resumeRequestMethodWaiters() + activeByMethod[request.method, default: 0] += 1 + maxActiveByMethod[request.method] = max( + maxActiveByMethod[request.method] ?? 0, + activeByMethod[request.method] ?? 0 + ) + let queuedResponse = dequeueResponse(for: request.method) + if let gate = dequeueOneShotGate(for: request.method) ?? gatesByMethod[request.method] { + 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()) + } + + 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() + } +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index a3b7114a..3120d178 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -1,12 +1,53 @@ import Foundation import Testing +import CodexAppServerKitTesting @testable import CodexAppServerKit @Suite("CodexAppServerKit") struct CodexAppServerKitTests { + @Test func testRuntimeStartsAppServerWithoutLaunchingProcess() async throws { + let runtime = try await CodexAppServerTestRuntime.start(codexHome: "/tmp/codex") + try await runtime.transport.enqueueThreadStart(threadID: "thread-test", model: "gpt-5") + + let thread = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + options: .init(model: "gpt-5") + ) + + #expect(thread.id == "thread-test") + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "thread/start", + ]) + #expect(await runtime.transport.recordedNotifications().map(\.method) == [ + "initialized" + ]) + } + + @Test func testTransportHoldsRequestsAtExplicitGate() async throws { + let transport = CodexAppServerTestTransport() + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "ping", gate: gate) + let client = AppServerClient(transport: transport) + + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + } + + await transport.waitForRequest(method: "ping") + #expect(await transport.maxActiveCount(for: "ping") == 1) + + await gate.open() + try await task.value + } + @Test func initializeSendsHandshakeAndInitializedNotification() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize") let client = AppServerClient(transport: transport) @@ -23,7 +64,7 @@ struct CodexAppServerKitTests { } @Test func appServerStartThreadSerializesDomainOptions() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start" @@ -57,7 +98,7 @@ struct CodexAppServerKitTests { } @Test func threadStartReviewSerializesTargetAndStreamsReviewThreadLogs() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Review.Start.Response( turnID: "turn-review", @@ -136,9 +177,8 @@ struct CodexAppServerKitTests { } @Test func clientRetriesOverloadedRequestsThenSucceeds() async throws { - let transport = FakeJSONRPCTransport() - await transport.enqueueFailure( - .responseError(code: -32001, message: "server busy"), for: "ping") + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32001, message: "server busy", for: "ping") try await transport.enqueue(EmptyResponse(), for: "ping") let client = AppServerClient( transport: transport, @@ -156,7 +196,7 @@ struct CodexAppServerKitTests { } @Test func turnResultReplaysEarlyNotificationsAndKeepsUnknownEvents() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() let client = AppServerClient(transport: transport) let router = CodexAppServerNotificationRouter(client: client) await router.start() @@ -204,7 +244,7 @@ struct CodexAppServerKitTests { } @Test func threadStreamsReplayMessagesTranscriptLogsAndUsage() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() let client = AppServerClient(transport: transport) let router = CodexAppServerNotificationRouter(client: client) await router.start() @@ -297,7 +337,7 @@ struct CodexAppServerKitTests { } @Test func responseStreamYieldsSnapshotsAndCollectsFinalResponse() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), for: "turn/start" @@ -346,7 +386,7 @@ struct CodexAppServerKitTests { } @Test func responseStreamInterruptSendsTurnInterrupt() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), for: "turn/start" @@ -376,7 +416,7 @@ struct CodexAppServerKitTests { } @Test func responseStreamSteerSubmitsInputToCurrentTurn() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), for: "turn/start" @@ -410,7 +450,7 @@ struct CodexAppServerKitTests { } @Test func responseStreamQueueStartsFollowUpAfterCurrentResponse() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), for: "turn/start" @@ -455,7 +495,7 @@ struct CodexAppServerKitTests { } @Test func responseStreamInterruptStartsFollowUpAfterServerTerminalEvent() async throws { - let transport = FakeJSONRPCTransport() + let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), for: "turn/start" @@ -519,140 +559,6 @@ private func collect( return elements } -private actor FakeJSONRPCTransport: JSONRPC.Transport { - 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 notificationContinuations: - [AsyncThrowingStream.Continuation] = [] - private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] - private var requestCountWaiters: [(Int, CheckedContinuation)] = [] - private var closed = false - - func enqueue(_ response: Response, for method: String) throws { - responses[method, default: []].append(.success(try JSONEncoder().encode(response))) - } - - func enqueueFailure(_ error: JSONRPC.Error, for method: String) { - responses[method, default: []].append(.failure(error)) - } - - func send(_ request: JSONRPC.Request) async throws -> Data { - guard closed == false else { - throw JSONRPC.Error.closed - } - requests.append(request) - resumeRequestCountWaiters() - let queued = - responses[request.method, default: []].isEmpty - ? nil - : responses[request.method, default: []].removeFirst() - switch queued { - case .success(let data): - return data - case .failure(let error): - throw error - case nil: - return try JSONEncoder().encode(EmptyResponse()) - } - } - - func notify(_ notification: JSONRPC.Notification) async throws { - notifications.append(notification) - } - - func notificationStream() -> AsyncThrowingStream { - AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in - notificationContinuations.append(continuation) - resumeNotificationStreamCountWaiters() - } - } - - func close() async { - closed = true - for continuation in notificationContinuations { - continuation.finish() - } - notificationContinuations.removeAll() - } - - func recordedRequests() -> [JSONRPC.Request] { - requests - } - - func recordedNotifications() -> [JSONRPC.Notification] { - notifications - } - - 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)) - } - } - } - - func waitForNotificationStreamCount(_ count: Int) async { - if notificationContinuations.count >= count { - return - } - await withCheckedContinuation { continuation in - if notificationContinuations.count >= count { - continuation.resume() - } else { - notificationStreamCountWaiters.append((count, continuation)) - } - } - } - - func emitServerNotification( - method: String, - params: Params - ) throws { - let notification = JSONRPC.Notification( - method: method, - params: try JSONEncoder().encode(params) - ) - for continuation in notificationContinuations { - continuation.yield(notification) - } - } - - private func resumeNotificationStreamCountWaiters() { - var remaining: [(Int, CheckedContinuation)] = [] - for waiter in notificationStreamCountWaiters { - if notificationContinuations.count >= waiter.0 { - waiter.1.resume() - } else { - remaining.append(waiter) - } - } - notificationStreamCountWaiters = remaining - } - - 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 struct TurnIDParams: Encodable, Sendable { var turnID: String From ec72cfb09d0dee7ecf9225bc80d853d03f5f35d3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:35:45 +0900 Subject: [PATCH 013/359] refactor(app-server): use Kit API for runtime account operations --- .../AppServerCodexReviewBackend.swift | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 40ebf354..984f290f 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -21,6 +21,7 @@ private func makeAppServerReviewAttemptID() -> String { package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" + private let appServer: CodexAppServer? private let client: AppServerClient private let threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy private var controlsByThreadID: [String: AppServerReviewControl] = [:] @@ -40,6 +41,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var reviewStartRequestsInFlight = 0 package init(appServer: CodexAppServer) { + self.appServer = appServer self.client = appServer.appServerClient self.threadStartPermissionStrategy = appServer.threadStartPermissionStrategy } @@ -48,11 +50,24 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { client: AppServerClient, threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions ) { + self.appServer = nil self.client = client self.threadStartPermissionStrategy = threadStartPermissionStrategy } package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { + if let appServer { + let configuration = try await appServer.configuration() + let models = try await appServer.models(includeHidden: true) + .map(\.reviewModelCatalogItem) + return .init( + model: configuration.reviewModel?.nilIfEmpty, + fallbackModel: configuration.model?.nilIfEmpty ?? models.first(where: \.isDefault)?.model, + reasoningEffort: configuration.reasoningEffort, + serviceTier: configuration.serviceTier, + models: models + ) + } _ = try await client.initialize() let response = try await client.send(AppServerAPI.Config.Read.Request()) let models = try await readModelCatalog() @@ -77,6 +92,13 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot { + if let appServer { + guard let account = try await appServer.account() else { + return .init() + } + let backendAccount = account.backendAccount + return .init(accounts: [backendAccount], activeAccountID: backendAccount.id) + } _ = try await client.initialize() let response = try await client.send(AppServerAPI.Auth.Read.Request()) guard let account = response.account?.backendAccount else { @@ -86,12 +108,23 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func readRateLimits() async throws -> CodexRateLimits { + if let appServer { + return try await appServer.rateLimits() + } _ = try await client.initialize() let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) return .init(appServer: response) } package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { + if let appServer { + let handle = try await appServer.loginChatGPT( + callbackURLScheme: request.nativeWebAuthenticationCallbackScheme + ) + return try handle.backendChallenge( + nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme + ) + } _ = try await client.initialize() let nativeWebAuthentication = request.nativeWebAuthenticationCallbackScheme .map(AppServerAPI.Account.Login.NativeWebAuthentication.init(callbackURLScheme:)) @@ -104,6 +137,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { + if let appServer { + try await appServer.cancelLogin(id: .init(rawValue: challenge.id)) + return + } _ = try await client.initialize() let _: AppServerAPI.Account.Login.Cancel.Response = try await client.send( method: "account/login/cancel", @@ -114,6 +151,16 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot { if let callbackURL = response.callbackURL { + if let appServer { + guard let url = URL(string: callbackURL) else { + throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") + } + try await appServer.completeLogin( + id: .init(rawValue: response.challengeID), + callbackURL: url + ) + return try await readAuth() + } _ = try await client.initialize() let _: AppServerAPI.Account.Login.Complete.Response = try await client.send( method: "account/login/complete", @@ -125,6 +172,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func logout(_: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot { + if let appServer { + try await appServer.logout() + return try await readAuth() + } _ = try await client.initialize() let _: EmptyResponse = try await client.send( method: "account/logout", @@ -1814,6 +1865,21 @@ private extension AppServerCodexReviewBackend { } } +private extension CodexAppServerKit.CodexAccount { + var backendAccount: CodexReviewBackendModel.Account.Snapshot { + 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: backendKind.capabilities + ) + } +} + private extension AppServerAPI.Account.Snapshot { var backendAccount: CodexReviewBackendModel.Account.Snapshot { let backendKind: CodexReviewBackendModel.Account.Kind = @@ -1854,6 +1920,29 @@ private extension CodexModel { } } +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 + ) + case .chatGPTDeviceCode(let loginID, let verificationURL, let userCode): + return .init( + id: loginID.rawValue, + verificationURL: verificationURL, + userCode: userCode + ) + } + } +} + private extension AppServerAPI.Account.Login.Response { var backendChallenge: CodexReviewBackendModel.Login.Challenge { get throws { From 99b88a2c68be9ba3b0f044f79649da818d088b09 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:40:00 +0900 Subject: [PATCH 014/359] refactor(testing): share app server test transport --- Package.swift | 1 + .../CodexAppServerTestRuntime.swift | 16 + Sources/CodexReviewTesting/TestSupport.swift | 277 +----------------- 3 files changed, 20 insertions(+), 274 deletions(-) diff --git a/Package.swift b/Package.swift index 449616c6..b05beeb4 100644 --- a/Package.swift +++ b/Package.swift @@ -141,6 +141,7 @@ let package = Package( name: "CodexReviewTesting", dependencies: [ "CodexAppServerKit", + "CodexAppServerKitTesting", "CodexReview", "CodexReviewAppServer", ], diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift index 3e2327a3..51217a53 100644 --- a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift @@ -186,6 +186,10 @@ public actor CodexAppServerTestTransport { /// Creates an in-memory app-server transport. public init() {} + package init(responses: [String: [Data]]) { + self.responses = responses.mapValues { $0.map(QueuedResponse.success) } + } + /// Enqueues a raw Encodable response for `method`. public func enqueue( _ response: Response, @@ -207,6 +211,10 @@ public actor CodexAppServerTestTransport { ))) } + package func enqueueFailure(_ error: JSONRPC.Error, for method: String) { + responses[method, default: []].append(.failure(error)) + } + /// Enqueues an empty JSON object response for `method`. public func enqueueEmpty(for method: String) throws { try enqueue(EmptyResponse(), for: method) @@ -414,6 +422,14 @@ public actor CodexAppServerTestTransport { maxActiveByMethod[method] ?? 0 } + package func notificationStreamCount() -> Int { + serverNotificationContinuations.count + } + + package func isClosedForTesting() -> Bool { + closed + } + /// Emits a server notification to all attached app-server notification streams. public func emitServerNotification( method: String, diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index f679fee9..4cf83efc 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -1,65 +1,10 @@ import Foundation import CodexAppServerKit +import CodexAppServerKitTesting 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() - } -} - +package typealias AsyncGate = CodexAppServerTestGate package typealias OneShotGate = AsyncGate package actor ManualClock { @@ -886,220 +831,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 FakeJSONRPCTransport = CodexAppServerTestTransport From e69c02bd28a758d47aa10ec5eb546c3bd8f65ac1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:45:34 +0900 Subject: [PATCH 015/359] refactor(app-server-wire): split review notification types --- .../AppServerWireEvent.swift | 1530 ----------------- .../AppServerWireJSONValue.swift | 106 ++ .../AppServerWireReviewNotification.swift | 253 +++ .../AppServerWireReviewPayload.swift | 1174 +++++++++++++ 4 files changed, 1533 insertions(+), 1530 deletions(-) create mode 100644 Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift create mode 100644 Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift create mode 100644 Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift index 6dc0293b..adb85363 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift @@ -19,1533 +19,3 @@ public struct AppServerWireEvent: Equatable, Sendable { 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: AppServerReviewEventKind - public var payload: Payload - public var rawPayload: AppServerWireJSONValue? - - public var rawMethod: String { - method.rawValue - } - - public init( - method: AppServerReviewEventKind, - payload: Payload = Payload(), - rawPayload: AppServerWireJSONValue? = nil - ) { - self.method = method - self.payload = payload - self.rawPayload = rawPayload - } - - public init(method: String, paramsData: Data) throws { - let paramsObject = try JSONSerialization.jsonObject( - with: paramsData, - options: [.fragmentsAllowed] - ) - let envelope: [String: Any] = [ - "method": method, - "params": paramsObject, - ] - let data = try JSONSerialization.data(withJSONObject: envelope) - self = try JSONDecoder().decode(Self.self, from: data) - } - - 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 = AppServerReviewEventKind(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 AppServerReviewEventKind { - var isReviewNotificationMethod: Bool { - switch self { - case .threadClosed, - .threadStatusChanged, - .turnStarted, - .turnCompleted, - .turnFailed, - .turnCancelled, - .turnAborted, - .turnDiffUpdated, - .turnPlanUpdated, - .itemStarted, - .itemUpdated, - .itemCompleted, - .autoApprovalReviewStarted, - .autoApprovalReviewCompleted, - .agentMessageDelta, - .planDelta, - .reasoningSummaryTextDelta, - .reasoningSummaryPartAdded, - .reasoningTextDelta, - .commandExecutionOutputDelta, - .commandExecutionTerminalInteraction, - .commandExecOutputDelta, - .processOutputDelta, - .fileChangeOutputDelta, - .fileChangePatchUpdated, - .mcpToolCallProgress, - .agentMessage, - .log, - .error, - .modelRerouted, - .modelVerification, - .threadCompacted, - .warning, - .guardianWarning, - .deprecationNotice, - .configWarning, - .diagnostic: - true - default: - false - } - } - - var isThreadlessReviewBroadcast: Bool { - switch self { - case .warning, .deprecationNotice, .configWarning, .error: - true - default: - false - } - } -} - -public extension AppServerWireReviewNotification { - var startsReviewMode: Bool { - method == .itemStarted && payload.item?.type.rawValue == "enteredReviewMode" - } - - var finishesReviewMode: Bool { - method == .itemCompleted && payload.item?.type.rawValue == "exitedReviewMode" - } -} - -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) - } - - public 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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [ReviewDomainEvent] { - guard let message = diagnosticMessage else { - return unknownEvent(method: method) - } - return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] - } - - func modelReroutedEvents(method: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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 - } - } - - public var startedAt: Date? { - startedAtMs.map(Self.date(millisecondsSince1970:)) - } - - public var completedAt: Date? { - completedAtMs.map(Self.date(millisecondsSince1970:)) - } - - private func diagnosticSeed( - method: AppServerReviewEventKind, - 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 AppServerReviewEventKind { - 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/CodexReviewAppServerWire/AppServerWireJSONValue.swift b/Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift new file mode 100644 index 00000000..82825448 --- /dev/null +++ b/Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift @@ -0,0 +1,106 @@ +import Foundation + +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 + } +} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift new file mode 100644 index 00000000..d0fd8b0c --- /dev/null +++ b/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift @@ -0,0 +1,253 @@ +import Foundation +import CodexReviewDomain + +public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { + public var method: AppServerReviewEventKind + public var payload: Payload + public var rawPayload: AppServerWireJSONValue? + + public var rawMethod: String { + method.rawValue + } + + public init( + method: AppServerReviewEventKind, + payload: Payload = Payload(), + rawPayload: AppServerWireJSONValue? = nil + ) { + self.method = method + self.payload = payload + self.rawPayload = rawPayload + } + + public init(method: String, paramsData: Data) throws { + let paramsObject = try JSONSerialization.jsonObject( + with: paramsData, + options: [.fragmentsAllowed] + ) + let envelope: [String: Any] = [ + "method": method, + "params": paramsObject, + ] + let data = try JSONSerialization.data(withJSONObject: envelope) + self = try JSONDecoder().decode(Self.self, from: data) + } + + 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 = AppServerReviewEventKind(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 AppServerReviewEventKind { + var isReviewNotificationMethod: Bool { + switch self { + case .threadClosed, + .threadStatusChanged, + .turnStarted, + .turnCompleted, + .turnFailed, + .turnCancelled, + .turnAborted, + .turnDiffUpdated, + .turnPlanUpdated, + .itemStarted, + .itemUpdated, + .itemCompleted, + .autoApprovalReviewStarted, + .autoApprovalReviewCompleted, + .agentMessageDelta, + .planDelta, + .reasoningSummaryTextDelta, + .reasoningSummaryPartAdded, + .reasoningTextDelta, + .commandExecutionOutputDelta, + .commandExecutionTerminalInteraction, + .commandExecOutputDelta, + .processOutputDelta, + .fileChangeOutputDelta, + .fileChangePatchUpdated, + .mcpToolCallProgress, + .agentMessage, + .log, + .error, + .modelRerouted, + .modelVerification, + .threadCompacted, + .warning, + .guardianWarning, + .deprecationNotice, + .configWarning, + .diagnostic: + true + default: + false + } + } + + var isThreadlessReviewBroadcast: Bool { + switch self { + case .warning, .deprecationNotice, .configWarning, .error: + true + default: + false + } + } +} + +public extension AppServerWireReviewNotification { + var startsReviewMode: Bool { + method == .itemStarted && payload.item?.type.rawValue == "enteredReviewMode" + } + + var finishesReviewMode: Bool { + method == .itemCompleted && payload.item?.type.rawValue == "exitedReviewMode" + } +} +private extension AppServerReviewEventKind { + 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" +} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift new file mode 100644 index 00000000..64964c89 --- /dev/null +++ b/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift @@ -0,0 +1,1174 @@ +import Foundation +import CodexReviewDomain + +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) + } + + public 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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [ReviewDomainEvent] { + guard let message = diagnosticMessage else { + return unknownEvent(method: method) + } + return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] + } + + func modelReroutedEvents(method: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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 + } + } + + public var startedAt: Date? { + startedAtMs.map(Self.date(millisecondsSince1970:)) + } + + public var completedAt: Date? { + completedAtMs.map(Self.date(millisecondsSince1970:)) + } + + private func diagnosticSeed( + method: AppServerReviewEventKind, + 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 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 +} From 7891882be5caced20211454c19c1bf13248a253a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:47:31 +0900 Subject: [PATCH 016/359] refactor(mcp-server): split protocol server responsibilities --- .../CodexReviewMCPHelpResources.swift | 109 ++ .../CodexReviewMCPProtocolServer.swift | 1056 ----------------- .../CodexReviewMCPProtocolServerError.swift | 15 + .../CodexReviewMCPToolRequests.swift | 174 +++ .../CodexReviewMCPToolResults.swift | 682 +++++++++++ .../CodexReviewMCPToolSchemas.swift | 87 ++ 6 files changed, 1067 insertions(+), 1056 deletions(-) create mode 100644 Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift create mode 100644 Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServerError.swift create mode 100644 Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift create mode 100644 Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift create mode 100644 Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift new file mode 100644 index 00000000..60c629fc --- /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 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"}` + """ + ), +] + +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..5cc6e7af 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift @@ -101,1059 +101,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/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift new file mode 100644 index 00000000..16fd1c28 --- /dev/null +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -0,0 +1,174 @@ +import Foundation +import MCP +import CodexReview + +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.") + ) + } +} + +func sessionID( + in arguments: [String: Value], + defaultSessionID: String?, + fallback: String? = nil +) -> String? { + defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback +} + +func optionalJobID(in arguments: [String: Value]) -> String? { + arguments["jobID"]?.stringValue?.nilIfEmpty ?? arguments["jobId"]?.stringValue?.nilIfEmpty +} + +func requiredJobID(in arguments: [String: Value]) throws -> String { + guard let jobID = optionalJobID(in: arguments) else { + throw MCPProtocolServerError.missingArgument("jobID/jobId") + } + return jobID +} + +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 +} + +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) +} + +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 -> [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 + } +} + +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..8f09fd9d --- /dev/null +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -0,0 +1,682 @@ +import Foundation +import MCP +import CodexReview +import CodexReviewMCPAdapter + +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 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), + ]) + } +} diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift new file mode 100644 index 00000000..56fbeb01 --- /dev/null +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift @@ -0,0 +1,87 @@ +import MCP +import CodexReview + +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")]), + ]), + ]) + } +} From f153e9782dac15f48eb72507e8ea52161b250c5d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:49:19 +0900 Subject: [PATCH 017/359] refactor(mcp-server): model review tool responses explicitly --- .../CodexReviewMCPProtocolServer.swift | 2 +- .../CodexReviewMCPServer.swift | 56 +++++++++++-------- .../CodexReviewMCPToolResults.swift | 15 ++--- .../CodexReviewMCPServerTests.swift | 4 +- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift index 5cc6e7af..7fb1144c 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift @@ -76,7 +76,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)], diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index cfa69de5..ffbae965 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -43,10 +43,18 @@ enum Request: Equatable, Sendable { package extension CodexReviewMCP.Tool { enum Response: Equatable, Sendable { + case reviewStart( + CodexReviewAPI.Read.Result, + timeline: ReviewMCPProjection + ) + case reviewAwait( + CodexReviewAPI.Read.Result, + timeline: ReviewMCPProjection + ) case reviewRead( CodexReviewAPI.Read.Result, timeline: ReviewMCPProjection, - timelinePage: CodexReviewAPI.Log.PageRequest? + timelinePage: CodexReviewAPI.Log.PageRequest ) case reviewList(CodexReviewAPI.List.Result) case reviewCancel(CodexReviewAPI.Cancel.Outcome) @@ -75,22 +83,23 @@ package final class CodexReviewMCPServer { package 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 reviewSnapshot( + result, sessionID: sessionID ) + return .reviewStart(snapshot.result, timeline: snapshot.timeline) case .reviewAwait(let sessionID, let jobID, let waitTimeout): - return try reviewReadResponse( + let snapshot = try reviewSnapshot( try await store.awaitReview( sessionID: sessionID, jobID: jobID, @@ -98,17 +107,18 @@ package final class CodexReviewMCPServer { ), sessionID: sessionID ) + return .reviewAwait(snapshot.result, timeline: snapshot.timeline) case .reviewRead(let sessionID, let jobID, let logFilter, let logPage): - return try reviewReadResponse( + let snapshot = try reviewSnapshot( try store.readReview( sessionID: sessionID, jobID: jobID, logFilter: logFilter, logPage: logPage ), - sessionID: sessionID, - timelinePage: logPage + sessionID: sessionID ) + return .reviewRead(snapshot.result, timeline: snapshot.timeline, timelinePage: logPage) case .reviewList(let sessionID, let cwd, let statuses, let limit): return .reviewList(store.listReviews( sessionID: sessionID, @@ -128,20 +138,15 @@ package final class CodexReviewMCPServer { } } - private func reviewReadResponse( + private func reviewSnapshot( _ result: CodexReviewAPI.Read.Result, - sessionID: String?, - timelinePage: CodexReviewAPI.Log.PageRequest? = nil - ) throws -> CodexReviewMCP.Tool.Response { + sessionID: String? + ) throws -> ReviewSnapshot { let job = try store.resolveJob( sessionID: sessionID, selector: .init(jobID: result.jobID) ) - return .reviewRead( - result, - timeline: ReviewMCPProjection(timeline: job.timeline), - timelinePage: timelinePage - ) + return ReviewSnapshot(result: result, timeline: ReviewMCPProjection(timeline: job.timeline)) } package func closeSession(_ sessionID: String) async { @@ -153,6 +158,11 @@ package final class CodexReviewMCPServer { } } +private struct ReviewSnapshot: Equatable, Sendable { + var result: CodexReviewAPI.Read.Result + var timeline: ReviewMCPProjection +} + private extension CodexReviewAPI.Job.Selector { func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Job.Selector { guard jobID == nil, statuses == nil else { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 8f09fd9d..302adcbe 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -3,18 +3,19 @@ import MCP import CodexReview import CodexReviewMCPAdapter -func toolResult(tool: CodexReviewMCP.Tool.Name, response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result { +func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result { let value: Value let text: String let isError: Bool switch response { + case .reviewStart(let result, let timeline), + .reviewAwait(let result, let timeline): + value = result.structuredContentForStartOrAwait(timeline: timeline) + text = result.textContentForStartOrAwait() + isError = result.core.lifecycle.status == .failed 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() + value = result.structuredContentForRead(timeline: timeline, timelinePage: timelinePage) + text = result.textContentForRead() isError = result.core.lifecycle.status == .failed case .reviewList(let result): value = result.structuredContent() diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 2361bf0c..71a962d3 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -38,8 +38,8 @@ struct CodexReviewMCPServerTests { await backend.yield(.completed(summary: "Done", result: "review")) let resolved = try await response - guard case .reviewRead(let read, let timeline, _) = resolved else { - Issue.record("Expected reviewRead response") + guard case .reviewStart(let read, let timeline) = resolved else { + Issue.record("Expected reviewStart response") return } #expect(read.jobID == "job-1") From 6ae96565cd100556079763be94cb86fc5576e9ab Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:09:38 +0900 Subject: [PATCH 018/359] refactor(mcp-server): simplify review tool snapshots --- .../CodexReviewMCPServer.swift | 95 +++++++++---------- .../CodexReviewMCPToolResults.swift | 18 ++-- .../CodexReviewMCPServerTests.swift | 4 +- 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index ffbae965..c98b6082 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -7,60 +7,58 @@ package enum CodexReviewMCP { } 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?, 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) + } } - package extension CodexReviewMCP.Tool { -enum Response: Equatable, Sendable { - case reviewStart( - CodexReviewAPI.Read.Result, - timeline: ReviewMCPProjection - ) - case reviewAwait( - CodexReviewAPI.Read.Result, - timeline: ReviewMCPProjection - ) - case reviewRead( - CodexReviewAPI.Read.Result, - timeline: ReviewMCPProjection, - timelinePage: CodexReviewAPI.Log.PageRequest - ) - case reviewList(CodexReviewAPI.List.Result) - case reviewCancel(CodexReviewAPI.Cancel.Outcome) -} + struct ReviewSnapshot: Equatable, Sendable { + package var result: CodexReviewAPI.Read.Result + package var timeline: ReviewMCPProjection + + package init(result: CodexReviewAPI.Read.Result, timeline: ReviewMCPProjection) { + self.result = result + self.timeline = timeline + } + } } +package extension CodexReviewMCP.Tool { + enum Response: Equatable, Sendable { + case reviewStart(ReviewSnapshot) + case reviewAwait(ReviewSnapshot) + case reviewRead(ReviewSnapshot, timelinePage: CodexReviewAPI.Log.PageRequest) + case reviewList(CodexReviewAPI.List.Result) + case reviewCancel(CodexReviewAPI.Cancel.Outcome) + } +} @MainActor package final class CodexReviewMCPServer { @@ -97,7 +95,7 @@ package final class CodexReviewMCPServer { result, sessionID: sessionID ) - return .reviewStart(snapshot.result, timeline: snapshot.timeline) + return .reviewStart(snapshot) case .reviewAwait(let sessionID, let jobID, let waitTimeout): let snapshot = try reviewSnapshot( try await store.awaitReview( @@ -107,7 +105,7 @@ package final class CodexReviewMCPServer { ), sessionID: sessionID ) - return .reviewAwait(snapshot.result, timeline: snapshot.timeline) + return .reviewAwait(snapshot) case .reviewRead(let sessionID, let jobID, let logFilter, let logPage): let snapshot = try reviewSnapshot( try store.readReview( @@ -118,7 +116,7 @@ package final class CodexReviewMCPServer { ), sessionID: sessionID ) - return .reviewRead(snapshot.result, timeline: snapshot.timeline, timelinePage: logPage) + return .reviewRead(snapshot, timelinePage: logPage) case .reviewList(let sessionID, let cwd, let statuses, let limit): return .reviewList(store.listReviews( sessionID: sessionID, @@ -141,12 +139,12 @@ package final class CodexReviewMCPServer { private func reviewSnapshot( _ result: CodexReviewAPI.Read.Result, sessionID: String? - ) throws -> ReviewSnapshot { + ) throws -> CodexReviewMCP.Tool.ReviewSnapshot { let job = try store.resolveJob( sessionID: sessionID, selector: .init(jobID: result.jobID) ) - return ReviewSnapshot(result: result, timeline: ReviewMCPProjection(timeline: job.timeline)) + return .init(result: result, timeline: ReviewMCPProjection(timeline: job.timeline)) } package func closeSession(_ sessionID: String) async { @@ -158,11 +156,6 @@ package final class CodexReviewMCPServer { } } -private struct ReviewSnapshot: Equatable, Sendable { - var result: CodexReviewAPI.Read.Result - var timeline: ReviewMCPProjection -} - private extension CodexReviewAPI.Job.Selector { func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Job.Selector { guard jobID == nil, statuses == nil else { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 302adcbe..af8514a0 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -8,15 +8,15 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul let text: String let isError: Bool switch response { - case .reviewStart(let result, let timeline), - .reviewAwait(let result, let timeline): - value = result.structuredContentForStartOrAwait(timeline: timeline) - text = result.textContentForStartOrAwait() - isError = result.core.lifecycle.status == .failed - case .reviewRead(let result, let timeline, let timelinePage): - value = result.structuredContentForRead(timeline: timeline, timelinePage: timelinePage) - text = result.textContentForRead() - isError = result.core.lifecycle.status == .failed + case .reviewStart(let snapshot), + .reviewAwait(let snapshot): + value = snapshot.result.structuredContentForStartOrAwait(timeline: snapshot.timeline) + text = snapshot.result.textContentForStartOrAwait() + isError = snapshot.result.core.lifecycle.status == .failed + case .reviewRead(let snapshot, let timelinePage): + value = snapshot.result.structuredContentForRead(timeline: snapshot.timeline, timelinePage: timelinePage) + text = snapshot.result.textContentForRead() + isError = snapshot.result.core.lifecycle.status == .failed case .reviewList(let result): value = result.structuredContent() text = "Listed \(result.items.count) review job(s)." diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 71a962d3..c198669e 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -38,10 +38,12 @@ struct CodexReviewMCPServerTests { await backend.yield(.completed(summary: "Done", result: "review")) let resolved = try await response - guard case .reviewStart(let read, let timeline) = resolved else { + guard case .reviewStart(let snapshot) = resolved else { Issue.record("Expected reviewStart response") return } + let read = snapshot.result + let timeline = snapshot.timeline #expect(read.jobID == "job-1") #expect(read.core.lifecycle.status == .succeeded) #expect(timeline.terminalSummary == "Done") From c154e98967df2b5d87b5b0f315c8fab902f11216 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:09:57 +0900 Subject: [PATCH 019/359] refactor(app-server-kit): isolate local process configuration --- .../AppServerProcessTransport.swift | 31 +--- .../CodexAppServerKit/CodexAppServer.swift | 154 ++++++++++++------ Sources/CodexAppServerKit/README.md | 17 ++ .../LiveCodexReviewStoreBackend.swift | 29 +++- .../ArchitectureFenceTests.swift | 2 + .../CodexAppServerKitTests.swift | 28 ++++ .../AppServerClientTests.swift | 45 +++-- 7 files changed, 208 insertions(+), 98 deletions(-) diff --git a/Sources/CodexAppServerKit/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift index 283c71dd..b46d2215 100644 --- a/Sources/CodexAppServerKit/AppServerProcessTransport.swift +++ b/Sources/CodexAppServerKit/AppServerProcessTransport.swift @@ -16,10 +16,8 @@ package actor AppServerProcessTransport: JSONRPC.Transport { executable: String? = nil, arguments: [String]? = nil, environment: [String: String] = ProcessInfo.processInfo.environment, - codexHomeURL: URL? = nil + codexHomeURL: URL ) { - let resolvedCodexHomeURL = - codexHomeURL ?? AppServerCodexHome.url(environment: environment) let resolvedExecutable = executable ?? CodexAppServerExecutable.resolveExecutable( @@ -42,9 +40,9 @@ package actor AppServerProcessTransport: JSONRPC.Transport { ) self.environment = AppServerCodexHome.environment( environment, - codexHomeURL: resolvedCodexHomeURL + codexHomeURL: codexHomeURL ) - self.codexHomeURL = resolvedCodexHomeURL + self.codexHomeURL = codexHomeURL self.threadStartPermissionStrategy = supportsSessionSource ? .modernPermissions @@ -69,7 +67,7 @@ package actor AppServerProcessTransport: JSONRPC.Transport { private var stderrLogFilter = AppServerStderrLogFilter() private var closed = false - package init(configuration: Configuration = .init()) throws { + package init(configuration: Configuration) throws { guard FileManager.default.isExecutableFile(atPath: configuration.executable) else { throw AppServerProcessTransportError.executableNotFound( command: configuration.executable, @@ -836,27 +834,6 @@ private enum AppServerProcessTransportError: LocalizedError { } 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 diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 5e4287eb..956b947f 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -6,28 +6,97 @@ import Foundation /// JSON-RPC handshake, and routes server notifications to thread, turn, and /// login domain objects. public actor CodexAppServer { - /// Options for launching and identifying a Codex app-server process. + /// Options for creating a Codex app-server container. + @available(iOS, unavailable, message: "The current local app-server runtime requires macOS.") + @available(tvOS, unavailable, message: "The current local app-server runtime requires macOS.") + @available(watchOS, unavailable, message: "The current local app-server runtime requires macOS.") + @available(visionOS, unavailable, message: "The current local app-server runtime requires macOS.") public struct Configuration: Sendable { - /// The `codex` executable path or command name. - /// - /// Set this when the executable is not available through the process - /// environment. When `nil`, the default transport command is used. - public var executable: String? + /// Options for launching a local `codex app-server` process. + public struct LocalProcess: Sendable { + /// The `codex` executable path or command name. + /// + /// Set this when the executable is not available through the process + /// environment. When `nil`, the default transport command is used. + public var executable: String? + + /// Command-line arguments passed to the app-server executable. + /// + /// When `nil`, the transport uses the default arguments for starting + /// `codex app-server`. + public var arguments: [String]? + + /// Environment variables supplied to the app-server process. + public var environment: [String: String] + + /// The Codex home directory used by the app-server process. + public var codexHomeURL: URL + + /// Creates a configuration for launching a local app-server process. + /// + /// - Parameters: + /// - executable: The `codex` executable path or command name. + /// - arguments: Command-line arguments for the app-server process. + /// - environment: Environment variables for the app-server process. + /// - codexHomeURL: Codex home directory, or `nil` to use the local-process default. + public init( + executable: String? = nil, + arguments: [String]? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + codexHomeURL: URL? = nil + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.codexHomeURL = codexHomeURL ?? Self.defaultCodexHomeURL(environment: environment) + } - /// Command-line arguments passed to the app-server executable. - /// - /// When `nil`, the transport uses the default arguments for starting - /// `codex app-server`. - public var arguments: [String]? + /// A local-process configuration that uses the current process environment. + public static var `default`: LocalProcess { + LocalProcess() + } - /// Environment variables supplied to the app-server process. - public var environment: [String: String] + /// Returns the default Codex home for a local app-server process. + /// + /// The value honors `CODEX_HOME` first. On macOS command-line runs, + /// it then matches the Codex CLI convention of `~/.codex`. Other + /// Apple platform environments prefer Application Support so the + /// default stays inside the app container when this API is compiled + /// for a non-command-line host. + public static func defaultCodexHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectoryForCurrentUser: URL = FileManager.default.homeDirectoryForCurrentUser, + applicationSupportDirectory: URL? = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first + ) -> URL { + if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters( + in: .whitespacesAndNewlines + ), + codexHome.isEmpty == false { + return URL(fileURLWithPath: codexHome, isDirectory: true) + } +#if os(macOS) + if let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + home.isEmpty == false { + return URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".codex", isDirectory: true) + } +#endif + if let applicationSupportDirectory { + return applicationSupportDirectory + .appendingPathComponent("Codex", isDirectory: true) + } + return homeDirectoryForCurrentUser + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Codex", isDirectory: true) + } + } - /// The Codex home directory used by the app-server process. - /// - /// When `nil`, the value is derived from `CODEX_HOME` or the user's - /// home directory in the supplied environment. - public var codexHomeURL: URL? + /// Local process launch settings for the app-server runtime. + public var localProcess: LocalProcess /// The client name sent in the app-server `initialize` request. public var clientName: String @@ -35,33 +104,26 @@ public actor CodexAppServer { /// The client version sent in the app-server `initialize` request. public var clientVersion: String - /// Creates a configuration for launching a Codex app-server process. + /// Creates a configuration for a Codex app-server container. /// /// - Parameters: - /// - executable: The `codex` executable path or command name. - /// - arguments: Command-line arguments for the app-server process. - /// - environment: Environment variables for the app-server process. - /// - codexHomeURL: Codex home directory, or `nil` to infer it. + /// - localProcess: Local process launch settings. /// - clientName: Client name sent during app-server initialization. /// - clientVersion: Client version sent during app-server initialization. public init( - executable: String? = nil, - arguments: [String]? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment, - codexHomeURL: URL? = nil, + localProcess: LocalProcess = .default, clientName: String = "CodexAppServerKit", clientVersion: String = "1" ) { - self.executable = executable - self.arguments = arguments - self.environment = environment - self.codexHomeURL = codexHomeURL + self.localProcess = localProcess self.clientName = clientName self.clientVersion = clientVersion } /// A configuration that uses the local `codex` executable and current environment. - public static let `default` = Configuration() + public static var `default`: Configuration { + Configuration() + } } private let client: AppServerClient @@ -81,15 +143,18 @@ public actor CodexAppServer { /// The initializer completes after the app-server has accepted the /// `initialize` request and notification routing is ready. /// - /// - Parameter configuration: Process and client identity configuration. + /// - Parameter configuration: Container and local-process configuration. /// - Throws: A transport, JSON-RPC, or app-server initialization error. + @available(iOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") + @available(tvOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") + @available(watchOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") + @available(visionOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") public init(configuration: Configuration = .default) async throws { let transportConfiguration = AppServerProcessTransport.Configuration( - executable: configuration.executable, - arguments: configuration.arguments, - environment: configuration.environment, - codexHomeURL: configuration.codexHomeURL - ?? Self.defaultCodexHomeURL(environment: configuration.environment) + executable: configuration.localProcess.executable, + arguments: configuration.localProcess.arguments, + environment: configuration.localProcess.environment, + codexHomeURL: configuration.localProcess.codexHomeURL ) let client = AppServerClient( transport: try AppServerProcessTransport(configuration: transportConfiguration)) @@ -583,19 +648,6 @@ public actor CodexAppServer { } } - private nonisolated static func defaultCodexHomeURL(environment: [String: String]) -> 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", isDirectory: true) - } - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex", isDirectory: true) - } } private struct AppServerAccountLoginCompletedNotification: Decodable, Equatable, Sendable { diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index 1a0536c9..eb2a4b2b 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -29,6 +29,23 @@ await appServer.close() notifications, retries app-server overload responses, and preserves schema-new notifications as unknown domain events. +## Configuration + +`CodexAppServer.Configuration` owns the container identity and local-process +runtime settings. The default local process resolves Codex home from +`CODEX_HOME`, then `HOME/.codex` on macOS command-line runs, then Application +Support for container-style environments. Pass `localProcess.codexHomeURL` when +an app wants an isolated runtime directory. + +```swift +let configuration = CodexAppServer.Configuration( + localProcess: .init( + codexHomeURL: appSupportURL.appendingPathComponent("Codex", isDirectory: true) + ) +) +let appServer = try await CodexAppServer(configuration: configuration) +``` + ## Threads `CodexThread` is the long-lived session handle for a Codex conversation in a diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 64a1a38f..08bd8130 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -292,7 +292,28 @@ 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 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( @@ -376,8 +397,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let appServer = try await Task.detached(priority: .userInitiated) { // The configuration probe can wait on `codex app-server --help`; keep it off the MainActor. try await CodexAppServer(configuration: .init( - executable: codexExecutablePath, - codexHomeURL: codexHomeURL + localProcess: .init( + executable: codexExecutablePath, + codexHomeURL: codexHomeURL + ) )) }.value return .init( diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift index 370a5e98..5ce4727b 100644 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift @@ -70,6 +70,8 @@ struct ArchitectureFenceTests { forbiddenFragments: [ "CodexReviewKit", "ReviewMonitor", + ".codex_review", + "codex_review", ] ) diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 3120d178..a33efa57 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -6,6 +6,34 @@ import CodexAppServerKitTesting @Suite("CodexAppServerKit") struct CodexAppServerKitTests { + @Test func localProcessConfigurationOwnsDefaultCodexHome() { + let fromHome = CodexAppServer.Configuration.LocalProcess(environment: [ + "HOME": "/tmp/user-home", + ]) + #expect(fromHome.codexHomeURL.path == "/tmp/user-home/.codex") + + let fromCodexHome = CodexAppServer.Configuration.LocalProcess(environment: [ + "CODEX_HOME": "/tmp/codex-home", + "HOME": "/tmp/user-home", + ]) + #expect(fromCodexHome.codexHomeURL.path == "/tmp/codex-home") + + let appSupport = URL(fileURLWithPath: "/tmp/app-support", isDirectory: true) + let containerDefault = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( + environment: [:], + homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + applicationSupportDirectory: appSupport + ) + #expect(containerDefault.path == "/tmp/app-support/Codex") + + let homeFallback = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( + environment: [:], + homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + applicationSupportDirectory: nil + ) + #expect(homeFallback.path == "/tmp/home/Library/Application Support/Codex") + } + @Test func testRuntimeStartsAppServerWithoutLaunchingProcess() async throws { let runtime = try await CodexAppServerTestRuntime.start(codexHome: "/tmp/codex") try await runtime.transport.enqueueThreadStart(threadID: "thread-test", model: "gpt-5") diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index bc877f25..3f539bc4 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -121,7 +121,8 @@ struct AppServerClientTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) let configuration = AppServerProcessTransport.Configuration( - environment: ["PATH": directory.path, "HOME": "/tmp/review-home"] + environment: ["PATH": directory.path, "HOME": "/tmp/review-home"], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) ) #expect(configuration.executable == codex.path) @@ -159,7 +160,8 @@ struct AppServerClientTests { environment: [ "PATH": "/tmp/not-used", "HOME": "/tmp/review-home", - ] + ], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) ) #expect(configuration.executable == codex.path) @@ -184,7 +186,8 @@ struct AppServerClientTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) let configuration = AppServerProcessTransport.Configuration( - environment: ["PATH": directory.path, "HOME": "/tmp/review-home"] + environment: ["PATH": directory.path, "HOME": "/tmp/review-home"], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) ) #expect(configuration.executable == codex.path) @@ -212,7 +215,8 @@ struct AppServerClientTests { let configuration = AppServerProcessTransport.Configuration( executable: codex.path, arguments: ["custom", "argument"], - environment: ["PATH": directory.path, "HOME": "/tmp/review-home"] + environment: ["PATH": directory.path, "HOME": "/tmp/review-home"], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) ) #expect(configuration.executable == codex.path) @@ -260,33 +264,37 @@ struct AppServerClientTests { ]) } - @Test func processTransportConfigurationUsesDedicatedCodexHome() throws { + @Test func processTransportConfigurationUsesConfiguredCodexHome() throws { + let codexHomeURL = URL(fileURLWithPath: "/tmp/app-server-home", isDirectory: true) let configuration = AppServerProcessTransport.Configuration( environment: [ "PATH": "/usr/bin", "HOME": "/tmp/review-home", "CODEX_SQLITE_HOME": "/tmp/main-codex-sqlite", - ] + ], + codexHomeURL: codexHomeURL ) - #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") + #expect(configuration.codexHomeURL == codexHomeURL) + #expect(configuration.environment["CODEX_HOME"] == "/tmp/app-server-home") + #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/app-server-home/sqlite") } - @Test func processTransportConfigurationUsesExplicitCodexHome() throws { + @Test func processTransportConfigurationOverridesCodexHomeEnvironment() throws { + let codexHomeURL = URL(fileURLWithPath: "/tmp/configured-codex-home", isDirectory: true) 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", - ] + ], + codexHomeURL: codexHomeURL ) - #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") + #expect(configuration.codexHomeURL == codexHomeURL) + #expect(configuration.environment["CODEX_HOME"] == "/tmp/configured-codex-home") + #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/configured-codex-home/sqlite") } @Test func processTransportScaffoldsDedicatedSqliteHome() throws { @@ -336,7 +344,8 @@ struct AppServerClientTests { environment: [ "HOME": directory.path, "PATH": "/bin:/usr/bin", - ] + ], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) )) let becameReady = await waitUntil(timeout: .seconds(2)) { FileManager.default.fileExists(atPath: readyFile.path) @@ -389,7 +398,8 @@ struct AppServerClientTests { environment: [ "HOME": directory.path, "PATH": "/bin:/usr/bin", - ] + ], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) )) let data = try await transport.send(JSONRPC.Request( @@ -432,7 +442,8 @@ struct AppServerClientTests { environment: [ "HOME": directory.path, "PATH": "/bin:/usr/bin", - ] + ], + codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) )) _ = try await transport.send(JSONRPC.Request( From 7047ec2ab8c6613d14c0cd48ee0b1f21ced1a530 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:56:27 +0900 Subject: [PATCH 020/359] feat(app-server): add reasoning stream domain API --- .../CodexAppServerKit/CodexAppServer.swift | 5 +- .../CodexAppServerNotificationRouter.swift | 106 +++++++- .../CodexAppServerKit/CodexDomainTypes.swift | 137 ++++++++++- .../CodexThreadOperations.swift | 4 +- .../CodexTurnSequences.swift | 69 +++++- Sources/CodexAppServerKit/README.md | 20 ++ .../CodexAppServerTestRuntime.swift | 2 +- .../AppServerCodexReviewBackend.swift | 6 +- .../CodexAppServerKitTests.swift | 228 +++++++++++++++++- .../AppServerClientTests.swift | 4 +- 10 files changed, 551 insertions(+), 30 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 956b947f..e35a0ac7 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -407,10 +407,13 @@ public actor CodexAppServer { /// - Throws: A transport, JSON-RPC, or app-server request error. public func configuration() async throws -> CodexConfiguration { let response = try await client.send(AppServerAPI.Config.Read.Request()) + let reasoningEffort = response.config.modelReasoningEffort.map { + CodexReasoningEffort(rawValue: $0) + } return .init( model: response.config.model, reviewModel: response.config.reviewModel, - reasoningEffort: response.config.modelReasoningEffort, + reasoningEffort: reasoningEffort, serviceTier: response.config.serviceTier ) } diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 676dec53..1b24021d 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -148,7 +148,7 @@ package actor CodexAppServerNotificationRouter { case .completed, .failed: true case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .tokenUsageUpdated, .unknown: + .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .unknown: false } } @@ -197,6 +197,21 @@ package actor CodexAppServerNotificationRouter { return .messageDelta(delta) } return .unknown(raw) + case "item/reasoning/summaryPartAdded": + if let part = reasoningSummaryPart(from: notification.params) { + return .reasoningSummaryPartAdded(part) + } + return .unknown(raw) + case "item/reasoning/summaryTextDelta": + if let delta = reasoningSummaryDelta(from: notification.params) { + return .reasoningDelta(delta) + } + return .unknown(raw) + case "item/reasoning/textDelta": + if let delta = reasoningTextDelta(from: notification.params) { + return .reasoningDelta(delta) + } + return .unknown(raw) case "thread/tokenUsage/updated": if let usage = tokenUsage(from: notification.params) { return .tokenUsageUpdated(usage) @@ -250,6 +265,21 @@ package actor CodexAppServerNotificationRouter { return .messageDelta(delta, turnID: context.turnID) } return .unknown(raw) + case "item/reasoning/summaryPartAdded": + if let part = reasoningSummaryPart(from: notification.params) { + return .reasoningSummaryPartAdded(part, turnID: context.turnID) + } + return .unknown(raw) + case "item/reasoning/summaryTextDelta": + if let delta = reasoningSummaryDelta(from: notification.params) { + return .reasoningDelta(delta, turnID: context.turnID) + } + return .unknown(raw) + case "item/reasoning/textDelta": + if let delta = reasoningTextDelta(from: notification.params) { + return .reasoningDelta(delta, turnID: context.turnID) + } + return .unknown(raw) case "thread/tokenUsage/updated": if let usage = tokenUsage(from: notification.params) { return .tokenUsageUpdated(usage, turnID: context.turnID) @@ -336,6 +366,46 @@ package actor CodexAppServerNotificationRouter { ) } + private func reasoningSummaryPart(from data: Data) -> CodexReasoningPart? { + guard let payload = try? decoder.decode( + ReasoningSummaryPartPayload.self, + from: data + ) else { + return nil + } + return .init(itemID: payload.itemID, kind: .summary, index: payload.summaryIndex) + } + + private func reasoningSummaryDelta(from data: Data) -> CodexReasoningDelta? { + guard let payload = try? decoder.decode( + ReasoningSummaryTextDeltaPayload.self, + from: data + ) else { + return nil + } + let part = CodexReasoningPart( + itemID: payload.itemID, + kind: .summary, + index: payload.summaryIndex + ) + return .init(part: part, delta: payload.delta) + } + + private func reasoningTextDelta(from data: Data) -> CodexReasoningDelta? { + guard let payload = try? decoder.decode( + ReasoningTextDeltaPayload.self, + from: data + ) else { + return nil + } + let part = CodexReasoningPart( + itemID: payload.itemID, + kind: .text, + index: payload.contentIndex + ) + return .init(part: part, delta: payload.delta) + } + private func tokenUsage(from data: Data) -> CodexTokenUsage? { guard let payload = try? decoder.decode(TokenUsagePayload.self, from: data) else { return nil @@ -426,6 +496,40 @@ private struct AgentMessageDeltaPayload: Decodable { } } +private struct ReasoningSummaryPartPayload: Decodable { + var itemID: String + var summaryIndex: Int + + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case summaryIndex + } +} + +private struct ReasoningSummaryTextDeltaPayload: Decodable { + var itemID: String + var summaryIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case summaryIndex + case delta + } +} + +private struct ReasoningTextDeltaPayload: Decodable { + var itemID: String + var contentIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case contentIndex + case delta + } +} + private struct ItemPayload: Decodable { var item: RawThreadItem } diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index ed83f38d..857c2c1c 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -180,14 +180,76 @@ public enum CodexApprovalMode: String, Codable, Equatable, Sendable { } } +/// The amount of model reasoning requested for a Codex turn. +public struct CodexReasoningEffort: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let minimal = Self(rawValue: "minimal") + public static let low = Self(rawValue: "low") + public static let medium = Self(rawValue: "medium") + public static let high = Self(rawValue: "high") + public static let xhigh = Self(rawValue: "xhigh") +} + +extension CodexReasoningEffort: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The reasoning summary mode requested for a Codex turn. +public struct CodexReasoningSummary: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let auto = Self(rawValue: "auto") + public static let concise = Self(rawValue: "concise") + public static let detailed = Self(rawValue: "detailed") +} + +extension CodexReasoningSummary: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + public struct CodexGenerationOptions: Equatable, Sendable { public var model: String? public var approvalMode: CodexApprovalMode? public var sandbox: CodexSandbox? public var cwd: URL? - public var effort: String? + public var effort: CodexReasoningEffort? public var serviceTier: String? - public var summary: String? + public var summary: CodexReasoningSummary? public var transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy public init( @@ -195,9 +257,9 @@ public struct CodexGenerationOptions: Equatable, Sendable { approvalMode: CodexApprovalMode? = nil, sandbox: CodexSandbox? = nil, cwd: URL? = nil, - effort: String? = nil, + effort: CodexReasoningEffort? = nil, serviceTier: String? = nil, - summary: String? = nil, + summary: CodexReasoningSummary? = nil, transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript ) { self.model = model @@ -1135,7 +1197,7 @@ public struct CodexResponseStream: AsyncSequence, Sendable { case .failed(let message): throw CodexAppServerError.turnFailed(message) case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .tokenUsageUpdated, .unknown: + .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .unknown: continue } } @@ -1233,12 +1295,56 @@ public struct CodexMessageDelta: Equatable, Sendable { } } +/// A reasoning summary or raw reasoning text part emitted by app-server. +public struct CodexReasoningPart: Identifiable, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case summary + case text + } + + public var itemID: String + public var kind: Kind + public var index: Int + + public var id: String { + switch kind { + case .summary: + "\(itemID):summary:\(index)" + case .text: + "\(itemID):content:\(index)" + } + } + + public init(itemID: String, kind: Kind, index: Int) { + self.itemID = itemID + self.kind = kind + self.index = index + } +} + +/// Incremental text for a reasoning summary or raw reasoning text part. +public struct CodexReasoningDelta: Identifiable, Equatable, Sendable { + public var part: CodexReasoningPart + public var delta: String + + public var id: String { + part.id + } + + public init(part: CodexReasoningPart, delta: String) { + self.part = part + self.delta = delta + } +} + package enum CodexTurnEvent: Equatable, Sendable { case started(CodexTurnID) case itemStarted(CodexThreadItem) case itemUpdated(CodexThreadItem) case itemCompleted(CodexThreadItem) case messageDelta(CodexMessageDelta) + case reasoningSummaryPartAdded(CodexReasoningPart) + case reasoningDelta(CodexReasoningDelta) case tokenUsageUpdated(CodexTokenUsage) case completed(CodexResponse) case failed(String) @@ -1254,6 +1360,8 @@ public enum CodexThreadEvent: Equatable, Sendable { case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) case message(CodexMessage, turnID: CodexTurnID?) case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) + case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) case statusChanged(CodexThreadStatus) case closed @@ -1273,19 +1381,22 @@ public struct CodexThreadLogEntry: Identifiable, Equatable, Sendable { public var phase: Phase public var item: CodexThreadItem? public var messageDelta: CodexMessageDelta? + public var reasoningDelta: CodexReasoningDelta? public init( id: String, turnID: CodexTurnID? = nil, phase: Phase, item: CodexThreadItem? = nil, - messageDelta: CodexMessageDelta? = nil + messageDelta: CodexMessageDelta? = nil, + reasoningDelta: CodexReasoningDelta? = nil ) { self.id = id self.turnID = turnID self.phase = phase self.item = item self.messageDelta = messageDelta + self.reasoningDelta = reasoningDelta } } @@ -1361,13 +1472,13 @@ public struct CodexRawNotification: Equatable, Sendable { public struct CodexConfiguration: Equatable, Sendable { public var model: String? public var reviewModel: String? - public var reasoningEffort: String? + public var reasoningEffort: CodexReasoningEffort? public var serviceTier: String? public init( model: String? = nil, reviewModel: String? = nil, - reasoningEffort: String? = nil, + reasoningEffort: CodexReasoningEffort? = nil, serviceTier: String? = nil ) { self.model = model @@ -1416,10 +1527,10 @@ package extension CodexRateLimits { public struct CodexModel: Codable, Identifiable, Equatable, Sendable { public struct ReasoningOption: Codable, Equatable, Sendable { - public var reasoningEffort: String + public var reasoningEffort: CodexReasoningEffort public var description: String - public init(reasoningEffort: String, description: String) { + public init(reasoningEffort: CodexReasoningEffort, description: String) { self.reasoningEffort = reasoningEffort self.description = description } @@ -1434,7 +1545,7 @@ public struct CodexModel: Codable, Identifiable, Equatable, Sendable { public var displayName: String public var hidden: Bool public var supportedReasoningEfforts: [ReasoningOption] - public var defaultReasoningEffort: String? + public var defaultReasoningEffort: CodexReasoningEffort? public var supportedServiceTiers: [String] public var isDefault: Bool @@ -1456,7 +1567,7 @@ public struct CodexModel: Codable, Identifiable, Equatable, Sendable { displayName: String, hidden: Bool = false, supportedReasoningEfforts: [ReasoningOption] = [], - defaultReasoningEffort: String? = nil, + defaultReasoningEffort: CodexReasoningEffort? = nil, supportedServiceTiers: [String] = [], isDefault: Bool = false ) { @@ -1482,7 +1593,7 @@ public struct CodexModel: Codable, Identifiable, Equatable, Sendable { forKey: .supportedReasoningEfforts ) ?? [] self.defaultReasoningEffort = try container.decodeIfPresent( - String.self, forKey: .defaultReasoningEffort) + CodexReasoningEffort.self, forKey: .defaultReasoningEffort) let additionalSpeedTiers = try container.decodeIfPresent([String].self, forKey: .supportedServiceTiers) ?? [] let serviceTierIDs = diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 5400e1d0..d77b700e 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -268,11 +268,11 @@ package func startCodexTurn( approvalPolicy: options.approvalMode?.approvalPolicy, approvalsReviewer: options.approvalMode?.approvalsReviewer, cwd: options.cwd?.path, - effort: options.effort, + effort: options.effort?.rawValue, model: options.model, sandboxPolicy: options.sandbox?.turnSandboxPolicy, serviceTier: options.serviceTier, - summary: options.summary + summary: options.summary?.rawValue ) )) return CodexTurn( diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index c0954507..7644514c 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -53,8 +53,8 @@ public struct CodexThreadMessageSequence: AsyncSequence, Sendable { phase: delta.phase, text: delta.text ) - case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, - .statusChanged, .closed, .unknown: + case .turnStarted, .turnCompleted, .turnFailed, .reasoningSummaryPartAdded, + .reasoningDelta, .tokenUsageUpdated, .statusChanged, .closed, .unknown: continue } } @@ -138,8 +138,22 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { phase: .delta, messageDelta: delta ) - case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, - .statusChanged, .closed, .unknown: + case .reasoningSummaryPartAdded(let part, let turnID): + return .init( + id: part.id, + turnID: turnID, + phase: .started, + item: .init(id: part.id, kind: .reasoning, content: .reasoning("")) + ) + case .reasoningDelta(let delta, let turnID): + return .init( + id: delta.id, + turnID: turnID, + phase: .delta, + reasoningDelta: delta + ) + case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, + .closed, .unknown: continue } } @@ -191,7 +205,8 @@ package struct CodexTurnProgressSequence: AsyncSequence, Sendable { switch event { case .started, .unknown: return .init(phase: .running, transcript: accumulator.transcript) - case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta: + case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: _ = accumulator.apply(event) return .init(phase: .running, transcript: accumulator.transcript) case .tokenUsageUpdated(let newUsage): @@ -246,7 +261,8 @@ package struct CodexResponseCollector { switch event { case .started, .unknown: continue - case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta: + case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: _ = accumulator.apply(event) case .tokenUsageUpdated(let newUsage): usage = newUsage @@ -279,6 +295,7 @@ private struct CodexTranscriptAccumulator { private var items: [CodexThreadItem] = [] private var itemIndexesByID: [String: Int] = [:] private var messageDeltaTextByItemID: [String: String] = [:] + private var reasoningDeltaTextByPartID: [String: String] = [:] var transcript: CodexTranscript { .init(items: items) @@ -292,6 +309,12 @@ private struct CodexTranscriptAccumulator { case .messageDelta(let delta): append(delta) return true + case .reasoningSummaryPartAdded(let part): + start(part) + return true + case .reasoningDelta(let delta): + append(delta) + return true case .started, .tokenUsageUpdated, .completed, .failed, .unknown: return false } @@ -313,6 +336,12 @@ private struct CodexTranscriptAccumulator { case .messageDelta(let delta, _): append(delta) return true + case .reasoningSummaryPartAdded(let part, _): + start(part) + return true + case .reasoningDelta(let delta, _): + append(delta) + return true case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, .closed, .unknown: return false @@ -320,6 +349,11 @@ private struct CodexTranscriptAccumulator { } private mutating func upsert(_ item: CodexThreadItem) { + if item.kind == .reasoning && item.id.contains(":summary:") == false + && item.id.contains(":content:") == false + { + removeReasoningParts(parentItemID: item.id) + } if let index = itemIndexesByID[item.id] { items[index] = item } else { @@ -340,4 +374,27 @@ private struct CodexTranscriptAccumulator { ) upsert(.init(id: itemID, kind: .agentMessage, content: .message(message))) } + + private mutating func start(_ part: CodexReasoningPart) { + upsert(.init(id: part.id, kind: .reasoning, content: .reasoning(""))) + } + + private mutating func append(_ delta: CodexReasoningDelta) { + let text = (reasoningDeltaTextByPartID[delta.id] ?? "") + delta.delta + reasoningDeltaTextByPartID[delta.id] = text + upsert(.init(id: delta.id, kind: .reasoning, content: .reasoning(text))) + } + + private mutating func removeReasoningParts(parentItemID: String) { + let prefixes = ["\(parentItemID):summary:", "\(parentItemID):content:"] + items.removeAll { item in + prefixes.contains { item.id.hasPrefix($0) } + } + reasoningDeltaTextByPartID = reasoningDeltaTextByPartID.filter { id, _ in + prefixes.contains { id.hasPrefix($0) } == false + } + itemIndexesByID = Dictionary( + uniqueKeysWithValues: items.enumerated().map { index, item in (item.id, index) } + ) + } } diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index eb2a4b2b..31e6d38b 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -138,6 +138,17 @@ let stream = try await thread.streamResponse( ) ``` +It also exposes reasoning controls with domain values instead of raw strings: + +```swift +let response = try await thread.respond( + to: "Find the risky part of this change.", + options: .init(effort: .high, summary: .detailed) +) + +print(response.usage?.reasoningOutputTokens ?? 0) +``` + Threads also expose async sequences for chat, transcript updates, and log-style consumers. This is the API surface intended for higher-level products that need to render Codex output continuously outside a single response stream. @@ -156,9 +167,16 @@ for try await transcript in thread.transcriptUpdates { ```swift for try await entry in thread.logEntries { + if let delta = entry.reasoningDelta { + renderReasoningDelta(delta) + continue + } + switch entry.item?.content { case .message(let message): renderMessage(message) + case .reasoning(let text): + renderReasoning(text) case .command(let command): renderCommand(command.command, output: command.output) case .toolCall(let tool): @@ -178,6 +196,8 @@ unknown notifications: ```swift for try await event in thread.events { switch event { + case .reasoningDelta(let delta, _): + renderReasoningDelta(delta) case .tokenUsageUpdated(let usage, _): updateUsage(usage.totalTokens) case .unknown(let raw): diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift index 51217a53..48866ef6 100644 --- a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift @@ -277,7 +277,7 @@ public actor CodexAppServerTestTransport { AppServerAPI.Config.Read.Response(config: .init( model: configuration.model, reviewModel: configuration.reviewModel, - modelReasoningEffort: configuration.reasoningEffort, + modelReasoningEffort: configuration.reasoningEffort?.rawValue, serviceTier: configuration.serviceTier )), for: "config/read" diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 984f290f..01dfbd51 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -63,7 +63,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return .init( model: configuration.reviewModel?.nilIfEmpty, fallbackModel: configuration.model?.nilIfEmpty ?? models.first(where: \.isDefault)?.model, - reasoningEffort: configuration.reasoningEffort, + reasoningEffort: configuration.reasoningEffort?.rawValue, serviceTier: configuration.serviceTier, models: models ) @@ -1898,12 +1898,12 @@ private extension AppServerAPI.Account.Snapshot { private extension CodexModel { var reviewModelCatalogItem: CodexReviewSettings.ModelCatalogItem { let reasoningOptions = supportedReasoningEfforts.compactMap { option in - CodexReviewSettings.ReasoningEffort(rawValue: option.reasoningEffort).map { + CodexReviewSettings.ReasoningEffort(rawValue: option.reasoningEffort.rawValue).map { CodexReviewSettings.ReasoningOption(reasoningEffort: $0, description: option.description) } } let defaultReasoningEffort = defaultReasoningEffort - .flatMap(CodexReviewSettings.ReasoningEffort.init(rawValue:)) + .flatMap { CodexReviewSettings.ReasoningEffort(rawValue: $0.rawValue) } ?? reasoningOptions.first?.reasoningEffort ?? .medium let serviceTiers = supportedServiceTiers.compactMap(CodexReviewSettings.ServiceTier.init(rawValue:)) diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index a33efa57..7a8219c0 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -413,6 +413,180 @@ struct CodexAppServerKitTests { ]) } + @Test func responseStreamSerializesReasoningOptions() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + _ = try await thread.streamResponse( + to: "Explain the patch.", + options: .init(effort: .high, summary: .detailed) + ) + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, + from: request.params + ) + #expect(params.effort == "high") + #expect(params.summary == "detailed") + } + + @Test func reasoningNotificationsRouteAsTypedEventsLogsAndTranscript() async throws { + let transport = CodexAppServerTestTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + + try await transport.emitServerNotification( + method: "item/reasoning/summaryPartAdded", + params: ReasoningSummaryPartParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + summaryIndex: 0 + ) + ) + try await transport.emitServerNotification( + method: "item/reasoning/summaryTextDelta", + params: ReasoningSummaryDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + summaryIndex: 0, + delta: "Checking" + ) + ) + try await transport.emitServerNotification( + method: "item/reasoning/textDelta", + params: ReasoningTextDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + contentIndex: 1, + delta: "Raw trace" + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "reasoning-1", + type: "reasoning", + summary: ["Final summary"], + content: ["Final raw"] + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let thread = CodexThread(id: "thread-1", client: client, router: router) + let events = try await collect(thread.events) + #expect( + events.contains { + if case .reasoningSummaryPartAdded(let part, let turnID) = $0 { + part.id == "reasoning-1:summary:0" && turnID == "turn-1" + } else { + false + } + }) + #expect( + events.contains { + if case .reasoningDelta(let delta, let turnID) = $0 { + delta.id == "reasoning-1:content:1" + && delta.delta == "Raw trace" + && turnID == "turn-1" + } else { + false + } + }) + + let logs = try await collect(thread.logEntries) + #expect(logs.contains { $0.id == "reasoning-1:summary:0" && $0.phase == .started }) + #expect( + logs.contains { + $0.reasoningDelta?.id == "reasoning-1:summary:0" + && $0.reasoningDelta?.delta == "Checking" + }) + #expect( + logs.contains { + $0.reasoningDelta?.id == "reasoning-1:content:1" + && $0.reasoningDelta?.delta == "Raw trace" + }) + + let transcripts = try await collect(thread.transcriptUpdates) + let finalTranscript = try #require(transcripts.last) + #expect(finalTranscript.items.map(\.id) == ["reasoning-1"]) + #expect(finalTranscript.items.first?.content == .reasoning("Final summary")) + } + + @Test func modelAndConfigurationDecodeReasoningTypes() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "data": [ + { + "id": "gpt-5-codex", + "model": "gpt-5-codex", + "displayName": "GPT-5 Codex", + "hidden": false, + "supportedReasoningEfforts": [ + {"reasoningEffort": "medium", "description": "Balanced"}, + {"reasoningEffort": "xhigh", "description": "Maximum"} + ], + "defaultReasoningEffort": "xhigh", + "additionalSpeedTiers": [], + "isDefault": true + } + ], + "nextCursor": null + } + """, + for: "model/list" + ) + try await transport.enqueueJSON( + """ + { + "config": { + "model": "gpt-5-codex", + "model_reasoning_effort": "high", + "service_tier": "flex" + } + } + """, + for: "config/read" + ) + let client = AppServerClient(transport: transport) + let server = CodexAppServer( + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + + let models = try await server.models() + let reasoningEfforts = models.first?.supportedReasoningEfforts.map(\.reasoningEffort) + #expect(reasoningEfforts == [.medium, .xhigh]) + #expect(models.first?.defaultReasoningEffort == .xhigh) + + let configuration = try await server.configuration() + #expect(configuration.reasoningEffort == .high) + } + @Test func responseStreamInterruptSendsTurnInterrupt() async throws { let transport = CodexAppServerTestTransport() try await transport.enqueue( @@ -636,6 +810,8 @@ private struct ThreadItemParams: Encodable, Sendable { var command: String? var aggregatedOutput: String? var status: String? + var summary: [String]? + var content: [String]? init( id: String, @@ -644,7 +820,9 @@ private struct ThreadItemParams: Encodable, Sendable { phase: String? = nil, command: String? = nil, aggregatedOutput: String? = nil, - status: String? = nil + status: String? = nil, + summary: [String]? = nil, + content: [String]? = nil ) { self.id = id self.type = type @@ -653,10 +831,58 @@ private struct ThreadItemParams: Encodable, Sendable { self.command = command self.aggregatedOutput = aggregatedOutput self.status = status + self.summary = summary + self.content = content } } } +private struct ReasoningSummaryPartParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var summaryIndex: Int + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case summaryIndex + } +} + +private struct ReasoningSummaryDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var summaryIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case summaryIndex + case delta + } +} + +private struct ReasoningTextDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var contentIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case contentIndex + case delta + } +} + private struct TokenUsageParams: Encodable, Sendable { var threadID: String var turnID: String diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 3f539bc4..21e75cc5 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -5830,8 +5830,8 @@ private func makeModelCatalogItem( model: model, displayName: model, hidden: false, - supportedReasoningEfforts: [.init(reasoningEffort: "medium", description: "Balanced")], - defaultReasoningEffort: "medium", + supportedReasoningEfforts: [.init(reasoningEffort: .medium, description: "Balanced")], + defaultReasoningEffort: .medium, supportedServiceTiers: ["fast"], isDefault: isDefault ) From 9646c64d40e11d48b3ac9ad420f85bc27bac2993 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:48:18 +0900 Subject: [PATCH 021/359] feat(app-server): improve response stream domain parity --- .../CodexAppServerKit/AppServerRequests.swift | 42 +- .../CodexAppServerKit/CodexAppServer.swift | 21 +- .../CodexAppServerNotificationRouter.swift | 22 +- .../CodexAppServerKit/CodexDomainTypes.swift | 365 ++++++++++++++++-- .../CodexThreadOperations.swift | 3 + .../CodexTurnSequences.swift | 76 ++-- Sources/CodexAppServerKit/README.md | 56 ++- .../CodexAppServerKitTests.swift | 164 +++++++- 8 files changed, 648 insertions(+), 101 deletions(-) diff --git a/Sources/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift index 8f1cf0bf..c20f7095 100644 --- a/Sources/CodexAppServerKit/AppServerRequests.swift +++ b/Sources/CodexAppServerKit/AppServerRequests.swift @@ -361,10 +361,20 @@ extension AppServerAPI.Thread.Start { } extension AppServerAPI.Thread { - package enum Source: String, Codable, Equatable, Sendable { - case user - case subagent - case memoryConsolidation = "memory_consolidation" + package struct Source: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + package var rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: String) { + self.rawValue = value + } + + package static let user = Self(rawValue: "user") + package static let subagent = Self(rawValue: "subagent") + package static let memoryConsolidation = Self(rawValue: "memory_consolidation") } } @@ -574,11 +584,33 @@ extension AppServerAPI.Turn { package var id: String package var status: String? package var error: AppServerAPI.Turn.Error? + package var startedAt: Int? + package var completedAt: Int? + package var durationMS: Int? - package init(id: String, status: String? = nil, error: AppServerAPI.Turn.Error? = nil) { + enum CodingKeys: String, CodingKey { + case id + case status + case error + case startedAt + case completedAt + case durationMS = "durationMs" + } + + package init( + id: String, + status: String? = nil, + error: AppServerAPI.Turn.Error? = nil, + startedAt: Int? = nil, + completedAt: Int? = nil, + durationMS: Int? = nil + ) { self.id = id self.status = status self.error = error + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMS = durationMS } } } diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index e35a0ac7..ed74267b 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -274,7 +274,12 @@ public actor CodexAppServer { approvalPolicy: approvalMode.approvalPolicy, approvalsReviewer: approvalMode.approvalsReviewer, sandbox: options.sandbox?.threadSandboxValue, - serviceTier: options.serviceTier + serviceName: options.serviceName, + serviceTier: options.serviceTier, + personality: options.personality?.rawValue, + config: options.config?.mapValues(\.appServerJSONValue), + sessionStartSource: options.sessionStartSource?.appServerSource, + threadSource: options.threadSource?.appServerSource ) )) return CodexThread( @@ -361,7 +366,12 @@ public actor CodexAppServer { cursor: query.cursor, cwd: query.workspace.map { .path($0.path) }, limit: query.limit, - searchTerm: query.searchTerm + modelProviders: query.modelProviders, + searchTerm: query.searchTerm, + sortDirection: query.sortDirection?.rawValue, + sortKey: query.sortKey?.rawValue, + sourceKinds: query.sourceKinds?.map(\.rawValue), + useStateDbOnly: query.useStateDBOnly ))) return .init( threads: response.data.map(Self.threadSnapshot), @@ -540,7 +550,12 @@ public actor CodexAppServer { approvalPolicy: options.approvalMode?.approvalPolicy, approvalsReviewer: options.approvalMode?.approvalsReviewer, sandbox: options.sandbox?.threadSandboxValue, - serviceTier: options.serviceTier + serviceName: options.serviceName, + serviceTier: options.serviceTier, + personality: options.personality?.rawValue, + config: options.config?.mapValues(\.appServerJSONValue), + sessionStartSource: options.sessionStartSource?.appServerSource, + threadSource: options.threadSource?.appServerSource ) } diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 1b24021d..2d356e23 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -244,20 +244,17 @@ package actor CodexAppServerNotificationRouter { ) case "item/started": if let item = item(from: notification.params) { - return item.message.map { .message($0, turnID: context.turnID) } - ?? .itemStarted(item, turnID: context.turnID) + return .itemStarted(item, turnID: context.turnID) } return .unknown(raw) case "item/updated": if let item = item(from: notification.params) { - return item.message.map { .message($0, turnID: context.turnID) } - ?? .itemUpdated(item, turnID: context.turnID) + return .itemUpdated(item, turnID: context.turnID) } return .unknown(raw) case "item/completed": if let item = item(from: notification.params) { - return item.message.map { .message($0, turnID: context.turnID) } - ?? .itemCompleted(item, turnID: context.turnID) + return .itemCompleted(item, turnID: context.turnID) } return .unknown(raw) case "item/agentMessage/delta": @@ -430,7 +427,10 @@ package actor CodexAppServerNotificationRouter { return .init( turnID: turn.map { .init(rawValue: $0.id) } ?? context.turnID ?? .init(rawValue: ""), status: status, - errorMessage: turn?.error?.message + errorMessage: turn?.error?.message, + startedAt: turn?.startedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + completedAt: turn?.completedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + duration: turn?.durationMS.map { .milliseconds(Int64($0)) } ) } @@ -687,8 +687,12 @@ private struct RawThreadItem: Decodable { case .plan: return .plan(messageText) case .reasoning: - return .reasoning( - summary?.joined(separator: "\n") ?? content?.joined(separator: "\n") ?? messageText) + let summary = summary ?? [] + let content = content ?? [] + if summary.isEmpty && content.isEmpty { + return .reasoning(.init(summary: messageText)) + } + return .reasoning(.init(summary: summary, content: content)) case .commandExecution: return .command( .init( diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 857c2c1c..fa05b872 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -93,6 +93,81 @@ public struct CodexInstructions: Equatable, Sendable { } } +/// A JSON value used for app-server configuration and structured output schema. +public enum CodexJSONValue: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([CodexJSONValue]) + case object([String: CodexJSONValue]) + case null + + public init(stringLiteral value: String) { + self = .string(value) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([CodexJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: CodexJSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +extension CodexJSONValue { + package var appServerJSONValue: AppServerJSONValue { + switch self { + case .string(let value): + .string(value) + case .int(let value): + .int(value) + case .double(let value): + .double(value) + case .bool(let value): + .bool(value) + case .array(let value): + .array(value.map(\.appServerJSONValue)) + case .object(let value): + .object(value.mapValues(\.appServerJSONValue)) + case .null: + .null + } + } +} + @resultBuilder public enum CodexInstructionsBuilder { public static func buildBlock(_ components: String...) -> String { @@ -242,6 +317,59 @@ extension CodexReasoningSummary: Codable { } } +/// The app-server personality to apply to a thread or turn. +public struct CodexPersonality: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let friendly = Self(rawValue: "friendly") + public static let pragmatic = Self(rawValue: "pragmatic") +} + +/// The source that created a new app-server session. +public enum CodexThreadStartSource: String, Codable, Equatable, Sendable { + case startup + case clear + + package var appServerSource: AppServerAPI.Thread.Start.Source { + switch self { + case .startup: + .startup + case .clear: + .clear + } + } +} + +/// Client-supplied analytics classification for a Codex thread. +public struct CodexThreadSource: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let user = Self(rawValue: "user") + public static let subagent = Self(rawValue: "subagent") + public static let memoryConsolidation = Self(rawValue: "memory_consolidation") + + package var appServerSource: AppServerAPI.Thread.Source { + .init(rawValue: rawValue) + } +} + public struct CodexGenerationOptions: Equatable, Sendable { public var model: String? public var approvalMode: CodexApprovalMode? @@ -250,6 +378,9 @@ public struct CodexGenerationOptions: Equatable, Sendable { public var effort: CodexReasoningEffort? public var serviceTier: String? public var summary: CodexReasoningSummary? + public var outputSchema: CodexJSONValue? + public var personality: CodexPersonality? + public var clientUserMessageID: String? public var transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy public init( @@ -260,6 +391,9 @@ public struct CodexGenerationOptions: Equatable, Sendable { effort: CodexReasoningEffort? = nil, serviceTier: String? = nil, summary: CodexReasoningSummary? = nil, + outputSchema: CodexJSONValue? = nil, + personality: CodexPersonality? = nil, + clientUserMessageID: String? = nil, transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript ) { self.model = model @@ -269,6 +403,9 @@ public struct CodexGenerationOptions: Equatable, Sendable { self.effort = effort self.serviceTier = serviceTier self.summary = summary + self.outputSchema = outputSchema + self.personality = personality + self.clientUserMessageID = clientUserMessageID self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy } } @@ -313,6 +450,11 @@ public struct CodexThread: Identifiable, Sendable { public var sandbox: CodexSandbox? public var serviceTier: String? public var ephemeral: Bool? + public var config: [String: CodexJSONValue]? + public var personality: CodexPersonality? + public var serviceName: String? + public var sessionStartSource: CodexThreadStartSource? + public var threadSource: CodexThreadSource? public init( model: String? = nil, @@ -320,7 +462,12 @@ public struct CodexThread: Identifiable, Sendable { approvalMode: CodexApprovalMode? = nil, sandbox: CodexSandbox? = nil, serviceTier: String? = nil, - ephemeral: Bool? = nil + ephemeral: Bool? = nil, + config: [String: CodexJSONValue]? = nil, + personality: CodexPersonality? = nil, + serviceName: String? = nil, + sessionStartSource: CodexThreadStartSource? = nil, + threadSource: CodexThreadSource? = nil ) { self.model = model self.modelProvider = modelProvider @@ -328,6 +475,11 @@ public struct CodexThread: Identifiable, Sendable { self.sandbox = sandbox self.serviceTier = serviceTier self.ephemeral = ephemeral + self.config = config + self.personality = personality + self.serviceName = serviceName + self.sessionStartSource = sessionStartSource + self.threadSource = threadSource } } @@ -589,22 +741,76 @@ public struct CodexThreadQuery: Equatable, Sendable { public var workspace: URL? public var limit: Int? public var searchTerm: String? + public var modelProviders: [String]? + public var sortDirection: CodexSortDirection? + public var sortKey: CodexThreadSortKey? + public var sourceKinds: [CodexThreadSourceKind]? + public var useStateDBOnly: Bool? public init( archived: Bool? = nil, cursor: String? = nil, workspace: URL? = nil, limit: Int? = nil, - searchTerm: String? = nil + searchTerm: String? = nil, + modelProviders: [String]? = nil, + sortDirection: CodexSortDirection? = nil, + sortKey: CodexThreadSortKey? = nil, + sourceKinds: [CodexThreadSourceKind]? = nil, + useStateDBOnly: Bool? = nil ) { self.archived = archived self.cursor = cursor self.workspace = workspace self.limit = limit self.searchTerm = searchTerm + self.modelProviders = modelProviders + self.sortDirection = sortDirection + self.sortKey = sortKey + self.sourceKinds = sourceKinds + self.useStateDBOnly = useStateDBOnly } } +/// Sort direction for thread list queries. +public enum CodexSortDirection: String, Codable, Equatable, Sendable { + case ascending = "asc" + case descending = "desc" +} + +/// Sort key for thread list queries. +public enum CodexThreadSortKey: String, Codable, Equatable, Sendable { + case createdAt = "created_at" + case updatedAt = "updated_at" + case recencyAt = "recency_at" +} + +/// Source-kind filter for thread list queries. +public struct CodexThreadSourceKind: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let cli = Self(rawValue: "cli") + public static let vscode = Self(rawValue: "vscode") + public static let exec = Self(rawValue: "exec") + public static let appServer = Self(rawValue: "appServer") + public static let subAgent = Self(rawValue: "subAgent") + public static let subAgentReview = Self(rawValue: "subAgentReview") + public static let subAgentCompact = Self(rawValue: "subAgentCompact") + public static let subAgentThreadSpawn = Self(rawValue: "subAgentThreadSpawn") + public static let subAgentOther = Self(rawValue: "subAgentOther") + public static let unknown = Self(rawValue: "unknown") +} + +extension CodexThreadSourceKind: Codable {} + public struct CodexThreadPage: Equatable, Sendable { public var threads: [CodexThreadSnapshot] public var nextCursor: String? @@ -757,7 +963,7 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { public enum Content: Equatable, Sendable { case message(CodexMessage) case plan(String) - case reasoning(String) + case reasoning(CodexReasoning) case command(CodexCommand) case fileChange(CodexFileChange) case toolCall(CodexToolCall) @@ -788,8 +994,10 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { switch content { case .message(let message): message.text - case .plan(let text), .reasoning(let text), .diagnostic(let text), .log(let text): + case .plan(let text), .diagnostic(let text), .log(let text): text + case .reasoning(let reasoning): + reasoning.text case .command(let command): command.output ?? command.command case .fileChange(let fileChange): @@ -811,6 +1019,33 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { } } +public struct CodexReasoning: Equatable, Sendable { + public var summary: [String] + public var content: [String] + + public static let empty = Self(summary: [], content: []) + + public init(summary: [String] = [], content: [String] = []) { + self.summary = summary + self.content = content + } + + public init(summary: String, content: String? = nil) { + self.summary = [summary] + self.content = content.map { [$0] } ?? [] + } + + public init(content: String) { + self.summary = [] + self.content = [content] + } + + public var text: String { + let preferred = summary.isEmpty ? content : summary + return preferred.joined(separator: "\n") + } +} + public struct CodexMessage: Identifiable, Equatable, Sendable { public enum Role: Equatable, Sendable { case user @@ -1029,6 +1264,9 @@ public struct CodexResponse: Identifiable, Equatable, Sendable { public var finalAnswer: String? public var transcript: CodexTranscript public var usage: CodexTokenUsage? + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? public var id: CodexTurnID { turnID @@ -1040,7 +1278,10 @@ public struct CodexResponse: Identifiable, Equatable, Sendable { errorMessage: String? = nil, finalAnswer: String? = nil, transcript: CodexTranscript = .init(), - usage: CodexTokenUsage? = nil + usage: CodexTokenUsage? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil ) { self.turnID = turnID self.status = status @@ -1048,6 +1289,9 @@ public struct CodexResponse: Identifiable, Equatable, Sendable { self.finalAnswer = finalAnswer self.transcript = transcript self.usage = usage + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration } } @@ -1218,6 +1462,7 @@ public struct CodexResponseStream: AsyncSequence, Sendable { private let turn: CodexTurn private let transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy private var progress: CodexTurnProgressSequence.Iterator + private var pendingError: Error? fileprivate init( turn: CodexTurn, @@ -1230,11 +1475,25 @@ public struct CodexResponseStream: AsyncSequence, Sendable { } public mutating func next() async throws -> Snapshot? { + if let error = pendingError { + pendingError = nil + throw error + } guard let progress = try await progress.next() else { return nil } if case .failed(let error) = progress.phase { try await handleFailure() + if let response = progress.result { + pendingError = error + return Snapshot( + turnID: turn.id, + content: response.finalAnswer ?? response.transcript.responseText, + transcript: response.transcript, + usage: response.usage, + response: response + ) + } throw error } return Snapshot( @@ -1368,7 +1627,7 @@ public enum CodexThreadEvent: Equatable, Sendable { case unknown(CodexRawNotification) } -public struct CodexThreadLogEntry: Identifiable, Equatable, Sendable { +public enum CodexThreadLogEntry: Identifiable, Equatable, Sendable { public enum Phase: Equatable, Sendable { case started case updated @@ -1376,27 +1635,71 @@ public struct CodexThreadLogEntry: Identifiable, Equatable, Sendable { case delta } - public var id: String - public var turnID: CodexTurnID? - public var phase: Phase - public var item: CodexThreadItem? - public var messageDelta: CodexMessageDelta? - public var reasoningDelta: CodexReasoningDelta? + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?, id: String) + case reasoningPartStarted(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) - public init( - id: String, - turnID: CodexTurnID? = nil, - phase: Phase, - item: CodexThreadItem? = nil, - messageDelta: CodexMessageDelta? = nil, - reasoningDelta: CodexReasoningDelta? = nil - ) { - self.id = id - self.turnID = turnID - self.phase = phase - self.item = item - self.messageDelta = messageDelta - self.reasoningDelta = reasoningDelta + public var id: String { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item.id + case .messageDelta(_, _, let id): + id + case .reasoningPartStarted(let part, _): + part.id + case .reasoningDelta(let delta, _): + delta.id + } + } + + public var turnID: CodexTurnID? { + switch self { + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .messageDelta(_, let turnID, _), + .reasoningPartStarted(_, let turnID), .reasoningDelta(_, let turnID): + turnID + } + } + + public var phase: Phase { + switch self { + case .itemStarted, .reasoningPartStarted: + .started + case .itemUpdated: + .updated + case .itemCompleted: + .completed + case .messageDelta, .reasoningDelta: + .delta + } + } + + public var item: CodexThreadItem? { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item + case .reasoningPartStarted(let part, _): + .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)) + case .messageDelta, .reasoningDelta: + nil + } + } + + public var messageDelta: CodexMessageDelta? { + if case .messageDelta(let delta, _, _) = self { + return delta + } + return nil + } + + public var reasoningDelta: CodexReasoningDelta? { + if case .reasoningDelta(let delta, _) = self { + return delta + } + return nil } } @@ -1706,6 +2009,7 @@ public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { case retryLimitExceeded case malformedNotification(String) case turnFailed(String) + case turnFailedWithResponse(CodexResponse) public var errorDescription: String? { switch self { @@ -1721,6 +2025,15 @@ public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { message case .turnFailed(let message): message + case .turnFailedWithResponse(let response): + response.errorMessage ?? response.status?.rawValue ?? "Turn failed." } } + + public var response: CodexResponse? { + if case .turnFailedWithResponse(let response) = self { + return response + } + return nil + } } diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index d77b700e..fe006931 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -267,9 +267,12 @@ package func startCodexTurn( input: prompt.appServerInput, approvalPolicy: options.approvalMode?.approvalPolicy, approvalsReviewer: options.approvalMode?.approvalsReviewer, + clientUserMessageID: options.clientUserMessageID, cwd: options.cwd?.path, effort: options.effort?.rawValue, model: options.model, + outputSchema: options.outputSchema?.appServerJSONValue, + personality: options.personality?.rawValue, sandboxPolicy: options.sandbox?.turnSandboxPolicy, serviceTier: options.serviceTier, summary: options.summary?.rawValue diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index 7644514c..c89ca3ea 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -41,18 +41,12 @@ public struct CodexThreadMessageSequence: AsyncSequence, Sendable { switch event { case .message(let message, _): return message - case .itemStarted(let item, _), .itemUpdated(let item, _), - .itemCompleted(let item, _): + case .itemCompleted(let item, _): if let message = item.message { return message } - case .messageDelta(let delta, _): - return .init( - id: delta.itemID ?? UUID().uuidString, - role: .assistant, - phase: delta.phase, - text: delta.text - ) + case .itemStarted, .itemUpdated, .messageDelta: + continue case .turnStarted, .turnCompleted, .turnFailed, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .closed, .unknown: continue @@ -110,6 +104,7 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { public struct Iterator: AsyncIteratorProtocol { private var events: AsyncThrowingStream.Iterator + private var logEntryIndex = 0 fileprivate init(events: AsyncThrowingStream.Iterator) { self.events = events @@ -119,39 +114,24 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { while let event = try await events.next() { switch event { case .itemStarted(let item, let turnID): - return .init(id: item.id, turnID: turnID, phase: .started, item: item) + return .itemStarted(item, turnID: turnID) case .itemUpdated(let item, let turnID): - return .init(id: item.id, turnID: turnID, phase: .updated, item: item) + return .itemUpdated(item, turnID: turnID) case .itemCompleted(let item, let turnID): - return .init(id: item.id, turnID: turnID, phase: .completed, item: item) + return .itemCompleted(item, turnID: turnID) case .message(let message, let turnID): let item = CodexThreadItem( id: message.id, kind: message.role == .user ? .userMessage : .agentMessage, content: .message(message) ) - return .init(id: message.id, turnID: turnID, phase: .completed, item: item) + return .itemCompleted(item, turnID: turnID) case .messageDelta(let delta, let turnID): - return .init( - id: delta.itemID ?? UUID().uuidString, - turnID: turnID, - phase: .delta, - messageDelta: delta - ) + return .messageDelta(delta, turnID: turnID, id: nextDeltaLogEntryID(for: delta)) case .reasoningSummaryPartAdded(let part, let turnID): - return .init( - id: part.id, - turnID: turnID, - phase: .started, - item: .init(id: part.id, kind: .reasoning, content: .reasoning("")) - ) + return .reasoningPartStarted(part, turnID: turnID) case .reasoningDelta(let delta, let turnID): - return .init( - id: delta.id, - turnID: turnID, - phase: .delta, - reasoningDelta: delta - ) + return .reasoningDelta(delta, turnID: turnID) case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, .closed, .unknown: continue @@ -159,6 +139,13 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { } return nil } + + private mutating func nextDeltaLogEntryID(for delta: CodexMessageDelta) -> String { + defer { + logEntryIndex += 1 + } + return "\(delta.itemID ?? "agent-message-delta"):\(logEntryIndex)" + } } } @@ -214,16 +201,16 @@ package struct CodexTurnProgressSequence: AsyncSequence, Sendable { return .init(phase: .running, transcript: accumulator.transcript) case .completed(var result): result = finalizedResult(result) - if let errorMessage = result.errorMessage { + if result.errorMessage != nil { return .init( - phase: .failed(.turnFailed(errorMessage)), + phase: .failed(.turnFailedWithResponse(result)), transcript: result.transcript, result: result ) } if result.status?.isFailure == true { return .init( - phase: .failed(.turnFailed(result.status?.rawValue ?? "Turn failed.")), + phase: .failed(.turnFailedWithResponse(result)), transcript: result.transcript, result: result ) @@ -276,11 +263,11 @@ package struct CodexResponseCollector { if result.usage == nil { result.usage = usage } - if let message = result.errorMessage { - throw CodexAppServerError.turnFailed(message) + if result.errorMessage != nil { + throw CodexAppServerError.turnFailedWithResponse(result) } if result.status?.isFailure == true { - throw CodexAppServerError.turnFailed(result.status?.rawValue ?? "Turn failed.") + throw CodexAppServerError.turnFailedWithResponse(result) } return result case .failed(let message): @@ -376,13 +363,24 @@ private struct CodexTranscriptAccumulator { } private mutating func start(_ part: CodexReasoningPart) { - upsert(.init(id: part.id, kind: .reasoning, content: .reasoning(""))) + upsert(.init( + id: part.id, + kind: .reasoning, + content: .reasoning(.empty) + )) } private mutating func append(_ delta: CodexReasoningDelta) { let text = (reasoningDeltaTextByPartID[delta.id] ?? "") + delta.delta reasoningDeltaTextByPartID[delta.id] = text - upsert(.init(id: delta.id, kind: .reasoning, content: .reasoning(text))) + let reasoning: CodexReasoning + switch delta.part.kind { + case .summary: + reasoning = .init(summary: text) + case .text: + reasoning = .init(content: text) + } + upsert(.init(id: delta.id, kind: .reasoning, content: .reasoning(reasoning))) } private mutating func removeReasoningParts(parentItemID: String) { diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index 31e6d38b..7900e1f8 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -143,12 +143,32 @@ It also exposes reasoning controls with domain values instead of raw strings: ```swift let response = try await thread.respond( to: "Find the risky part of this change.", - options: .init(effort: .high, summary: .detailed) + options: .init( + effort: .high, + summary: .detailed, + personality: .pragmatic + ) ) print(response.usage?.reasoningOutputTokens ?? 0) ``` +Structured final answers can be constrained with a JSON schema: + +```swift +let response = try await thread.respond( + to: "Summarize the change as JSON.", + options: .init(outputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "summary": .object(["type": .string("string")]), + "risk": .object(["type": .string("string")]), + ]), + "required": .array([.string("summary"), .string("risk")]), + ])) +) +``` + Threads also expose async sequences for chat, transcript updates, and log-style consumers. This is the API surface intended for higher-level products that need to render Codex output continuously outside a single response stream. @@ -167,22 +187,26 @@ for try await transcript in thread.transcriptUpdates { ```swift for try await entry in thread.logEntries { - if let delta = entry.reasoningDelta { + switch entry { + case .reasoningDelta(let delta, _): renderReasoningDelta(delta) - continue - } - - switch entry.item?.content { - case .message(let message): - renderMessage(message) - case .reasoning(let text): - renderReasoning(text) - case .command(let command): - renderCommand(command.command, output: command.output) - case .toolCall(let tool): - renderToolCall(tool.name, result: tool.result, error: tool.error) - case .fileChange(let fileChange): - renderFileChange(fileChange.path, output: fileChange.output) + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + switch item.content { + case .message(let message): + renderMessage(message) + case .reasoning(let reasoning): + renderReasoning(summary: reasoning.summary, content: reasoning.content) + case .command(let command): + renderCommand(command.command, output: command.output) + case .toolCall(let tool): + renderToolCall(tool.name, result: tool.result, error: tool.error) + case .fileChange(let fileChange): + renderFileChange(fileChange.path, output: fileChange.output) + default: + break + } + case .messageDelta(let delta, _, _): + renderMessageDelta(delta.text) default: break } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 7a8219c0..c28474b7 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -107,7 +107,16 @@ struct CodexAppServerKitTests { let thread = try await server.startThread( in: workspace, instructions: .init(base: "Base", developer: "Developer"), - options: .init(model: "gpt-5", sandbox: .workspaceWrite, ephemeral: true) + options: .init( + model: "gpt-5", + sandbox: .workspaceWrite, + ephemeral: true, + config: ["experimental": .bool(true)], + personality: .pragmatic, + serviceName: "review-monitor", + sessionStartSource: .startup, + threadSource: "automation" + ) ) #expect(thread.id == "thread-1") @@ -123,6 +132,56 @@ struct CodexAppServerKitTests { #expect(params.approvalPolicy == "on-request") #expect(params.approvalsReviewer == "auto_review") #expect(params.sandbox == "workspace-write") + #expect(params.config == ["experimental": .bool(true)]) + #expect(params.personality == "pragmatic") + #expect(params.serviceName == "review-monitor") + #expect(params.sessionStartSource == .startup) + #expect(params.threadSource?.rawValue == "automation") + } + + @Test func appServerListThreadsSerializesQueryOptions() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: [], nextCursor: "next"), + for: "thread/list" + ) + let client = AppServerClient(transport: transport) + let server = CodexAppServer( + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let page = try await server.listThreads(.init( + archived: false, + cursor: "cursor", + workspace: workspace, + limit: 10, + searchTerm: "review", + modelProviders: ["openai"], + sortDirection: .descending, + sortKey: .recencyAt, + sourceKinds: [.appServer, .subAgentReview], + useStateDBOnly: true + )) + + #expect(page.nextCursor == "next") + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "thread/list") + let params = try JSONDecoder().decode( + AppServerAPI.Thread.List.Params.self, + from: request.params + ) + #expect(params.archived == false) + #expect(params.cursor == "cursor") + #expect(params.cwd == .path(workspace.path)) + #expect(params.limit == 10) + #expect(params.searchTerm == "review") + #expect(params.modelProviders == ["openai"]) + #expect(params.sortDirection == "desc") + #expect(params.sortKey == "recency_at") + #expect(params.sourceKinds == ["appServer", "subAgentReview"]) + #expect(params.useStateDbOnly == true) } @Test func threadStartReviewSerializesTargetAndStreamsReviewThreadLogs() async throws { @@ -413,6 +472,61 @@ struct CodexAppServerKitTests { ]) } + @Test func responseStreamFailureCarriesPartialResponse() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let stream = try await thread.streamResponse(to: "Try this.") + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", delta: "Partial") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-1", + status: "failed", + error: .init(message: "Tool failed."), + startedAt: 1_700_000_000, + completedAt: 1_700_000_001, + durationMS: 1_000 + )) + ) + + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + #expect(first?.content == "Partial") + let terminal = try await iterator.next() + #expect(terminal?.response?.status == .failed) + #expect(terminal?.response?.errorMessage == "Tool failed.") + #expect(terminal?.response?.transcript.responseText == "Partial") + #expect(terminal?.response?.startedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(terminal?.response?.duration == .milliseconds(1_000)) + do { + _ = try await iterator.next() + Issue.record("Expected failed stream to throw after terminal snapshot.") + } catch let error as CodexAppServerError { + #expect(error.response?.status == .failed) + #expect(error.response?.transcript.responseText == "Partial") + } + + do { + _ = try await stream.collect() + Issue.record("Expected collect() to throw for failed turn.") + } catch let error as CodexAppServerError { + #expect(error.response?.status == .failed) + #expect(error.response?.transcript.responseText == "Partial") + } + } + @Test func responseStreamSerializesReasoningOptions() async throws { let transport = CodexAppServerTestTransport() try await transport.enqueue( @@ -425,7 +539,16 @@ struct CodexAppServerKitTests { _ = try await thread.streamResponse( to: "Explain the patch.", - options: .init(effort: .high, summary: .detailed) + options: .init( + effort: .high, + summary: .detailed, + outputSchema: .object([ + "type": .string("object"), + "properties": .object(["summary": .object(["type": .string("string")])]), + ]), + personality: .pragmatic, + clientUserMessageID: "client-message-1" + ) ) let request = try #require(await transport.recordedRequests().first) @@ -435,6 +558,37 @@ struct CodexAppServerKitTests { ) #expect(params.effort == "high") #expect(params.summary == "detailed") + #expect(params.outputSchema == .object([ + "type": .string("object"), + "properties": .object(["summary": .object(["type": .string("string")])]), + ])) + #expect(params.personality == "pragmatic") + #expect(params.clientUserMessageID == "client-message-1") + } + + @Test func messageDeltaLogEntriesUseUniqueEntryIDs() async throws { + let transport = CodexAppServerTestTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(threadID: "thread-1", turnID: "turn-1", delta: "First") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(threadID: "thread-1", turnID: "turn-1", delta: "Second") + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let thread = CodexThread(id: "thread-1", client: client, router: router) + let logs = try await collect(thread.logEntries) + #expect(logs.map(\.id) == ["agent-message-delta:0", "agent-message-delta:1"]) + #expect(logs.compactMap(\.messageDelta).map(\.text) == ["First", "Second"]) } @Test func reasoningNotificationsRouteAsTypedEventsLogsAndTranscript() async throws { @@ -532,7 +686,9 @@ struct CodexAppServerKitTests { let transcripts = try await collect(thread.transcriptUpdates) let finalTranscript = try #require(transcripts.last) #expect(finalTranscript.items.map(\.id) == ["reasoning-1"]) - #expect(finalTranscript.items.first?.content == .reasoning("Final summary")) + #expect(finalTranscript.items.first?.content == .reasoning( + .init(summary: ["Final summary"], content: ["Final raw"]) + )) } @Test func modelAndConfigurationDecodeReasoningTypes() async throws { @@ -778,10 +934,12 @@ private struct ThreadIDParams: Encodable, Sendable { } private struct TurnDeltaParams: Encodable, Sendable { + var threadID: String? = nil var turnID: String var delta: String enum CodingKeys: String, CodingKey { + case threadID = "threadId" case turnID = "turnId" case delta } From 09a60bc622bbdb269c710f452b01f86c3e83063a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:40:09 +0900 Subject: [PATCH 022/359] fix(app-server): cancel event stream forwarding tasks --- .../CodexAppServerNotificationRouter.swift | 8 +++ .../CodexThreadOperations.swift | 10 ++- .../CodexAppServerKitTests.swift | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 2d356e23..f4392c02 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -74,6 +74,14 @@ package actor CodexAppServerNotificationRouter { finishAll(throwing: JSONRPC.Error.closed) } + package func turnSubscriberCountForTesting(for turnID: CodexTurnID) -> Int { + turnSubscribersByTurnID[turnID]?.count ?? 0 + } + + package func threadSubscriberCountForTesting(for threadID: CodexThreadID) -> Int { + threadSubscribersByThreadID[threadID]?.count ?? 0 + } + private func route(_ notification: JSONRPC.Notification) { var context = Self.context(from: notification.params) if let threadID = context.threadID, let turnID = context.turnID { diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index fe006931..77ebc391 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -9,7 +9,7 @@ extension CodexThread { public var events: CodexThreadEventSequence { .init { AsyncThrowingStream { continuation in - Task { + let task = Task { let stream = await router.events(for: id) do { for try await event in stream { @@ -20,6 +20,9 @@ extension CodexThread { continuation.finish(throwing: error) } } + continuation.onTermination = { _ in + task.cancel() + } } } } @@ -290,7 +293,7 @@ extension CodexTurn { package var events: CodexTurnEventSequence { .init { AsyncThrowingStream { continuation in - Task { + let task = Task { let stream = await router.events(for: id) do { for try await event in stream { @@ -301,6 +304,9 @@ extension CodexTurn { continuation.finish(throwing: error) } } + continuation.onTermination = { _ in + task.cancel() + } } } } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index c28474b7..f19007d6 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -330,6 +330,59 @@ struct CodexAppServerKitTests { #expect(result.finalAnswer == "Done") } + @Test func threadEventStreamCancellationRemovesRouterSubscriber() async throws { + let transport = CodexAppServerTestTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let consumer = Task { + var iterator = thread.events.makeAsyncIterator() + _ = try await iterator.next() + } + + #expect(await eventually { + await router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + consumer.cancel() + let removed = await eventually { + await router.threadSubscriberCountForTesting(for: "thread-1") == 0 + } + #expect(removed) + if removed { + try await consumer.value + } + } + + @Test func turnEventStreamCancellationRemovesRouterSubscriber() async throws { + let transport = CodexAppServerTestTransport() + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let turn = CodexTurn( + id: "turn-1", + threadID: "thread-1", + client: client, + router: router + ) + + let consumer = Task { + var iterator = turn.events.makeAsyncIterator() + _ = try await iterator.next() + } + + #expect(await eventually { + await router.turnSubscriberCountForTesting(for: "turn-1") == 1 + }) + consumer.cancel() + let removed = await eventually { + await router.turnSubscriberCountForTesting(for: "turn-1") == 0 + } + #expect(removed) + if removed { + try await consumer.value + } + } + @Test func threadStreamsReplayMessagesTranscriptLogsAndUsage() async throws { let transport = CodexAppServerTestTransport() let client = AppServerClient(transport: transport) @@ -1065,3 +1118,16 @@ private struct TokenUsageParams: Encodable, Sendable { var totalTokens: Int } } + +private func eventually( + attempts: Int = 50, + _ condition: () async -> Bool +) async -> Bool { + for _ in 0.. Date: Wed, 24 Jun 2026 13:19:36 +0900 Subject: [PATCH 023/359] refactor(review-kit): consolidate review core target --- Docs/architecture.md | 30 +- Package.swift | 76 +-- Sources/CodexReview/CodexReviewExports.swift | 2 - .../AppServerCodexReviewBackend.swift | 4 +- .../AppServerReviewControl.swift | 2 +- .../CodexReviewTarget+AppServer.swift | 2 +- .../AppServerReviewEventKind.swift | 2 +- .../AppServerWireEvent.swift | 2 +- .../AppServerWireReviewNotification.swift | 2 +- .../AppServerWireReviewPayload.swift | 2 +- Sources/CodexReviewHost/CodexReviewHost.swift | 4 +- .../LiveCodexReviewStoreBackend.swift | 4 +- .../ReviewObservationAwaiter.swift | 1 - .../Application}/ReviewStore.swift | 1 - .../CodexReviewBackend.swift | 0 .../CodexReviewTypes.swift | 1 - .../Domain}/ReviewDomainEvent.swift | 0 .../Domain}/ReviewIdentifiers.swift | 0 .../Domain}/ReviewJob.swift | 0 .../Domain}/ReviewKinds.swift | 0 .../Domain}/ReviewRun.swift | 0 .../Domain}/ReviewTimeline.swift | 0 .../Domain}/ReviewTimelineItem.swift | 0 .../Model/CodexAccount.swift | 0 .../Model/CodexReviewAuthModel.swift | 0 .../Model/CodexReviewJob.swift | 1 - .../Model/CodexReviewJobTesting.swift | 0 .../Model/CodexReviewServerState.swift | 0 .../Model/CodexReviewSettings.swift | 0 .../Model/CodexReviewWorkspace.swift | 0 .../Model/ParsedReviewResult.swift | 0 .../Model/ReviewCancellation.swift | 0 .../Model/ReviewJobCore.swift | 0 .../Model/ReviewJobState.swift | 0 .../Model/ReviewLogEntry.swift | 0 .../ReviewLogEntryTimelineProjection.swift | 1 - .../ReviewTimelineLegacyLogProjection.swift | 1 - .../Model/SettingsStore.swift | 0 .../CodexReviewNetworkMonitoring.swift | 0 .../ReviewAPI/CodexReviewAPI.swift | 0 .../ReviewAPI/ReviewError.swift | 0 .../ReviewAPI/ReviewResults.swift | 0 .../ReviewAPI/ReviewStartRequest.swift | 0 .../ReviewAPI/ReviewTarget.swift | 0 .../Settings/CodexReviewSettingsService.swift | 0 .../Store/CodexReviewStore.swift | 0 .../Store/CodexReviewStoreBackend.swift | 0 .../Store/CodexReviewStoreCancellation.swift | 1 - .../Store/CodexReviewStoreDiagnostics.swift | 0 .../Store/CodexReviewStoreOrderQueries.swift | 0 .../Store/CodexReviewStoreOrdering.swift | 0 ...CodexReviewStoreRateLimitAutoRefresh.swift | 0 .../Store/CodexReviewStoreReviews.swift | 2 - .../Store/CodexReviewStoreTesting.swift | 0 .../PreviewCodexReviewStoreBackend.swift | 0 .../Support/String+NilIfEmpty.swift | 0 .../ReviewMCPProjection.swift | 2 +- .../CodexReviewMCPProtocolServer.swift | 2 +- .../CodexReviewMCPServer.swift | 2 +- .../CodexReviewMCPToolRequests.swift | 2 +- .../CodexReviewMCPToolResults.swift | 2 +- .../CodexReviewMCPToolSchemas.swift | 2 +- Sources/CodexReviewTesting/TestSupport.swift | 6 +- .../ReviewTimelineDocument.swift | 2 +- .../ReviewTimelineDocumentRenderer.swift | 2 +- ...wMonitorCommandOutputDisplayDocument.swift | 2 +- .../Detail/ReviewMonitorLogDocumentView.swift | 2 +- .../Detail/ReviewMonitorLogProjection.swift | 2 +- .../Detail/ReviewMonitorLogRenderer.swift | 2 +- .../Detail/ReviewMonitorLogScrollView.swift | 2 +- .../ReviewMonitorTimelineLogProjection.swift | 4 +- ...ReviewMonitorTransportViewController.swift | 4 +- .../ReviewUI/MCPServerUnavailableView.swift | 2 +- .../ReviewMonitorAddAccountAction.swift | 2 +- .../ReviewMonitorAddAccountToolbarItem.swift | 2 +- .../ReviewMonitorContentPreview.swift | 2 +- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 4 +- .../ReviewMonitorSplitViewController.swift | 2 +- Sources/ReviewUI/ReviewMonitorUIState.swift | 2 +- .../ReviewMonitorWindowController.swift | 2 +- .../Accounts/AccountContextMenuView.swift | 2 +- .../ReviewMonitorAccountRowView.swift | 2 +- .../ReviewMonitorAccountsViewController.swift | 2 +- .../Jobs/ReviewMonitorJobRowView.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 2 +- .../ReviewMonitorSignInViewController.swift | 2 +- Sources/ReviewUI/SignIn/SignInView.swift | 2 +- .../Status/AccountRateLimitGaugesView.swift | 2 +- ...rServerStatusAccessoryViewController.swift | 2 +- .../ArchitectureFenceTests.swift | 450 ------------------ .../AppServerClientTests.swift | 18 +- .../AppServerWireEventTests.swift | 2 +- .../CodexReviewHostTests.swift | 12 +- .../ReviewObservationAwaiterTests.swift | 3 +- .../CodexReviewAuthModelTests.swift | 2 +- .../CodexReviewJobRenderingTests.swift | 3 +- .../CodexReviewStoreCommandTests.swift | 3 +- ...ReviewStoreRateLimitAutoRefreshTests.swift | 2 +- .../Domain}/ReviewTimelineTests.swift | 2 +- .../ParsedReviewResultTests.swift | 2 +- .../ReviewMCPProjectionTests.swift | 2 +- .../CodexReviewMCPHTTPServerTests.swift | 4 +- .../CodexReviewMCPServerTests.swift | 2 +- .../ReviewTimelineDocumentRendererTests.swift | 2 +- .../ReviewMonitorLogProjectionTests.swift | 2 +- .../ReviewMonitorLogTesting.swift | 2 +- .../ReviewUITests/ReviewUISettingsTests.swift | 2 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 4 +- .../project.pbxproj | 10 +- .../CodexReviewMonitorApp.swift | 2 +- .../CodexReviewMonitorCITests.swift | 2 +- 113 files changed, 120 insertions(+), 634 deletions(-) delete mode 100644 Sources/CodexReview/CodexReviewExports.swift rename Sources/{CodexReviewApplication => CodexReviewKit/Application}/ReviewObservationAwaiter.swift (98%) rename Sources/{CodexReviewApplication => CodexReviewKit/Application}/ReviewStore.swift (97%) rename Sources/{CodexReview => CodexReviewKit}/CodexReviewBackend.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/CodexReviewTypes.swift (99%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewDomainEvent.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewIdentifiers.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewJob.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewKinds.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewRun.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewTimeline.swift (100%) rename Sources/{CodexReviewDomain => CodexReviewKit/Domain}/ReviewTimelineItem.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexAccount.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewAuthModel.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewJob.swift (99%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewJobTesting.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewServerState.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewSettings.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/CodexReviewWorkspace.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ParsedReviewResult.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewCancellation.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewJobCore.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewJobState.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewLogEntry.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewLogEntryTimelineProjection.swift (99%) rename Sources/{CodexReview => CodexReviewKit}/Model/ReviewTimelineLegacyLogProjection.swift (99%) rename Sources/{CodexReview => CodexReviewKit}/Model/SettingsStore.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Network/CodexReviewNetworkMonitoring.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/ReviewAPI/CodexReviewAPI.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/ReviewAPI/ReviewError.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/ReviewAPI/ReviewResults.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/ReviewAPI/ReviewStartRequest.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/ReviewAPI/ReviewTarget.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Settings/CodexReviewSettingsService.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStore.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreBackend.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreCancellation.swift (99%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreDiagnostics.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreOrderQueries.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreOrdering.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreRateLimitAutoRefresh.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreReviews.swift (99%) rename Sources/{CodexReview => CodexReviewKit}/Store/CodexReviewStoreTesting.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Store/PreviewCodexReviewStoreBackend.swift (100%) rename Sources/{CodexReview => CodexReviewKit}/Support/String+NilIfEmpty.swift (100%) delete mode 100644 Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift rename Tests/{CodexReviewApplicationTests => CodexReviewKitTests/Application}/ReviewObservationAwaiterTests.swift (96%) rename Tests/{CodexReviewTests => CodexReviewKitTests}/CodexReviewAuthModelTests.swift (97%) rename Tests/{CodexReviewTests => CodexReviewKitTests}/CodexReviewJobRenderingTests.swift (99%) rename Tests/{CodexReviewTests => CodexReviewKitTests}/CodexReviewStoreCommandTests.swift (99%) rename Tests/{CodexReviewTests => CodexReviewKitTests}/CodexReviewStoreRateLimitAutoRefreshTests.swift (99%) rename Tests/{CodexReviewDomainTests => CodexReviewKitTests/Domain}/ReviewTimelineTests.swift (99%) rename Tests/{CodexReviewTests => CodexReviewKitTests}/ParsedReviewResultTests.swift (98%) diff --git a/Docs/architecture.md b/Docs/architecture.md index 4fe2ee67..9fb32c5e 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -23,9 +23,7 @@ have been converted into domain events. | 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 | +| `CodexReviewKit` | Review identifiers, kinds, runs, jobs, semantic timeline, application store/use-case primitives, `CodexReviewStore`, auth/settings/runtime product state, and legacy log compatibility projections | | `CodexAppServerKit` | Generic `codex app-server` process transport, JSON-RPC client, typed request DTOs, and Swift domain API for threads, turns, prompts, models, accounts, and login | | `CodexReviewAppServerWire` | Raw `codex app-server` review notification DTO decode and conversion into domain events | | `CodexReviewAppServer` | Review-specific `review/start` orchestration, ReviewMonitor notification routing, and runtime conversion | @@ -42,7 +40,7 @@ runtime together; lower targets do not import the host. ## Source Of Truth -`CodexReviewDomain` owns the semantic timeline vocabulary. It defines the +`CodexReviewKit` 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. @@ -55,8 +53,7 @@ and login flows. It must not import ReviewMonitor targets. `CodexReviewAppServerWire` owns raw app-server notification shapes. Its job is to decode review-specific wire payloads and expose domain events. It may depend on -`CodexReviewDomain`, but it must not import `CodexReviewApplication`, UI, -rendering, or MCP targets. +`CodexReviewKit`, but it must not import UI, rendering, or MCP targets. `CodexReviewAppServer` owns ReviewMonitor's `review/start` runtime behavior. It uses the generic app-server boundary for transport and common DTOs, converts @@ -83,17 +80,17 @@ events and timeline state as the owner. ```mermaid flowchart TB - subgraph Domain["CodexReviewDomain"] + subgraph Domain["CodexReviewKit domain layer"] DomainEvents["ReviewDomainEvent"] Timeline["ReviewTimeline"] end - subgraph Application["CodexReviewApplication"] + subgraph Application["CodexReviewKit application layer"] AppStore["ReviewStore"] Awaiter["ReviewObservationAwaiter"] end - subgraph Product["CodexReview"] + subgraph Product["CodexReviewKit product API"] PublicStore["CodexReviewStore"] LegacyProjection["Legacy log projections"] end @@ -147,8 +144,8 @@ flowchart TB ``` The diagram describes ownership direction, not every SwiftPM dependency. New -code should not create reverse imports from Domain, Wire, Application, -Rendering, UI, or MCP adapter targets back into runtime/protocol owners. +code should not move domain, application, rendering, UI, or MCP adapter +responsibilities back into runtime/protocol owners. ## Observation Ownership @@ -160,7 +157,7 @@ source of truth. - 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 +- `ReviewObservationAwaiter` belongs in `CodexReviewKit` 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. @@ -168,9 +165,9 @@ source of truth. must not retain ObservationBridge tokens or persist their projection as model state. -## CodexReview +## CodexReviewKit -`CodexReview` is the public product surface used by existing ReviewMonitor code. +`CodexReviewKit` 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`. @@ -264,17 +261,14 @@ 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 | | `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, and domain result aggregation | | `CodexReviewAppServerWireTests` | Raw notification JSON | Wire decode and domain event conversion | -| `CodexReviewTests` | Fake `CodexReviewStoreBackend` | Review/auth/settings state machines, cancellation, result retention | +| `CodexReviewKitTests` | Domain timelines, ObservationBridge awaiters, and fake `CodexReviewStoreBackend` | Semantic timeline mutation, use-case observation behavior, review/auth/settings state machines, cancellation, result retention | | `CodexReviewAppServerTests` | Fake JSON-RPC transport | Review-specific request serialization, notification buffering, interrupt/cleanup, and recovery | | `CodexReviewMCPAdapterTests` | Domain timeline projections | MCP adapter snapshots | | `CodexReviewMCPServerTests` | Fake review store | MCP protocol conversion and response shape | | `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 | Forbidden test patterns: diff --git a/Package.swift b/Package.swift index b05beeb4..e5cc9a28 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", @@ -40,26 +40,8 @@ let package = Package( ], targets: [ .target( - name: "CodexReviewDomain", - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), - .target( - name: "CodexReviewApplication", - dependencies: [ - "CodexReviewDomain", - .product(name: "ObservationBridge", package: "ObservationBridge"), - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), - .target( - name: "CodexReview", + name: "CodexReviewKit", dependencies: [ - "CodexReviewApplication", - "CodexReviewDomain", .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ @@ -84,7 +66,7 @@ let package = Package( .target( name: "CodexReviewAppServerWire", dependencies: [ - "CodexReviewDomain", + "CodexReviewKit", ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -94,9 +76,8 @@ let package = Package( name: "CodexReviewAppServer", dependencies: [ "CodexAppServerKit", - "CodexReview", + "CodexReviewKit", "CodexReviewAppServerWire", - "CodexReviewDomain", ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -105,7 +86,7 @@ let package = Package( .target( name: "CodexReviewMCPAdapter", dependencies: [ - "CodexReviewDomain", + "CodexReviewKit", ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -114,7 +95,7 @@ let package = Package( .target( name: "CodexReviewMCPServer", dependencies: [ - "CodexReview", + "CodexReviewKit", "CodexReviewMCPAdapter", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), @@ -129,7 +110,7 @@ let package = Package( name: "CodexReviewHost", dependencies: [ "CodexAppServerKit", - "CodexReview", + "CodexReviewKit", "CodexReviewAppServer", "CodexReviewMCPServer", ], @@ -142,7 +123,7 @@ let package = Package( dependencies: [ "CodexAppServerKit", "CodexAppServerKitTesting", - "CodexReview", + "CodexReviewKit", "CodexReviewAppServer", ], swiftSettings: [ @@ -152,8 +133,7 @@ let package = Package( .target( name: "ReviewUI", dependencies: [ - "CodexReview", - "CodexReviewDomain", + "CodexReviewKit", "ReviewMonitorRendering", "TextTransitions", .product(name: "ObservationBridge", package: "ObservationBridge"), @@ -165,7 +145,7 @@ let package = Package( .target( name: "ReviewMonitorRendering", dependencies: [ - "CodexReviewDomain", + "CodexReviewKit", ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -187,24 +167,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "CodexReviewDomainTests", - dependencies: [ - "CodexReviewDomain", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), - .testTarget( - name: "CodexReviewApplicationTests", - dependencies: [ - "CodexReviewApplication", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "CodexReviewAppServerWireTests", dependencies: [ @@ -224,15 +186,15 @@ let package = Package( ] ), .testTarget( - name: "CodexReviewTests", - dependencies: ["CodexReview", "CodexReviewDomain", "CodexReviewTesting"], + name: "CodexReviewKitTests", + dependencies: ["CodexReviewKit", "CodexReviewTesting"], swiftSettings: [ .swiftLanguageMode(.v6), ] ), .testTarget( name: "CodexReviewAppServerTests", - dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewDomain", "CodexReviewTesting"], + dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting"], swiftSettings: [ .swiftLanguageMode(.v6), ] @@ -258,7 +220,7 @@ let package = Package( .testTarget( name: "ReviewUITests", dependencies: [ - "CodexReview", + "CodexReviewKit", "CodexReviewTesting", "ReviewUI", ], @@ -269,7 +231,7 @@ let package = Package( .testTarget( name: "ReviewMonitorRenderingTests", dependencies: [ - "CodexReviewDomain", + "CodexReviewKit", "ReviewMonitorRendering", ], swiftSettings: [ @@ -285,12 +247,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "ArchitectureFenceTests", - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), ], swiftLanguageModes: [.v6] ) 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/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 01dfbd51..09ffd29e 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1,8 +1,8 @@ import Foundation import CodexAppServerKit -import CodexReview +import CodexReviewKit import CodexReviewAppServerWire -import CodexReviewDomain +import CodexReviewKit import OSLog private let appServerBackendLogger = Logger( diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index 31b976d1..fadb845e 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -1,6 +1,6 @@ import Foundation import CodexAppServerKit -import CodexReview +import CodexReviewKit package struct AppServerReviewInterruption: Equatable, Sendable { package var threadID: String diff --git a/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift index a741cdc4..38b806df 100644 --- a/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift +++ b/Sources/CodexReviewAppServer/CodexReviewTarget+AppServer.swift @@ -1,5 +1,5 @@ import CodexAppServerKit -import CodexReview +import CodexReviewKit import Foundation extension CodexReviewAPI.Target { diff --git a/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift b/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift index b2f762e5..acfc78ac 100644 --- a/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift +++ b/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct AppServerReviewEventKind: ReviewStringIdentifier, CustomStringConvertible { public var rawValue: String diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift index adb85363..5dde4bd5 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct AppServerWireEvent: Equatable, Sendable { public var kind: AppServerReviewEventKind diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift index d0fd8b0c..095c4b43 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { public var method: AppServerReviewEventKind diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift index 64964c89..414d94ec 100644 --- a/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift +++ b/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public extension AppServerWireReviewNotification { struct Payload: Decodable, Equatable, Sendable { diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift index 6509eaa6..3752d7ea 100644 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ b/Sources/CodexReviewHost/CodexReviewHost.swift @@ -1,8 +1,8 @@ import Foundation -import CodexReview +import CodexReviewKit import CodexReviewMCPServer -private typealias ReviewCodexAccount = CodexReview.CodexAccount +private typealias ReviewCodexAccount = CodexReviewKit.CodexAccount @MainActor package final class CodexReviewHost { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 08bd8130..df9075c7 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -2,11 +2,11 @@ import AppKit import Foundation import OSLog import CodexAppServerKit -import CodexReview +import CodexReviewKit import CodexReviewAppServer import CodexReviewMCPServer -private typealias ReviewCodexAccount = CodexReview.CodexAccount +private typealias ReviewCodexAccount = CodexReviewKit.CodexAccount private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void diff --git a/Sources/CodexReviewApplication/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift similarity index 98% rename from Sources/CodexReviewApplication/ReviewObservationAwaiter.swift rename to Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index f96d560a..11bb11dd 100644 --- a/Sources/CodexReviewApplication/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewDomain import ObservationBridge @MainActor diff --git a/Sources/CodexReviewApplication/ReviewStore.swift b/Sources/CodexReviewKit/Application/ReviewStore.swift similarity index 97% rename from Sources/CodexReviewApplication/ReviewStore.swift rename to Sources/CodexReviewKit/Application/ReviewStore.swift index 7a839e68..31733a4e 100644 --- a/Sources/CodexReviewApplication/ReviewStore.swift +++ b/Sources/CodexReviewKit/Application/ReviewStore.swift @@ -1,6 +1,5 @@ import Foundation import Observation -import CodexReviewDomain @MainActor @Observable diff --git a/Sources/CodexReview/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift similarity index 100% rename from Sources/CodexReview/CodexReviewBackend.swift rename to Sources/CodexReviewKit/CodexReviewBackend.swift diff --git a/Sources/CodexReview/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift similarity index 99% rename from Sources/CodexReview/CodexReviewTypes.swift rename to Sources/CodexReviewKit/CodexReviewTypes.swift index a85f3814..7b26e325 100644 --- a/Sources/CodexReview/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewDomain package enum CodexReviewBackendModel { package enum Settings {} diff --git a/Sources/CodexReviewDomain/ReviewDomainEvent.swift b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewDomainEvent.swift rename to Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift diff --git a/Sources/CodexReviewDomain/ReviewIdentifiers.swift b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewIdentifiers.swift rename to Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift diff --git a/Sources/CodexReviewDomain/ReviewJob.swift b/Sources/CodexReviewKit/Domain/ReviewJob.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewJob.swift rename to Sources/CodexReviewKit/Domain/ReviewJob.swift diff --git a/Sources/CodexReviewDomain/ReviewKinds.swift b/Sources/CodexReviewKit/Domain/ReviewKinds.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewKinds.swift rename to Sources/CodexReviewKit/Domain/ReviewKinds.swift diff --git a/Sources/CodexReviewDomain/ReviewRun.swift b/Sources/CodexReviewKit/Domain/ReviewRun.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewRun.swift rename to Sources/CodexReviewKit/Domain/ReviewRun.swift diff --git a/Sources/CodexReviewDomain/ReviewTimeline.swift b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewTimeline.swift rename to Sources/CodexReviewKit/Domain/ReviewTimeline.swift diff --git a/Sources/CodexReviewDomain/ReviewTimelineItem.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift similarity index 100% rename from Sources/CodexReviewDomain/ReviewTimelineItem.swift rename to Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift diff --git a/Sources/CodexReview/Model/CodexAccount.swift b/Sources/CodexReviewKit/Model/CodexAccount.swift similarity index 100% rename from Sources/CodexReview/Model/CodexAccount.swift rename to Sources/CodexReviewKit/Model/CodexAccount.swift diff --git a/Sources/CodexReview/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift similarity index 100% rename from Sources/CodexReview/Model/CodexReviewAuthModel.swift rename to Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift diff --git a/Sources/CodexReview/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift similarity index 99% rename from Sources/CodexReview/Model/CodexReviewJob.swift rename to Sources/CodexReviewKit/Model/CodexReviewJob.swift index 62585b11..a5523acc 100644 --- a/Sources/CodexReview/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -1,6 +1,5 @@ import Foundation import Observation -import CodexReviewDomain @MainActor @Observable diff --git a/Sources/CodexReview/Model/CodexReviewJobTesting.swift b/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift similarity index 100% rename from Sources/CodexReview/Model/CodexReviewJobTesting.swift rename to Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift 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/CodexReviewWorkspace.swift b/Sources/CodexReviewKit/Model/CodexReviewWorkspace.swift similarity index 100% rename from Sources/CodexReview/Model/CodexReviewWorkspace.swift rename to Sources/CodexReviewKit/Model/CodexReviewWorkspace.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 100% rename from Sources/CodexReview/Model/ReviewCancellation.swift rename to Sources/CodexReviewKit/Model/ReviewCancellation.swift diff --git a/Sources/CodexReview/Model/ReviewJobCore.swift b/Sources/CodexReviewKit/Model/ReviewJobCore.swift similarity index 100% rename from Sources/CodexReview/Model/ReviewJobCore.swift rename to Sources/CodexReviewKit/Model/ReviewJobCore.swift diff --git a/Sources/CodexReview/Model/ReviewJobState.swift b/Sources/CodexReviewKit/Model/ReviewJobState.swift similarity index 100% rename from Sources/CodexReview/Model/ReviewJobState.swift rename to Sources/CodexReviewKit/Model/ReviewJobState.swift diff --git a/Sources/CodexReview/Model/ReviewLogEntry.swift b/Sources/CodexReviewKit/Model/ReviewLogEntry.swift similarity index 100% rename from Sources/CodexReview/Model/ReviewLogEntry.swift rename to Sources/CodexReviewKit/Model/ReviewLogEntry.swift diff --git a/Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift similarity index 99% rename from Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift rename to Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift index caabcf3f..6f8d220a 100644 --- a/Sources/CodexReview/Model/ReviewLogEntryTimelineProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewDomain @MainActor extension CodexReviewJob { diff --git a/Sources/CodexReview/Model/ReviewTimelineLegacyLogProjection.swift b/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift similarity index 99% rename from Sources/CodexReview/Model/ReviewTimelineLegacyLogProjection.swift rename to Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift index f1948644..e239cb64 100644 --- a/Sources/CodexReview/Model/ReviewTimelineLegacyLogProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewDomain @MainActor extension CodexReviewJob { 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 100% rename from Sources/CodexReview/ReviewAPI/CodexReviewAPI.swift rename to Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift diff --git a/Sources/CodexReview/ReviewAPI/ReviewError.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewError.swift similarity index 100% rename from Sources/CodexReview/ReviewAPI/ReviewError.swift rename to Sources/CodexReviewKit/ReviewAPI/ReviewError.swift diff --git a/Sources/CodexReview/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift similarity index 100% rename from Sources/CodexReview/ReviewAPI/ReviewResults.swift rename to Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift 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 100% rename from Sources/CodexReview/Store/CodexReviewStore.swift rename to Sources/CodexReviewKit/Store/CodexReviewStore.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreBackend.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift similarity index 99% rename from Sources/CodexReview/Store/CodexReviewStoreCancellation.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 49fcd752..16fcb3de 100644 --- a/Sources/CodexReview/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewDomain private actor RuntimeStopDetachedReviewWorkerDrainRace { private var result: Bool? diff --git a/Sources/CodexReview/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreDiagnostics.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreOrderQueries.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreOrdering.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreRateLimitAutoRefresh.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreRateLimitAutoRefresh.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift diff --git a/Sources/CodexReview/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift similarity index 99% rename from Sources/CodexReview/Store/CodexReviewStoreReviews.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 3672666b..b0f4d568 100644 --- a/Sources/CodexReview/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -1,6 +1,4 @@ import Foundation -import CodexReviewApplication -import CodexReviewDomain private let networkRecoveryUnavailableMessage = "Network unavailable; waiting to reconnect." private let networkRecoveryRestoredMessage = "Network restored; restarting review." diff --git a/Sources/CodexReview/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift similarity index 100% rename from Sources/CodexReview/Store/CodexReviewStoreTesting.swift rename to Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift diff --git a/Sources/CodexReview/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift similarity index 100% rename from Sources/CodexReview/Store/PreviewCodexReviewStoreBackend.swift rename to Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift 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 index 8c4af6e6..27e10100 100644 --- a/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift +++ b/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct ReviewMCPProjection: Sendable, Equatable { public struct Item: Sendable, Equatable { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift index 7fb1144c..6fff2d7e 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift @@ -1,6 +1,6 @@ import Foundation import MCP -import CodexReview +import CodexReviewKit import CodexReviewMCPAdapter package actor MCPClientSessionState { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index c98b6082..86f46ad2 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReview +import CodexReviewKit import CodexReviewMCPAdapter package enum CodexReviewMCP { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift index 16fd1c28..61948400 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -1,6 +1,6 @@ import Foundation import MCP -import CodexReview +import CodexReviewKit func toolRequest( tool: CodexReviewMCP.Tool.Name, diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index af8514a0..099eb78c 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -1,6 +1,6 @@ import Foundation import MCP -import CodexReview +import CodexReviewKit import CodexReviewMCPAdapter func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift index 56fbeb01..025ad64c 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift @@ -1,5 +1,5 @@ import MCP -import CodexReview +import CodexReviewKit func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { switch tool { diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 4cf83efc..f1790c01 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -1,7 +1,7 @@ import Foundation import CodexAppServerKit import CodexAppServerKitTesting -import CodexReview +import CodexReviewKit import CodexReviewAppServer package typealias AsyncGate = CodexAppServerTestGate @@ -666,12 +666,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 -> CodexReview.CodexAccount? in + let accounts = snapshot.accounts.compactMap { account -> CodexReviewKit.CodexAccount? in let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) guard label.isEmpty == false else { return nil } - return CodexReview.CodexAccount(email: label) + return CodexReviewKit.CodexAccount(email: label) } auth.applyPersistedAccountStates( accounts.map(savedAccountPayload(from:)), diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift b/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift index 5a9b7415..b90c4c46 100644 --- a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift +++ b/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct ReviewTimelineDocument: Codable, Equatable, Sendable { public struct Block: Identifiable, Codable, Equatable, Sendable { diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift b/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift index 7ad38420..9ca5f0a5 100644 --- a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift +++ b/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReviewDomain +import CodexReviewKit public struct ReviewTimelineDocumentRenderer: Sendable { public init() {} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index a83d15d6..47abcc2e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReview +import CodexReviewKit enum ReviewMonitorCommandOutputDisplayDocument { static let toggleAttachmentCharacter = "\u{fffc}" diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift index ce777f86..e201e8ab 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift @@ -1,5 +1,5 @@ import AppKit -import CodexReview +import CodexReviewKit import QuartzCore private extension ReviewLogEntry.Kind { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift index 0faff050..9da2bf14 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift @@ -1,5 +1,5 @@ import Foundation -import CodexReview +import CodexReviewKit enum ReviewMonitorLog {} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift index 69aa0861..cda5d099 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift @@ -1,4 +1,4 @@ -import CodexReview +import CodexReviewKit import ReviewMonitorRendering struct ReviewMonitorRenderedLogDocument: Equatable, Sendable { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift index 31ccbf1c..7c3d2f14 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift @@ -1,6 +1,6 @@ import AppKit import ObjectiveC.runtime -import CodexReview +import CodexReviewKit @MainActor final class ReviewMonitorLogScrollView: NSScrollView { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift index 2fab4576..b69f5097 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift @@ -1,6 +1,6 @@ import Foundation -import CodexReview -import CodexReviewDomain +import CodexReviewKit +import CodexReviewKit import ReviewMonitorRendering struct ReviewMonitorTimelineLogProjection: Sendable { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 91d8f3ce..b870a313 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,7 +1,7 @@ import AppKit import ObservationBridge -import CodexReview -import CodexReviewDomain +import CodexReviewKit +import CodexReviewKit import ReviewMonitorRendering @MainActor 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/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/ReviewMonitorContentPreview.swift b/Sources/ReviewUI/ReviewMonitorContentPreview.swift index 3df88225..de763758 100644 --- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift +++ b/Sources/ReviewUI/ReviewMonitorContentPreview.swift @@ -1,6 +1,6 @@ #if DEBUG import AppKit -import CodexReview +import CodexReviewKit import SwiftUI #Preview("Normal") { diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 43caa773..002adf02 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -1,7 +1,7 @@ import AppKit import Combine import ObservationBridge -import CodexReview +import CodexReviewKit typealias ReviewMonitorContentTransitionAnimator = @MainActor ( NSView, diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 059cbad1..f42ab864 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -1,5 +1,5 @@ import Foundation -@_spi(Testing) import CodexReview +@_spi(Testing) import CodexReviewKit @_spi(PreviewSupport) @MainActor @@ -401,7 +401,7 @@ public enum ReviewMonitorPreviewContent { text: """ import AppKit import ObjectiveC.runtime - import CodexReview + import CodexReviewKit @MainActor final class ReviewMonitorLogScrollView: NSScrollView { diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 3d711cba..d364fa6b 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -2,7 +2,7 @@ import AppKit import Combine import Foundation import ObservationBridge -import CodexReview +import CodexReviewKit @MainActor final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDelegate { diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index f50ae484..cb49b5cb 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -1,5 +1,5 @@ import Observation -import CodexReview +import CodexReviewKit import Foundation import SwiftUI diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 086c4107..49d42dc4 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -1,5 +1,5 @@ import AppKit -import CodexReview +import CodexReviewKit @MainActor func configureReviewMonitorWindowBase(_ window: NSWindow) { diff --git a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift index dfee5ad3..1bdfe251 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift @@ -1,4 +1,4 @@ -import CodexReview +import CodexReviewKit import SwiftUI struct AccountContextMenuView: View { diff --git a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift index 1f2d348b..78a5cb47 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift @@ -1,4 +1,4 @@ -import CodexReview +import CodexReviewKit import SwiftUI struct ReviewMonitorAccountRowView: View { diff --git a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift index b8fe7082..ee3a96e1 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 diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift index 8317c1c9..70e8f3cb 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -import CodexReview +import CodexReviewKit struct ReviewMonitorJobRowView: View { var job: CodexReviewJob diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 72c7cf4b..86daed9d 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1,6 +1,6 @@ import AppKit import ObservationBridge -import CodexReview +import CodexReviewKit import SwiftUI package enum SidebarLayout { 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..737a9a7b 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 diff --git a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift index 122e3645..7cafc7bb 100644 --- a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift +++ b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift @@ -1,5 +1,5 @@ import SwiftUI -import CodexReview +import CodexReviewKit struct AccountRateLimitGaugesView: View { var account: CodexAccount? diff --git a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift index 47d564c3..ce49fea4 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 diff --git a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift b/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift deleted file mode 100644 index 5ce4727b..00000000 --- a/Tests/ArchitectureFenceTests/ArchitectureFenceTests.swift +++ /dev/null @@ -1,450 +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 codexReviewDomainDoesNotOwnAppServerWireVocabulary() throws { - let repo = Self.repositoryRoot - let domainSources = repo.appending(path: "Sources/CodexReviewDomain") - let forbiddenFragments = [ - "AppServer", - "JSONRPC", - "WireEvent", - "turn/", - "item/", - ] - - let violations = try Self.sourceFragmentViolations( - in: domainSources, - relativeRoot: "Sources/CodexReviewDomain", - forbiddenFragments: forbiddenFragments - ) - - Self.expectNoViolations(violations) - } - - @Test func codexAppServerKitDoesNotUseReviewMonitorProductNames() throws { - let repo = Self.repositoryRoot - let kitSources = repo.appending(path: "Sources/CodexAppServerKit") - let violations = try Self.sourceFragmentViolations( - in: kitSources, - relativeRoot: "Sources/CodexAppServerKit", - forbiddenFragments: [ - "CodexReviewKit", - "ReviewMonitor", - ".codex_review", - "codex_review", - ] - ) - - Self.expectNoViolations(violations) - } - - @Test func codexReviewHostDoesNotDecodeRawAppServerAccountNotifications() throws { - let repo = Self.repositoryRoot - let hostSources = repo.appending(path: "Sources/CodexReviewHost") - let violations = try Self.sourceFragmentViolations( - in: hostSources, - relativeRoot: "Sources/CodexReviewHost", - forbiddenFragments: [ - "JSONRPC.Notification", - "AppServerAPI.Account", - "AppServerAccountLoginCompletedNotification", - "AppServerAccountRateLimitsUpdatedPayload", - "account/login/completed", - "account/rateLimits/updated", - ] - ) - - Self.expectNoViolations(violations) - } - - @Test func codexAppServerAPINamespaceStaysOwnedByKit() throws { - let repo = Self.repositoryRoot - let checkedRoots = [ - "Sources/CodexReviewAppServer", - "Sources/CodexReviewHost", - "Sources/CodexReviewMCPAdapter", - "Sources/CodexReviewMCPServer", - ] - var violations: [String] = [] - for checkedRoot in checkedRoots { - let root = repo.appending(path: checkedRoot) - violations += try Self.sourceFragmentViolations( - in: root, - relativeRoot: checkedRoot, - forbiddenFragments: [ - "extension AppServerAPI {", - "AppServerReviewStart", - ] - ) - } - - 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 sourceFragmentViolations( - in root: URL, - relativeRoot: String, - forbiddenFragments: [String] - ) throws -> [String] { - var violations: [String] = [] - 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 lineText = String(line) - guard let fragment = forbiddenFragments.first(where: { lineText.contains($0) }) else { - continue - } - violations.append( - "\(relativeRoot)/\(file):\(offset + 1): contains \(fragment)" - ) - } - } - 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 21e75cc5..800ffdab 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -3,8 +3,8 @@ import Foundation import Testing import CodexAppServerKit @testable import CodexReviewAppServer -import CodexReview -import CodexReviewDomain +import CodexReviewKit +import CodexReviewKit import CodexReviewTesting private extension AppServerCodexReviewBackend { @@ -4556,7 +4556,7 @@ struct AppServerClientTests { item: .init( type: "commandExecution", id: "cmd-failed", - command: "git diff -- Sources/CodexReview" + command: "git diff -- Sources/CodexReviewKit" ), startedAtMs: startedAtMs ) @@ -4569,7 +4569,7 @@ struct AppServerClientTests { item: .init( type: "commandExecution", id: "cmd-failed", - command: "git diff -- Sources/CodexReview", + command: "git diff -- Sources/CodexReviewKit", aggregatedOutput: "execution error: No such process", exitCode: -1, durationMs: 0, @@ -4585,28 +4585,28 @@ struct AppServerClientTests { #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", + text: "$ git diff -- Sources/CodexReviewKit", groupID: "cmd-failed", replacesGroup: true, metadata: .init( sourceType: "commandExecution", status: "inProgress", itemID: "cmd-failed", - command: "git diff -- Sources/CodexReview", + command: "git diff -- Sources/CodexReviewKit", startedAt: startedAt, commandStatus: "inProgress" ) )) #expect(try await iterator.next() == .logEntry( kind: .command, - text: "$ git diff -- Sources/CodexReview", + text: "$ git diff -- Sources/CodexReviewKit", groupID: "cmd-failed", replacesGroup: true, metadata: .init( sourceType: "commandExecution", status: "failed", itemID: "cmd-failed", - command: "git diff -- Sources/CodexReview", + command: "git diff -- Sources/CodexReviewKit", exitCode: -1, startedAt: startedAt, completedAt: completedAt, @@ -4623,7 +4623,7 @@ struct AppServerClientTests { sourceType: "commandExecution", status: "failed", itemID: "cmd-failed", - command: "git diff -- Sources/CodexReview", + command: "git diff -- Sources/CodexReviewKit", exitCode: -1, startedAt: startedAt, completedAt: completedAt, diff --git a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift index 56e5aec1..274653e7 100644 --- a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift +++ b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing @testable import CodexReviewAppServerWire -import CodexReviewDomain +import CodexReviewKit @Suite("app-server wire events") struct AppServerWireEventTests { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 2f2122ea..b8aa1771 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -3,7 +3,7 @@ import AppKit import AuthenticationServices import Testing import CodexAppServerKit -import CodexReview +import CodexReviewKit import CodexReviewAppServer import CodexReviewHost import CodexReviewMCPServer @@ -1042,7 +1042,7 @@ struct CodexReviewHostTests { ) await waitUntil { store.jobs.first?.core.run.turnID == "turn-first" } - try await store.switchAccount(CodexReview.CodexAccount(email: "second@example.com")) + try await store.switchAccount(CodexReviewKit.CodexAccount(email: "second@example.com")) let result = try await reviewRead await secondTransport.waitForRequestCount(2) await firstTransport.waitForRequestCount(8) @@ -1166,7 +1166,7 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await #expect(throws: (any Error).self) { - try await store.switchAccount(CodexReview.CodexAccount(email: "second@example.com")) + try await store.switchAccount(CodexReviewKit.CodexAccount(email: "second@example.com")) } #expect(store.auth.selectedAccount?.accountKey == "first@example.com") @@ -1679,7 +1679,7 @@ struct CodexReviewHostTests { @Test func liveStoreRemovesOnlyEncodedSavedAccountDirectory() async throws { let homeURL = try temporaryHome() let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) - let account = CodexReview.CodexAccount(email: "../outside@example.com") + let account = CodexReviewKit.CodexAccount(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( @@ -1702,8 +1702,8 @@ struct CodexReviewHostTests { let sentinelURL = codexHomeURL.appendingPathComponent("sentinel.txt") try Data("keep".utf8).write(to: sentinelURL) - let dotAccount = CodexReview.CodexAccount(email: ".") - let dotDotAccount = CodexReview.CodexAccount(email: "..") + let dotAccount = CodexReviewKit.CodexAccount(email: ".") + let dotDotAccount = CodexReviewKit.CodexAccount(email: "..") let dotDirectoryURL = accountsURL.appendingPathComponent("%2E", isDirectory: true) let dotDotDirectoryURL = accountsURL.appendingPathComponent("%2E%2E", isDirectory: true) try FileManager.default.createDirectory(at: dotDirectoryURL, withIntermediateDirectories: true) diff --git a/Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift similarity index 96% rename from Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift rename to Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index 944e1424..76dba7bf 100644 --- a/Tests/CodexReviewApplicationTests/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@testable import CodexReviewApplication -import CodexReviewDomain +@testable import CodexReviewKit @MainActor @Suite("review observation awaiter") diff --git a/Tests/CodexReviewTests/CodexReviewAuthModelTests.swift b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift similarity index 97% rename from Tests/CodexReviewTests/CodexReviewAuthModelTests.swift rename to Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift index 3dbf7f38..2728c706 100644 --- a/Tests/CodexReviewTests/CodexReviewAuthModelTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift @@ -1,5 +1,5 @@ import Testing -@_spi(Testing) @testable import CodexReview +@_spi(Testing) @testable import CodexReviewKit @Suite("Codex review auth model") @MainActor diff --git a/Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift similarity index 99% rename from Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift rename to Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift index c889d7f2..03d4efc4 100644 --- a/Tests/CodexReviewTests/CodexReviewJobRenderingTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@_spi(Testing) @testable import CodexReview -import CodexReviewDomain +@_spi(Testing) @testable import CodexReviewKit @Suite("Codex review job rendering") @MainActor diff --git a/Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift similarity index 99% rename from Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift rename to Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 17de817b..37e0c44c 100644 --- a/Tests/CodexReviewTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@_spi(Testing) @testable import CodexReview -import CodexReviewDomain +@_spi(Testing) @testable import CodexReviewKit import CodexReviewTesting @Suite("Codex review store", .serialized) diff --git a/Tests/CodexReviewTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift similarity index 99% rename from Tests/CodexReviewTests/CodexReviewStoreRateLimitAutoRefreshTests.swift rename to Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 41042100..990d01b8 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") diff --git a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift b/Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift similarity index 99% rename from Tests/CodexReviewDomainTests/ReviewTimelineTests.swift rename to Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift index b6987baf..f6fb839b 100644 --- a/Tests/CodexReviewDomainTests/ReviewTimelineTests.swift +++ b/Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import CodexReviewDomain +@testable import CodexReviewKit @MainActor @Suite("review timeline") 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/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift index 6463fecb..545141ea 100644 --- a/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift +++ b/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing @testable import CodexReviewMCPAdapter -import CodexReviewDomain +import CodexReviewKit @MainActor @Suite("review MCP projection") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 3aea152d..86fca926 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 diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index c198669e..cccaa286 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import CodexReview +@testable import CodexReviewKit import CodexReviewMCPServer import CodexReviewTesting diff --git a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift index 20e7ffa7..9d13079e 100644 --- a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift +++ b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import CodexReviewDomain +import CodexReviewKit @testable import ReviewMonitorRendering @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift b/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift index e07bbd98..b6038172 100644 --- a/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@_spi(Testing) @testable import CodexReview +@_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI @Suite("ReviewMonitor log projection") diff --git a/Tests/ReviewUITests/ReviewMonitorLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorLogTesting.swift index 71834d07..c2658522 100644 --- a/Tests/ReviewUITests/ReviewMonitorLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorLogTesting.swift @@ -1,4 +1,4 @@ -import CodexReview +import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI @MainActor diff --git a/Tests/ReviewUITests/ReviewUISettingsTests.swift b/Tests/ReviewUITests/ReviewUISettingsTests.swift index 5e9bf2ad..2d52ed0b 100644 --- a/Tests/ReviewUITests/ReviewUISettingsTests.swift +++ b/Tests/ReviewUITests/ReviewUISettingsTests.swift @@ -1,7 +1,7 @@ import AppKit import Foundation import Testing -@_spi(Testing) @testable import CodexReview +@_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI import CodexReviewTesting diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index ff6801d1..3e8c915d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -3,7 +3,7 @@ import Foundation import ObservationBridge import SwiftUI import Testing -@_spi(Testing) @testable import CodexReview +@_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI import CodexReviewTesting diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index d5979906..e4aa6761 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1,11 +1,11 @@ import AppKit import Foundation import ObservationBridge -import CodexReviewDomain +import CodexReviewKit import ReviewMonitorRendering import SwiftUI import Testing -@_spi(Testing) @testable import CodexReview +@_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI import CodexReviewTesting diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj index 909c0e50..48be6374 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj @@ -9,7 +9,7 @@ /* 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 */; }; /* End PBXBuildFile section */ @@ -61,7 +61,7 @@ buildActionMask = 2147483647; files = ( F0A100000000000000000004 /* ReviewUI in Frameworks */, - F0A10000000000000000000A /* CodexReview in Frameworks */, + F0A10000000000000000000A /* CodexReviewKit in Frameworks */, F0A10000000000000000000C /* CodexReviewHost in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -116,7 +116,7 @@ name = CodexReviewMonitor; packageProductDependencies = ( F0A100000000000000000005 /* ReviewUI */, - F0A10000000000000000000B /* CodexReview */, + F0A10000000000000000000B /* CodexReviewKit */, F0A10000000000000000000D /* CodexReviewHost */, ); productName = CodexReviewMonitor; @@ -565,10 +565,10 @@ package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */; productName = ReviewUI; }; - F0A10000000000000000000B /* CodexReview */ = { + F0A10000000000000000000B /* CodexReviewKit */ = { isa = XCSwiftPackageProductDependency; package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */; - productName = CodexReview; + productName = CodexReviewKit; }; F0A10000000000000000000D /* CodexReviewHost */ = { isa = XCSwiftPackageProductDependency; diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 7046ddaa..ac61ca86 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -6,7 +6,7 @@ // import AppKit -import CodexReview +import CodexReviewKit import CodexReviewHost @_spi(PreviewSupport) import ReviewUI diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index ffe13cee..85cf6407 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -1,6 +1,6 @@ import AppKit import Foundation -import CodexReview +import CodexReviewKit import CodexReviewHost import Testing @testable import CodexReviewMonitor From 5dd10f99511935859db25926b23d0dc09f333d3b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:26:10 +0900 Subject: [PATCH 024/359] refactor(mcp): absorb review projection into server target --- Docs/architecture.md | 32 ++++++++----------- Package.swift | 19 ----------- .../CodexReviewMCPProtocolServer.swift | 1 - .../CodexReviewMCPServer.swift | 1 - .../CodexReviewMCPToolResults.swift | 1 - .../ReviewMCPProjection.swift | 0 .../ReviewMCPProjectionTests.swift | 2 +- 7 files changed, 14 insertions(+), 42 deletions(-) rename Sources/{CodexReviewMCPAdapter => CodexReviewMCPServer}/ReviewMCPProjection.swift (100%) rename Tests/{CodexReviewMCPAdapterTests => CodexReviewMCPServerTests}/ReviewMCPProjectionTests.swift (99%) diff --git a/Docs/architecture.md b/Docs/architecture.md index 9fb32c5e..9c538f96 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -27,8 +27,7 @@ have been converted into domain events. | `CodexAppServerKit` | Generic `codex app-server` process transport, JSON-RPC client, typed request DTOs, and Swift domain API for threads, turns, prompts, models, accounts, and login | | `CodexReviewAppServerWire` | Raw `codex app-server` review notification DTO decode and conversion into domain events | | `CodexReviewAppServer` | Review-specific `review/start` orchestration, ReviewMonitor 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 | +| `CodexReviewMCPServer` | MCP-facing projections from observable review/domain state, internal MCP protocol request/response conversion, and Streamable HTTP endpoint | | `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 | @@ -62,9 +61,9 @@ application-facing review events to the store. Review 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. +after conversion. UI views, rendering helpers, MCP server projections, 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: @@ -110,7 +109,6 @@ flowchart TB end subgraph MCP["MCP"] - MCPAdapter["CodexReviewMCPAdapter"] MCPServer["CodexReviewMCPServer"] end @@ -128,11 +126,10 @@ flowchart TB Timeline --> AppStore Timeline --> PublicStore Timeline --> LegacyProjection - Timeline --> MCPAdapter + Timeline --> MCPServer Timeline --> Renderer PublicStore --> UI Renderer --> UI - MCPAdapter --> MCPServer DomainAPI --> Client Client --> Process Runtime --> DomainAPI @@ -144,8 +141,8 @@ flowchart TB ``` The diagram describes ownership direction, not every SwiftPM dependency. New -code should not move domain, application, rendering, UI, or MCP adapter -responsibilities back into runtime/protocol owners. +code should not move domain, application, rendering, UI, or MCP projection +responsibilities back into runtime owners. ## Observation Ownership @@ -217,12 +214,10 @@ 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 +domain/product state. It calls store commands and projects the review timeline +into MCP tool response shape. It does not know Codex JSON-RPC details 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 @@ -245,7 +240,7 @@ The public tool surface is: - 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 +- 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. @@ -265,8 +260,7 @@ Default tests are deterministic and do not start a live `codex app-server`. | `CodexReviewAppServerWireTests` | Raw notification JSON | Wire decode and domain event conversion | | `CodexReviewKitTests` | Domain timelines, ObservationBridge awaiters, and fake `CodexReviewStoreBackend` | Semantic timeline mutation, use-case observation behavior, review/auth/settings state machines, cancellation, result retention | | `CodexReviewAppServerTests` | Fake JSON-RPC transport | Review-specific request serialization, notification buffering, interrupt/cleanup, and recovery | -| `CodexReviewMCPAdapterTests` | Domain timeline projections | MCP adapter snapshots | -| `CodexReviewMCPServerTests` | Fake review store | MCP protocol conversion and response shape | +| `CodexReviewMCPServerTests` | Fake review store and domain timeline projections | MCP protocol conversion, response shape, and timeline projection snapshots | | `CodexReviewHostTests` | Fake runtime dependencies | Composition, startup, shutdown | | `ReviewUITests` | Preview/test monitor backend | Native UI behavior and user-intent forwarding | diff --git a/Package.swift b/Package.swift index e5cc9a28..58e8b6bf 100644 --- a/Package.swift +++ b/Package.swift @@ -83,20 +83,10 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .target( - name: "CodexReviewMCPAdapter", - dependencies: [ - "CodexReviewKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .target( name: "CodexReviewMCPServer", dependencies: [ "CodexReviewKit", - "CodexReviewMCPAdapter", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"), @@ -176,15 +166,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "CodexReviewMCPAdapterTests", - dependencies: [ - "CodexReviewMCPAdapter", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "CodexReviewKitTests", dependencies: ["CodexReviewKit", "CodexReviewTesting"], diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift index 6fff2d7e..14d5e61a 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift @@ -1,7 +1,6 @@ import Foundation import MCP import CodexReviewKit -import CodexReviewMCPAdapter package actor MCPClientSessionState { private var clientInfo: Client.Info? diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 86f46ad2..1190814f 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -1,6 +1,5 @@ import Foundation import CodexReviewKit -import CodexReviewMCPAdapter package enum CodexReviewMCP { package enum Tool {} diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 099eb78c..bf80f142 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -1,7 +1,6 @@ import Foundation import MCP import CodexReviewKit -import CodexReviewMCPAdapter func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Result { let value: Value diff --git a/Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift similarity index 100% rename from Sources/CodexReviewMCPAdapter/ReviewMCPProjection.swift rename to Sources/CodexReviewMCPServer/ReviewMCPProjection.swift diff --git a/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift similarity index 99% rename from Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift rename to Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift index 545141ea..9e65a61e 100644 --- a/Tests/CodexReviewMCPAdapterTests/ReviewMCPProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import CodexReviewMCPAdapter +@testable import CodexReviewMCPServer import CodexReviewKit @MainActor From f00037cb3b85f4df43cc2ce47ede6fa08c202280 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:29:39 +0900 Subject: [PATCH 025/359] refactor(mcp): limit projection visibility --- .../CodexReviewMCPServer.swift | 12 +- .../ReviewMCPProjection.swift | 124 +++++++++--------- .../CodexReviewMCPServerTests.swift | 2 +- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 1190814f..60fcf4ba 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -38,11 +38,11 @@ package extension CodexReviewMCP.Tool { } package extension CodexReviewMCP.Tool { - struct ReviewSnapshot: Equatable, Sendable { - package var result: CodexReviewAPI.Read.Result - package var timeline: ReviewMCPProjection + internal struct ReviewSnapshot: Equatable, Sendable { + var result: CodexReviewAPI.Read.Result + var timeline: ReviewMCPProjection - package init(result: CodexReviewAPI.Read.Result, timeline: ReviewMCPProjection) { + init(result: CodexReviewAPI.Read.Result, timeline: ReviewMCPProjection) { self.result = result self.timeline = timeline } @@ -50,7 +50,7 @@ package extension CodexReviewMCP.Tool { } package extension CodexReviewMCP.Tool { - enum Response: Equatable, Sendable { + internal enum Response: Equatable, Sendable { case reviewStart(ReviewSnapshot) case reviewAwait(ReviewSnapshot) case reviewRead(ReviewSnapshot, timelinePage: CodexReviewAPI.Log.PageRequest) @@ -77,7 +77,7 @@ package final class CodexReviewMCPServer { ] } - 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 diff --git a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift index 27e10100..051398e1 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift @@ -1,22 +1,22 @@ import Foundation import CodexReviewKit -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? +struct ReviewMCPProjection: Sendable, Equatable { + struct Item: Sendable, Equatable { + var id: ReviewTimelineItem.ID + var kind: ReviewItemKind + var family: ReviewItemFamily + var phase: ReviewItemPhase + var isActive: Bool + var content: Content + var createdAt: Date + var updatedAt: Date + var startedAt: Date? + var completedAt: Date? + var durationMs: Int? @MainActor - public init(item: ReviewTimelineItem, isActive: Bool) { + init(item: ReviewTimelineItem, isActive: Bool) { self.id = item.id self.kind = item.kind self.family = item.family @@ -31,7 +31,7 @@ public struct ReviewMCPProjection: Sendable, Equatable { } } - public enum Content: Sendable, Equatable { + enum Content: Sendable, Equatable { case approval(Approval) case command(Command) case contextCompaction(ContextCompaction) @@ -44,7 +44,7 @@ public struct ReviewMCPProjection: Sendable, Equatable { case toolCall(ToolCall) case unknown(Unknown) - public var type: String { + var type: String { switch self { case .approval: "approval" @@ -71,7 +71,7 @@ public struct ReviewMCPProjection: Sendable, Equatable { } } - public init(_ content: ReviewTimelineItem.Content) { + init(_ content: ReviewTimelineItem.Content) { switch content { case .approval(let approval): self = .approval(.init(title: approval.title, detail: approval.detail)) @@ -112,75 +112,75 @@ public struct ReviewMCPProjection: Sendable, Equatable { } } - public struct Approval: Sendable, Equatable { - public var title: String - public var detail: String? + struct Approval: Sendable, Equatable { + var title: String + var detail: String? } - public struct Command: Sendable, Equatable { - public var command: String - public var cwd: String? - public var output: String - public var exitCode: Int? + struct Command: Sendable, Equatable { + var command: String + var cwd: String? + var output: String + var exitCode: Int? } - public struct ContextCompaction: Sendable, Equatable { - public var title: String + struct ContextCompaction: Sendable, Equatable { + var title: String } - public struct Diagnostic: Sendable, Equatable { - public var message: String + struct Diagnostic: Sendable, Equatable { + var message: String } - public struct FileChange: Sendable, Equatable { - public var title: String - public var output: String + struct FileChange: Sendable, Equatable { + var title: String + var output: String } - public struct Message: Sendable, Equatable { - public var text: String + struct Message: Sendable, Equatable { + var text: String } - public struct Plan: Sendable, Equatable { - public var markdown: String + struct Plan: Sendable, Equatable { + var markdown: String } - public struct Reasoning: Sendable, Equatable { - public var text: String - public var style: ReviewTimelineItem.Reasoning.Style + struct Reasoning: Sendable, Equatable { + var text: String + var style: ReviewTimelineItem.Reasoning.Style } - public struct Search: Sendable, Equatable { - public var query: String - public var result: String? + struct Search: Sendable, Equatable { + var query: String + 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? + struct ToolCall: Sendable, Equatable { + var namespace: String? + var server: String? + var tool: String? + var arguments: String? + var progress: String? + var result: String? + var error: String? } - public struct Unknown: Sendable, Equatable { - public var title: String - public var detail: String? + struct Unknown: Sendable, Equatable { + var title: String + 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] + var timelineRevision: ReviewTimeline.Revision + var orderedItemIDs: [ReviewTimelineItem.ID] + var activeItemIDs: [ReviewTimelineItem.ID] + var activeItemCount: Int + var latestActivityID: ReviewTimelineItem.ID? + var terminalSummary: String? + var terminalResult: String? + var items: [Item] @MainActor - public init(timeline: ReviewTimeline) { + init(timeline: ReviewTimeline) { let activeItemIDs = timeline.orderedItemIDs.filter { timeline.activeItemIDs.contains($0) } self.timelineRevision = timeline.revision self.orderedItemIDs = timeline.orderedItemIDs diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index cccaa286..5bf6c7e7 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -1,6 +1,6 @@ import Testing @testable import CodexReviewKit -import CodexReviewMCPServer +@testable import CodexReviewMCPServer import CodexReviewTesting @Suite("MCP server adapter") From b5d186c31b2fae078961a5b25f207ead9b6ef861 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:39:48 +0900 Subject: [PATCH 026/359] feat(app-server): expose typed review streams --- .../CodexAppServerNotificationRouter.swift | 74 ++++++- .../CodexAppServerKit/CodexDomainTypes.swift | 181 +++++++++++++++++- .../CodexThreadOperations.swift | 4 +- .../CodexTurnSequences.swift | 150 +++++++++++++++ Sources/CodexAppServerKit/README.md | 38 +++- .../CodexAppServerKitTests.swift | 146 +++++++++++++- 6 files changed, 572 insertions(+), 21 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index f4392c02..7b515224 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -68,6 +68,24 @@ package actor CodexAppServerNotificationRouter { } } + package func seedReviewTurn(_ turnID: CodexTurnID, reviewThreadID: CodexThreadID) { + threadIDByTurnID[turnID] = reviewThreadID + guard let turnHistory = turnHistoryByTurnID[turnID] else { + return + } + for turnEvent in turnHistory { + let threadEvent = Self.threadEvent( + from: turnEvent, + turnID: turnID, + threadID: reviewThreadID + ) + if (threadHistoryByThreadID[reviewThreadID] ?? []).contains(threadEvent) { + continue + } + appendThreadEvent(threadEvent, threadID: reviewThreadID) + } + } + package func stop() { routerTask?.cancel() routerTask = nil @@ -92,15 +110,7 @@ package actor CodexAppServerNotificationRouter { if let threadID = context.threadID { let event = decodeThreadEvent(notification: notification, context: context) - threadHistoryByThreadID[threadID, default: []].append(event) - if let subscribers = threadSubscribersByThreadID[threadID] { - for subscriber in subscribers.values { - subscriber.continuation.yield(event) - } - } - if case .closed = event { - finishThreadSubscribers(threadID: threadID) - } + appendThreadEvent(event, threadID: threadID) } if let turnID = context.turnID { @@ -117,6 +127,18 @@ package actor CodexAppServerNotificationRouter { } } + private func appendThreadEvent(_ event: CodexThreadEvent, threadID: CodexThreadID) { + threadHistoryByThreadID[threadID, default: []].append(event) + if let subscribers = threadSubscribersByThreadID[threadID] { + for subscriber in subscribers.values { + subscriber.continuation.yield(event) + } + } + if case .closed = event { + finishThreadSubscribers(threadID: threadID) + } + } + private func addTurnSubscriber( _ subscriptionID: UUID, turnID: CodexTurnID, @@ -168,6 +190,40 @@ package actor CodexAppServerNotificationRouter { return false } + private nonisolated static func threadEvent( + from event: CodexTurnEvent, + turnID: CodexTurnID, + threadID: CodexThreadID + ) -> CodexThreadEvent { + switch event { + case .started(let turnID): + return .turnStarted(turnID) + case .itemStarted(let item): + return .itemStarted(item, turnID: turnID) + case .itemUpdated(let item): + return .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item): + return .itemCompleted(item, turnID: turnID) + case .messageDelta(let delta): + return .messageDelta(delta, turnID: turnID) + case .reasoningSummaryPartAdded(let part): + return .reasoningSummaryPartAdded(part, turnID: turnID) + case .reasoningDelta(let delta): + return .reasoningDelta(delta, turnID: turnID) + case .tokenUsageUpdated(let usage): + return .tokenUsageUpdated(usage, turnID: turnID) + case .completed(let response): + return .turnCompleted(response) + case .failed(let message): + return .turnFailed(turnID: turnID, message: message) + case .unknown(let raw): + var raw = raw + raw.threadID = threadID + raw.turnID = turnID + return .unknown(raw) + } + } + private func decodeTurnEvent( notification: JSONRPC.Notification, context: NotificationContext diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index fa05b872..0de486d3 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -583,6 +583,172 @@ public enum CodexReviewDelivery: String, Codable, Equatable, Sendable { case detached } +/// Review-scoped events emitted for a `CodexReviewSession`. +public enum CodexReviewEvent: Equatable, Sendable { + case turnStarted(CodexTurnID) + case turnCompleted(CodexResponse) + case turnFailed(turnID: CodexTurnID?, message: String) + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case message(CodexMessage, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) + case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) + case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) + case statusChanged(CodexThreadStatus) + case closed + case unknown(CodexRawNotification) + + package init(_ event: CodexThreadEvent) { + switch event { + case .turnStarted(let turnID): + self = .turnStarted(turnID) + case .turnCompleted(let response): + self = .turnCompleted(response) + case .turnFailed(let turnID, let message): + self = .turnFailed(turnID: turnID, message: message) + case .itemStarted(let item, let turnID): + self = .itemStarted(item, turnID: turnID) + case .itemUpdated(let item, let turnID): + self = .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item, let turnID): + self = .itemCompleted(item, turnID: turnID) + case .message(let message, let turnID): + self = .message(message, turnID: turnID) + case .messageDelta(let delta, let turnID): + self = .messageDelta(delta, turnID: turnID) + case .reasoningSummaryPartAdded(let part, let turnID): + self = .reasoningSummaryPartAdded(part, turnID: turnID) + case .reasoningDelta(let delta, let turnID): + self = .reasoningDelta(delta, turnID: turnID) + case .tokenUsageUpdated(let usage, let turnID): + self = .tokenUsageUpdated(usage, turnID: turnID) + case .statusChanged(let status): + self = .statusChanged(status) + case .closed: + self = .closed + case .unknown(let raw): + self = .unknown(raw) + } + } +} + +/// A log-oriented review event suitable for review output views. +public enum CodexReviewLogEntry: Identifiable, Equatable, Sendable { + public typealias Phase = CodexThreadLogEntry.Phase + + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?, id: String) + case reasoningPartStarted(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) + + package init(_ entry: CodexThreadLogEntry) { + switch entry { + case .itemStarted(let item, let turnID): + self = .itemStarted(item, turnID: turnID) + case .itemUpdated(let item, let turnID): + self = .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item, let turnID): + self = .itemCompleted(item, turnID: turnID) + case .messageDelta(let delta, let turnID, let id): + self = .messageDelta(delta, turnID: turnID, id: id) + case .reasoningPartStarted(let part, let turnID): + self = .reasoningPartStarted(part, turnID: turnID) + case .reasoningDelta(let delta, let turnID): + self = .reasoningDelta(delta, turnID: turnID) + } + } + + public var id: String { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item.id + case .messageDelta(_, _, let id): + id + case .reasoningPartStarted(let part, _): + part.id + case .reasoningDelta(let delta, _): + delta.id + } + } + + public var turnID: CodexTurnID? { + switch self { + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .messageDelta(_, let turnID, _), + .reasoningPartStarted(_, let turnID), .reasoningDelta(_, let turnID): + turnID + } + } + + public var phase: Phase { + switch self { + case .itemStarted, .reasoningPartStarted: + .started + case .itemUpdated: + .updated + case .itemCompleted: + .completed + case .messageDelta, .reasoningDelta: + .delta + } + } + + public var item: CodexThreadItem? { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item + case .reasoningPartStarted(let part, _): + .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)) + case .messageDelta, .reasoningDelta: + nil + } + } + + public var messageDelta: CodexMessageDelta? { + if case .messageDelta(let delta, _, _) = self { + return delta + } + return nil + } + + public var reasoningDelta: CodexReasoningDelta? { + if case .reasoningDelta(let delta, _) = self { + return delta + } + return nil + } +} + +/// Incremental review progress derived from review-domain events. +public struct CodexReviewProgress: Equatable, Sendable { + public enum Phase: Equatable, Sendable { + case running + case completed + case failed(CodexAppServerError) + } + + public var phase: Phase + public var transcript: CodexTranscript + public var usage: CodexTokenUsage? + public var result: CodexResponse? + + public init( + phase: Phase, + transcript: CodexTranscript = .init(), + usage: CodexTokenUsage? = nil, + result: CodexResponse? = nil + ) { + self.phase = phase + self.transcript = transcript + self.usage = usage + self.result = result + } +} + /// A review run started by `codex app-server`. public struct CodexReviewSession: Identifiable, Sendable { /// The response turn identifier, used as the stable session identity. @@ -621,9 +787,9 @@ public struct CodexReviewSession: Identifiable, Sendable { self.eventThread = eventThread } - /// Thread-scoped events for the review thread. - public var events: CodexThreadEventSequence { - eventThread.events + /// Review-scoped events emitted by the review thread. + public var events: CodexReviewEventSequence { + .init(events: eventThread.events) } /// Agent messages emitted by the review thread. @@ -640,8 +806,13 @@ public struct CodexReviewSession: Identifiable, Sendable { /// /// Review log views can be built from this sequence without depending on /// raw JSON-RPC notifications. - public var logEntries: CodexThreadLogSequence { - eventThread.logEntries + public var logEntries: CodexReviewLogSequence { + .init(logs: eventThread.logEntries) + } + + /// Incremental progress snapshots for the review thread. + public var progress: CodexReviewProgressSequence { + .init(events: eventThread.events) } /// Collects the review response until the turn finishes. diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 77ebc391..2388a2ca 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -138,8 +138,10 @@ extension CodexThread { params: .init(threadID: id.rawValue, target: target, delivery: delivery) )) let reviewThreadID = response.reviewThreadID.map(CodexThreadID.init(rawValue:)) ?? id + let turnID = CodexTurnID(rawValue: response.turnID) + await router.seedReviewTurn(turnID, reviewThreadID: reviewThreadID) let turn = CodexTurn( - id: .init(rawValue: response.turnID), + id: turnID, threadID: reviewThreadID, client: client, router: router diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index c89ca3ea..faf51c91 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -149,6 +149,156 @@ public struct CodexThreadLogSequence: AsyncSequence, Sendable { } } +/// Review-scoped event stream for a `CodexReviewSession`. +public struct CodexReviewEventSequence: AsyncSequence, Sendable { + public typealias Element = CodexReviewEvent + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexReviewEvent? { + guard let event = try await events.next() else { + return nil + } + return CodexReviewEvent(event) + } + } +} + +/// Review-scoped log stream for a `CodexReviewSession`. +public struct CodexReviewLogSequence: AsyncSequence, Sendable { + public typealias Element = CodexReviewLogEntry + + private let logs: CodexThreadLogSequence + + package init(logs: CodexThreadLogSequence) { + self.logs = logs + } + + public func makeAsyncIterator() -> Iterator { + Iterator(logs: logs.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var logs: CodexThreadLogSequence.Iterator + + fileprivate init(logs: CodexThreadLogSequence.Iterator) { + self.logs = logs + } + + public mutating func next() async throws -> CodexReviewLogEntry? { + guard let entry = try await logs.next() else { + return nil + } + return CodexReviewLogEntry(entry) + } + } +} + +/// Incremental progress stream for a `CodexReviewSession`. +public struct CodexReviewProgressSequence: AsyncSequence, Sendable { + public typealias Element = CodexReviewProgress + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + public func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + public struct Iterator: AsyncIteratorProtocol { + private var events: AsyncThrowingStream.Iterator + private var accumulator = CodexTranscriptAccumulator() + private var usage: CodexTokenUsage? + private var finished = false + + fileprivate init(events: AsyncThrowingStream.Iterator) { + self.events = events + } + + public mutating func next() async throws -> CodexReviewProgress? { + guard finished == false else { + return nil + } + while let event = try await events.next() { + switch event { + case .turnStarted, .unknown: + return .init(phase: .running, transcript: accumulator.transcript, usage: usage) + case .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: + _ = accumulator.apply(event) + return .init(phase: .running, transcript: accumulator.transcript, usage: usage) + case .tokenUsageUpdated(let newUsage, _): + usage = newUsage + return .init(phase: .running, transcript: accumulator.transcript, usage: usage) + case .turnCompleted(var result): + finished = true + result = finalizedResult(result) + if result.errorMessage != nil || result.status?.isFailure == true { + return .init( + phase: .failed(.turnFailedWithResponse(result)), + transcript: result.transcript, + usage: result.usage, + result: result + ) + } + return .init( + phase: .completed, + transcript: result.transcript, + usage: result.usage, + result: result + ) + case .turnFailed(_, let message): + finished = true + return .init( + phase: .failed(.turnFailed(message)), + transcript: accumulator.transcript, + usage: usage + ) + case .statusChanged: + return .init(phase: .running, transcript: accumulator.transcript, usage: usage) + case .closed: + finished = true + return nil + } + } + finished = true + return nil + } + + private func finalizedResult(_ result: CodexResponse) -> CodexResponse { + var result = result + if result.finalAnswer == nil { + result.finalAnswer = accumulator.transcript.finalAnswer + } + if result.transcript.items.isEmpty { + result.transcript = accumulator.transcript + } + if result.usage == nil { + result.usage = usage + } + return result + } + } +} + package struct CodexTurnEventSequence: AsyncSequence, Sendable { package typealias Element = CodexTurnEvent diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index 7900e1f8..d2f8a9ad 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -245,15 +245,31 @@ not yet model directly. `review/start` is part of the app-server surface, so CodexAppServerKit exposes it as a thread operation. A review session provides the review turn response and -the review thread event streams. If app-server detaches the review into a -separate thread, `logEntries`, `events`, and `transcriptUpdates` are bound to -that review thread automatically. +review-domain streams. If app-server detaches the review into a separate +thread, `events`, `logEntries`, `progress`, and `transcriptUpdates` are bound to +that review thread automatically. Detached review notification routing is owned +by CodexAppServerKit, so callers do not need to parse JSON-RPC notifications or +track app-server thread event details. ```swift let review = try await thread.startReview(target: .baseBranch("main")) for try await entry in review.logEntries { - renderReviewLog(entry) + switch entry { + case .itemCompleted(let item, _): + renderReviewLog(item) + case .reasoningDelta(let delta, _): + renderReasoningDelta(delta) + default: + break + } +} + +for try await progress in review.progress { + renderReviewTranscript(progress.transcript) + if progress.phase == .completed { + break + } } let response = try await review.collect() @@ -268,6 +284,14 @@ try await thread.startReview(target: .commit(sha: sha, title: title)) try await thread.startReview(target: .custom(instructions: instructions)) ``` +`CodexReviewSession.events` yields `CodexReviewEvent`, preserving unknown +schema-new notifications as `CodexRawNotification`. `logEntries` yields +`CodexReviewLogEntry` values for command, reasoning, tool, file-change, message, +and delta output. `progress` yields `CodexReviewProgress` snapshots until the +review turn completes or fails. `transcriptUpdates` remains the transcript +sequence for the review thread itself, which is useful when a UI wants +thread-bound transcript snapshots rather than review progress phases. + ## Responses `CodexResponse` is the final result from `respond` or `ResponseStream.collect()`. @@ -397,6 +421,12 @@ The public boundary is: - `CodexThread` - `CodexReviewTarget` - `CodexReviewSession` +- `CodexReviewEvent` +- `CodexReviewLogEntry` +- `CodexReviewProgress` +- `CodexReviewEventSequence` +- `CodexReviewLogSequence` +- `CodexReviewProgressSequence` - `CodexResponse` - `CodexResponseStream` - `CodexGenerationOptions` diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index f19007d6..aa8a97f9 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -184,7 +184,7 @@ struct CodexAppServerKitTests { #expect(params.useStateDbOnly == true) } - @Test func threadStartReviewSerializesTargetAndStreamsReviewThreadLogs() async throws { + @Test func threadStartReviewSerializesTargetAndStreamsTypedReviewLogs() async throws { let transport = CodexAppServerTestTransport() try await transport.enqueue( AppServerAPI.Review.Start.Response( @@ -232,16 +232,152 @@ struct CodexAppServerKitTests { ) ) ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "reasoning-1", + type: "reasoning", + summary: ["Checked the diff"], + content: ["trace"] + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "tool-1", + type: "mcpToolCall", + text: "ok", + status: "completed", + tool: "review_read" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "file-1", + type: "fileChange", + text: "updated", + status: "completed", + path: "Sources/File.swift" + ) + ) + ) try await transport.emitServerNotification( method: "thread/closed", params: ThreadIDParams(threadID: "thread-review") ) let logs = try await collect(review.logEntries) - #expect(logs.count == 1) + #expect(logs.count == 4) #expect(logs.first?.turnID == "turn-review") #expect(logs.first?.item?.kind == .commandExecution) #expect(logs.first?.item?.text == "passed") + #expect(logs.contains { + if case .reasoning(let reasoning) = $0.item?.content { + reasoning.summary == ["Checked the diff"] + } else { + false + } + }) + #expect(logs.contains { + if case .toolCall(let toolCall) = $0.item?.content { + toolCall.name == "review_read" + } else { + false + } + }) + #expect(logs.contains { + if case .fileChange(let fileChange) = $0.item?.content { + fileChange.path == "Sources/File.swift" + } else { + false + } + }) + } + + @Test func reviewStartSeedsDetachedTurnRoutingForTurnOnlyTerminalNotifications() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-review", status: "completed")) + ) + + var eventIterator = review.events.makeAsyncIterator() + let event = try await eventIterator.next() + if case .turnCompleted(let response) = event { + #expect(response.turnID == "turn-review") + } else { + Issue.record("Expected review.events to receive turn-only completion.") + } + + var progressIterator = review.progress.makeAsyncIterator() + let progress = try #require(try await progressIterator.next()) + #expect(progress.phase == .completed) + #expect(progress.result?.turnID == "turn-review") + #expect(try await progressIterator.next() == nil) + } + + @Test func reviewEventsPreserveUnknownNotificationsAsRawDomainEvents() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + try await transport.emitServerNotification( + method: "review/futureEvent", + params: TurnIDParams(turnID: "turn-review") + ) + + var iterator = review.events.makeAsyncIterator() + let event = try await iterator.next() + if case .unknown(let raw) = event { + #expect(raw.method == "review/futureEvent") + #expect(raw.threadID == "thread-review") + #expect(raw.turnID == "turn-review") + } else { + Issue.record("Expected unknown review notification to be preserved.") + } } @Test func promptPartsEncodeToAppServerInputItems() { @@ -1021,6 +1157,8 @@ private struct ThreadItemParams: Encodable, Sendable { var command: String? var aggregatedOutput: String? var status: String? + var path: String? + var tool: String? var summary: [String]? var content: [String]? @@ -1032,6 +1170,8 @@ private struct ThreadItemParams: Encodable, Sendable { command: String? = nil, aggregatedOutput: String? = nil, status: String? = nil, + path: String? = nil, + tool: String? = nil, summary: [String]? = nil, content: [String]? = nil ) { @@ -1042,6 +1182,8 @@ private struct ThreadItemParams: Encodable, Sendable { self.command = command self.aggregatedOutput = aggregatedOutput self.status = status + self.path = path + self.tool = tool self.summary = summary self.content = content } From c079fcf132c62d2a3d97e0330afbd11263a49bd4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:44:14 +0900 Subject: [PATCH 027/359] fix(app-server): finish review streams at turn terminal --- .../CodexAppServerKit/CodexDomainTypes.swift | 6 +- .../CodexTurnSequences.swift | 150 ++++++++++++++++-- .../CodexAppServerKitTests.swift | 4 + 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 0de486d3..932cb048 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -789,7 +789,7 @@ public struct CodexReviewSession: Identifiable, Sendable { /// Review-scoped events emitted by the review thread. public var events: CodexReviewEventSequence { - .init(events: eventThread.events) + .init(events: eventThread.events, terminalTurnID: turnID) } /// Agent messages emitted by the review thread. @@ -807,12 +807,12 @@ public struct CodexReviewSession: Identifiable, Sendable { /// Review log views can be built from this sequence without depending on /// raw JSON-RPC notifications. public var logEntries: CodexReviewLogSequence { - .init(logs: eventThread.logEntries) + .init(events: eventThread.events, terminalTurnID: turnID) } /// Incremental progress snapshots for the review thread. public var progress: CodexReviewProgressSequence { - .init(events: eventThread.events) + .init(events: eventThread.events, terminalTurnID: turnID) } /// Collects the review response until the turn finishes. diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index faf51c91..98830fa4 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -154,28 +154,57 @@ public struct CodexReviewEventSequence: AsyncSequence, Sendable { public typealias Element = CodexReviewEvent private let events: CodexThreadEventSequence + private let terminalTurnID: CodexTurnID? - package init(events: CodexThreadEventSequence) { + package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { self.events = events + self.terminalTurnID = terminalTurnID } public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) + Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) } public struct Iterator: AsyncIteratorProtocol { private var events: AsyncThrowingStream.Iterator + private let terminalTurnID: CodexTurnID? + private var finished = false - fileprivate init(events: AsyncThrowingStream.Iterator) { + fileprivate init( + events: AsyncThrowingStream.Iterator, + terminalTurnID: CodexTurnID? + ) { self.events = events + self.terminalTurnID = terminalTurnID } public mutating func next() async throws -> CodexReviewEvent? { + guard finished == false else { + return nil + } guard let event = try await events.next() else { return nil } + if isTerminal(event) { + finished = true + } return CodexReviewEvent(event) } + + private func isTerminal(_ event: CodexThreadEvent) -> Bool { + switch event { + case .turnCompleted(let response): + terminalTurnID.map { response.turnID == $0 } ?? true + case .turnFailed(let turnID, _): + terminalTurnID.map { turnID == $0 } ?? true + case .closed: + true + case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, + .unknown: + false + } + } } } @@ -183,28 +212,91 @@ public struct CodexReviewEventSequence: AsyncSequence, Sendable { public struct CodexReviewLogSequence: AsyncSequence, Sendable { public typealias Element = CodexReviewLogEntry - private let logs: CodexThreadLogSequence + private let events: CodexThreadEventSequence + private let terminalTurnID: CodexTurnID? - package init(logs: CodexThreadLogSequence) { - self.logs = logs + package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { + self.events = events + self.terminalTurnID = terminalTurnID } public func makeAsyncIterator() -> Iterator { - Iterator(logs: logs.makeAsyncIterator()) + Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) } public struct Iterator: AsyncIteratorProtocol { - private var logs: CodexThreadLogSequence.Iterator + private var events: AsyncThrowingStream.Iterator + private let terminalTurnID: CodexTurnID? + private var logEntryIndex = 0 + private var finished = false - fileprivate init(logs: CodexThreadLogSequence.Iterator) { - self.logs = logs + fileprivate init( + events: AsyncThrowingStream.Iterator, + terminalTurnID: CodexTurnID? + ) { + self.events = events + self.terminalTurnID = terminalTurnID } public mutating func next() async throws -> CodexReviewLogEntry? { - guard let entry = try await logs.next() else { + guard finished == false else { return nil } - return CodexReviewLogEntry(entry) + while let event = try await events.next() { + switch event { + case .itemStarted(let item, let turnID): + return .itemStarted(item, turnID: turnID) + case .itemUpdated(let item, let turnID): + return .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item, let turnID): + return .itemCompleted(item, turnID: turnID) + case .message(let message, let turnID): + let item = CodexThreadItem( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + return .itemCompleted(item, turnID: turnID) + case .messageDelta(let delta, let turnID): + return .messageDelta(delta, turnID: turnID, id: nextDeltaLogEntryID(for: delta)) + case .reasoningSummaryPartAdded(let part, let turnID): + return .reasoningPartStarted(part, turnID: turnID) + case .reasoningDelta(let delta, let turnID): + return .reasoningDelta(delta, turnID: turnID) + case .turnCompleted, .turnFailed, .closed: + if isTerminal(event) { + finished = true + return nil + } + continue + case .turnStarted, .tokenUsageUpdated, .statusChanged, .unknown: + continue + } + } + finished = true + return nil + } + + private mutating func nextDeltaLogEntryID(for delta: CodexMessageDelta) -> String { + defer { + logEntryIndex += 1 + } + return "\(delta.itemID ?? "agent-message-delta"):\(logEntryIndex)" + } + + private func isTerminal(_ event: CodexThreadEvent) -> Bool { + switch event { + case .turnCompleted(let response): + terminalTurnID.map { response.turnID == $0 } ?? true + case .turnFailed(let turnID, _): + terminalTurnID.map { turnID == $0 } ?? true + case .closed: + true + case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, + .unknown: + false + } } } } @@ -214,23 +306,30 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { public typealias Element = CodexReviewProgress private let events: CodexThreadEventSequence + private let terminalTurnID: CodexTurnID? - package init(events: CodexThreadEventSequence) { + package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { self.events = events + self.terminalTurnID = terminalTurnID } public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) + Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) } public struct Iterator: AsyncIteratorProtocol { private var events: AsyncThrowingStream.Iterator + private let terminalTurnID: CodexTurnID? private var accumulator = CodexTranscriptAccumulator() private var usage: CodexTokenUsage? private var finished = false - fileprivate init(events: AsyncThrowingStream.Iterator) { + fileprivate init( + events: AsyncThrowingStream.Iterator, + terminalTurnID: CodexTurnID? + ) { self.events = events + self.terminalTurnID = terminalTurnID } public mutating func next() async throws -> CodexReviewProgress? { @@ -249,6 +348,9 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { usage = newUsage return .init(phase: .running, transcript: accumulator.transcript, usage: usage) case .turnCompleted(var result): + guard isTerminal(event) else { + continue + } finished = true result = finalizedResult(result) if result.errorMessage != nil || result.status?.isFailure == true { @@ -266,6 +368,9 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { result: result ) case .turnFailed(_, let message): + guard isTerminal(event) else { + continue + } finished = true return .init( phase: .failed(.turnFailed(message)), @@ -296,6 +401,21 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { } return result } + + private func isTerminal(_ event: CodexThreadEvent) -> Bool { + switch event { + case .turnCompleted(let response): + terminalTurnID.map { response.turnID == $0 } ?? true + case .turnFailed(let turnID, _): + terminalTurnID.map { turnID == $0 } ?? true + case .closed: + true + case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, + .unknown: + false + } + } } } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index aa8a97f9..16b68497 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -337,6 +337,10 @@ struct CodexAppServerKitTests { } else { Issue.record("Expected review.events to receive turn-only completion.") } + #expect(try await eventIterator.next() == nil) + + var logIterator = review.logEntries.makeAsyncIterator() + #expect(try await logIterator.next() == nil) var progressIterator = review.progress.makeAsyncIterator() let progress = try #require(try await progressIterator.next()) From c603ce975e1888a587c1ba6dee902c9ae58cf1aa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:57:37 +0900 Subject: [PATCH 028/359] docs(architecture): define target ownership --- Docs/architecture.md | 58 +++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index 9c538f96..05aa5f6d 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -21,42 +21,58 @@ have been converted into domain events. ## Targets +This table describes intended ownership. Some names in this table are target +ownership names that may still be pending migration in `Package.swift`; do not +read them as a claim that every symbol has already moved. + | Target | Responsibility | | --- | --- | -| `CodexReviewKit` | Review identifiers, kinds, runs, jobs, semantic timeline, application store/use-case primitives, `CodexReviewStore`, auth/settings/runtime product state, and legacy log compatibility projections | -| `CodexAppServerKit` | Generic `codex app-server` process transport, JSON-RPC client, typed request DTOs, and Swift domain API for threads, turns, prompts, models, accounts, and login | +| `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, and high-level Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. It has no Review, UI, or MCP dependencies | +| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and legacy log compatibility projections. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServerWire` | Raw `codex app-server` review notification DTO decode and conversion into domain events | -| `CodexReviewAppServer` | Review-specific `review/start` orchestration, ReviewMonitor notification routing, and runtime conversion | -| `CodexReviewMCPServer` | MCP-facing projections from observable review/domain state, internal MCP protocol request/response conversion, and Streamable HTTP endpoint | +| `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level review sessions into `CodexReviewKit` backend events | +| `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | +| `CodexUIKit` | Future generic UI-facing model/API layer over Codex and app-server concepts. It provides UI-construction models and APIs, uses `LocalizedStringResource` for display values where appropriate, and avoids mirror snapshot state | +| `CodexUI` | Concrete AppKit UI and existing hosted SwiftUI views. SwiftUI views remain concrete UI here; there is no requirement to delete or rewrite them just to satisfy target ownership | | `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` | Current concrete native monitor UI target while UI ownership names are still migrating | | `CodexReviewTesting` | Deterministic fake backend, fake JSON-RPC transport, gates, manual clock | | `TextTransitions` | UI text transition view support | ReviewMonitor is the product entry point. The host target wires the concrete runtime together; lower targets do not import the host. +`CodexUIKit` must not own `CodexReviewStore`, `ReviewTimeline`, +`ReviewMonitorUIState`, sidebar/detail concepts, or concrete `NSView`, +`ViewController`, or SwiftUI `View` types. Those belong to review core/store or +concrete UI targets. `CodexUIKit` may describe reusable UI construction models +over generic Codex/app-server concepts, but it must not become a mirror cache of +ReviewMonitor state. + ## Source Of Truth `CodexReviewKit` 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. +Review parsing, store commands, backend contracts, and timeline mutation stay +in this target. `CodexAppServerKit` owns the generic app-server connection boundary. It starts the local `codex app-server` process, performs JSON-RPC framing, sends `initialize`/`initialized`, retries overload responses, preserves unknown notifications, and exposes `CodexAppServer` as the Swift-facing container for threads, turns, prompts, messages, transcripts, log entries, models, accounts, -and login flows. It must not import ReviewMonitor targets. +and login flows. It must not import Review, UI, or MCP targets. `CodexReviewAppServerWire` owns raw app-server notification shapes. Its job is to decode review-specific wire payloads and expose domain events. It may depend on `CodexReviewKit`, but it must not import UI, rendering, or MCP targets. -`CodexReviewAppServer` owns ReviewMonitor's `review/start` runtime behavior. It -uses the generic app-server boundary for transport and common DTOs, converts -review notifications through `CodexReviewAppServerWire`, and hands +`CodexReviewAppServer` owns the adapter from high-level app-server review +sessions into `CodexReviewKit` backend events. It uses the generic +app-server boundary for transport and common DTOs, converts review +notifications through `CodexReviewAppServerWire`, and hands application-facing review events to the store. Review wire details stop at this boundary. @@ -200,9 +216,9 @@ the store. - Cancellation is represented by typed control/cleanup requests, not by closing the transport. -`CodexReviewAppServer` adds ReviewMonitor-specific `review/start`, review -notification conversion, cleanup, recovery, and UI-facing orchestration on top -of that generic boundary. +`CodexReviewAppServer` adds ReviewMonitor-specific review session adaptation, +review notification conversion, cleanup, and recovery on top of that generic +boundary. The intended ownership for review logs is that `CodexAppServerKit` supplies generic app-server thread events and log entries, while ReviewMonitor-specific @@ -216,8 +232,9 @@ Fake and live tests use the same transport protocol. `CodexReviewMCPServer` knows MCP tool names, request arguments, response shape, session headers, Streamable HTTP behavior, and MCP-facing value snapshots from domain/product state. It calls store commands and projects the review timeline -into MCP tool response shape. It does not know Codex JSON-RPC details and must -not import ReviewUI, the app-server runtime, or app-server wire DTOs. +into MCP tool response shape. 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 @@ -235,6 +252,16 @@ The public tool surface is: ## Monitor UI Boundary +The intended UI ownership has two layers: + +- `CodexUIKit` is a future generic UI-facing model/API layer over Codex and + app-server concepts. It supplies reusable UI-construction values and APIs, + can use `LocalizedStringResource` for display values where appropriate, and + derives values from the owning model instead of keeping mirror snapshot state. +- `CodexUI` owns concrete AppKit objects and existing hosted SwiftUI views. + SwiftUI views remain here as concrete UI; target ownership does not force a + deletion or rewrite of those views. + `ReviewUI` observes product/domain state and forwards user intent. - Views and view controllers render observable state. @@ -267,6 +294,9 @@ Default tests are deterministic and do not start a live `codex app-server`. 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 From 1bb3536944da2f6deda52eb2d6eed96d264e72ca Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:01:07 +0900 Subject: [PATCH 029/359] docs(architecture): clarify review stream ownership --- Docs/architecture.md | 85 ++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index 05aa5f6d..db819103 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -10,6 +10,7 @@ direction: ```text raw app-server wire + -> high-level app-server review stream -> domain review event -> observable review timeline -> UI / MCP / rendering / legacy log projections @@ -17,7 +18,8 @@ raw app-server wire 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. +have been normalized into high-level app-server review streams and converted +into domain events. ## Targets @@ -27,10 +29,9 @@ read them as a claim that every symbol has already moved. | Target | Responsibility | | --- | --- | -| `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, and high-level Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. It has no Review, UI, or MCP dependencies | +| `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server review notification schema, high-level review stream/session API, and Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. Raw review DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | | `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and legacy log compatibility projections. It has no app-server wire, UI, or MCP dependencies | -| `CodexReviewAppServerWire` | Raw `codex app-server` review notification DTO decode and conversion into domain events | -| `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level review sessions into `CodexReviewKit` backend events | +| `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | | `CodexUIKit` | Future generic UI-facing model/API layer over Codex and app-server concepts. It provides UI-construction models and APIs, uses `LocalizedStringResource` for display values where appropriate, and avoids mirror snapshot state | | `CodexUI` | Concrete AppKit UI and existing hosted SwiftUI views. SwiftUI views remain concrete UI here; there is no requirement to delete or rewrite them just to satisfy target ownership | @@ -43,6 +44,12 @@ read them as a claim that every symbol has already moved. ReviewMonitor is the product entry point. The host target wires the concrete runtime together; lower targets do not import the host. +`CodexReviewAppServerWire` exists today as a transitional raw decoder target. +It is not an intended ownership boundary. The intended owner for app-server +review notification schema and high-level review streams is +`CodexAppServerKit`; raw review DTOs should remain implementation details +behind that SDK boundary rather than becoming public ReviewMonitor API. + `CodexUIKit` must not own `CodexReviewStore`, `ReviewTimeline`, `ReviewMonitorUIState`, sidebar/detail concepts, or concrete `NSView`, `ViewController`, or SwiftUI `View` types. Those belong to review core/store or @@ -58,23 +65,28 @@ describe review progress independently of any transport, UI, or MCP protocol. Review parsing, store commands, backend contracts, and timeline mutation stay in this target. -`CodexAppServerKit` owns the generic app-server connection boundary. It starts -the local `codex app-server` process, performs JSON-RPC framing, sends +`CodexAppServerKit` owns the app-server connection and schema boundary. It +starts the local `codex app-server` process, performs JSON-RPC framing, sends `initialize`/`initialized`, retries overload responses, preserves unknown -notifications, and exposes `CodexAppServer` as the Swift-facing container for -threads, turns, prompts, messages, transcripts, log entries, models, accounts, -and login flows. It must not import Review, UI, or MCP targets. - -`CodexReviewAppServerWire` owns raw app-server notification shapes. Its job is -to decode review-specific wire payloads and expose domain events. It may depend on -`CodexReviewKit`, but it must not import UI, rendering, or MCP targets. +notifications, owns app-server review notification schema, and exposes +`CodexAppServer` plus high-level review session streams as the Swift-facing +API for threads, turns, prompts, review sessions, messages, transcripts, log +entries, models, accounts, and login flows. Raw review DTOs may exist as +internal implementation detail, but they are not the public API boundary. It +must not import Review, UI, or MCP targets. + +`CodexReviewAppServerWire` is a current transitional decoder for raw app-server +review notification shapes. While it exists during migration, it must not be +treated as the intended owner of review wire schema or public API. The intended +direction is for `CodexAppServerKit` to provide the high-level review stream +boundary and keep raw DTOs out of ReviewMonitor-facing contracts. `CodexReviewAppServer` owns the adapter from high-level app-server review -sessions into `CodexReviewKit` backend events. It uses the generic -app-server boundary for transport and common DTOs, converts review -notifications through `CodexReviewAppServerWire`, and hands -application-facing review events to the store. Review wire details stop at this -boundary. +sessions into `CodexReviewKit` backend events. It consumes `CodexReviewSession` +values from `CodexAppServerKit` and hands application-facing review events to +the store backend. If current code still passes through +`CodexReviewAppServerWire`, that is a migration detail, not the ownership +model. `ReviewTimeline` and application/store state are the observable source of truth after conversion. UI views, rendering helpers, MCP server projections, and @@ -110,12 +122,9 @@ flowchart TB LegacyProjection["Legacy log projections"] end - subgraph Wire["CodexReviewAppServerWire"] - WireDTO["Raw app-server DTOs"] - end - subgraph Kit["CodexAppServerKit"] DomainAPI["CodexAppServer"] + ReviewSession["CodexReviewSession"] Client["JSON-RPC client"] Process["codex app-server"] end @@ -137,7 +146,8 @@ flowchart TB Composition["Composition root"] end - WireDTO --> DomainEvents + ReviewSession --> Runtime + Runtime --> DomainEvents DomainEvents --> Timeline Timeline --> AppStore Timeline --> PublicStore @@ -147,9 +157,8 @@ flowchart TB PublicStore --> UI Renderer --> UI DomainAPI --> Client + ReviewSession --> Client Client --> Process - Runtime --> DomainAPI - Runtime --> WireDTO Runtime --> PublicStore Composition --> Runtime Composition --> MCPServer @@ -159,6 +168,8 @@ flowchart TB The diagram describes ownership direction, not every SwiftPM dependency. New code should not move domain, application, rendering, UI, or MCP projection responsibilities back into runtime owners. +`CodexReviewAppServerWire` is intentionally omitted from the intended ownership +graph because it is a current transitional decoder, not a target owner. ## Observation Ownership @@ -205,7 +216,11 @@ the store. `CodexTurnID`, `CodexThread`, `CodexPrompt`, Foundation Models-style `respond` / `streamResponse` / `CodexResponseStream.collect()` calls, Codex-specific response stream controls, thread event streams, messages, - transcript/log values, model values, account values, and login handles. + transcript/log values, high-level review sessions such as + `CodexReviewSession`, model values, account values, and login handles. +- App-server review notification schemas belong inside the Kit boundary. The + public review surface should be a high-level review stream/session API, 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. @@ -216,14 +231,14 @@ the store. - Cancellation is represented by typed control/cleanup requests, not by closing the transport. -`CodexReviewAppServer` adds ReviewMonitor-specific review session adaptation, -review notification conversion, cleanup, and recovery on top of that generic -boundary. +`CodexReviewAppServer` adapts high-level `CodexReviewSession` output into +ReviewMonitor backend events and adds ReviewMonitor-specific cleanup and +recovery on top of that generic boundary. The intended ownership for review logs is that `CodexAppServerKit` supplies -generic app-server thread events and log entries, while ReviewMonitor-specific -targets adapt those values into `ReviewDomainEvent`, `ReviewTimeline`, and -legacy review log projections. +generic app-server thread events, log entries, and high-level review session +events, while ReviewMonitor-specific targets adapt those values into +`ReviewDomainEvent`, `ReviewTimeline`, and legacy review log projections. Fake and live tests use the same transport protocol. @@ -283,10 +298,10 @@ Default tests are deterministic and do not start a live `codex app-server`. | Test area | Uses | Verifies | | --- | --- | --- | -| `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, and domain result aggregation | -| `CodexReviewAppServerWireTests` | Raw notification JSON | Wire decode and domain event conversion | +| `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, domain result aggregation, and high-level review stream behavior | +| `CodexReviewAppServerWireTests` | Raw notification JSON while the transitional decoder exists | Migration compatibility for decode/conversion behavior, not intended target ownership | | `CodexReviewKitTests` | Domain timelines, ObservationBridge awaiters, and fake `CodexReviewStoreBackend` | Semantic timeline mutation, use-case observation behavior, review/auth/settings state machines, cancellation, result retention | -| `CodexReviewAppServerTests` | Fake JSON-RPC transport | Review-specific request serialization, notification buffering, interrupt/cleanup, and recovery | +| `CodexReviewAppServerTests` | Fake app-server review sessions or transport as needed | Adapter behavior from high-level review sessions into backend events, cleanup, and recovery | | `CodexReviewMCPServerTests` | Fake review store and domain timeline projections | MCP protocol conversion, response shape, and timeline projection snapshots | | `CodexReviewHostTests` | Fake runtime dependencies | Composition, startup, shutdown | | `ReviewUITests` | Preview/test monitor backend | Native UI behavior and user-intent forwarding | From 1faa80e5ae15a17b62588e4d253ea5e0f6b23011 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:03:05 +0900 Subject: [PATCH 030/359] refactor(review-app-server): consume typed review sessions --- Package.swift | 8 +- .../CodexAppServerKit/CodexAppServer.swift | 2 + .../CodexAppServerKit/CodexDomainTypes.swift | 34 + .../AppServerCodexReviewBackend.swift | 804 +++++++++++++++++- .../AppServerClientTests.swift | 129 ++- 5 files changed, 974 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 58e8b6bf..dd829e29 100644 --- a/Package.swift +++ b/Package.swift @@ -175,7 +175,13 @@ let package = Package( ), .testTarget( name: "CodexReviewAppServerTests", - dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting"], + dependencies: [ + "CodexAppServerKit", + "CodexAppServerKitTesting", + "CodexReviewAppServer", + "CodexReviewKit", + "CodexReviewTesting", + ], swiftSettings: [ .swiftLanguageMode(.v6), ] diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index ed74267b..233b483f 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -278,6 +278,7 @@ public actor CodexAppServer { serviceTier: options.serviceTier, personality: options.personality?.rawValue, config: options.config?.mapValues(\.appServerJSONValue), + permissions: options.permissions?.appServerPermissions, sessionStartSource: options.sessionStartSource?.appServerSource, threadSource: options.threadSource?.appServerSource ) @@ -554,6 +555,7 @@ public actor CodexAppServer { serviceTier: options.serviceTier, personality: options.personality?.rawValue, config: options.config?.mapValues(\.appServerJSONValue), + permissions: options.permissions?.appServerPermissions, sessionStartSource: options.sessionStartSource?.appServerSource, threadSource: options.threadSource?.appServerSource ) diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 932cb048..fd2f846d 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -232,6 +232,37 @@ public enum CodexSandbox: String, Codable, Equatable, Sendable { } } +/// Permission profile selection for a newly created Codex thread. +public struct CodexThreadPermissions: Equatable, Sendable { + package enum Kind: Equatable, Sendable { + case profileID(String) + case profileSelection(String) + } + + package var kind: Kind + + package init(kind: Kind) { + self.kind = kind + } + + public static func profile(id: String) -> Self { + .init(kind: .profileID(id)) + } + + package static func profileSelection(id: String) -> Self { + .init(kind: .profileSelection(id)) + } + + package var appServerPermissions: AppServerAPI.Thread.Start.Permissions { + switch kind { + case .profileID(let id): + .profileID(id) + case .profileSelection(let id): + .profileSelection(.init(id: id)) + } + } +} + public enum CodexApprovalMode: String, Codable, Equatable, Sendable { case autoReview case denyAll @@ -448,6 +479,7 @@ public struct CodexThread: Identifiable, Sendable { public var modelProvider: String? public var approvalMode: CodexApprovalMode? public var sandbox: CodexSandbox? + public var permissions: CodexThreadPermissions? public var serviceTier: String? public var ephemeral: Bool? public var config: [String: CodexJSONValue]? @@ -461,6 +493,7 @@ public struct CodexThread: Identifiable, Sendable { modelProvider: String? = nil, approvalMode: CodexApprovalMode? = nil, sandbox: CodexSandbox? = nil, + permissions: CodexThreadPermissions? = nil, serviceTier: String? = nil, ephemeral: Bool? = nil, config: [String: CodexJSONValue]? = nil, @@ -473,6 +506,7 @@ public struct CodexThread: Identifiable, Sendable { self.modelProvider = modelProvider self.approvalMode = approvalMode self.sandbox = sandbox + self.permissions = permissions self.serviceTier = serviceTier self.ephemeral = ephemeral self.config = config diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 09ffd29e..01c1abe1 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -2,7 +2,6 @@ import Foundation import CodexAppServerKit import CodexReviewKit import CodexReviewAppServerWire -import CodexReviewKit import OSLog private let appServerBackendLogger = Logger( @@ -186,6 +185,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { + if let appServer { + return try await startReview(appServer: appServer, request: request) + } _ = try await client.initialize() await ensureNotificationRouterStarted() let control = AppServerReviewControl(client: client) @@ -241,6 +243,119 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return await session.attempt() } + private func startReview( + appServer: CodexAppServer, + request: CodexReviewBackendModel.Review.Start + ) async throws -> BackendReviewAttempt { + let control = AppServerReviewControl(client: client) + let thread = try await startReviewThread(appServer: appServer, request: request) + controlsByThreadID[thread.id.rawValue] = control + + let attemptID = makeAppServerReviewAttemptID() + let provisionalRun = CodexReviewBackendModel.Review.Run( + attemptID: attemptID, + threadID: thread.id.rawValue, + reviewThreadID: thread.id.rawValue, + model: thread.model ?? request.model + ) + let session = AppServerReviewEventSession( + run: provisionalRun, + control: control, + isRunFinalized: false + ) + registerReviewEventSession(session, for: provisionalRun) + control.recordThreadStarted(threadID: thread.id.rawValue) + + let review: CodexReviewSession + do { + review = try await thread.startReview(target: request.request.target.appServerReviewTarget) + } catch { + await cleanupReview(provisionalRun) + throw error + } + + let run = CodexReviewBackendModel.Review.Run( + attemptID: attemptID, + threadID: thread.id.rawValue, + turnID: review.turnID.rawValue, + reviewThreadID: review.reviewThreadID.rawValue, + model: thread.model ?? request.model + ) + await session.updateRun(run) + registerReviewEventSession(session, for: run) + control.recordReviewStarted( + turnThreadID: appServerTurnThreadID(for: run), + turnID: review.turnID.rawValue + ) + await session.finalizeRun() + await session.startConsuming(review) + + return await session.attempt() + } + + private func startReviewThread( + appServer: CodexAppServer, + request: CodexReviewBackendModel.Review.Start + ) async throws -> CodexThread { + let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) + 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 appServer.startThread( + in: workspace, + options: reviewThreadOptions(request, sandbox: .fullAccess) + ) + } + do { + return try await appServer.startThread( + in: workspace, + options: reviewThreadOptions(request, permissions: .profile(id: Self.reviewPermissionProfileID)) + ) + } 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. + do { + return try await appServer.startThread( + in: workspace, + options: reviewThreadOptions( + request, + permissions: .profileSelection(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 appServer.startThread( + in: workspace, + options: reviewThreadOptions(request, sandbox: .fullAccess) + ) + } + } 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 appServer.startThread( + in: workspace, + options: reviewThreadOptions(request, sandbox: .fullAccess) + ) + } + } + + private func reviewThreadOptions( + _ request: CodexReviewBackendModel.Review.Start, + permissions: CodexThreadPermissions? = nil, + sandbox: CodexSandbox? = nil + ) -> CodexThread.Options { + .init( + model: request.model, + approvalMode: .denyAll, + sandbox: sandbox, + permissions: permissions, + ephemeral: false, + sessionStartSource: .startup, + threadSource: .user + ) + } + 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 @@ -977,6 +1092,594 @@ private struct PendingStreamedLogEntry: Sendable { } } +private struct AppServerTypedReviewEvent: Sendable { + var events: [CodexReviewBackendModel.Review.Event] + var controlThreadID: String? + + init( + events: [CodexReviewBackendModel.Review.Event], + controlThreadID: String? = nil + ) { + self.events = events + self.controlThreadID = controlThreadID + } +} + +private enum AppServerTypedItemPhase { + case started + case updated + case completed +} + +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(let message, _): + .init(events: messageEvents(message), controlThreadID: controlThreadID) + case .messageDelta(let delta, _): + .init(events: messageDeltaEvents(delta), controlThreadID: controlThreadID) + case .reasoningSummaryPartAdded(let part, _): + .init(events: reasoningPartEvents(part), controlThreadID: controlThreadID) + case .reasoningDelta(let delta, _): + .init(events: reasoningDeltaEvents(delta), controlThreadID: controlThreadID) + case .tokenUsageUpdated, .statusChanged(.running): + .init(events: [], controlThreadID: controlThreadID) + case .statusChanged(.closed): + .init(events: [.failed("Review thread is no longer loaded.")], controlThreadID: controlThreadID) + case .statusChanged(.unknown(let status)): + .init(events: [.logEntry( + kind: .diagnostic, + text: "Review thread status changed: \(status).", + groupID: review.turnID.rawValue, + replacesGroup: false + )], controlThreadID: controlThreadID) + case .closed: + .init(events: [.failed("Review thread closed.")], controlThreadID: controlThreadID) + case .unknown(let raw): + .init(events: unknownEvents(raw), controlThreadID: controlThreadID) + } + } + + 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." + ) + } + return [.completed( + summary: "Succeeded.", + result: response.finalAnswer?.nilIfEmpty + ?? response.transcript.finalAnswer?.nilIfEmpty + ?? response.transcript.responseText?.nilIfEmpty + )] + } + + 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)] + } + } + + private static func itemEvents( + _ item: CodexThreadItem, + phase: AppServerTypedItemPhase + ) -> [CodexReviewBackendModel.Review.Event] { + guard item.kind != .userMessage, + let seed = timelineSeed(for: item, phase: phase) + else { + return [] + } + let domainEvent: ReviewDomainEvent = switch phase { + case .started: + .itemStarted(seed) + case .updated: + .itemUpdated(seed) + case .completed: + .itemCompleted(seed) + } + let legacyEvents = legacyLogEvents(for: item, phase: phase) + return [.domainEvents( + [domainEvent], + legacyProjectionSuppressionCount: legacyEvents.legacyTimelineProjectionCount + )] + legacyEvents.addingTerminalFailureLogProjectionSuppressionIfNeeded + } + + private static func messageEvents( + _ message: CodexMessage + ) -> [CodexReviewBackendModel.Review.Event] { + guard message.role != .user, + message.text.isEmpty == false + else { + return [] + } + let item = CodexThreadItem( + id: message.id, + kind: .agentMessage, + content: .message(message) + ) + return itemEvents(item, phase: .completed) + } + + private static func messageDeltaEvents( + _ delta: CodexMessageDelta + ) -> [CodexReviewBackendModel.Review.Event] { + guard delta.text.isEmpty == false else { + return [] + } + let itemID = delta.itemID?.nilIfEmpty ?? "agent-message-delta" + let domainEvent = ReviewDomainEvent.textDelta( + itemID: .init(rawValue: itemID), + kind: .agentMessage, + family: .message, + content: .message(.init(text: "")), + delta: delta.text + ) + return [ + .domainEvents([domainEvent], legacyProjectionSuppressionCount: 1), + .messageDelta(delta.text, itemID: itemID), + ] + } + + private static func reasoningPartEvents( + _ part: CodexReasoningPart + ) -> [CodexReviewBackendModel.Review.Event] { + let style: ReviewTimelineItem.Reasoning.Style = switch part.kind { + case .summary: + .summary + case .text: + .raw + } + let seed = ReviewTimelineItemSeed( + id: .init(rawValue: part.id), + kind: .reasoning, + family: .reasoning, + phase: .running, + content: .reasoning(.init(text: "", style: style)) + ) + return [.domainEvents([.itemStarted(seed)], legacyProjectionSuppressionCount: 0)] + } + + private static func reasoningDeltaEvents( + _ delta: CodexReasoningDelta + ) -> [CodexReviewBackendModel.Review.Event] { + guard delta.delta.isEmpty == false else { + return [] + } + let style: ReviewTimelineItem.Reasoning.Style + let kind: ReviewLogEntry.Kind + switch delta.part.kind { + case .summary: + style = .summary + kind = .reasoningSummary + case .text: + style = .raw + kind = .rawReasoning + } + let domainEvent = ReviewDomainEvent.textDelta( + itemID: .init(rawValue: delta.id), + kind: .reasoning, + family: .reasoning, + content: .reasoning(.init(text: "", style: style)), + delta: delta.delta + ) + return [ + .domainEvents([domainEvent], legacyProjectionSuppressionCount: 1), + .logEntry(kind: kind, text: delta.delta, groupID: delta.id, replacesGroup: false), + ] + } + + private static func unknownEvents( + _ raw: CodexRawNotification + ) -> [CodexReviewBackendModel.Review.Event] { + let itemID = raw.turnID?.rawValue + ?? raw.threadID?.rawValue + ?? raw.method + let detail = String(data: raw.params, encoding: .utf8) + let seed = ReviewTimelineItemSeed( + id: .init(rawValue: "\(itemID):\(raw.method)"), + kind: .init(rawValue: raw.method), + family: .unknown, + phase: .running, + content: .unknown(.init( + title: raw.method, + detail: detail, + rawKind: .init(rawValue: raw.method) + )) + ) + return [.domainEvents([.itemUpdated(seed)], legacyProjectionSuppressionCount: 0)] + } + + private static func timelineSeed( + for item: CodexThreadItem, + phase: AppServerTypedItemPhase + ) -> ReviewTimelineItemSeed? { + ReviewTimelineItemSeed( + id: .init(rawValue: item.id), + kind: .init(rawValue: item.kind.rawValue), + family: item.reviewItemFamily, + phase: item.reviewItemPhase(defaultingTo: phase), + content: item.reviewTimelineContent + ) + } + + private static func legacyLogEvents( + for item: CodexThreadItem, + phase: AppServerTypedItemPhase + ) -> [CodexReviewBackendModel.Review.Event] { + switch item.content { + case .message(let message): + guard message.role != .user, + message.text.isEmpty == false + else { + return [] + } + return [item.logEntry( + kind: .agentMessage, + text: message.text, + phase: phase, + title: nil + )] + case .plan(let text): + return text.nilIfEmpty.map { + [item.logEntry(kind: .plan, text: $0, phase: phase, title: nil)] + } ?? [] + case .reasoning(let reasoning): + let summaryEvents = reasoning.summary.enumerated().compactMap { index, text in + text.nilIfEmpty.map { + CodexReviewBackendModel.Review.Event.logEntry( + kind: .reasoningSummary, + text: $0, + groupID: reasoningSummaryGroupID(itemID: item.id, summaryIndex: index), + replacesGroup: phase != .started + ) + } + } + let rawEvents = reasoning.content.enumerated().compactMap { index, text in + text.nilIfEmpty.map { + CodexReviewBackendModel.Review.Event.logEntry( + kind: .rawReasoning, + text: $0, + groupID: rawReasoningGroupID(itemID: item.id, contentIndex: index), + replacesGroup: phase != .started + ) + } + } + return summaryEvents + rawEvents + case .command(let command): + return commandLogEvents(item: item, command: command, phase: phase) + case .fileChange(let fileChange): + let text = fileChange.output?.nilIfEmpty + ?? fileChange.path?.nilIfEmpty + ?? "File changes \(item.legacyStatus(phase: phase) ?? "updated")." + let kind: ReviewLogEntry.Kind = fileChange.output?.nilIfEmpty == nil ? .toolCall : .commandOutput + return [item.logEntry( + kind: kind, + text: text, + phase: phase, + title: "File changes" + )] + case .toolCall(let toolCall): + let label = item.toolLabel + let text = toolCall.error?.nilIfEmpty + ?? toolCall.result?.nilIfEmpty + ?? "\(label) \(item.legacyStatus(phase: phase) ?? "updated")." + return [item.logEntry( + kind: .toolCall, + text: text, + phase: phase, + title: label + )] + case .contextCompaction(let text): + return [item.logEntry( + kind: .contextCompaction, + text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.legacyStatus(phase: phase)), + phase: phase, + title: nil + )] + case .diagnostic(let text): + return text.nilIfEmpty.map { + [item.logEntry(kind: .diagnostic, text: $0, phase: phase, title: nil)] + } ?? [] + case .log(let text): + return text.nilIfEmpty.map { + [item.logEntry(kind: .event, text: $0, phase: phase, title: item.kind.rawValue)] + } ?? [] + case .unknown(let raw): + return raw.text?.nilIfEmpty.map { + [item.logEntry(kind: .event, text: $0, phase: phase, title: raw.rawType)] + } ?? [] + } + } + + private static func commandLogEvents( + item: CodexThreadItem, + command: CodexCommand, + phase: AppServerTypedItemPhase + ) -> [CodexReviewBackendModel.Review.Event] { + guard command.command.isEmpty == false else { + return [] + } + var events: [CodexReviewBackendModel.Review.Event] = [ + item.logEntry( + kind: .command, + text: "$ \(command.command)", + phase: phase, + title: nil + ), + ] + if let output = command.output?.nilIfEmpty { + events.append(item.logEntry( + kind: .commandOutput, + text: output, + phase: phase, + title: nil + )) + } + return events + } + + private static func contextCompactionText( + phase: AppServerTypedItemPhase, + status: String? + ) -> String { + switch phase { + case .started, .updated: + return appServerContextCompactionStartedText + case .completed: + 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 extension CodexThreadItem { + var reviewItemFamily: ReviewItemFamily { + switch kind { + case .agentMessage: + .message + case .plan: + .plan + case .reasoning: + .reasoning + case .commandExecution: + .command + case .fileChange: + .fileChange + case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .imageView, .imageGeneration, .sleep: + .tool + case .webSearch: + .search + case .contextCompaction: + .contextCompaction + case .diagnostic, .error: + .diagnostic + case .userMessage, .subAgentActivity, .unknown: + .unknown + } + } + + var reviewTimelineContent: ReviewTimelineItem.Content { + switch content { + case .message(let message): + .message(.init(text: message.text)) + case .plan(let text): + .plan(.init(markdown: text)) + case .reasoning(let reasoning): + .reasoning(.init( + text: reasoning.text, + style: reasoning.summary.isEmpty ? .raw : .summary + )) + case .command(let command): + .command(.init( + command: command.command, + cwd: command.cwd, + output: command.output ?? "", + exitCode: command.exitCode, + status: command.status.map { .init(rawValue: $0.rawValue) } + )) + case .fileChange(let fileChange): + .fileChange(.init( + title: "File changes", + output: fileChange.output ?? "", + paths: fileChange.path.map { [$0] } ?? [], + status: fileChange.status.map { .init(rawValue: $0.rawValue) } + )) + case .toolCall(let toolCall): + .toolCall(.init( + namespace: toolCall.namespace, + server: toolCall.server, + tool: toolCall.name, + arguments: toolCall.arguments, + result: toolCall.result, + error: toolCall.error, + status: toolCall.status.map { .init(rawValue: $0.rawValue) } + )) + case .contextCompaction(let text): + .contextCompaction(.init( + title: text?.nilIfEmpty ?? appServerContextCompactionStartedText, + status: legacyStatus(phase: .updated).map { .init(rawValue: $0) } + )) + case .diagnostic(let text): + .diagnostic(.init( + message: text, + severity: kind == .error ? .error : nil + )) + case .log(let text): + .unknown(.init(title: kind.rawValue, detail: text, rawKind: .init(rawValue: kind.rawValue))) + case .unknown(let raw): + .unknown(.init( + title: raw.rawType, + detail: raw.text, + rawKind: .init(rawValue: raw.rawType) + )) + } + } + + func reviewItemPhase(defaultingTo phase: AppServerTypedItemPhase) -> ReviewItemPhase { + if let status = statusRaw?.nilIfEmpty { + let normalized = ReviewItemPhase.normalized(status) + if phase == .completed, normalized == .running { + return .completed + } + return normalized + } + return switch phase { + case .started, .updated: + .running + case .completed: + .completed + } + } + + var statusRaw: String? { + switch content { + case .command(let command): + command.status?.rawValue + case .fileChange(let fileChange): + fileChange.status?.rawValue + case .toolCall(let toolCall): + toolCall.status?.rawValue + case .contextCompaction, .diagnostic, .log, .message, .plan, .reasoning, .unknown: + nil + } + } + + var toolLabel: String { + guard case .toolCall(let toolCall) = content else { + return kind.rawValue + } + return [toolCall.namespace, toolCall.server, toolCall.name] + .compactMap { $0?.nilIfEmpty } + .joined(separator: ".") + .nilIfEmpty ?? kind.rawValue + } + + func legacyStatus(phase: AppServerTypedItemPhase) -> String? { + if let status = statusRaw?.nilIfEmpty { + return status + } + return switch phase { + case .started: + "inProgress" + case .updated: + nil + case .completed: + "completed" + } + } + + func logEntry( + kind logKind: ReviewLogEntry.Kind, + text: String, + phase: AppServerTypedItemPhase, + title: String? + ) -> CodexReviewBackendModel.Review.Event { + .logEntry( + kind: logKind, + text: text, + groupID: id, + replacesGroup: phase != .started, + metadata: reviewLogMetadata(title: title, phase: phase) + ) + } + + private func reviewLogMetadata( + title: String?, + phase: AppServerTypedItemPhase + ) -> ReviewLogEntry.Metadata { + let status = legacyStatus(phase: phase) + let command: CodexCommand? + let fileChange: CodexFileChange? + let toolCall: CodexToolCall? + switch content { + case .command(let value): + command = value + fileChange = nil + toolCall = nil + case .fileChange(let value): + command = nil + fileChange = value + toolCall = nil + case .toolCall(let value): + command = nil + fileChange = nil + toolCall = value + case .contextCompaction, .diagnostic, .log, .message, .plan, .reasoning, .unknown: + command = nil + fileChange = nil + toolCall = nil + } + return .init( + sourceType: kind.rawValue, + title: title?.nilIfEmpty, + status: status, + itemID: kind == .commandExecution || kind == .contextCompaction ? id : nil, + command: command?.command, + cwd: command?.cwd, + exitCode: command?.exitCode, + commandStatus: kind == .commandExecution ? status : nil, + namespace: toolCall?.namespace, + server: toolCall?.server, + tool: toolCall?.name, + path: fileChange?.path, + resultText: toolCall?.result?.nilIfEmpty, + errorText: toolCall?.error?.nilIfEmpty + ) + } +} + private actor AppServerReviewEventSession { private static let commandTimeoutExitCode = 124 private static let longCommandDurationWarningMs = 100_000 @@ -1001,6 +1704,9 @@ private actor AppServerReviewEventSession { private var isDrainingStartupNotifications = false private var pendingStartupNotifications: [AppServerRoutedReviewNotification] = [] private var metrics = AppServerReviewEventSessionMetrics() + private var typedReviewStreamTask: Task? + private var typedMessageTextByItemID: [String: String] = [:] + private var typedReviewResultText: String? init( run: CodexReviewBackendModel.Review.Run, @@ -1085,6 +1791,7 @@ private actor AppServerReviewEventSession { cancellationMessage: String?, buffersMissingContinuation _: Bool = false ) async { + cancelTypedReviewStream() var precedingEvents = drainPendingStreamedLogEvents() if cancellationMessage == nil { cancelPendingStreamedLogFlush() @@ -1100,6 +1807,7 @@ private actor AppServerReviewEventSession { guard finished == false else { return } + cancelTypedReviewStream() let precedingEvents = drainPendingStreamedLogEvents() finished = true completionCoordinator.cancelPendingCompletion() @@ -1118,6 +1826,7 @@ private actor AppServerReviewEventSession { guard finished == false else { return } + cancelTypedReviewStream() finished = true completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() @@ -1138,6 +1847,93 @@ private actor AppServerReviewEventSession { func detach(subscriptionID _: Int) {} + func startConsuming(_ review: CodexReviewSession) { + guard typedReviewStreamTask == nil else { + return + } + typedReviewStreamTask = Task { [weak self] in + await self?.consume(review) + } + } + + private func consume(_ review: CodexReviewSession) async { + defer { + typedReviewStreamTask = nil + } + await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run)) + do { + for try await event in review.events { + if Task.isCancelled { + return + } + await receive(AppServerTypedReviewEventAdapter.convert(event, review: review)) + } + await finish(throwing: nil) + } catch is CancellationError { + await finish(throwing: CancellationError()) + } catch { + await finish(throwing: error) + } + } + + private func receive(_ converted: AppServerTypedReviewEvent) async { + metrics.routed += 1 + guard finished == false else { + metrics.ignored += 1 + return + } + var events = converted.events + guard events.isEmpty == false else { + metrics.ignored += 1 + return + } + metrics.decoded += 1 + events = eventsWithTypedResultFallback(events) + for event in events { + if await emit(event, controlThreadID: converted.controlThreadID) { + return + } + } + } + + private func eventsWithTypedResultFallback( + _ events: [CodexReviewBackendModel.Review.Event] + ) -> [CodexReviewBackendModel.Review.Event] { + events.map { event in + switch event { + case .message(let text): + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .messageDelta(let delta, let itemID): + let text = (typedMessageTextByItemID[itemID] ?? "") + delta + typedMessageTextByItemID[itemID] = text + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .logEntry(.agentMessage, let text, _, _, _): + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .completed(let summary, nil): + return .completed(summary: summary, result: typedReviewResultText) + case .domainEvents, + .suppressNextLegacyTimelineProjection, + .suppressNextTerminalFailureLogTimelineProjection, + .started, + .log, + .logEntry, + .completed, + .failed, + .cancelled: + return event + } + } + } + private func finish( precedingEvents: [CodexReviewBackendModel.Review.Event], cancellationMessage: String? @@ -1145,6 +1941,7 @@ private actor AppServerReviewEventSession { guard finished == false else { return } + cancelTypedReviewStream() completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() pendingStartupNotifications.removeAll(keepingCapacity: true) @@ -1546,6 +2343,11 @@ private actor AppServerReviewEventSession { streamedLogFlushTask = nil } + private func cancelTypedReviewStream() { + typedReviewStreamTask?.cancel() + typedReviewStreamTask = nil + } + private func flushPendingCompletion( controlThreadID: String? = nil ) async -> Bool { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 800ffdab..946a10ed 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -2,9 +2,9 @@ import Darwin import Foundation import Testing import CodexAppServerKit +import CodexAppServerKitTesting @testable import CodexReviewAppServer import CodexReviewKit -import CodexReviewKit import CodexReviewTesting private extension AppServerCodexReviewBackend { @@ -976,6 +976,133 @@ struct AppServerClientTests { #expect(object["sandbox"] == nil) } + @Test func appServerBackendConsumesTypedReviewSessionStream() 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(.init( + jobID: "job-1", + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + + #expect(attempt.threadID == "thread-1") + #expect(attempt.turnID == "turn-1") + #expect(attempt.reviewThreadID == "review-thread") + #expect(await backend.notificationRouterIsRunningForTesting() == false) + + 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 threadStart.decodeParams(AppServerAPI.Thread.Start.Params.self) + #expect(threadParams.cwd == "/tmp/project") + #expect(threadParams.model == "gpt-5") + #expect(threadParams.approvalPolicy == "never") + #expect(threadParams.permissions == .profileID(":danger-full-access")) + #expect(threadParams.sessionStartSource == .startup) + #expect(threadParams.threadSource == .user) + + let reviewStart = try #require(requests.first { $0.method == "review/start" }) + let reviewParams = try reviewStart.decodeParams(AppServerAPI.Review.Start.Params.self) + #expect(reviewParams.threadID == "thread-1") + #expect(reviewParams.target == .baseBranch("main")) + + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: TestItemNotification( + threadID: "review-thread", + turnID: "turn-1", + item: .init( + type: "commandExecution", + id: "cmd-1", + command: "swift test", + aggregatedOutput: "passed", + status: "completed" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TestDeltaNotification( + threadID: "review-thread", + turnID: "turn-1", + itemID: "msg-1", + delta: "Looks good." + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init(id: "turn-1", status: "completed") + ) + ) + + var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() + #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() else { + Issue.record("expected typed command domain event") + return + } + #expect(commandSuppressionCount == 2) + guard case .itemCompleted(let commandSeed) = try #require(commandDomainEvents.first) else { + Issue.record("expected completed command seed") + return + } + #expect(commandSeed.id.rawValue == "cmd-1") + guard case .command(let command) = commandSeed.content else { + Issue.record("expected command content") + return + } + #expect(command.command == "swift test") + #expect(command.output == "passed") + + #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: "passed", + groupID: "cmd-1", + replacesGroup: true, + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd-1", + command: "swift test", + commandStatus: "completed" + ) + )) + guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() else { + Issue.record("expected typed message delta domain event") + return + } + #expect(messageSuppressionCount == 1) + guard case .textDelta(let itemID, _, let family, _, let delta) = try #require(messageDomainEvents.first) else { + Issue.record("expected message text delta") + return + } + #expect(itemID.rawValue == "msg-1") + #expect(family == .message) + #expect(delta == "Looks good.") + #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) + #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) + } + @Test func backendUsesLegacySandboxWhenProcessDoesNotSupportModernSessionSource() async throws { let transport = FakeJSONRPCTransport() try await enqueueInitialize(transport) From 37d017478b38a6d154400662f158a9e2c72d2a59 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:07:17 +0900 Subject: [PATCH 031/359] fix(review-app-server): avoid raw router for typed sessions --- .../AppServerCodexReviewBackend.swift | 4 ++- .../AppServerClientTests.swift | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 01c1abe1..daf3738b 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -660,7 +660,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } private func reviewEventSession(for run: CodexReviewBackendModel.Review.Run) async -> AppServerReviewEventSession { - await ensureNotificationRouterStarted() + if appServer == nil { + await ensureNotificationRouterStarted() + } if let session = reviewEventSessionsByAttemptID[run.attemptID] { await session.updateRun(run) registerReviewEventSession(session, for: run) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 946a10ed..739a47a5 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -994,6 +994,11 @@ struct AppServerClientTests { #expect(attempt.turnID == "turn-1") #expect(attempt.reviewThreadID == "review-thread") #expect(await backend.notificationRouterIsRunningForTesting() == false) + let storedAttempt = await backend.reviewAttemptForTesting(attempt.run) + #expect(storedAttempt.threadID == "thread-1") + #expect(storedAttempt.turnID == "turn-1") + #expect(storedAttempt.reviewThreadID == "review-thread") + #expect(await backend.notificationRouterIsRunningForTesting() == false) let requests = await runtime.transport.recordedRequests() #expect(requests.map(\.method) == ["initialize", "thread/start", "review/start"]) @@ -1103,6 +1108,32 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } + @Test func appServerBackendInterruptUsesTypedSessionWithoutStartingRawRouter() 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: "turn/interrupt") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = AppServerCodexReviewBackend(appServer: runtime.server) + + let attempt = try await backend.startReview(.init( + jobID: "job-1", + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + #expect(await backend.notificationRouterIsRunningForTesting() == false) + + try await backend.interruptReview(attempt, reason: .init(message: "Stop")) + + #expect(await backend.notificationRouterIsRunningForTesting() == false) + let interrupt = try #require(await runtime.transport.recordedRequests().last) + #expect(interrupt.method == "turn/interrupt") + let params = try interrupt.decodeParams(AppServerAPI.Turn.Interrupt.Params.self) + #expect(params.threadID == "review-thread") + #expect(params.turnID == "turn-1") + } + @Test func backendUsesLegacySandboxWhenProcessDoesNotSupportModernSessionSource() async throws { let transport = FakeJSONRPCTransport() try await enqueueInitialize(transport) From eff10df5eac27be6bf7f37edbcae68aa3989a54a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:07:05 +0900 Subject: [PATCH 032/359] feat(ui-kit): add codex model selection API --- Package.swift | 22 ++ Sources/CodexUIKit/CodexModelSelection.swift | 228 ++++++++++++++++++ .../CodexModelSelectionTests.swift | 125 ++++++++++ 3 files changed, 375 insertions(+) create mode 100644 Sources/CodexUIKit/CodexModelSelection.swift create mode 100644 Tests/CodexUIKitTests/CodexModelSelectionTests.swift diff --git a/Package.swift b/Package.swift index dd829e29..17bdc52f 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,10 @@ let package = Package( name: "CodexAppServerKit", targets: ["CodexAppServerKit"] ), + .library( + name: "CodexUIKit", + targets: ["CodexUIKit"] + ), .library( name: "CodexAppServerKitTesting", targets: ["CodexAppServerKitTesting"] @@ -54,6 +58,15 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .target( + name: "CodexUIKit", + dependencies: [ + "CodexAppServerKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .target( name: "CodexAppServerKitTesting", dependencies: [ @@ -157,6 +170,15 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .testTarget( + name: "CodexUIKitTests", + dependencies: [ + "CodexUIKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .testTarget( name: "CodexReviewAppServerWireTests", dependencies: [ diff --git a/Sources/CodexUIKit/CodexModelSelection.swift b/Sources/CodexUIKit/CodexModelSelection.swift new file mode 100644 index 00000000..a7449ed4 --- /dev/null +++ b/Sources/CodexUIKit/CodexModelSelection.swift @@ -0,0 +1,228 @@ +import CodexAppServerKit +import Foundation + +/// Describes why an option is present in a UI-facing selection list. +public enum CodexSelectionOptionSource: Equatable, Sendable { + case catalog + case currentSelection +} + +/// A selectable Codex model derived from a model catalog or current selection. +public struct CodexModelOption: Identifiable, Equatable, Sendable { + public var id: String { modelID } + + public var catalogID: String? + public var modelID: String + public var displayName: String + public var isHidden: Bool + public var source: CodexSelectionOptionSource + + public init( + modelID: String, + displayName: String? = nil, + isHidden: Bool = false, + source: CodexSelectionOptionSource = .catalog, + catalogID: String? = nil + ) { + self.catalogID = catalogID + self.modelID = modelID + self.displayName = displayName ?? modelID + self.isHidden = isHidden + self.source = source + } + + public init(catalogModel: CodexModel) { + self.init( + modelID: catalogModel.model, + displayName: catalogModel.displayName, + isHidden: catalogModel.hidden, + source: .catalog, + catalogID: catalogModel.id + ) + } +} + +/// A selectable reasoning effort with display metadata suitable for UI controls. +public struct CodexReasoningEffortOption: Identifiable, Equatable, Sendable { + public var id: String { reasoningEffort.rawValue } + + public var reasoningEffort: CodexReasoningEffort + public var localizedLabel: LocalizedStringResource + public var description: String? + public var source: CodexSelectionOptionSource + + public init( + reasoningEffort: CodexReasoningEffort, + description: String? = nil, + source: CodexSelectionOptionSource = .catalog + ) { + self.reasoningEffort = reasoningEffort + self.localizedLabel = reasoningEffort.localizedLabel + self.description = description + self.source = source + } + + public init(catalogOption: CodexModel.ReasoningOption) { + self.init( + reasoningEffort: catalogOption.reasoningEffort, + description: catalogOption.description, + source: .catalog + ) + } +} + +/// A selectable service tier. Unknown tier identifiers are preserved as raw strings. +public struct CodexServiceTierOption: Identifiable, Equatable, Sendable { + public var id: String { serviceTierID } + + public var serviceTierID: String + public var localizedLabel: LocalizedStringResource + public var source: CodexSelectionOptionSource + + public init( + serviceTierID: String, + source: CodexSelectionOptionSource = .catalog + ) { + self.serviceTierID = serviceTierID + self.localizedLabel = Self.localizedLabel(for: serviceTierID) + self.source = source + } + + public static func localizedLabel(for serviceTierID: String) -> LocalizedStringResource { + switch serviceTierID { + case "fast": + "Fast" + case "flex": + "Flex" + default: + LocalizedStringResource(stringLiteral: serviceTierID) + } + } +} + +/// Builds UI-facing model, reasoning, and service tier options from a Codex model catalog. +public struct CodexModelSelection: Equatable, Sendable { + public var models: [CodexModel] + /// The selected Codex model string, matched against `CodexModel.model`. + public var selectedModelID: String? + /// The fallback Codex model string, matched against `CodexModel.model`. + public var fallbackModelID: String? + public var selectedReasoningEffort: CodexReasoningEffort? + public var selectedServiceTierID: String? + + public init( + models: [CodexModel], + selectedModelID: String? = nil, + fallbackModelID: String? = nil, + selectedReasoningEffort: CodexReasoningEffort? = nil, + selectedServiceTierID: String? = nil + ) { + self.models = models + self.selectedModelID = selectedModelID + self.fallbackModelID = fallbackModelID + self.selectedReasoningEffort = selectedReasoningEffort + self.selectedServiceTierID = selectedServiceTierID + } + + public var effectiveModelID: String? { + selectedModelID ?? fallbackModelID + } + + public var effectiveModel: CodexModel? { + guard let effectiveModelID else { + return nil + } + return models.first { $0.model == effectiveModelID } + } + + public var modelOptions: [CodexModelOption] { + var options = models.compactMap { model -> CodexModelOption? in + guard model.hidden == false || model.model == effectiveModelID else { + return nil + } + return .init(catalogModel: model) + } + + if let effectiveModelID, + options.contains(where: { $0.modelID == effectiveModelID }) == false + { + options.append( + .init( + modelID: effectiveModelID, + source: .currentSelection + ) + ) + } + + return options + } + + public var reasoningEffortOptions: [CodexReasoningEffortOption] { + var options = effectiveModel?.supportedReasoningEfforts.map { + CodexReasoningEffortOption(catalogOption: $0) + } ?? [] + + if let selectedReasoningEffort, + options.contains(where: { $0.reasoningEffort == selectedReasoningEffort }) == false + { + options.append( + .init( + reasoningEffort: selectedReasoningEffort, + source: .currentSelection + ) + ) + } + + return options + } + + public var effectiveReasoningEffort: CodexReasoningEffort? { + selectedReasoningEffort ?? effectiveModel?.defaultReasoningEffort + } + + public var serviceTierOptions: [CodexServiceTierOption] { + let catalogServiceTierIDs = Set(effectiveModel?.supportedServiceTiers ?? []) + var serviceTierIDs = effectiveModel?.supportedServiceTiers ?? [] + if let selectedServiceTierID { + serviceTierIDs.append(selectedServiceTierID) + } + + return serviceTierIDs + .uniqueSorted() + .map { serviceTierID in + CodexServiceTierOption( + serviceTierID: serviceTierID, + source: catalogServiceTierIDs.contains(serviceTierID) + ? .catalog + : .currentSelection + ) + } + } +} + +public extension CodexReasoningEffort { + var localizedLabel: LocalizedStringResource { + switch rawValue { + case Self.none.rawValue: + "None" + case Self.minimal.rawValue: + "Minimal" + case Self.low.rawValue: + "Low" + case Self.medium.rawValue: + "Medium" + case Self.high.rawValue: + "High" + case Self.xhigh.rawValue: + "Extra high" + default: + LocalizedStringResource(stringLiteral: rawValue) + } + } +} + +private extension Array where Element == String { + func uniqueSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Tests/CodexUIKitTests/CodexModelSelectionTests.swift b/Tests/CodexUIKitTests/CodexModelSelectionTests.swift new file mode 100644 index 00000000..52b7184f --- /dev/null +++ b/Tests/CodexUIKitTests/CodexModelSelectionTests.swift @@ -0,0 +1,125 @@ +import CodexAppServerKit +import CodexUIKit +import Foundation +import Testing + +@Suite("CodexUIKit model selection") +struct CodexModelSelectionTests { + @Test func hiddenSelectedModelIsPreservedInOptions() throws { + let visible = makeModel( + model: "gpt-visible", + displayName: "GPT Visible" + ) + let hidden = makeModel( + model: "gpt-hidden", + displayName: "GPT Hidden", + hidden: true + ) + + let selection = CodexModelSelection( + models: [visible, hidden], + selectedModelID: "gpt-hidden" + ) + + #expect(selection.modelOptions.map(\.modelID) == ["gpt-visible", "gpt-hidden"]) + let hiddenOption = try #require(selection.modelOptions.last) + #expect(hiddenOption.displayName == "GPT Hidden") + #expect(hiddenOption.isHidden) + #expect(hiddenOption.source == .catalog) + } + + @Test func missingSelectedModelIsRepresentedInOptions() throws { + let visible = makeModel( + model: "gpt-visible", + displayName: "GPT Visible" + ) + + let selection = CodexModelSelection( + models: [visible], + selectedModelID: "gpt-missing" + ) + + #expect(selection.modelOptions.map(\.modelID) == ["gpt-visible", "gpt-missing"]) + #expect(selection.effectiveModel == nil) + + let missingOption = try #require(selection.modelOptions.last) + #expect(missingOption.displayName == "gpt-missing") + #expect(missingOption.isHidden == false) + #expect(missingOption.source == .currentSelection) + } + + @Test func reasoningLabelsUseLocalizedStringResourcesAndPreserveUnknownValues() throws { + let experimentalEffort = CodexReasoningEffort(rawValue: "experimental") + let selectedEffort = CodexReasoningEffort(rawValue: "bespoke") + let model = makeModel( + model: "gpt-reasoning", + reasoningOptions: [ + .init(reasoningEffort: .medium, description: "Balanced"), + .init(reasoningEffort: experimentalEffort, description: "Experimental"), + ], + defaultReasoningEffort: .medium, + serviceTiers: ["priority"] + ) + + let selection = CodexModelSelection( + models: [model], + selectedModelID: "gpt-reasoning", + selectedReasoningEffort: selectedEffort, + selectedServiceTierID: "priority" + ) + + let options = selection.reasoningEffortOptions + #expect(options.map(\.reasoningEffort) == [.medium, experimentalEffort, selectedEffort]) + #expect(options.map(\.source) == [.catalog, .catalog, .currentSelection]) + #expect(options.first?.localizedLabel == LocalizedStringResource("Medium")) + + let experimentalOption = try #require(options.first { $0.reasoningEffort == experimentalEffort }) + #expect(String(localized: experimentalOption.localizedLabel) == "experimental") + #expect(selection.selectedServiceTierID == "priority") + #expect(selection.serviceTierOptions.map(\.serviceTierID) == ["priority"]) + } + + @Test func serviceTierIDsRemainRawStringsAndAreSorted() throws { + let model = makeModel( + model: "gpt-tiers", + serviceTiers: ["priority", "flex", "fast", "batch", "fast"] + ) + + let selection = CodexModelSelection( + models: [model], + selectedModelID: "gpt-tiers", + selectedServiceTierID: "zippy" + ) + + let options = selection.serviceTierOptions + #expect(options.map(\.serviceTierID) == ["batch", "fast", "flex", "priority", "zippy"]) + #expect(options.map(\.id) == ["batch", "fast", "flex", "priority", "zippy"]) + #expect(options.first { $0.serviceTierID == "fast" }?.localizedLabel == LocalizedStringResource("Fast")) + #expect(options.first { $0.serviceTierID == "flex" }?.localizedLabel == LocalizedStringResource("Flex")) + + let rawOption = try #require(options.first { $0.serviceTierID == "priority" }) + #expect(String(localized: rawOption.localizedLabel) == "priority") + + let selectedOnlyOption = try #require(options.first { $0.serviceTierID == "zippy" }) + #expect(selectedOnlyOption.source == .currentSelection) + } +} + +private func makeModel( + model: String, + displayName: String? = nil, + hidden: Bool = false, + reasoningOptions: [CodexModel.ReasoningOption] = [], + defaultReasoningEffort: CodexReasoningEffort? = nil, + serviceTiers: [String] = [] +) -> CodexModel { + CodexModel( + id: model, + model: model, + displayName: displayName ?? model, + hidden: hidden, + supportedReasoningEfforts: reasoningOptions, + defaultReasoningEffort: defaultReasoningEffort, + supportedServiceTiers: serviceTiers + ) +} From 30615425275299cda799508b9cea349236439c52 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:08:54 +0900 Subject: [PATCH 033/359] fix(ui-kit): keep model option labels scoped --- Sources/CodexUIKit/CodexModelSelection.swift | 44 ++++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/Sources/CodexUIKit/CodexModelSelection.swift b/Sources/CodexUIKit/CodexModelSelection.swift index a7449ed4..cdd323c0 100644 --- a/Sources/CodexUIKit/CodexModelSelection.swift +++ b/Sources/CodexUIKit/CodexModelSelection.swift @@ -57,7 +57,7 @@ public struct CodexReasoningEffortOption: Identifiable, Equatable, Sendable { source: CodexSelectionOptionSource = .catalog ) { self.reasoningEffort = reasoningEffort - self.localizedLabel = reasoningEffort.localizedLabel + self.localizedLabel = Self.localizedLabel(for: reasoningEffort) self.description = description self.source = source } @@ -69,6 +69,25 @@ public struct CodexReasoningEffortOption: Identifiable, Equatable, Sendable { source: .catalog ) } + + private static func localizedLabel(for reasoningEffort: CodexReasoningEffort) -> LocalizedStringResource { + switch reasoningEffort.rawValue { + case CodexReasoningEffort.none.rawValue: + "None" + case CodexReasoningEffort.minimal.rawValue: + "Minimal" + case CodexReasoningEffort.low.rawValue: + "Low" + case CodexReasoningEffort.medium.rawValue: + "Medium" + case CodexReasoningEffort.high.rawValue: + "High" + case CodexReasoningEffort.xhigh.rawValue: + "Extra high" + default: + LocalizedStringResource(stringLiteral: reasoningEffort.rawValue) + } + } } /// A selectable service tier. Unknown tier identifiers are preserved as raw strings. @@ -88,7 +107,7 @@ public struct CodexServiceTierOption: Identifiable, Equatable, Sendable { self.source = source } - public static func localizedLabel(for serviceTierID: String) -> LocalizedStringResource { + private static func localizedLabel(for serviceTierID: String) -> LocalizedStringResource { switch serviceTierID { case "fast": "Fast" @@ -200,27 +219,6 @@ public struct CodexModelSelection: Equatable, Sendable { } } -public extension CodexReasoningEffort { - var localizedLabel: LocalizedStringResource { - switch rawValue { - case Self.none.rawValue: - "None" - case Self.minimal.rawValue: - "Minimal" - case Self.low.rawValue: - "Low" - case Self.medium.rawValue: - "Medium" - case Self.high.rawValue: - "High" - case Self.xhigh.rawValue: - "Extra high" - default: - LocalizedStringResource(stringLiteral: rawValue) - } - } -} - private extension Array where Element == String { func uniqueSorted() -> [String] { Array(Set(self)).sorted() From 6fb6a3e6ee680cd542d405d5f65d2ae5dfde1e22 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:54:08 +0900 Subject: [PATCH 034/359] chore(testing): remove unused app server dependency --- Package.swift | 1 - Sources/CodexReviewTesting/TestSupport.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Package.swift b/Package.swift index 17bdc52f..26292b03 100644 --- a/Package.swift +++ b/Package.swift @@ -127,7 +127,6 @@ let package = Package( "CodexAppServerKit", "CodexAppServerKitTesting", "CodexReviewKit", - "CodexReviewAppServer", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index f1790c01..4d195e8b 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -2,7 +2,6 @@ import Foundation import CodexAppServerKit import CodexAppServerKitTesting import CodexReviewKit -import CodexReviewAppServer package typealias AsyncGate = CodexAppServerTestGate package typealias OneShotGate = AsyncGate From 3ad61da704283cfaf70ea5a30594f55e1ddf1e55 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:58:36 +0900 Subject: [PATCH 035/359] refactor(app-server): add review notification raw decoder --- .../AppServerReviewNotification.swift | 369 ++++++++++++++++++ .../CodexAppServerKitTests.swift | 88 +++++ 2 files changed, 457 insertions(+) create mode 100644 Sources/CodexAppServerKit/AppServerReviewNotification.swift diff --git a/Sources/CodexAppServerKit/AppServerReviewNotification.swift b/Sources/CodexAppServerKit/AppServerReviewNotification.swift new file mode 100644 index 00000000..3856e8d3 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerReviewNotification.swift @@ -0,0 +1,369 @@ +import Foundation + +package struct AppServerReviewNotification: Decodable, Equatable, Sendable { + package var method: Method + package var payload: Payload + package var rawPayload: AppServerJSONValue? + package var rawNotification: CodexRawNotification + + package var rawMethod: String { + method.rawValue + } + + package init(method: String, paramsData: Data) throws { + let rawPayload = try JSONDecoder().decode(AppServerJSONValue.self, from: paramsData) + let payload: Payload + if case .object = rawPayload, + let decodedPayload = try? JSONDecoder().decode(Payload.self, from: paramsData) { + payload = decodedPayload + } else { + payload = Payload(rawValue: rawPayload) + } + + self.method = Method(rawValue: method) + self.payload = payload + self.rawPayload = rawPayload + self.rawNotification = CodexRawNotification( + method: method, + params: paramsData, + threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), + turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) + ) + } + + enum CodingKeys: String, CodingKey { + case method + case payload = "params" + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawMethod = try container.decode(String.self, forKey: .method) + let rawPayload = container.contains(.payload) + ? try container.decode(AppServerJSONValue.self, forKey: .payload) + : nil + let payload: Payload + if case .object? = rawPayload, + let decodedPayload = try? container.decodeIfPresent(Payload.self, forKey: .payload) { + payload = decodedPayload + } else { + payload = Payload(rawValue: rawPayload) + } + + self.method = Method(rawValue: rawMethod) + self.payload = payload + self.rawPayload = rawPayload + self.rawNotification = CodexRawNotification( + method: rawMethod, + params: rawPayload.flatMap { try? JSONEncoder().encode($0) } ?? Data("null".utf8), + threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), + turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) + ) + } + + package var startsReviewMode: Bool { + method == .itemStarted && payload.item?.resolvedType == "enteredReviewMode" + } + + package var finishesReviewMode: Bool { + method == .itemCompleted && payload.item?.resolvedType == "exitedReviewMode" + } +} + +extension AppServerReviewNotification { + package struct Method: RawRepresentable, Hashable, ExpressibleByStringLiteral, CustomStringConvertible, Sendable { + package var rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: String) { + self.rawValue = value + } + + package var description: String { + rawValue + } + + package static let turnStarted: Self = "turn/started" + package static let turnCompleted: Self = "turn/completed" + package static let turnFailed: Self = "turn/failed" + package static let turnCancelled: Self = "turn/cancelled" + package static let turnAborted: Self = "turn/aborted" + package static let turnDiffUpdated: Self = "turn/diff/updated" + package static let turnPlanUpdated: Self = "turn/plan/updated" + package static let itemStarted: Self = "item/started" + package static let itemUpdated: Self = "item/updated" + package static let itemCompleted: Self = "item/completed" + package static let agentMessageDelta: Self = "item/agentMessage/delta" + package static let planDelta: Self = "item/plan/delta" + package static let reasoningSummaryTextDelta: Self = "item/reasoning/summaryTextDelta" + package static let reasoningSummaryPartAdded: Self = "item/reasoning/summaryPartAdded" + package static let reasoningTextDelta: Self = "item/reasoning/textDelta" + package static let commandExecutionOutputDelta: Self = "item/commandExecution/outputDelta" + package static let commandExecOutputDelta: Self = "command/exec/outputDelta" + package static let processOutputDelta: Self = "process/outputDelta" + package static let commandExecutionTerminalInteraction: Self = + "item/commandExecution/terminalInteraction" + package static let fileChangeOutputDelta: Self = "item/fileChange/outputDelta" + package static let fileChangePatchUpdated: Self = "item/fileChange/patchUpdated" + package static let mcpToolCallProgress: Self = "item/mcpToolCall/progress" + package static let autoApprovalReviewStarted: Self = "item/autoApprovalReview/started" + package static let autoApprovalReviewCompleted: Self = "item/autoApprovalReview/completed" + package static let agentMessage: Self = "agent/message" + package static let log: Self = "log" + package static let error: Self = "error" + package static let warning: Self = "warning" + package static let guardianWarning: Self = "guardianWarning" + package static let deprecationNotice: Self = "deprecationNotice" + package static let configWarning: Self = "configWarning" + package static let diagnostic: Self = "diagnostic" + package static let modelRerouted: Self = "model/rerouted" + package static let modelVerification: Self = "model/verification" + package static let threadCompacted: Self = "thread/compacted" + package static let threadClosed: Self = "thread/closed" + package static let threadStatusChanged: Self = "thread/status/changed" + + package var isReviewNotificationMethod: Bool { + switch self { + case .threadClosed, + .threadStatusChanged, + .turnStarted, + .turnCompleted, + .turnFailed, + .turnCancelled, + .turnAborted, + .turnDiffUpdated, + .turnPlanUpdated, + .itemStarted, + .itemUpdated, + .itemCompleted, + .autoApprovalReviewStarted, + .autoApprovalReviewCompleted, + .agentMessageDelta, + .planDelta, + .reasoningSummaryTextDelta, + .reasoningSummaryPartAdded, + .reasoningTextDelta, + .commandExecutionOutputDelta, + .commandExecutionTerminalInteraction, + .commandExecOutputDelta, + .processOutputDelta, + .fileChangeOutputDelta, + .fileChangePatchUpdated, + .mcpToolCallProgress, + .agentMessage, + .log, + .error, + .modelRerouted, + .modelVerification, + .threadCompacted, + .warning, + .guardianWarning, + .deprecationNotice, + .configWarning, + .diagnostic: + true + default: + false + } + } + + package var isThreadlessReviewBroadcast: Bool { + switch self { + case .warning, .deprecationNotice, .configWarning, .error: + true + default: + false + } + } + } +} + +extension AppServerReviewNotification { + package struct Payload: Decodable, Equatable, Sendable { + package var threadID: String? + package var turn: AppServerAPI.Turn.Payload? + package var turnID: String? + package var reviewThreadID: String? + package var itemID: String? + package var item: Item? + package var rawValue: AppServerJSONValue? + + package var rawFields: [String: AppServerJSONValue] { + if case .object(let value) = rawValue { + return value + } + return [:] + } + + package var resolvedTurnID: String? { + guard let turnID = turn?.id, turnID.isEmpty == false else { + return self.turnID + } + return turnID + } + + package init( + threadID: String? = nil, + turn: AppServerAPI.Turn.Payload? = nil, + turnID: String? = nil, + reviewThreadID: String? = nil, + itemID: String? = nil, + item: Item? = nil, + rawValue: AppServerJSONValue? = nil + ) { + self.threadID = threadID + self.turn = turn + self.turnID = turnID + self.reviewThreadID = reviewThreadID + self.itemID = itemID + self.item = item + self.rawValue = rawValue + } + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + case turnID = "turnId" + case reviewThreadID = "reviewThreadId" + case itemID = "itemId" + case item + } + + package init(from decoder: Decoder) throws { + let rawValue = try? AppServerJSONValue(from: decoder) + guard case .object? = rawValue else { + self.threadID = nil + self.turn = nil + self.turnID = nil + self.reviewThreadID = nil + self.itemID = nil + self.item = nil + self.rawValue = rawValue + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.threadID = container.decodeReviewStringIfPresent(forKey: .threadID) + self.turn = try? container.decodeIfPresent(AppServerAPI.Turn.Payload.self, forKey: .turn) + self.turnID = container.decodeReviewStringIfPresent(forKey: .turnID) + self.reviewThreadID = container.decodeReviewStringIfPresent(forKey: .reviewThreadID) + self.itemID = container.decodeReviewStringIfPresent(forKey: .itemID) + self.item = try? container.decodeIfPresent(Item.self, forKey: .item) + self.rawValue = rawValue + } + } +} + +extension AppServerReviewNotification.Payload { + package struct Item: Decodable, Equatable, Sendable { + package var id: String? + package var type: String? + package var kind: String? + package var text: String? + package var review: String? + package var phase: String? + package var command: String? + package var cwd: String? + package var aggregatedOutput: String? + package var output: String? + package var exitCode: Int? + package var status: String? + package var path: String? + package var namespace: String? + package var server: String? + package var tool: String? + package var name: String? + package var query: String? + package var prompt: String? + package var summary: [String]? + package var content: [String]? + package var arguments: AppServerJSONValue? + package var input: AppServerJSONValue? + package var result: AppServerJSONValue? + package var error: AppServerJSONValue? + package var rawValue: AppServerJSONValue? + + package var resolvedType: String? { + type ?? kind + } + + enum CodingKeys: String, CodingKey { + case id + case type + case kind + case text + case review + case phase + case command + case cwd + case aggregatedOutput + case output + case exitCode + case status + case path + case namespace + case server + case tool + case name + case query + case prompt + case summary + case content + case arguments + case input + case result + case error + } + + package init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.decodeReviewStringIfPresent(forKey: .id) + type = container.decodeReviewStringIfPresent(forKey: .type) + kind = container.decodeReviewStringIfPresent(forKey: .kind) + text = container.decodeReviewStringIfPresent(forKey: .text) + review = container.decodeReviewStringIfPresent(forKey: .review) + phase = container.decodeReviewStringIfPresent(forKey: .phase) + command = container.decodeReviewStringIfPresent(forKey: .command) + cwd = container.decodeReviewStringIfPresent(forKey: .cwd) + aggregatedOutput = container.decodeReviewStringIfPresent(forKey: .aggregatedOutput) + output = container.decodeReviewStringIfPresent(forKey: .output) + exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) + status = container.decodeReviewStringIfPresent(forKey: .status) + path = container.decodeReviewStringIfPresent(forKey: .path) + namespace = container.decodeReviewStringIfPresent(forKey: .namespace) + server = container.decodeReviewStringIfPresent(forKey: .server) + tool = container.decodeReviewStringIfPresent(forKey: .tool) + name = container.decodeReviewStringIfPresent(forKey: .name) + query = container.decodeReviewStringIfPresent(forKey: .query) + prompt = container.decodeReviewStringIfPresent(forKey: .prompt) + summary = try? container.decodeIfPresent([String].self, forKey: .summary) + content = try? container.decodeIfPresent([String].self, forKey: .content) + arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) + input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) + result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) + error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) + } + } +} + +private extension KeyedDecodingContainer { + func decodeReviewStringIfPresent(forKey key: Key) -> String? { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + if let bool = try? decode(Bool.self, forKey: key) { + return bool ? "true" : "false" + } + return nil + } +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 16b68497..898ee586 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -184,6 +184,94 @@ struct CodexAppServerKitTests { #expect(params.useStateDbOnly == true) } + @Test func reviewNotificationMethodsClassifyReviewEvents() { + #expect(AppServerReviewNotification.Method(rawValue: "turn/started").isReviewNotificationMethod) + #expect(AppServerReviewNotification.Method(rawValue: "item/started").isReviewNotificationMethod) + #expect( + AppServerReviewNotification.Method(rawValue: "item/commandExecution/outputDelta") + .isReviewNotificationMethod + ) + #expect(AppServerReviewNotification.Method(rawValue: "thread/status/changed").isReviewNotificationMethod) + #expect(AppServerReviewNotification.Method(rawValue: "model/verification").isReviewNotificationMethod) + #expect(AppServerReviewNotification.Method(rawValue: "review/futureEvent").isReviewNotificationMethod == false) + } + + @Test func reviewNotificationMethodsClassifyThreadlessBroadcasts() { + #expect(AppServerReviewNotification.Method(rawValue: "warning").isThreadlessReviewBroadcast) + #expect(AppServerReviewNotification.Method(rawValue: "deprecationNotice").isThreadlessReviewBroadcast) + #expect(AppServerReviewNotification.Method(rawValue: "configWarning").isThreadlessReviewBroadcast) + #expect(AppServerReviewNotification.Method(rawValue: "error").isThreadlessReviewBroadcast) + #expect(AppServerReviewNotification.Method(rawValue: "diagnostic").isThreadlessReviewBroadcast == false) + #expect(AppServerReviewNotification.Method(rawValue: "turn/started").isThreadlessReviewBroadcast == false) + } + + @Test func reviewNotificationDecodePreservesUnknownRawPayloads() throws { + let objectData = Data(""" + { + "threadId": "thread-review", + "turnId": "turn-review", + "future": { "enabled": true }, + "count": 2 + } + """.utf8) + let objectNotification = try AppServerReviewNotification( + method: "review/futureEvent", + paramsData: objectData + ) + + #expect(objectNotification.method.rawValue == "review/futureEvent") + #expect(objectNotification.payload.threadID == "thread-review") + #expect(objectNotification.payload.turnID == "turn-review") + #expect(objectNotification.rawNotification.threadID == "thread-review") + #expect(objectNotification.rawNotification.turnID == "turn-review") + #expect(objectNotification.rawNotification.params == objectData) + #expect(objectNotification.rawPayload == .object([ + "threadId": .string("thread-review"), + "turnId": .string("turn-review"), + "future": .object(["enabled": .bool(true)]), + "count": .int(2), + ])) + + let nonObjectData = Data(#""future schema""#.utf8) + let nonObjectNotification = try AppServerReviewNotification( + method: "review/futureEvent", + paramsData: nonObjectData + ) + + #expect(nonObjectNotification.payload.threadID == nil) + #expect(nonObjectNotification.payload.turnID == nil) + #expect(nonObjectNotification.rawPayload == .string("future schema")) + #expect(nonObjectNotification.rawNotification.params == nonObjectData) + } + + @Test func reviewNotificationDecodeReadsOfficialItemPayload() throws { + let notification = try AppServerReviewNotification( + method: "item/completed", + paramsData: Data(""" + { + "threadId": "thread-review", + "turnId": "turn-review", + "item": { + "id": "command-1", + "type": "commandExecution", + "command": "swift test", + "aggregatedOutput": "passed", + "status": "completed" + } + } + """.utf8) + ) + + #expect(notification.method.isReviewNotificationMethod) + #expect(notification.payload.threadID == "thread-review") + #expect(notification.payload.resolvedTurnID == "turn-review") + #expect(notification.payload.item?.id == "command-1") + #expect(notification.payload.item?.resolvedType == "commandExecution") + #expect(notification.payload.item?.command == "swift test") + #expect(notification.payload.item?.aggregatedOutput == "passed") + #expect(notification.payload.item?.status == "completed") + } + @Test func threadStartReviewSerializesTargetAndStreamsTypedReviewLogs() async throws { let transport = CodexAppServerTestTransport() try await transport.enqueue( From e566c7f798917c222e52a197e05c152f329e9970 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:00:19 +0900 Subject: [PATCH 036/359] fix(app-server): keep container API platform-neutral --- Sources/CodexAppServerKit/CodexAppServer.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 233b483f..0c93b77d 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -7,10 +7,6 @@ import Foundation /// login domain objects. public actor CodexAppServer { /// Options for creating a Codex app-server container. - @available(iOS, unavailable, message: "The current local app-server runtime requires macOS.") - @available(tvOS, unavailable, message: "The current local app-server runtime requires macOS.") - @available(watchOS, unavailable, message: "The current local app-server runtime requires macOS.") - @available(visionOS, unavailable, message: "The current local app-server runtime requires macOS.") public struct Configuration: Sendable { /// Options for launching a local `codex app-server` process. public struct LocalProcess: Sendable { @@ -145,10 +141,6 @@ public actor CodexAppServer { /// /// - Parameter configuration: Container and local-process configuration. /// - Throws: A transport, JSON-RPC, or app-server initialization error. - @available(iOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") - @available(tvOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") - @available(watchOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") - @available(visionOS, unavailable, message: "Launching a local Codex app-server process requires macOS.") public init(configuration: Configuration = .default) async throws { let transportConfiguration = AppServerProcessTransport.Configuration( executable: configuration.localProcess.executable, From e3f507ca5f2c02d442ff5ab9a22d1605d908ca95 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:03:39 +0900 Subject: [PATCH 037/359] fix(app-server): route review broadcast notifications --- .../CodexAppServerNotificationRouter.swift | 42 +++++++- .../CodexAppServerKitTests.swift | 95 +++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 7b515224..b5337368 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -13,6 +13,7 @@ package actor CodexAppServerNotificationRouter { private let client: AppServerClient private var routerTask: Task? private var threadIDByTurnID: [CodexTurnID: CodexThreadID] = [:] + private var reviewThreadIDs: Set = [] private var turnHistoryByTurnID: [CodexTurnID: [CodexTurnEvent]] = [:] private var threadHistoryByThreadID: [CodexThreadID: [CodexThreadEvent]] = [:] private var turnSubscribersByTurnID: [CodexTurnID: [UUID: Subscriber]] = [:] @@ -70,6 +71,7 @@ package actor CodexAppServerNotificationRouter { package func seedReviewTurn(_ turnID: CodexTurnID, reviewThreadID: CodexThreadID) { threadIDByTurnID[turnID] = reviewThreadID + reviewThreadIDs.insert(reviewThreadID) guard let turnHistory = turnHistoryByTurnID[turnID] else { return } @@ -101,7 +103,19 @@ package actor CodexAppServerNotificationRouter { } private func route(_ notification: JSONRPC.Notification) { - var context = Self.context(from: notification.params) + let reviewNotification = try? AppServerReviewNotification( + method: notification.method, + paramsData: notification.params + ) + if let reviewNotification, + reviewNotification.method.isThreadlessReviewBroadcast, + reviewNotification.payload.threadID == nil, + reviewNotification.payload.resolvedTurnID == nil, + routeReviewBroadcast(reviewNotification) { + return + } + + var context = Self.context(from: notification.params, reviewNotification: reviewNotification) if let threadID = context.threadID, let turnID = context.turnID { threadIDByTurnID[turnID] = threadID } else if let turnID = context.turnID, let threadID = threadIDByTurnID[turnID] { @@ -127,6 +141,18 @@ package actor CodexAppServerNotificationRouter { } } + private func routeReviewBroadcast(_ notification: AppServerReviewNotification) -> Bool { + guard reviewThreadIDs.isEmpty == false else { + return false + } + for threadID in reviewThreadIDs { + var raw = notification.rawNotification + raw.threadID = threadID + appendThreadEvent(.unknown(raw), threadID: threadID) + } + return true + } + private func appendThreadEvent(_ event: CodexThreadEvent, threadID: CodexThreadID) { threadHistoryByThreadID[threadID, default: []].append(event) if let subscribers = threadSubscribersByThreadID[threadID] { @@ -508,7 +534,19 @@ package actor CodexAppServerNotificationRouter { return nil } - private static func context(from data: Data) -> NotificationContext { + private static func context( + from data: Data, + reviewNotification: AppServerReviewNotification? = nil + ) -> NotificationContext { + if let reviewNotification { + let payload = reviewNotification.payload + if payload.threadID != nil || payload.resolvedTurnID != nil { + return .init( + threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), + turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) + ) + } + } guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return .init() } diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 898ee586..a4c783de 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -472,6 +472,87 @@ struct CodexAppServerKitTests { } } + @Test func reviewEventsRouteThreadlessBroadcastsToSeededReviewThread() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + try await transport.emitServerNotification( + method: "warning", + params: ReviewWarningParams(message: "model warning") + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let events = try await collect(review.events) + #expect( + events.contains { + if case .unknown(let raw) = $0 { + let text = String(data: raw.params, encoding: .utf8) ?? "" + return raw.method == "warning" + && raw.threadID == "thread-review" + && text.contains("model warning") + } + return false + }) + } + + @Test func reviewEventsRouteIdentifiedBroadcastMethodsThroughTurnSeed() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + await router.start() + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + try await transport.emitServerNotification( + method: "error", + params: ReviewErrorParams(turnID: "turn-review", message: "recoverable") + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let events = try await collect(review.events) + #expect( + events.contains { + if case .unknown(let raw) = $0 { + return raw.method == "error" + && raw.threadID == "thread-review" + && raw.turnID == "turn-review" + } + return false + }) + } + @Test func promptPartsEncodeToAppServerInputItems() { let prompt = CodexPrompt(parts: [ .text("Describe these files."), @@ -1214,6 +1295,20 @@ private struct ThreadIDParams: Encodable, Sendable { } } +private struct ReviewWarningParams: Encodable, Sendable { + var message: String +} + +private struct ReviewErrorParams: Encodable, Sendable { + var turnID: String + var message: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + case message + } +} + private struct TurnDeltaParams: Encodable, Sendable { var threadID: String? = nil var turnID: String From d7d6b455975ce13a15f72a57c6f7b5fc2ed7a7e8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:04:34 +0900 Subject: [PATCH 038/359] refactor(review-app-server): use app server review method classifier --- .../CodexReviewAppServer/AppServerCodexReviewBackend.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index daf3738b..2fe0ae3b 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -890,7 +890,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private func routeReviewNotification(_ notification: JSONRPC.Notification) async { notificationRouterMetrics.received += 1 - let method = AppServerReviewEventKind(rawValue: notification.method) + let method = AppServerReviewNotification.Method(rawValue: notification.method) guard method.isReviewNotificationMethod else { notificationRouterMetrics.ignored += 1 return @@ -925,7 +925,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } notificationRouterMetrics.routed += 1 await session.receive(routed) - } else if wireNotification.method.isThreadlessReviewBroadcast { + } else if method.isThreadlessReviewBroadcast { let sessions = Array(reviewEventSessionsByAttemptID.values) guard sessions.isEmpty == false else { notificationRouterMetrics.ignored += 1 From fe581513d0de5a5bfe738b5e6c8eba2b95610f1f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:16:12 +0900 Subject: [PATCH 039/359] refactor(review-app-server): consume app server review notifications --- Package.swift | 1 - .../AppServerReviewNotification.swift | 386 +++++++- .../AppServerCodexReviewBackend.swift | 840 +++++++++++++++++- 3 files changed, 1183 insertions(+), 44 deletions(-) diff --git a/Package.swift b/Package.swift index 26292b03..4ca8f3cb 100644 --- a/Package.swift +++ b/Package.swift @@ -90,7 +90,6 @@ let package = Package( dependencies: [ "CodexAppServerKit", "CodexReviewKit", - "CodexReviewAppServerWire", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Sources/CodexAppServerKit/AppServerReviewNotification.swift b/Sources/CodexAppServerKit/AppServerReviewNotification.swift index 3856e8d3..2f669bd3 100644 --- a/Sources/CodexAppServerKit/AppServerReviewNotification.swift +++ b/Sources/CodexAppServerKit/AppServerReviewNotification.swift @@ -184,10 +184,34 @@ extension AppServerReviewNotification { extension AppServerReviewNotification { package struct Payload: Decodable, Equatable, Sendable { package var threadID: String? - package var turn: AppServerAPI.Turn.Payload? + package var turn: Turn? package var turnID: String? package var reviewThreadID: String? + package var model: String? + package var fromModel: String? + package var toModel: String? + package var reason: String? package var itemID: String? + package var processID: String? + package var processHandle: String? + package var delta: String? + package var deltaBase64: String? + package var stdin: String? + package var message: String? + package var summary: String? + package var details: String? + package var diff: String? + package var result: AppServerJSONValue? + package var error: ErrorPayload? + package var willRetry: Bool? + package var status: Status? + package var startedAtMs: Int64? + package var completedAtMs: Int64? + package var summaryIndex: Int? + package var contentIndex: Int? + package var plan: [PlanStep] + package var verifications: [String] + package var changes: [FileChange] package var item: Item? package var rawValue: AppServerJSONValue? @@ -207,10 +231,34 @@ extension AppServerReviewNotification { package init( threadID: String? = nil, - turn: AppServerAPI.Turn.Payload? = 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: AppServerJSONValue? = 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: AppServerJSONValue? = nil ) { @@ -218,7 +266,31 @@ extension AppServerReviewNotification { 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 } @@ -228,7 +300,31 @@ extension AppServerReviewNotification { 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 } @@ -239,7 +335,31 @@ extension AppServerReviewNotification { self.turn = nil self.turnID = nil self.reviewThreadID = nil + self.model = nil + self.fromModel = nil + self.toModel = nil + self.reason = nil self.itemID = nil + self.processID = nil + self.processHandle = nil + self.delta = nil + self.deltaBase64 = nil + self.stdin = nil + self.message = nil + self.summary = nil + self.details = nil + self.diff = nil + self.result = nil + self.error = nil + self.willRetry = nil + self.status = nil + self.startedAtMs = nil + self.completedAtMs = nil + self.summaryIndex = nil + self.contentIndex = nil + self.plan = [] + self.verifications = [] + self.changes = [] self.item = nil self.rawValue = rawValue return @@ -247,19 +367,163 @@ extension AppServerReviewNotification { let container = try decoder.container(keyedBy: CodingKeys.self) self.threadID = container.decodeReviewStringIfPresent(forKey: .threadID) - self.turn = try? container.decodeIfPresent(AppServerAPI.Turn.Payload.self, forKey: .turn) + self.turn = try? container.decodeIfPresent(Turn.self, forKey: .turn) self.turnID = container.decodeReviewStringIfPresent(forKey: .turnID) self.reviewThreadID = container.decodeReviewStringIfPresent(forKey: .reviewThreadID) + self.model = container.decodeReviewStringIfPresent(forKey: .model) + self.fromModel = container.decodeReviewStringIfPresent(forKey: .fromModel) + self.toModel = container.decodeReviewStringIfPresent(forKey: .toModel) + self.reason = container.decodeReviewStringIfPresent(forKey: .reason) self.itemID = container.decodeReviewStringIfPresent(forKey: .itemID) + self.processID = container.decodeReviewStringIfPresent(forKey: .processID) + self.processHandle = container.decodeReviewStringIfPresent(forKey: .processHandle) + self.delta = container.decodeReviewStringIfPresent(forKey: .delta) + self.deltaBase64 = container.decodeReviewStringIfPresent(forKey: .deltaBase64) + self.stdin = container.decodeReviewStringIfPresent(forKey: .stdin) + self.message = container.decodeReviewStringIfPresent(forKey: .message) + self.summary = container.decodeReviewStringIfPresent(forKey: .summary) + self.details = container.decodeReviewStringIfPresent(forKey: .details) + self.diff = container.decodeReviewStringIfPresent(forKey: .diff) + self.result = try? container.decodeIfPresent(AppServerJSONValue.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) self.rawValue = rawValue } + + package struct ErrorPayload: Decodable, Equatable, Sendable { + package var message: String? + package var rawValue: AppServerJSONValue? + + enum CodingKeys: String, CodingKey { + case message + } + + package init(message: String? = nil, rawValue: AppServerJSONValue? = nil) { + self.message = message + self.rawValue = rawValue + } + + package init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let singleValue = try decoder.singleValueContainer() + if singleValue.decodeNil() { + message = nil + } else if let value = try? singleValue.decode(String.self) { + message = value + } else if let container = try? decoder.container(keyedBy: CodingKeys.self) { + message = container.decodeReviewStringIfPresent(forKey: .message) + } else { + message = nil + } + } + } + + package struct Turn: Decodable, Equatable, Sendable { + package var id: String + package var status: String? + package var error: ErrorPayload? + package var rawValue: AppServerJSONValue? + + enum CodingKeys: String, CodingKey { + case id + case status + case error + } + + package init( + id: String, + status: String? = nil, + error: ErrorPayload? = nil, + rawValue: AppServerJSONValue? = nil + ) { + self.id = id + self.status = status + self.error = error + self.rawValue = rawValue + } + + package init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.decodeReviewStringIfPresent(forKey: .id) ?? "" + status = container.decodeReviewStringIfPresent(forKey: .status) + error = try? container.decodeIfPresent(ErrorPayload.self, forKey: .error) + } + } + + package struct Status: Decodable, Equatable, Sendable { + package var type: String + package var rawValue: AppServerJSONValue? + + enum CodingKeys: String, CodingKey { + case type + } + + package init(type: String, rawValue: AppServerJSONValue? = nil) { + self.type = type + self.rawValue = rawValue + } + + package init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + type = container.decodeReviewStringIfPresent(forKey: .type) ?? "" + } + } + + package struct PlanStep: Decodable, Equatable, Sendable { + package var step: String + package var status: String + + package init(step: String, status: String) { + self.step = step + self.status = status + } + } + + package struct FileChange: Decodable, Equatable, Sendable { + package var path: String? + package var kind: String? + package var diff: String? + + package var summaryText: String { + [kind, path, diff].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") + } + + enum CodingKeys: String, CodingKey { + case path + case kind + case diff + } + + package init(path: String? = nil, kind: String? = nil, diff: String? = nil) { + self.path = path + self.kind = kind + self.diff = diff + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = container.decodeReviewStringIfPresent(forKey: .path) + kind = container.decodeReviewStringIfPresent(forKey: .kind) + diff = container.decodeReviewStringIfPresent(forKey: .diff) + } + } } } extension AppServerReviewNotification.Payload { package struct Item: Decodable, Equatable, Sendable { - package var id: String? + package var id: String package var type: String? package var kind: String? package var text: String? @@ -267,9 +531,14 @@ extension AppServerReviewNotification.Payload { package var phase: String? package var command: String? package var cwd: String? + package var processID: String? + package var source: String? package var aggregatedOutput: String? + package var hasAggregatedOutputField: Bool package var output: String? package var exitCode: Int? + package var durationMs: Int? + package var commandActions: [CommandAction] package var status: String? package var path: String? package var namespace: String? @@ -278,18 +547,26 @@ extension AppServerReviewNotification.Payload { package var name: String? package var query: String? package var prompt: String? - package var summary: [String]? - package var content: [String]? + package var summary: [String] + package var summaryFragments: [TextFragment] + package var content: [String] + package var contentFragments: [TextFragment] + package var fragments: [TextFragment] package var arguments: AppServerJSONValue? package var input: AppServerJSONValue? package var result: AppServerJSONValue? package var error: AppServerJSONValue? + package var success: Bool? package var rawValue: AppServerJSONValue? package var resolvedType: String? { type ?? kind } + package var rawType: String { + resolvedType ?? "unknown" + } + enum CodingKeys: String, CodingKey { case id case type @@ -299,9 +576,13 @@ extension AppServerReviewNotification.Payload { case phase case command case cwd + case processID = "processId" + case source case aggregatedOutput case output case exitCode + case durationMs + case commandActions case status case path case namespace @@ -312,16 +593,18 @@ extension AppServerReviewNotification.Payload { case prompt case summary case content + case fragments case arguments case input case result case error + case success } package init(from decoder: Decoder) throws { rawValue = try? AppServerJSONValue(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) - id = container.decodeReviewStringIfPresent(forKey: .id) + id = container.decodeReviewStringIfPresent(forKey: .id) ?? "" type = container.decodeReviewStringIfPresent(forKey: .type) kind = container.decodeReviewStringIfPresent(forKey: .kind) text = container.decodeReviewStringIfPresent(forKey: .text) @@ -329,9 +612,14 @@ extension AppServerReviewNotification.Payload { phase = container.decodeReviewStringIfPresent(forKey: .phase) command = container.decodeReviewStringIfPresent(forKey: .command) cwd = container.decodeReviewStringIfPresent(forKey: .cwd) - aggregatedOutput = container.decodeReviewStringIfPresent(forKey: .aggregatedOutput) + processID = container.decodeReviewStringIfPresent(forKey: .processID) + source = container.decodeReviewStringIfPresent(forKey: .source) + hasAggregatedOutputField = container.contains(.aggregatedOutput) + aggregatedOutput = container.decodeReviewStringIfPresent(forKey: .aggregatedOutput)?.nilIfEmpty output = container.decodeReviewStringIfPresent(forKey: .output) exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) + durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs) + commandActions = (try? container.decodeIfPresent([CommandAction].self, forKey: .commandActions)) ?? [] status = container.decodeReviewStringIfPresent(forKey: .status) path = container.decodeReviewStringIfPresent(forKey: .path) namespace = container.decodeReviewStringIfPresent(forKey: .namespace) @@ -340,13 +628,91 @@ extension AppServerReviewNotification.Payload { name = container.decodeReviewStringIfPresent(forKey: .name) query = container.decodeReviewStringIfPresent(forKey: .query) prompt = container.decodeReviewStringIfPresent(forKey: .prompt) - summary = try? container.decodeIfPresent([String].self, forKey: .summary) - content = try? container.decodeIfPresent([String].self, forKey: .content) + summary = (try? container.decodeIfPresent([String].self, forKey: .summary)) ?? [] + summaryFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .summary)) ?? [] + content = (try? container.decodeIfPresent([String].self, forKey: .content)) ?? [] + contentFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .content)) ?? [] + fragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .fragments)) ?? [] arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) + success = try? container.decodeIfPresent(Bool.self, forKey: .success) } + + package struct CommandAction: Decodable, Equatable, Sendable { + package var kind: String + package var command: String? + package var name: String? + package var path: String? + package var query: String? + + enum CodingKeys: String, CodingKey { + case type + case kind + case command + case name + case path + case query + } + + package init( + kind: String, + 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 + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = container.decodeReviewStringIfPresent(forKey: .type) + ?? container.decodeReviewStringIfPresent(forKey: .kind) + ?? "unknown" + command = container.decodeReviewStringIfPresent(forKey: .command) + name = container.decodeReviewStringIfPresent(forKey: .name) + path = container.decodeReviewStringIfPresent(forKey: .path) + query = container.decodeReviewStringIfPresent(forKey: .query) + } + } + + package struct TextFragment: Decodable, Equatable, Sendable { + package var text: String? + + enum CodingKeys: String, CodingKey { + case text + } + + package init(text: String? = nil) { + self.text = text + } + + package init(from decoder: Decoder) throws { + let singleValueContainer = try decoder.singleValueContainer() + if singleValueContainer.decodeNil() { + text = nil + return + } + if let text = try? singleValueContainer.decode(String.self) { + self.text = text + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + text = container.decodeReviewStringIfPresent(forKey: .text) + } + } + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 2fe0ae3b..a7c8903f 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1,7 +1,6 @@ import Foundation import CodexAppServerKit import CodexReviewKit -import CodexReviewAppServerWire import OSLog private let appServerBackendLogger = Logger( @@ -895,14 +894,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { notificationRouterMetrics.ignored += 1 return } - guard let wireNotification = try? AppServerWireReviewNotification( + guard let reviewNotification = try? AppServerReviewNotification( method: notification.method, paramsData: notification.params ) else { notificationRouterMetrics.ignored += 1 return } - let payload = wireNotification.payload + let payload = reviewNotification.payload notificationRouterMetrics.decoded += 1 if let turnID = payload.resolvedTurnID, abandonedTurnIDs.contains(turnID) { @@ -913,7 +912,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewNotificationSequence += 1 let routed = AppServerRoutedReviewNotification( sequence: reviewNotificationSequence, - wireNotification: wireNotification + reviewNotification: reviewNotification ) if let threadID = payload.threadID { guard let session = reviewEventSession(forThreadID: threadID) else { @@ -986,14 +985,14 @@ package struct AppServerReviewEventSessionMetrics: Equatable, Sendable { private struct AppServerRoutedReviewNotification: Sendable { var sequence: Int - var wireNotification: AppServerWireReviewNotification + var reviewNotification: AppServerReviewNotification var method: String { - wireNotification.rawMethod + reviewNotification.rawMethod } var payload: TurnNotificationPayload { - wireNotification.payload + reviewNotification.payload } } @@ -2175,7 +2174,7 @@ private actor AppServerReviewEventSession { return false } let startsNewCommand = notification.method == "item/started" - && notification.payload.item?.type == .commandExecution + && notification.payload.item?.rawType == "commandExecution" let reachesModelProgress = decoded.events.contains(where: Self.isCommandProgressBoundary(_:)) guard startsNewCommand || reachesModelProgress else { return false @@ -2187,7 +2186,7 @@ private actor AppServerReviewEventSession { "process/outputDelta", "item/commandExecution/terminalInteraction": return false - case "item/completed" where notification.payload.item?.type == .commandExecution: + case "item/completed" where notification.payload.item?.rawType == "commandExecution": return false case "turn/completed", "turn/failed", "turn/cancelled", "thread/closed": return false @@ -2784,9 +2783,9 @@ private extension AppServerAPI.Account.Login.Response { } } -private typealias TurnNotificationPayload = AppServerWireReviewNotification.Payload -private typealias AppServerCommandAction = AppServerWireReviewNotification.CommandAction -private typealias AppServerThreadItem = AppServerWireReviewNotification.Item +private typealias TurnNotificationPayload = AppServerReviewNotification.Payload +private typealias AppServerCommandAction = AppServerReviewNotification.Payload.Item.CommandAction +private typealias AppServerThreadItem = AppServerReviewNotification.Payload.Item private let appServerContextCompactionStartedText = "Automatically compacting context" private let appServerContextCompactionCompletedText = "Context automatically compacted" @@ -2809,7 +2808,7 @@ private func decodeReviewNotification( )] case "item/started": if let item = payload.item, - item.type == .commandExecution { + item.rawType == "commandExecution" { let lifecycle = AppServerCommandLifecycle( item: item, startedAt: payload.startedAt, @@ -2824,7 +2823,7 @@ private func decodeReviewNotification( events = payload.item?.updatedEvents() ?? [] case "item/completed": if let item = payload.item, - item.type == .commandExecution { + item.rawType == "commandExecution" { let previous = commandLifecycleByItemID[item.id] let lifecycle = AppServerCommandLifecycle( item: item, @@ -3020,8 +3019,8 @@ private func decodeReviewNotification( return .init( events: orderedEvents, turnID: payload.resolvedTurnID, - startsReviewMode: notification.wireNotification.startsReviewMode, - finishesReviewMode: notification.wireNotification.finishesReviewMode, + startsReviewMode: notification.reviewNotification.startsReviewMode, + finishesReviewMode: notification.reviewNotification.finishesReviewMode, hasDirectTimelineEvents: directEvents.isEmpty == false ) } @@ -3030,13 +3029,493 @@ private func directTimelineDomainEvents( for notification: AppServerRoutedReviewNotification, fallbackReviewThreadID: String ) -> [ReviewDomainEvent] { - let wireNotification = notification.wireNotification - return wireNotification + let reviewNotification = notification.reviewNotification + return reviewNotification .domainEvents(fallbackReviewThreadID: .init(rawValue: fallbackReviewThreadID)) - .filter { wireNotification.allowsDirectTimelineEvent($0) } + .filter { reviewNotification.allowsDirectTimelineEvent($0) } } -private extension AppServerWireReviewNotification { +private extension AppServerReviewNotification { + 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) + } + } +} + +private extension TurnNotificationPayload { + var terminalMessage: String? { + turn?.error?.message?.nilIfEmpty + ?? error?.message?.nilIfEmpty + ?? message?.nilIfEmpty + ?? summary?.nilIfEmpty + } + + var outputDelta: String? { + if let delta, delta.isEmpty == false { + return delta + } + if let decodedBase64Output, decodedBase64Output.isEmpty == false { + return decodedBase64Output + } + return nil + } + + 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) } + } + + 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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [ReviewDomainEvent] { + guard let message = diagnosticMessage else { + return unknownEvent(method: method) + } + return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] + } + + func modelReroutedEvents(method: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerThreadItem, + phase: ReviewItemPhase, + content explicitContent: ReviewTimelineItem.Content? = nil + ) -> ReviewTimelineItemSeed { + ReviewTimelineItemSeed( + id: .init(rawValue: item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.rawType)), + kind: item.reviewItemKind, + family: item.family, + phase: phase, + content: explicitContent ?? item.content(fallbackDelta: delta), + startedAt: startedAt, + completedAt: completedAt, + durationMs: item.durationMs + ) + } + + private func reasoningPartSeeds(for item: AppServerThreadItem, phase: ReviewItemPhase) -> [ReviewTimelineItemSeed] { + guard item.family == .reasoning else { + return [] + } + let parentItemID = item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.rawType) + 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: AppServerThreadItem, + phase: ReviewItemPhase + ) -> ReviewTimelineItemSeed { + ReviewTimelineItemSeed( + id: .init(rawValue: id), + kind: item.reviewItemKind, + family: .reasoning, + phase: phase, + content: .reasoning(.init(text: text, style: style)), + startedAt: startedAt, + completedAt: completedAt, + durationMs: item.durationMs + ) + } + + private var terminalDisposition: AppServerTerminalDisposition { + switch normalizedStatus(turn?.status ?? status?.type) { + case "failed", "failure", "error", "errored": + return .failed + case "cancelled", "canceled", "interrupted", "aborted": + return .cancelled + default: + return .completed + } + } + + private func diagnosticSeed( + method: AppServerReviewNotification.Method, + 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 extension AppServerReviewNotification { func allowsDirectTimelineEvent(_ event: ReviewDomainEvent) -> Bool { guard event.isDirectTimelineEvent else { return false @@ -3061,7 +3540,7 @@ private extension AppServerWireReviewNotification { } private func allowsDirectTimelineSeed(_ seed: ReviewTimelineItemSeed) -> Bool { - if payload.item?.type.rawValue == "userMessage" || seed.kind.rawValue == "userMessage" { + if payload.item?.rawType == "userMessage" || seed.kind.rawValue == "userMessage" { return false } if seed.family == .message, @@ -3071,7 +3550,7 @@ private extension AppServerWireReviewNotification { } if seed.family == .message, isItemLifecycleMethod, - payload.item?.type == .agentMessage, + payload.item?.rawType == "agentMessage", payload.item?.id.nilIfEmpty == nil, payload.itemID?.nilIfEmpty == nil { return false @@ -3157,6 +3636,14 @@ private func rawReasoningGroupID(itemID: String, contentIndex: Int) -> String { } private extension TurnNotificationPayload { + var startedAt: Date? { + startedAtMs.map(Self.date(millisecondsSince1970:)) + } + + var completedAt: Date? { + completedAtMs.map(Self.date(millisecondsSince1970:)) + } + var commandOutputID: String? { itemID?.nilIfEmpty ?? processID?.nilIfEmpty ?? processHandle?.nilIfEmpty } @@ -3274,6 +3761,21 @@ private extension TurnNotificationPayload { return summary?.nilIfEmpty ?? details?.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 modelReroutedText: String { let route = [fromModel, toModel].compactMap { $0?.nilIfEmpty }.joined(separator: " -> ") let suffix = reason?.nilIfEmpty.map { " (\($0))" } ?? "" @@ -3286,6 +3788,10 @@ private extension TurnNotificationPayload { } return "Model verification required: \(verifications.joined(separator: ", "))." } + + private static func date(millisecondsSince1970 milliseconds: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) + } } private extension AppServerCommandAction { @@ -3300,7 +3806,7 @@ private extension AppServerCommandAction { } private var metadataKind: ReviewLogEntry.Metadata.CommandAction.Kind { - switch kind.rawValue { + switch kind { case "read": .read case "listFiles": @@ -3311,6 +3817,16 @@ private extension AppServerCommandAction { .unknown } } + + var timelineAction: ReviewTimelineItem.CommandAction { + .init( + kind: .init(rawValue: kind), + command: command, + name: name, + path: path, + query: query + ) + } } private struct AppServerCommandLifecycle: Sendable { @@ -3446,11 +3962,212 @@ private extension Dictionary where Key == String, Value == AppServerCommandLifec } private extension AppServerThreadItem { + var reviewItemKind: ReviewItemKind { + .init(rawValue: rawType) + } + + var family: ReviewItemFamily { + switch rawType { + 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, + status: status.map(ReviewCommandStatus.init(rawValue:)), + source: source.map(ReviewCommandSource.init(rawValue:)), + processID: processID, + actions: commandActions.map(\.timelineAction), + durationMs: durationMs + )) + 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: rawType, 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 + } + + 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) } + } + } + + 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 + } + func startedEvents( startedAt: Date?, lifecycle: AppServerCommandLifecycle? ) -> [CodexReviewBackendModel.Review.Event] { - switch type.rawValue { + switch rawType { case "userMessage": return [] case "enteredReviewMode": @@ -3500,12 +4217,12 @@ private extension AppServerThreadItem { case "agentMessage": return [] default: - return [.logEntry(kind: .event, text: "App-server item started: \(type).", groupID: id, replacesGroup: true)] + return [.logEntry(kind: .event, text: "App-server item started: \(rawType).", groupID: id, replacesGroup: true)] } } func updatedEvents() -> [CodexReviewBackendModel.Review.Event] { - switch type.rawValue { + switch rawType { case "agentMessage": return text.map { [logEntry( @@ -3577,7 +4294,7 @@ private extension AppServerThreadItem { completedAt: Date?, lifecycle: AppServerCommandLifecycle? ) -> [CodexReviewBackendModel.Review.Event] { - switch type.rawValue { + switch rawType { case "userMessage": return [] case "agentMessage": @@ -3678,7 +4395,7 @@ private extension AppServerThreadItem { case "enteredReviewMode": return [] default: - return [.logEntry(kind: .event, text: "App-server item completed: \(type).", groupID: id, replacesGroup: true)] + return [.logEntry(kind: .event, text: "App-server item completed: \(rawType).", groupID: id, replacesGroup: true)] } } @@ -3733,10 +4450,10 @@ private extension AppServerThreadItem { 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 + let isCommandExecution = rawType == "commandExecution" + let isLifecycleItem = isCommandExecution || rawType == "contextCompaction" return .init( - sourceType: type.rawValue, + sourceType: rawType, title: title?.nilIfEmpty, status: resolvedStatus, detail: detail?.nilIfEmpty, @@ -3829,7 +4546,7 @@ private extension AppServerThreadItem { [namespace, server, tool] .compactMap { $0?.nilIfEmpty } .joined(separator: ".") - .nilIfEmpty ?? type.rawValue + .nilIfEmpty ?? rawType } private var resultSuffix: String { @@ -3873,8 +4590,65 @@ private extension AppServerThreadItem { } } -private extension AppServerWireJSONValue { +private enum AppServerTerminalDisposition { + case completed + case failed + case cancelled +} + +private func normalizedStatus(_ value: String?) -> String? { + value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + .nilIfEmpty +} + +private extension AppServerJSONValue { var nonNullDebugText: String? { nonNullText } + + 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 .array, .object: + return jsonString + } + } + + 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" + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(self)) + .flatMap { String(data: $0, encoding: .utf8) } + ?? fallback + } } From 17ba3918002a7c0e8e6bd0ac2d9651da99cabe86 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:20:20 +0900 Subject: [PATCH 040/359] refactor(ui): remove unused codex ui kit selection api --- Docs/architecture.md | 18 -- Package.swift | 22 -- Sources/CodexUIKit/CodexModelSelection.swift | 226 ------------------ .../CodexModelSelectionTests.swift | 125 ---------- 4 files changed, 391 deletions(-) delete mode 100644 Sources/CodexUIKit/CodexModelSelection.swift delete mode 100644 Tests/CodexUIKitTests/CodexModelSelectionTests.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index db819103..e2fe0dfc 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -33,7 +33,6 @@ read them as a claim that every symbol has already moved. | `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and legacy log compatibility projections. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | -| `CodexUIKit` | Future generic UI-facing model/API layer over Codex and app-server concepts. It provides UI-construction models and APIs, uses `LocalizedStringResource` for display values where appropriate, and avoids mirror snapshot state | | `CodexUI` | Concrete AppKit UI and existing hosted SwiftUI views. SwiftUI views remain concrete UI here; there is no requirement to delete or rewrite them just to satisfy target ownership | | `CodexReviewHost` | Runtime composition for ReviewMonitor | | `ReviewMonitorRendering` | Domain timeline rendering helpers that do not know AppKit/SwiftUI or app-server wire | @@ -50,13 +49,6 @@ review notification schema and high-level review streams is `CodexAppServerKit`; raw review DTOs should remain implementation details behind that SDK boundary rather than becoming public ReviewMonitor API. -`CodexUIKit` must not own `CodexReviewStore`, `ReviewTimeline`, -`ReviewMonitorUIState`, sidebar/detail concepts, or concrete `NSView`, -`ViewController`, or SwiftUI `View` types. Those belong to review core/store or -concrete UI targets. `CodexUIKit` may describe reusable UI construction models -over generic Codex/app-server concepts, but it must not become a mirror cache of -ReviewMonitor state. - ## Source Of Truth `CodexReviewKit` owns the semantic timeline vocabulary. It defines the @@ -267,16 +259,6 @@ The public tool surface is: ## Monitor UI Boundary -The intended UI ownership has two layers: - -- `CodexUIKit` is a future generic UI-facing model/API layer over Codex and - app-server concepts. It supplies reusable UI-construction values and APIs, - can use `LocalizedStringResource` for display values where appropriate, and - derives values from the owning model instead of keeping mirror snapshot state. -- `CodexUI` owns concrete AppKit objects and existing hosted SwiftUI views. - SwiftUI views remain here as concrete UI; target ownership does not force a - deletion or rewrite of those views. - `ReviewUI` observes product/domain state and forwards user intent. - Views and view controllers render observable state. diff --git a/Package.swift b/Package.swift index 4ca8f3cb..b0438712 100644 --- a/Package.swift +++ b/Package.swift @@ -20,10 +20,6 @@ let package = Package( name: "CodexAppServerKit", targets: ["CodexAppServerKit"] ), - .library( - name: "CodexUIKit", - targets: ["CodexUIKit"] - ), .library( name: "CodexAppServerKitTesting", targets: ["CodexAppServerKitTesting"] @@ -58,15 +54,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .target( - name: "CodexUIKit", - dependencies: [ - "CodexAppServerKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .target( name: "CodexAppServerKitTesting", dependencies: [ @@ -168,15 +155,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "CodexUIKitTests", - dependencies: [ - "CodexUIKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "CodexReviewAppServerWireTests", dependencies: [ diff --git a/Sources/CodexUIKit/CodexModelSelection.swift b/Sources/CodexUIKit/CodexModelSelection.swift deleted file mode 100644 index cdd323c0..00000000 --- a/Sources/CodexUIKit/CodexModelSelection.swift +++ /dev/null @@ -1,226 +0,0 @@ -import CodexAppServerKit -import Foundation - -/// Describes why an option is present in a UI-facing selection list. -public enum CodexSelectionOptionSource: Equatable, Sendable { - case catalog - case currentSelection -} - -/// A selectable Codex model derived from a model catalog or current selection. -public struct CodexModelOption: Identifiable, Equatable, Sendable { - public var id: String { modelID } - - public var catalogID: String? - public var modelID: String - public var displayName: String - public var isHidden: Bool - public var source: CodexSelectionOptionSource - - public init( - modelID: String, - displayName: String? = nil, - isHidden: Bool = false, - source: CodexSelectionOptionSource = .catalog, - catalogID: String? = nil - ) { - self.catalogID = catalogID - self.modelID = modelID - self.displayName = displayName ?? modelID - self.isHidden = isHidden - self.source = source - } - - public init(catalogModel: CodexModel) { - self.init( - modelID: catalogModel.model, - displayName: catalogModel.displayName, - isHidden: catalogModel.hidden, - source: .catalog, - catalogID: catalogModel.id - ) - } -} - -/// A selectable reasoning effort with display metadata suitable for UI controls. -public struct CodexReasoningEffortOption: Identifiable, Equatable, Sendable { - public var id: String { reasoningEffort.rawValue } - - public var reasoningEffort: CodexReasoningEffort - public var localizedLabel: LocalizedStringResource - public var description: String? - public var source: CodexSelectionOptionSource - - public init( - reasoningEffort: CodexReasoningEffort, - description: String? = nil, - source: CodexSelectionOptionSource = .catalog - ) { - self.reasoningEffort = reasoningEffort - self.localizedLabel = Self.localizedLabel(for: reasoningEffort) - self.description = description - self.source = source - } - - public init(catalogOption: CodexModel.ReasoningOption) { - self.init( - reasoningEffort: catalogOption.reasoningEffort, - description: catalogOption.description, - source: .catalog - ) - } - - private static func localizedLabel(for reasoningEffort: CodexReasoningEffort) -> LocalizedStringResource { - switch reasoningEffort.rawValue { - case CodexReasoningEffort.none.rawValue: - "None" - case CodexReasoningEffort.minimal.rawValue: - "Minimal" - case CodexReasoningEffort.low.rawValue: - "Low" - case CodexReasoningEffort.medium.rawValue: - "Medium" - case CodexReasoningEffort.high.rawValue: - "High" - case CodexReasoningEffort.xhigh.rawValue: - "Extra high" - default: - LocalizedStringResource(stringLiteral: reasoningEffort.rawValue) - } - } -} - -/// A selectable service tier. Unknown tier identifiers are preserved as raw strings. -public struct CodexServiceTierOption: Identifiable, Equatable, Sendable { - public var id: String { serviceTierID } - - public var serviceTierID: String - public var localizedLabel: LocalizedStringResource - public var source: CodexSelectionOptionSource - - public init( - serviceTierID: String, - source: CodexSelectionOptionSource = .catalog - ) { - self.serviceTierID = serviceTierID - self.localizedLabel = Self.localizedLabel(for: serviceTierID) - self.source = source - } - - private static func localizedLabel(for serviceTierID: String) -> LocalizedStringResource { - switch serviceTierID { - case "fast": - "Fast" - case "flex": - "Flex" - default: - LocalizedStringResource(stringLiteral: serviceTierID) - } - } -} - -/// Builds UI-facing model, reasoning, and service tier options from a Codex model catalog. -public struct CodexModelSelection: Equatable, Sendable { - public var models: [CodexModel] - /// The selected Codex model string, matched against `CodexModel.model`. - public var selectedModelID: String? - /// The fallback Codex model string, matched against `CodexModel.model`. - public var fallbackModelID: String? - public var selectedReasoningEffort: CodexReasoningEffort? - public var selectedServiceTierID: String? - - public init( - models: [CodexModel], - selectedModelID: String? = nil, - fallbackModelID: String? = nil, - selectedReasoningEffort: CodexReasoningEffort? = nil, - selectedServiceTierID: String? = nil - ) { - self.models = models - self.selectedModelID = selectedModelID - self.fallbackModelID = fallbackModelID - self.selectedReasoningEffort = selectedReasoningEffort - self.selectedServiceTierID = selectedServiceTierID - } - - public var effectiveModelID: String? { - selectedModelID ?? fallbackModelID - } - - public var effectiveModel: CodexModel? { - guard let effectiveModelID else { - return nil - } - return models.first { $0.model == effectiveModelID } - } - - public var modelOptions: [CodexModelOption] { - var options = models.compactMap { model -> CodexModelOption? in - guard model.hidden == false || model.model == effectiveModelID else { - return nil - } - return .init(catalogModel: model) - } - - if let effectiveModelID, - options.contains(where: { $0.modelID == effectiveModelID }) == false - { - options.append( - .init( - modelID: effectiveModelID, - source: .currentSelection - ) - ) - } - - return options - } - - public var reasoningEffortOptions: [CodexReasoningEffortOption] { - var options = effectiveModel?.supportedReasoningEfforts.map { - CodexReasoningEffortOption(catalogOption: $0) - } ?? [] - - if let selectedReasoningEffort, - options.contains(where: { $0.reasoningEffort == selectedReasoningEffort }) == false - { - options.append( - .init( - reasoningEffort: selectedReasoningEffort, - source: .currentSelection - ) - ) - } - - return options - } - - public var effectiveReasoningEffort: CodexReasoningEffort? { - selectedReasoningEffort ?? effectiveModel?.defaultReasoningEffort - } - - public var serviceTierOptions: [CodexServiceTierOption] { - let catalogServiceTierIDs = Set(effectiveModel?.supportedServiceTiers ?? []) - var serviceTierIDs = effectiveModel?.supportedServiceTiers ?? [] - if let selectedServiceTierID { - serviceTierIDs.append(selectedServiceTierID) - } - - return serviceTierIDs - .uniqueSorted() - .map { serviceTierID in - CodexServiceTierOption( - serviceTierID: serviceTierID, - source: catalogServiceTierIDs.contains(serviceTierID) - ? .catalog - : .currentSelection - ) - } - } -} - -private extension Array where Element == String { - func uniqueSorted() -> [String] { - Array(Set(self)).sorted() - } -} diff --git a/Tests/CodexUIKitTests/CodexModelSelectionTests.swift b/Tests/CodexUIKitTests/CodexModelSelectionTests.swift deleted file mode 100644 index 52b7184f..00000000 --- a/Tests/CodexUIKitTests/CodexModelSelectionTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import CodexAppServerKit -import CodexUIKit -import Foundation -import Testing - -@Suite("CodexUIKit model selection") -struct CodexModelSelectionTests { - @Test func hiddenSelectedModelIsPreservedInOptions() throws { - let visible = makeModel( - model: "gpt-visible", - displayName: "GPT Visible" - ) - let hidden = makeModel( - model: "gpt-hidden", - displayName: "GPT Hidden", - hidden: true - ) - - let selection = CodexModelSelection( - models: [visible, hidden], - selectedModelID: "gpt-hidden" - ) - - #expect(selection.modelOptions.map(\.modelID) == ["gpt-visible", "gpt-hidden"]) - let hiddenOption = try #require(selection.modelOptions.last) - #expect(hiddenOption.displayName == "GPT Hidden") - #expect(hiddenOption.isHidden) - #expect(hiddenOption.source == .catalog) - } - - @Test func missingSelectedModelIsRepresentedInOptions() throws { - let visible = makeModel( - model: "gpt-visible", - displayName: "GPT Visible" - ) - - let selection = CodexModelSelection( - models: [visible], - selectedModelID: "gpt-missing" - ) - - #expect(selection.modelOptions.map(\.modelID) == ["gpt-visible", "gpt-missing"]) - #expect(selection.effectiveModel == nil) - - let missingOption = try #require(selection.modelOptions.last) - #expect(missingOption.displayName == "gpt-missing") - #expect(missingOption.isHidden == false) - #expect(missingOption.source == .currentSelection) - } - - @Test func reasoningLabelsUseLocalizedStringResourcesAndPreserveUnknownValues() throws { - let experimentalEffort = CodexReasoningEffort(rawValue: "experimental") - let selectedEffort = CodexReasoningEffort(rawValue: "bespoke") - let model = makeModel( - model: "gpt-reasoning", - reasoningOptions: [ - .init(reasoningEffort: .medium, description: "Balanced"), - .init(reasoningEffort: experimentalEffort, description: "Experimental"), - ], - defaultReasoningEffort: .medium, - serviceTiers: ["priority"] - ) - - let selection = CodexModelSelection( - models: [model], - selectedModelID: "gpt-reasoning", - selectedReasoningEffort: selectedEffort, - selectedServiceTierID: "priority" - ) - - let options = selection.reasoningEffortOptions - #expect(options.map(\.reasoningEffort) == [.medium, experimentalEffort, selectedEffort]) - #expect(options.map(\.source) == [.catalog, .catalog, .currentSelection]) - #expect(options.first?.localizedLabel == LocalizedStringResource("Medium")) - - let experimentalOption = try #require(options.first { $0.reasoningEffort == experimentalEffort }) - #expect(String(localized: experimentalOption.localizedLabel) == "experimental") - #expect(selection.selectedServiceTierID == "priority") - #expect(selection.serviceTierOptions.map(\.serviceTierID) == ["priority"]) - } - - @Test func serviceTierIDsRemainRawStringsAndAreSorted() throws { - let model = makeModel( - model: "gpt-tiers", - serviceTiers: ["priority", "flex", "fast", "batch", "fast"] - ) - - let selection = CodexModelSelection( - models: [model], - selectedModelID: "gpt-tiers", - selectedServiceTierID: "zippy" - ) - - let options = selection.serviceTierOptions - #expect(options.map(\.serviceTierID) == ["batch", "fast", "flex", "priority", "zippy"]) - #expect(options.map(\.id) == ["batch", "fast", "flex", "priority", "zippy"]) - #expect(options.first { $0.serviceTierID == "fast" }?.localizedLabel == LocalizedStringResource("Fast")) - #expect(options.first { $0.serviceTierID == "flex" }?.localizedLabel == LocalizedStringResource("Flex")) - - let rawOption = try #require(options.first { $0.serviceTierID == "priority" }) - #expect(String(localized: rawOption.localizedLabel) == "priority") - - let selectedOnlyOption = try #require(options.first { $0.serviceTierID == "zippy" }) - #expect(selectedOnlyOption.source == .currentSelection) - } -} - -private func makeModel( - model: String, - displayName: String? = nil, - hidden: Bool = false, - reasoningOptions: [CodexModel.ReasoningOption] = [], - defaultReasoningEffort: CodexReasoningEffort? = nil, - serviceTiers: [String] = [] -) -> CodexModel { - CodexModel( - id: model, - model: model, - displayName: displayName ?? model, - hidden: hidden, - supportedReasoningEfforts: reasoningOptions, - defaultReasoningEffort: defaultReasoningEffort, - supportedServiceTiers: serviceTiers - ) -} From a86552b6661f3efbd18152517818549fdc6a31e9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:23:55 +0900 Subject: [PATCH 041/359] refactor(app-server): remove review wire target --- Docs/architecture.md | 23 +- Package.swift | 18 - .../AppServerReviewEventKind.swift | 25 - .../AppServerWireEvent.swift | 21 - .../AppServerWireJSONValue.swift | 106 -- .../AppServerWireReviewNotification.swift | 253 --- .../AppServerWireReviewPayload.swift | 1174 ------------- .../AppServerWireEventTests.swift | 1457 ----------------- 8 files changed, 2 insertions(+), 3075 deletions(-) delete mode 100644 Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift delete mode 100644 Sources/CodexReviewAppServerWire/AppServerWireEvent.swift delete mode 100644 Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift delete mode 100644 Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift delete mode 100644 Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift delete mode 100644 Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index e2fe0dfc..b371dd80 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -23,9 +23,7 @@ into domain events. ## Targets -This table describes intended ownership. Some names in this table are target -ownership names that may still be pending migration in `Package.swift`; do not -read them as a claim that every symbol has already moved. +This table describes intended ownership. | Target | Responsibility | | --- | --- | @@ -43,12 +41,6 @@ read them as a claim that every symbol has already moved. ReviewMonitor is the product entry point. The host target wires the concrete runtime together; lower targets do not import the host. -`CodexReviewAppServerWire` exists today as a transitional raw decoder target. -It is not an intended ownership boundary. The intended owner for app-server -review notification schema and high-level review streams is -`CodexAppServerKit`; raw review DTOs should remain implementation details -behind that SDK boundary rather than becoming public ReviewMonitor API. - ## Source Of Truth `CodexReviewKit` owns the semantic timeline vocabulary. It defines the @@ -67,18 +59,10 @@ entries, models, accounts, and login flows. Raw review DTOs may exist as internal implementation detail, but they are not the public API boundary. It must not import Review, UI, or MCP targets. -`CodexReviewAppServerWire` is a current transitional decoder for raw app-server -review notification shapes. While it exists during migration, it must not be -treated as the intended owner of review wire schema or public API. The intended -direction is for `CodexAppServerKit` to provide the high-level review stream -boundary and keep raw DTOs out of ReviewMonitor-facing contracts. - `CodexReviewAppServer` owns the adapter from high-level app-server review sessions into `CodexReviewKit` backend events. It consumes `CodexReviewSession` values from `CodexAppServerKit` and hands application-facing review events to -the store backend. If current code still passes through -`CodexReviewAppServerWire`, that is a migration detail, not the ownership -model. +the store backend. `ReviewTimeline` and application/store state are the observable source of truth after conversion. UI views, rendering helpers, MCP server projections, and @@ -160,8 +144,6 @@ flowchart TB The diagram describes ownership direction, not every SwiftPM dependency. New code should not move domain, application, rendering, UI, or MCP projection responsibilities back into runtime owners. -`CodexReviewAppServerWire` is intentionally omitted from the intended ownership -graph because it is a current transitional decoder, not a target owner. ## Observation Ownership @@ -281,7 +263,6 @@ Default tests are deterministic and do not start a live `codex app-server`. | Test area | Uses | Verifies | | --- | --- | --- | | `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, domain result aggregation, and high-level review stream behavior | -| `CodexReviewAppServerWireTests` | Raw notification JSON while the transitional decoder exists | Migration compatibility for decode/conversion behavior, not intended target ownership | | `CodexReviewKitTests` | Domain timelines, ObservationBridge awaiters, and fake `CodexReviewStoreBackend` | Semantic timeline mutation, use-case observation behavior, review/auth/settings state machines, cancellation, result retention | | `CodexReviewAppServerTests` | Fake app-server review sessions or transport as needed | Adapter behavior from high-level review sessions into backend events, cleanup, and recovery | | `CodexReviewMCPServerTests` | Fake review store and domain timeline projections | MCP protocol conversion, response shape, and timeline projection snapshots | diff --git a/Package.swift b/Package.swift index b0438712..d17a029a 100644 --- a/Package.swift +++ b/Package.swift @@ -63,15 +63,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .target( - name: "CodexReviewAppServerWire", - dependencies: [ - "CodexReviewKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .target( name: "CodexReviewAppServer", dependencies: [ @@ -155,15 +146,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "CodexReviewAppServerWireTests", - dependencies: [ - "CodexReviewAppServerWire", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "CodexReviewKitTests", dependencies: ["CodexReviewKit", "CodexReviewTesting"], diff --git a/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift b/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift deleted file mode 100644 index acfc78ac..00000000 --- a/Sources/CodexReviewAppServerWire/AppServerReviewEventKind.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import CodexReviewKit - -public struct AppServerReviewEventKind: 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" -} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift b/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift deleted file mode 100644 index 5dde4bd5..00000000 --- a/Sources/CodexReviewAppServerWire/AppServerWireEvent.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import CodexReviewKit - -public struct AppServerWireEvent: Equatable, Sendable { - public var kind: AppServerReviewEventKind - public var itemKind: ReviewItemKind? - public var itemID: ReviewTimelineItem.ID? - public var timestamp: Date? - - public init( - kind: AppServerReviewEventKind, - itemKind: ReviewItemKind? = nil, - itemID: ReviewTimelineItem.ID? = nil, - timestamp: Date? = nil - ) { - self.kind = kind - self.itemKind = itemKind - self.itemID = itemID - self.timestamp = timestamp - } -} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift b/Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift deleted file mode 100644 index 82825448..00000000 --- a/Sources/CodexReviewAppServerWire/AppServerWireJSONValue.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -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 - } -} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift deleted file mode 100644 index 095c4b43..00000000 --- a/Sources/CodexReviewAppServerWire/AppServerWireReviewNotification.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Foundation -import CodexReviewKit - -public struct AppServerWireReviewNotification: Decodable, Equatable, Sendable { - public var method: AppServerReviewEventKind - public var payload: Payload - public var rawPayload: AppServerWireJSONValue? - - public var rawMethod: String { - method.rawValue - } - - public init( - method: AppServerReviewEventKind, - payload: Payload = Payload(), - rawPayload: AppServerWireJSONValue? = nil - ) { - self.method = method - self.payload = payload - self.rawPayload = rawPayload - } - - public init(method: String, paramsData: Data) throws { - let paramsObject = try JSONSerialization.jsonObject( - with: paramsData, - options: [.fragmentsAllowed] - ) - let envelope: [String: Any] = [ - "method": method, - "params": paramsObject, - ] - let data = try JSONSerialization.data(withJSONObject: envelope) - self = try JSONDecoder().decode(Self.self, from: data) - } - - 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 = AppServerReviewEventKind(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 AppServerReviewEventKind { - var isReviewNotificationMethod: Bool { - switch self { - case .threadClosed, - .threadStatusChanged, - .turnStarted, - .turnCompleted, - .turnFailed, - .turnCancelled, - .turnAborted, - .turnDiffUpdated, - .turnPlanUpdated, - .itemStarted, - .itemUpdated, - .itemCompleted, - .autoApprovalReviewStarted, - .autoApprovalReviewCompleted, - .agentMessageDelta, - .planDelta, - .reasoningSummaryTextDelta, - .reasoningSummaryPartAdded, - .reasoningTextDelta, - .commandExecutionOutputDelta, - .commandExecutionTerminalInteraction, - .commandExecOutputDelta, - .processOutputDelta, - .fileChangeOutputDelta, - .fileChangePatchUpdated, - .mcpToolCallProgress, - .agentMessage, - .log, - .error, - .modelRerouted, - .modelVerification, - .threadCompacted, - .warning, - .guardianWarning, - .deprecationNotice, - .configWarning, - .diagnostic: - true - default: - false - } - } - - var isThreadlessReviewBroadcast: Bool { - switch self { - case .warning, .deprecationNotice, .configWarning, .error: - true - default: - false - } - } -} - -public extension AppServerWireReviewNotification { - var startsReviewMode: Bool { - method == .itemStarted && payload.item?.type.rawValue == "enteredReviewMode" - } - - var finishesReviewMode: Bool { - method == .itemCompleted && payload.item?.type.rawValue == "exitedReviewMode" - } -} -private extension AppServerReviewEventKind { - 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" -} diff --git a/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift b/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift deleted file mode 100644 index 414d94ec..00000000 --- a/Sources/CodexReviewAppServerWire/AppServerWireReviewPayload.swift +++ /dev/null @@ -1,1174 +0,0 @@ -import Foundation -import CodexReviewKit - -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) - } - - public 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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [ReviewDomainEvent] { - guard let message = diagnosticMessage else { - return unknownEvent(method: method) - } - return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] - } - - func modelReroutedEvents(method: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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: AppServerReviewEventKind) -> [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 - } - } - - public var startedAt: Date? { - startedAtMs.map(Self.date(millisecondsSince1970:)) - } - - public var completedAt: Date? { - completedAtMs.map(Self.date(millisecondsSince1970:)) - } - - private func diagnosticSeed( - method: AppServerReviewEventKind, - 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 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/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift b/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift deleted file mode 100644 index 274653e7..00000000 --- a/Tests/CodexReviewAppServerWireTests/AppServerWireEventTests.swift +++ /dev/null @@ -1,1457 +0,0 @@ -import Foundation -import Testing -@testable import CodexReviewAppServerWire -import CodexReviewKit - -@Suite("app-server wire events") -struct AppServerWireEventTests { - @Test func classifiesReviewNotificationsAndReviewModeMarkers() throws { - #expect(AppServerReviewEventKind(rawValue: "turn/started").isReviewNotificationMethod) - #expect(AppServerReviewEventKind(rawValue: "warning").isThreadlessReviewBroadcast) - #expect(AppServerReviewEventKind(rawValue: "future/event").isReviewNotificationMethod == false) - #expect(AppServerReviewEventKind(rawValue: "diagnostic").isThreadlessReviewBroadcast == false) - - let entered = try decodeNotification(""" - { - "method": "item/started", - "params": { - "item": { - "id": "review-mode", - "type": "enteredReviewMode" - } - } - } - """) - #expect(entered.startsReviewMode) - #expect(entered.finishesReviewMode == false) - - let exited = try decodeNotification(""" - { - "method": "item/completed", - "params": { - "item": { - "id": "review-mode", - "type": "exitedReviewMode" - } - } - } - """) - #expect(exited.finishesReviewMode) - #expect(exited.startsReviewMode == false) - } - - @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)) -} From 31f4c156e7cf231968042c62d7ba614b9ea377c0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:29:44 +0900 Subject: [PATCH 042/359] refactor(app-server): own active turn interrupt retry --- .../CodexAppServerKit/CodexDomainTypes.swift | 35 ++++++++++- .../CodexThreadOperations.swift | 62 +++++++++++++++++-- .../AppServerReviewControl.swift | 50 ++++++--------- .../CodexAppServerKitTests.swift | 34 ++++++++++ 4 files changed, 144 insertions(+), 37 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index fd2f846d..f77b2c26 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -855,7 +855,11 @@ public struct CodexReviewSession: Identifiable, Sendable { } /// Interrupts the running review turn. - public func interrupt() async throws { + /// + /// - Returns: The turn that the app-server actually interrupted. This can + /// differ from `turnID` when the app-server reports a newer active turn. + @discardableResult + public func interrupt() async throws -> CodexTurnInterruption { try await response.interrupt() } @@ -1500,6 +1504,27 @@ public struct CodexResponse: Identifiable, Equatable, Sendable { } } +/// The turn interrupted by an app-server control request. +public struct CodexTurnInterruption: Equatable, Sendable { + /// The thread that owns the interrupted turn. + public var threadID: CodexThreadID + + /// The interrupted turn, when the app-server reported one. + public var turnID: CodexTurnID? + + public init(threadID: CodexThreadID, turnID: CodexTurnID?) { + self.threadID = threadID + self.turnID = turnID + } + + package init(threadID: String, turnID: String?) { + self.threadID = .init(rawValue: threadID) + self.turnID = turnID.flatMap { value in + value.isEmpty ? nil : CodexTurnID(rawValue: value) + } + } +} + public struct CodexResponseStream: AsyncSequence, Sendable { public enum SubmissionMode: Equatable, Sendable { case queueAfterCurrentResponse @@ -1563,7 +1588,13 @@ public struct CodexResponseStream: AsyncSequence, Sendable { } } - public func interrupt() async throws { + /// Interrupts the running response. + /// + /// - Returns: The turn that the app-server actually interrupted. This can + /// differ from the stream's original turn when the app-server reports a + /// newer active turn. + @discardableResult + public func interrupt() async throws -> CodexTurnInterruption { try await turn.interrupt() } diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 2388a2ca..a2bed175 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -336,11 +336,63 @@ extension CodexTurn { try await steer(with: CodexPrompt(prompt)) } - package func interrupt() async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Turn.Interrupt.Request( - params: .init(threadID: threadID.rawValue, turnID: id.rawValue) - )) + @discardableResult + package func interrupt() async throws -> CodexTurnInterruption { + try await interruptCodexTurn(threadID: threadID, turnID: id, client: client) + } +} + +@discardableResult +package func interruptCodexTurn( + threadID: CodexThreadID, + turnID: CodexTurnID?, + client: AppServerClient, + willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? = nil +) async throws -> CodexTurnInterruption { + do { + try await sendInterrupt(threadID: threadID, turnID: turnID, client: client) + return .init(threadID: threadID, turnID: turnID) + } catch { + guard let activeTurnID = activeTurnID(from: error), + activeTurnID != turnID?.rawValue + else { + throw error + } + let activeTurn = CodexTurnID(rawValue: activeTurnID) + let interruption = CodexTurnInterruption(threadID: threadID, turnID: activeTurn) + if let willInterruptActiveTurn { + await willInterruptActiveTurn(interruption) + } + try await sendInterrupt(threadID: threadID, turnID: activeTurn, client: client) + return interruption + } +} + +private func sendInterrupt( + threadID: CodexThreadID, + turnID: CodexTurnID?, + client: AppServerClient +) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Turn.Interrupt.Request( + params: .init(threadID: threadID.rawValue, turnID: turnID?.rawValue ?? "") + )) +} + +private 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)) + .nonEmpty +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self } } diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index fadb845e..e773a310 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -89,27 +89,28 @@ package final class AppServerReviewControl: @unchecked Sendable { 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 interruption = try await interruptCodexTurn( + threadID: .init(rawValue: threadID), + turnID: turnID.nilIfEmpty.map(CodexTurnID.init(rawValue:)), + client: client, + willInterruptActiveTurn: { interruption in + guard let willInterruptActiveTurn else { + return + } + await willInterruptActiveTurn(AppServerReviewInterruption( + threadID: interruption.threadID.rawValue, + turnID: interruption.turnID?.rawValue ?? "" + )) } - 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 + ) + let reviewInterruption = AppServerReviewInterruption( + threadID: interruption.threadID.rawValue, + turnID: interruption.turnID?.rawValue ?? "" + ) + if reviewInterruption.turnID != turnID { + setPhase(.reviewStarted(turnThreadID: threadID, turnID: reviewInterruption.turnID)) } + return reviewInterruption } private func setPhase(_ phase: Phase) { @@ -117,15 +118,4 @@ package final class AppServerReviewControl: @unchecked Sendable { 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/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index a4c783de..06190cbe 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -1135,6 +1135,40 @@ struct CodexAppServerKitTests { #expect(params.turnID == "turn-1") } + @Test func responseStreamInterruptRetriesWithCurrentActiveTurnID() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-old", status: "running")), + for: "turn/start" + ) + await transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-old but found turn-new", + for: "turn/interrupt" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let client = AppServerClient(transport: transport) + let router = CodexAppServerNotificationRouter(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + let interruption = try await stream.interrupt() + + #expect(interruption.threadID == "thread-1") + #expect(interruption.turnID == "turn-new") + let interruptRequests = await transport.recordedRequests().filter { + $0.method == "turn/interrupt" + } + let turnIDs = try interruptRequests.map { request in + try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(turnIDs == ["turn-old", "turn-new"]) + } + @Test func responseStreamSteerSubmitsInputToCurrentTurn() async throws { let transport = CodexAppServerTestTransport() try await transport.enqueue( From f2f9cd2d9040c2fe337d93f9e232351bc80a42c9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:36:37 +0900 Subject: [PATCH 043/359] refactor(ui): split monitor presentation state --- .../ReviewUI/ReviewMonitorContentKind.swift | 4 + Sources/ReviewUI/ReviewMonitorSelection.swift | 7 ++ Sources/ReviewUI/ReviewMonitorUIState.swift | 111 ------------------ Sources/ReviewUI/SidebarJobFilter.swift | 76 ++++++++++++ Sources/ReviewUI/SidebarPickerSelection.swift | 24 ++++ 5 files changed, 111 insertions(+), 111 deletions(-) create mode 100644 Sources/ReviewUI/ReviewMonitorContentKind.swift create mode 100644 Sources/ReviewUI/ReviewMonitorSelection.swift create mode 100644 Sources/ReviewUI/SidebarJobFilter.swift create mode 100644 Sources/ReviewUI/SidebarPickerSelection.swift 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/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift new file mode 100644 index 00000000..1280511a --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -0,0 +1,7 @@ +import CodexReviewKit + +@MainActor +enum ReviewMonitorSelection { + case workspaceSection(ReviewMonitorWorkspaceSectionSelection) + case job(CodexReviewJob) +} diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index cb49b5cb..ccfe6455 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -1,7 +1,5 @@ import Observation import CodexReviewKit -import Foundation -import SwiftUI @MainActor @Observable @@ -60,112 +58,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/SidebarJobFilter.swift b/Sources/ReviewUI/SidebarJobFilter.swift new file mode 100644 index 00000000..fbdb6abe --- /dev/null +++ b/Sources/ReviewUI/SidebarJobFilter.swift @@ -0,0 +1,76 @@ +import Foundation + +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/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" + } + } +} From d27006bfa9ce49c7d6ee976a90f27434c7c99f42 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:42:02 +0900 Subject: [PATCH 044/359] refactor(app-server): drive review interrupts through sessions --- .../CodexAppServerKit/CodexDomainTypes.swift | 14 ++++ .../CodexThreadOperations.swift | 12 +++ .../AppServerCodexReviewBackend.swift | 5 +- .../AppServerReviewControl.swift | 80 ++++++++++++++++--- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index f77b2c26..05272e43 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -863,6 +863,13 @@ public struct CodexReviewSession: Identifiable, Sendable { try await response.interrupt() } + @discardableResult + package func interrupt( + willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? + ) async throws -> CodexTurnInterruption { + try await response.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) + } + /// Sends additional input to the running review turn. public func steer(with prompt: CodexPrompt) async throws { try await response.steer(with: prompt) @@ -1598,6 +1605,13 @@ public struct CodexResponseStream: AsyncSequence, Sendable { try await turn.interrupt() } + @discardableResult + package func interrupt( + willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? + ) async throws -> CodexTurnInterruption { + try await turn.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) + } + public func steer(with prompt: CodexPrompt) async throws { try await turn.steer(with: prompt) } diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index a2bed175..4dbc8e77 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -340,6 +340,18 @@ extension CodexTurn { package func interrupt() async throws -> CodexTurnInterruption { try await interruptCodexTurn(threadID: threadID, turnID: id, client: client) } + + @discardableResult + package func interrupt( + willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? + ) async throws -> CodexTurnInterruption { + try await interruptCodexTurn( + threadID: threadID, + turnID: id, + client: client, + willInterruptActiveTurn: willInterruptActiveTurn + ) + } } @discardableResult diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index a7c8903f..fba6ae71 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -282,10 +282,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) await session.updateRun(run) registerReviewEventSession(session, for: run) - control.recordReviewStarted( - turnThreadID: appServerTurnThreadID(for: run), - turnID: review.turnID.rawValue - ) + control.recordReviewStarted(review) await session.finalizeRun() await session.startConsuming(review) diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index e773a310..37131a24 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -20,9 +20,15 @@ package final class AppServerReviewControl: @unchecked Sendable { case finished } + private struct Snapshot { + var phase: Phase + var reviewSession: CodexReviewSession? + } + private let client: AppServerClient private let phaseLock = NSLock() private var phase: Phase = .preparing + private var reviewSession: CodexReviewSession? package init(client: AppServerClient) { self.client = client @@ -35,26 +41,39 @@ package final class AppServerReviewControl: @unchecked Sendable { return } phase = .threadStarted(threadID: threadID) + reviewSession = nil } package func recordReviewStarted(turnThreadID: String, turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID) + reviewSession = nil + } + + package func recordReviewStarted(_ reviewSession: CodexReviewSession) { + phaseLock.lock() + defer { phaseLock.unlock() } + phase = .reviewStarted( + turnThreadID: reviewSession.reviewThreadID.rawValue, + turnID: reviewSession.turnID.rawValue + ) + self.reviewSession = reviewSession } package func recordTurnStarted(turnThreadID: String, turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID) + reviewSession = nil } @discardableResult package func interrupt( willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? = nil ) async throws -> AppServerReviewInterruption? { - let currentPhase = phaseSnapshot() - switch currentPhase { + let snapshot = stateSnapshot() + switch snapshot.phase { case .preparing, .finished: return nil case .threadStarted(let threadID): @@ -64,6 +83,13 @@ package final class AppServerReviewControl: @unchecked Sendable { willInterruptActiveTurn: willInterruptActiveTurn ) case .reviewStarted(let turnThreadID, let turnID): + if let reviewSession = snapshot.reviewSession { + return try await interrupt( + reviewSession, + expectedTurnID: turnID, + willInterruptActiveTurn: willInterruptActiveTurn + ) + } return try await sendInterrupt( threadID: turnThreadID, turnID: turnID, @@ -76,12 +102,36 @@ package final class AppServerReviewControl: @unchecked Sendable { phaseLock.lock() defer { phaseLock.unlock() } phase = .finished + reviewSession = nil } - private func phaseSnapshot() -> Phase { + private func stateSnapshot() -> Snapshot { phaseLock.lock() defer { phaseLock.unlock() } - return phase + return Snapshot(phase: phase, reviewSession: reviewSession) + } + + private func interrupt( + _ reviewSession: CodexReviewSession, + expectedTurnID: String, + willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? + ) async throws -> AppServerReviewInterruption { + let interruption = try await reviewSession.interrupt { interruption in + guard let willInterruptActiveTurn else { + return + } + await willInterruptActiveTurn(AppServerReviewInterruption(interruption)) + } + let reviewInterruption = AppServerReviewInterruption(interruption) + if reviewInterruption.turnID != expectedTurnID { + setPhase( + .reviewStarted( + turnThreadID: reviewInterruption.threadID, + turnID: reviewInterruption.turnID + ) + ) + } + return reviewInterruption } private func sendInterrupt( @@ -97,25 +147,29 @@ package final class AppServerReviewControl: @unchecked Sendable { guard let willInterruptActiveTurn else { return } - await willInterruptActiveTurn(AppServerReviewInterruption( - threadID: interruption.threadID.rawValue, - turnID: interruption.turnID?.rawValue ?? "" - )) + await willInterruptActiveTurn(AppServerReviewInterruption(interruption)) } ) - let reviewInterruption = AppServerReviewInterruption( - threadID: interruption.threadID.rawValue, - turnID: interruption.turnID?.rawValue ?? "" - ) + let reviewInterruption = AppServerReviewInterruption(interruption) if reviewInterruption.turnID != turnID { setPhase(.reviewStarted(turnThreadID: threadID, turnID: reviewInterruption.turnID)) } return reviewInterruption } - private func setPhase(_ phase: Phase) { + private func setPhase(_ phase: Phase, reviewSession: CodexReviewSession? = nil) { phaseLock.lock() defer { phaseLock.unlock() } self.phase = phase + self.reviewSession = reviewSession + } +} + +private extension AppServerReviewInterruption { + init(_ interruption: CodexTurnInterruption) { + self.init( + threadID: interruption.threadID.rawValue, + turnID: interruption.turnID?.rawValue ?? "" + ) } } From 04a23b07cfae80efd1cdd18b71c312a4cf683d1b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:43:02 +0900 Subject: [PATCH 045/359] docs: align architecture targets with implementation --- Docs/architecture.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index b371dd80..919f3bcf 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -31,10 +31,9 @@ This table describes intended ownership. | `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and legacy log compatibility projections. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | -| `CodexUI` | Concrete AppKit UI and existing hosted SwiftUI views. SwiftUI views remain concrete UI here; there is no requirement to delete or rewrite them just to satisfy target ownership | | `CodexReviewHost` | Runtime composition for ReviewMonitor | | `ReviewMonitorRendering` | Domain timeline rendering helpers that do not know AppKit/SwiftUI or app-server wire | -| `ReviewUI` | Current concrete native monitor UI target while UI ownership names are still migrating | +| `ReviewUI` | Concrete ReviewMonitor UI: AppKit views/controllers, existing hosted SwiftUI views, and monitor presentation state such as selection and sidebar filters | | `CodexReviewTesting` | Deterministic fake backend, fake JSON-RPC transport, gates, manual clock | | `TextTransitions` | UI text transition view support | From d2c81247f0ac270f4dc6e494d40e319d5a74e2c5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:55:43 +0900 Subject: [PATCH 046/359] refactor(review): extract legacy log buffer --- .../CodexReviewKit/Model/CodexReviewJob.swift | 974 ++--------------- .../Model/ReviewLegacyLogBuffer.swift | 987 ++++++++++++++++++ .../ReviewLogEntryTimelineProjection.swift | 86 +- .../ReviewTimelineLegacyLogProjection.swift | 15 +- 4 files changed, 1130 insertions(+), 932 deletions(-) create mode 100644 Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index a5523acc..9b39013d 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -9,418 +9,13 @@ public final class CodexReviewJob: Identifiable, Hashable { 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 + private var legacyLogBuffer: ReviewLegacyLogBuffer public nonisolated let id: String public let domainJob: ReviewJob @@ -488,7 +83,8 @@ public final class CodexReviewJob: Identifiable, Hashable { cancellationRequested: Bool = false, logEntries: [ReviewLogEntry] ) { - let initialState = Self.trimmedLogState(entries: logEntries) + let initialLogBuffer = ReviewLegacyLogBuffer(entries: logEntries) + let initialLogSnapshot = initialLogBuffer.snapshot self.id = id self.domainJob = ReviewJob(id: .init(rawValue: id)) self.sessionID = sessionID @@ -508,14 +104,14 @@ public final class CodexReviewJob: Identifiable, Hashable { 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.legacyLogBuffer = initialLogBuffer + self.logEntries = initialLogSnapshot.entries + self.logText = initialLogSnapshot.logText + self.rawLogText = initialLogSnapshot.rawLogText + self.reviewOutputText = initialLogSnapshot.reviewOutputText + self.activityLogText = initialLogSnapshot.activityLogText + self.diagnosticText = initialLogSnapshot.diagnosticText + self.cappedLogBytes = initialLogSnapshot.cappedBytes self.logRevision = 0 self.lastLogMutation = .reload if usesDirectTimelineEvents == false { @@ -532,12 +128,8 @@ public final class CodexReviewJob: Identifiable, Hashable { } 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 + legacyLogBuffer.replace(with: entries) + syncLogEntriesFromBuffer() if resetDirectTimeline { usesDirectTimelineEvents = false pendingLegacyTimelineProjectionSuppressions = 0 @@ -554,19 +146,13 @@ public final class CodexReviewJob: Identifiable, Hashable { } else { rebuildTimelineFromLogEntries() } - syncLogState(mutation: .reload) + syncLogSnapshot(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) - } + let appendResult = legacyLogBuffer.append(entry) + let entry = appendResult.entry + syncLogEntriesFromBuffer() if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { let allowsPendingFallback = suppressTimelineProjection || pendingLegacyTimelineProjectionSuppressions > 0 if entry.retainedTimelineText != nil { @@ -592,11 +178,11 @@ public final class CodexReviewJob: Identifiable, Hashable { if didTrim { rebuildTimelineFromLogEntries() } - syncLogState(mutation: didTrim || supportsIncrementalAppend == false ? .reload : .append) + syncLogSnapshot(mutation: didTrim ? .reload : LogMutation(appendResult.mutation)) } package func closeActiveCommandLogEntries(status: String, completedAt: Date) { - let replacements = Self.activeCommandClosingEntries( + let replacements = ReviewLegacyLogBuffer.activeCommandClosingEntries( entries: logEntries, status: status, completedAt: completedAt @@ -747,22 +333,16 @@ public final class CodexReviewJob: Identifiable, Hashable { guard trimReviewLogToLimit() else { return false } - syncLogState(mutation: .reload) + syncLogSnapshot(mutation: .reload) return true } @discardableResult private func trimReviewLogToLimit() -> Bool { - guard logState.cappedBytes > Self.logLimitBytes else { + guard legacyLogBuffer.applyLimit() else { return false } - - let trimmedState = Self.trimmedLogState(entries: logEntries) - guard trimmedState.entries != logEntries else { - return false - } - logEntries = trimmedState.entries - logState = trimmedState.logState + syncLogEntriesFromBuffer() if usesDirectTimelineEvents { trimTimelineTextContentToLogEntries() } else { @@ -776,496 +356,60 @@ public final class CodexReviewJob: Identifiable, Hashable { keeping direction: TruncationDirection, overflowBytes: Int ) { - var entries = logEntries - guard entries.indices.contains(index) else { + guard legacyLogBuffer.truncateOrRemoveEntry( + at: index, + keeping: ReviewLegacyLogBuffer.TruncationDirection(direction), + overflowBytes: overflowBytes + ) 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) + syncLogEntriesFromBuffer() + if usesDirectTimelineEvents { + trimTimelineTextContentToLogEntries() } 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) + rebuildTimelineFromLogEntries() } + syncLogSnapshot(mutation: .reload) } - 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 func syncLogEntriesFromBuffer() { + logEntries = legacyLogBuffer.entries } - 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 func syncLogSnapshot(mutation: LogMutation) { + let snapshot = legacyLogBuffer.snapshot + logEntries = snapshot.entries + logText = snapshot.logText + rawLogText = snapshot.rawLogText + reviewOutputText = snapshot.reviewOutputText + activityLogText = snapshot.activityLogText + diagnosticText = snapshot.diagnosticText + cappedLogBytes = snapshot.cappedBytes + lastLogMutation = mutation + logRevision &+= 1 } - 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 +private extension CodexReviewJob.LogMutation { + init(_ mutation: ReviewLegacyLogBuffer.Mutation) { + switch mutation { + case .reload: + self = .reload + case .append: + self = .append } - 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 +private extension ReviewLegacyLogBuffer.TruncationDirection { + init(_ direction: CodexReviewJob.TruncationDirection) { + switch direction { + case .prefix: + self = .prefix + case .suffix: + self = .suffix } - return nil } } diff --git a/Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift b/Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift new file mode 100644 index 00000000..51a836f4 --- /dev/null +++ b/Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift @@ -0,0 +1,987 @@ +import Foundation + +package struct ReviewLegacyLogBuffer { + package enum TruncationDirection { + case prefix + case suffix + } + + package enum Mutation { + case reload + case append + } + + package struct AppendResult { + package var entry: ReviewLogEntry + package var mutation: Mutation + } + + package struct Snapshot { + package var entries: [ReviewLogEntry] + package var logText: String + package var rawLogText: String + package var reviewOutputText: String + package var activityLogText: String + package var diagnosticText: String + package var cappedBytes: Int + } + + package private(set) var entries: [ReviewLogEntry] + private var projectionState: ReviewLegacyLogProjectionState + + package init(entries: [ReviewLogEntry]) { + let trimmed = Self.trimmedEntriesAndProjection(entries: entries) + self.entries = trimmed.entries + self.projectionState = trimmed.projectionState + } + + package var cappedBytes: Int { + projectionState.cappedBytes + } + + package var snapshot: Snapshot { + Snapshot( + entries: entries, + logText: projectionState.logText, + rawLogText: projectionState.rawLogText, + reviewOutputText: projectionState.reviewOutputText, + activityLogText: projectionState.activityLogText, + diagnosticText: projectionState.diagnosticText, + cappedBytes: projectionState.cappedBytes + ) + } + + package mutating func replace(with entries: [ReviewLogEntry]) { + let trimmed = Self.trimmedEntriesAndProjection(entries: entries) + self.entries = trimmed.entries + self.projectionState = trimmed.projectionState + } + + package mutating func append(_ entry: ReviewLogEntry) -> AppendResult { + let entry = entry.clampingUnownedRetainedMetadata(maxBytes: Self.limitBytes) + let supportsIncrementalAppend = projectionState.supportsIncrementalAppend(entry) + if supportsIncrementalAppend { + projectionState.append(entry) + } + entries.append(entry) + if supportsIncrementalAppend == false { + projectionState = ReviewLegacyLogProjectionState(entries: entries) + } + return AppendResult( + entry: entry, + mutation: supportsIncrementalAppend ? .append : .reload + ) + } + + @discardableResult + package mutating func applyLimit() -> Bool { + guard projectionState.cappedBytes > Self.limitBytes else { + return false + } + + let trimmed = Self.trimmedEntriesAndProjection(entries: entries) + guard trimmed.entries != entries else { + return false + } + entries = trimmed.entries + projectionState = trimmed.projectionState + return true + } + + @discardableResult + package mutating func truncateOrRemoveEntry( + at index: Int, + keeping direction: TruncationDirection, + overflowBytes: Int + ) -> Bool { + guard entries.indices.contains(index) else { + return false + } + + var updatedEntries = entries + let entry = updatedEntries[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 { + updatedEntries.remove(at: index) + } else { + updatedEntries[index] = ReviewLogEntry( + id: entry.id, + kind: entry.kind, + groupID: entry.groupID, + replacesGroup: entry.replacesGroup, + text: truncatedText, + metadata: entry.metadata, + timestamp: entry.timestamp + ) + } + replace(with: updatedEntries) + return true + } + + package 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 struct TrimmedEntriesAndProjection { + var entries: [ReviewLogEntry] + var projectionState: ReviewLegacyLogProjectionState + } + + private static func trimmedEntriesAndProjection( + entries initialEntries: [ReviewLogEntry] + ) -> TrimmedEntriesAndProjection { + var entries = initialEntries.map { + $0.clampingUnownedRetainedMetadata(maxBytes: limitBytes) + } + var projectionState = ReviewLegacyLogProjectionState(entries: entries) + + while projectionState.cappedBytes > limitBytes { + let overflowBytes = projectionState.cappedBytes - limitBytes + guard let trimmedEntries = trimOnce(entries: entries, overflowBytes: overflowBytes) else { + break + } + entries = trimmedEntries + projectionState = ReviewLegacyLogProjectionState(entries: entries) + } + + return .init(entries: entries, projectionState: projectionState) + } + + private 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: { + ReviewLegacyLogProjectionState.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 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 && ReviewLegacyLogProjectionState.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 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] = ReviewLogEntry( + 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 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 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()) + } + + package static let limitBytes = 256 * 1024 +} + +package struct ReviewLegacyLogProjectionState { + private struct GroupKey: Hashable { + var kind: ReviewLogEntry.Kind + var groupID: String + } + + private struct RenderedBlock { + var kind: ReviewLogEntry.Kind + var groupID: String? + var text: String + } + + 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 var blocks: [RenderedBlock] + private var indexByGroup: [GroupKey: Int] + private var logProjection: ProjectionAccumulator + private var reviewOutputProjection: ProjectionAccumulator + private var activityProjection: ProjectionAccumulator + private var errorProjection: ProjectionAccumulator + private var rawProjection: ProjectionAccumulator + private var cappedProjection: ProjectionAccumulator + + package init(entries: [ReviewLogEntry]) { + self = Self.rebuild(entries: entries) + } + + package var logText: String { + logProjection.text + } + + package var rawLogText: String { + rawProjection.text + } + + package var reviewOutputText: String { + reviewOutputProjection.text + } + + package var activityLogText: String { + activityProjection.text + } + + package var diagnosticText: String { + Self.combinedText( + sections: [ + errorProjection.text, + rawProjection.text, + ] + ) + } + + package var cappedBytes: Int { + cappedProjection.text.utf8.count + } + + package func supportsIncrementalAppend(_ entry: ReviewLogEntry) -> Bool { + guard entry.replacesGroup == false, + let key = Self.mergeKey(for: entry), + let blockIndex = indexByGroup[key] + else { + return entry.replacesGroup == false + } + return blockIndex == blocks.indices.last + } + + package mutating func append(_ entry: ReviewLogEntry) { + if let key = Self.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 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 + } + + private static func rebuild(entries: [ReviewLogEntry]) -> ReviewLegacyLogProjectionState { + var state = ReviewLegacyLogProjectionState( + 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 = Self.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 + } + + private mutating func ingestBlock(_ block: RenderedBlock, at index: Int) { + _ = Self.updateRenderedProjection( + &logProjection, + block: block, + blockIndex: index, + visibleKinds: Self.displayedLogKinds, + includeEmptyDiagnostic: true + ) + _ = Self.updateRenderedProjection( + &reviewOutputProjection, + block: block, + blockIndex: index, + visibleKinds: Self.reviewOutputKinds, + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &activityProjection, + block: block, + blockIndex: index, + visibleKinds: Self.activityKinds, + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &errorProjection, + block: block, + blockIndex: index, + visibleKinds: [.error], + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &cappedProjection, + block: block, + blockIndex: index, + visibleKinds: Self.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: Self.displayedLogKinds, + includeEmptyDiagnostic: true + ) + _ = Self.updateRenderedProjection( + &reviewOutputProjection, + block: block, + blockIndex: blockIndex, + visibleKinds: Self.reviewOutputKinds, + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &activityProjection, + block: block, + blockIndex: blockIndex, + visibleKinds: Self.activityKinds, + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &errorProjection, + block: block, + blockIndex: blockIndex, + visibleKinds: [.error], + includeEmptyDiagnostic: false + ) + _ = Self.updateRenderedProjection( + &cappedProjection, + block: block, + blockIndex: blockIndex, + visibleKinds: Self.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: Self.displayedLogKinds, + includeEmptyDiagnostic: true + ) + _ = Self.updateTailProjection( + &reviewOutputProjection, + kind: block.kind, + oldText: oldText, + newText: newText, + blockIndex: blockIndex, + delta: delta, + visibleKinds: Self.reviewOutputKinds, + includeEmptyDiagnostic: false + ) + _ = Self.updateTailProjection( + &activityProjection, + kind: block.kind, + oldText: oldText, + newText: newText, + blockIndex: blockIndex, + delta: delta, + visibleKinds: Self.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: Self.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 = Self.isVisibleInRenderedProjection( + kind: kind, + text: oldText, + visibleKinds: visibleKinds, + includeEmptyDiagnostic: includeEmptyDiagnostic + ) + let isVisible = Self.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 Self.isVisibleInRenderedProjection( + kind: block.kind, + text: block.text, + visibleKinds: visibleKinds, + includeEmptyDiagnostic: includeEmptyDiagnostic + ) else { + return nil + } + return projection.appendSection(block.text, at: blockIndex) + } + + private static func combinedText(sections: [String]) -> String { + joinSectionsPreservingWhitespace( + sections.filter { $0.isEmpty == false } + ) + } + + 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 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 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 static let displayedLogKinds: Set = [ + .agentMessage, + .command, + .commandOutput, + .plan, + .todoList, + .reasoning, + .reasoningSummary, + .rawReasoning, + .toolCall, + .diagnostic, + .error, + .progress, + .event, + .contextCompaction, + ] + + private static let reviewOutputKinds: Set = [ + .agentMessage, + .plan, + .reasoningSummary, + .reasoning, + .rawReasoning, + ] + + private static let activityKinds: Set = [ + .command, + .commandOutput, + .toolCall, + .progress, + .event, + .contextCompaction, + ] + + package static let cappedLogKinds: Set = [ + .agentMessage, + .commandOutput, + .toolCall, + .plan, + .todoList, + .reasoningSummary, + .rawReasoning, + .diagnostic, + .error, + .progress, + .event, + ] + + package static let prefixTrimmableCappedKinds = cappedLogKinds.subtracting([.rawReasoning, .diagnostic, .error]) +} + +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 + } +} diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift index 6f8d220a..8ffb145a 100644 --- a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift @@ -1,19 +1,26 @@ import Foundation @MainActor -extension CodexReviewJob { - package func rebuildTimelineFromLogEntries(keepingTerminal: Bool = true) { - guard usesDirectTimelineEvents == false else { - return - } +package struct ReviewLogEntryTimelineProjector { + private let timeline: ReviewTimeline + + package init(timeline: ReviewTimeline) { + self.timeline = timeline + } + + package func rebuild( + from entries: [ReviewLogEntry], + core: ReviewJobCore, + keepingTerminal: Bool = true + ) { timeline.reset(keepingTerminal: keepingTerminal && core.lifecycle.status.isTerminal) - for entry in logEntries { - applyTimelineEntry(entry) + for entry in entries { + apply(entry) } - syncTimelineTerminalStateFromCore() + syncTerminalState(from: core, fallbackTimestamp: entries.last?.timestamp) } - package func applyTimelineEntry(_ entry: ReviewLogEntry) { + package func apply(_ entry: ReviewLogEntry) { let itemID = entry.timelineItemID let existing = timeline.item(for: itemID) let kind = entry.timelineItemKind @@ -51,17 +58,26 @@ extension CodexReviewJob { } } - package func trimTimelineTextContentToLogEntries() { + package func trimTextContent( + to entries: [ReviewLogEntry], + directTimelineTextItemIDs: Set, + directTimelineTextItemIDsWithCompatibilityLog: Set, + directTimelineTextCompatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set], + legacyProjectedTimelineTextItemIDs: Set + ) { guard directTimelineTextItemIDs.isEmpty == false || legacyProjectedTimelineTextItemIDs.isEmpty == false else { return } var textByItemID: [ReviewTimelineItem.ID: String] = [:] - for entry in logEntries { + for entry in entries { guard let retainedTimelineText = entry.retainedTimelineText else { continue } - for itemID in directTimelineTextCandidateIDs(for: entry) { + for itemID in directTimelineTextCandidateIDs( + for: entry, + compatibilityItemIDsByLogEntryID: directTimelineTextCompatibilityItemIDsByLogEntryID + ) { if entry.shouldAppendRetainedTimelineText { textByItemID[itemID, default: ""] += retainedTimelineText } else { @@ -95,11 +111,11 @@ extension CodexReviewJob { } } - package func syncTimelineTerminalStateFromCore() { + package func syncTerminalState(from core: ReviewJobCore, fallbackTimestamp: Date?) { guard core.lifecycle.status.isTerminal else { return } - let timestamp = core.lifecycle.endedAt ?? logEntries.last?.timestamp ?? Date() + let timestamp = core.lifecycle.endedAt ?? fallbackTimestamp ?? Date() switch core.lifecycle.status { case .succeeded: timeline.apply( @@ -125,9 +141,12 @@ extension CodexReviewJob { } } - private func directTimelineTextCandidateIDs(for entry: ReviewLogEntry) -> [ReviewTimelineItem.ID] { + private func directTimelineTextCandidateIDs( + for entry: ReviewLogEntry, + compatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] + ) -> [ReviewTimelineItem.ID] { var ids = entry.directTimelineTextCandidateIDs - if let compatibilityItemIDs = directTimelineTextCompatibilityItemIDsByLogEntryID[entry.id] { + if let compatibilityItemIDs = compatibilityItemIDsByLogEntryID[entry.id] { ids.append(contentsOf: compatibilityItemIDs) } var seen: Set = [] @@ -135,6 +154,41 @@ extension CodexReviewJob { } } +@MainActor +extension CodexReviewJob { + package func rebuildTimelineFromLogEntries(keepingTerminal: Bool = true) { + guard usesDirectTimelineEvents == false else { + return + } + ReviewLogEntryTimelineProjector(timeline: timeline).rebuild( + from: logEntries, + core: core, + keepingTerminal: keepingTerminal + ) + } + + package func applyTimelineEntry(_ entry: ReviewLogEntry) { + ReviewLogEntryTimelineProjector(timeline: timeline).apply(entry) + } + + package func trimTimelineTextContentToLogEntries() { + ReviewLogEntryTimelineProjector(timeline: timeline).trimTextContent( + to: logEntries, + directTimelineTextItemIDs: directTimelineTextItemIDs, + directTimelineTextItemIDsWithCompatibilityLog: directTimelineTextItemIDsWithCompatibilityLog, + directTimelineTextCompatibilityItemIDsByLogEntryID: directTimelineTextCompatibilityItemIDsByLogEntryID, + legacyProjectedTimelineTextItemIDs: legacyProjectedTimelineTextItemIDs + ) + } + + package func syncTimelineTerminalStateFromCore() { + ReviewLogEntryTimelineProjector(timeline: timeline).syncTerminalState( + from: core, + fallbackTimestamp: logEntries.last?.timestamp + ) + } +} + package extension ReviewLogEntry { var retainedTimelineText: String? { guard canProvideDirectTimelineText else { diff --git a/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift b/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift index e239cb64..5b7ad1cd 100644 --- a/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift @@ -1,9 +1,22 @@ import Foundation +@MainActor +package struct ReviewTimelineLegacyLogProjector { + private let timeline: ReviewTimeline + + package init(timeline: ReviewTimeline) { + self.timeline = timeline + } + + package var logEntries: [ReviewLogEntry] { + timeline.items.flatMap { ReviewLogEntry.projectingTimelineItem($0) } + } +} + @MainActor extension CodexReviewJob { package var timelineLogEntries: [ReviewLogEntry] { - timeline.items.flatMap { ReviewLogEntry.projectingTimelineItem($0) } + ReviewTimelineLegacyLogProjector(timeline: timeline).logEntries } } From 2ced60700a7ee8885871738a71df10c0fc338b46 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:08:36 +0900 Subject: [PATCH 047/359] refactor(app-server): consume review sessions directly --- .../CodexAppServerKit/CodexAppServer.swift | 65 +- .../AppServerCodexReviewBackend.swift | 2905 +--------- .../AppServerClientTests.swift | 4695 +---------------- 3 files changed, 271 insertions(+), 7394 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 0c93b77d..8f743af1 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -346,6 +346,41 @@ public actor CodexAppServer { )) } + package nonisolated func threadHandle( + id: CodexThreadID, + workspace: URL? = nil, + model: String? = nil + ) -> CodexThread { + CodexThread( + id: id, + workspace: workspace, + model: model, + client: client, + router: router + ) + } + + package func rollbackThread(_ id: CodexThreadID, turnCount: Int = 1) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Rollback.Request( + params: .init(threadID: id.rawValue, numTurns: turnCount) + )) + } + + package func cleanBackgroundTerminals(in id: CodexThreadID) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.BackgroundTerminals.Clean.Request( + params: .init(threadID: id.rawValue) + )) + } + + package func unsubscribeThread(_ id: CodexThreadID) async throws { + let _: AppServerAPI.Thread.Unsubscribe.Response = try await client.send( + AppServerAPI.Thread.Unsubscribe.Request( + params: .init(threadID: id.rawValue) + )) + } + /// Lists Codex threads visible to the app-server account. /// /// - Parameter query: Paging and filtering options. @@ -596,19 +631,14 @@ public actor CodexAppServer { case .apiKey: return .apiKey case .chatgpt(let loginID, let authURL, _): - guard let url = URL(string: authURL) else { - throw CodexAppServerError.jsonRPC( - code: -32602, message: "Invalid ChatGPT authentication URL.") - } - return .chatGPT(id: .init(rawValue: loginID), authenticationURL: url) + return try .chatGPT( + id: .init(rawValue: loginID), + authenticationURL: webAuthenticationURL(authURL, field: "authUrl") + ) case .chatgptDeviceCode(let loginID, let verificationURL, let userCode): - guard let url = URL(string: verificationURL) else { - throw CodexAppServerError.jsonRPC( - code: -32602, message: "Invalid ChatGPT device-code verification URL.") - } return .chatGPTDeviceCode( id: .init(rawValue: loginID), - verificationURL: url, + verificationURL: try webAuthenticationURL(verificationURL, field: "verificationUrl"), userCode: userCode ) case .chatgptAuthTokens: @@ -616,6 +646,21 @@ public actor CodexAppServer { } } + private nonisolated 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 CodexAppServerError.jsonRPC( + code: -32602, + message: "Invalid ChatGPT authentication URL in \(field)." + ) + } + return url + } + private nonisolated static func accountEvent( from notification: JSONRPC.Notification ) -> CodexAccountEvent? { diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index fba6ae71..bcda034c 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -19,7 +19,7 @@ private func makeAppServerReviewAttemptID() -> String { package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" - private let appServer: CodexAppServer? + private let appServer: CodexAppServer private let client: AppServerClient private let threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy private var controlsByThreadID: [String: AppServerReviewControl] = [:] @@ -28,15 +28,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var activeThreadIDsByAttemptID: [String: Set] = [:] private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:] private var reviewThreadIDsForCleanupByThreadID: [String: Set] = [:] + private var threadsByThreadID: [String: CodexThread] = [:] 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(appServer: CodexAppServer) { self.appServer = appServer @@ -44,42 +39,20 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { self.threadStartPermissionStrategy = appServer.threadStartPermissionStrategy } - package init( - client: AppServerClient, - threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions - ) { - self.appServer = nil - self.client = client - self.threadStartPermissionStrategy = threadStartPermissionStrategy - } - package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { - if let appServer { - let configuration = try await appServer.configuration() - let models = try await appServer.models(includeHidden: true) - .map(\.reviewModelCatalogItem) - return .init( - model: configuration.reviewModel?.nilIfEmpty, - fallbackModel: configuration.model?.nilIfEmpty ?? models.first(where: \.isDefault)?.model, - reasoningEffort: configuration.reasoningEffort?.rawValue, - serviceTier: configuration.serviceTier, - models: models - ) - } - _ = 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( @@ -90,164 +63,52 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot { - if let appServer { - guard let account = try await appServer.account() else { - return .init() - } - let backendAccount = account.backendAccount - return .init(accounts: [backendAccount], activeAccountID: backendAccount.id) - } - _ = 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 -> CodexRateLimits { - if let appServer { - return try await appServer.rateLimits() - } - _ = try await client.initialize() - let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) - return .init(appServer: response) + try await appServer.rateLimits() } package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { - if let appServer { - let handle = try await appServer.loginChatGPT( - callbackURLScheme: request.nativeWebAuthenticationCallbackScheme - ) - return try handle.backendChallenge( - nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme - ) - } - _ = 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 + let handle = try await appServer.loginChatGPT( + callbackURLScheme: request.nativeWebAuthenticationCallbackScheme + ) + return try handle.backendChallenge( + nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme ) - return try response.backendChallenge } package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { - if let appServer { - try await appServer.cancelLogin(id: .init(rawValue: challenge.id)) - return - } - _ = 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 - ) + try await appServer.cancelLogin(id: .init(rawValue: challenge.id)) } package func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot { if let callbackURL = response.callbackURL { - if let appServer { - guard let url = URL(string: callbackURL) else { - throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") - } - try await appServer.completeLogin( - id: .init(rawValue: response.challengeID), - callbackURL: url - ) - return try await readAuth() + guard let url = URL(string: callbackURL) else { + throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") } - _ = 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 + try await appServer.completeLogin( + id: .init(rawValue: response.challengeID), + callbackURL: url ) } return try await readAuth() } package func logout(_: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot { - if let appServer { - try await appServer.logout() - return try await readAuth() - } - _ = 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 { - if let appServer { - return try await startReview(appServer: appServer, request: request) - } - _ = try await client.initialize() - await ensureNotificationRouterStarted() - let control = AppServerReviewControl(client: client) - - let thread = try await startReviewThread(request) - controlsByThreadID[thread.threadID] = control - 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.appServerReviewTarget - ) - )) - } 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 - ) - await session.updateRun(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() - - return await session.attempt() - } - - private func startReview( - appServer: CodexAppServer, - request: CodexReviewBackendModel.Review.Start - ) async throws -> BackendReviewAttempt { let control = AppServerReviewControl(client: client) let thread = try await startReviewThread(appServer: appServer, request: request) + threadsByThreadID[thread.id.rawValue] = thread controlsByThreadID[thread.id.rawValue] = control let attemptID = makeAppServerReviewAttemptID() @@ -257,11 +118,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewThreadID: thread.id.rawValue, model: thread.model ?? request.model ) - let session = AppServerReviewEventSession( - run: provisionalRun, - control: control, - isRunFinalized: false - ) + let session = AppServerReviewEventSession(run: provisionalRun, control: control) registerReviewEventSession(session, for: provisionalRun) control.recordThreadStarted(threadID: thread.id.rawValue) @@ -283,7 +140,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { await session.updateRun(run) registerReviewEventSession(session, for: run) control.recordReviewStarted(review) - await session.finalizeRun() await session.startConsuming(review) return await session.attempt() @@ -352,88 +208,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) } - 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 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 startReviewThreadWithProfileSelectionPermissions( - _ 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 - ) - } - - private func threadStartParamsWithLegacySandbox(_ request: CodexReviewBackendModel.Review.Start) -> AppServerAPI.Thread.Start.Params { - .init( - cwd: request.request.cwd, - model: request.model, - 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 @@ -451,7 +225,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws { - _ = try await client.initialize() guard abandonedReviewAttemptIDs.contains(run.attemptID) == false else { return } @@ -474,8 +247,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { _ 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) @@ -498,69 +269,47 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { _ token: CodexReviewBackendModel.Review.RecoveryToken, 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) - )) + try await appServer.rollbackThread(.init(rawValue: token.rollbackThreadID), turnCount: 1) let control = controlsByThreadID[interruptedRun.threadID] ?? AppServerReviewControl(client: client) controlsByThreadID[interruptedRun.threadID] = control + let thread = reviewThread(for: interruptedRun) 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 + model: thread.model ?? interruptedRun.model ?? request.model ) + let session = AppServerReviewEventSession(run: provisionalRun, control: control) registerReviewEventSession(session, for: provisionalRun) - let review: AppServerAPI.Review.Start.Response - reviewStartRequestsInFlight += 1 + let review: CodexReviewSession do { - review = try await client.send(AppServerAPI.Review.Start.Request( - params: .init( - threadID: interruptedRun.threadID, - target: request.request.target.appServerReviewTarget - ) - )) + review = try await thread.startReview(target: request.request.target.appServerReviewTarget) } catch { - reviewStartRequestsInFlight -= 1 _ = unregisterReviewEventSession(for: provisionalRun) await session.abandon() - discardUnmatchedReviewNotificationsIfIdle() throw error } 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: thread.model ?? interruptedRun.model ?? request.model ) await session.updateRun(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() + controlsByThreadID[interruptedRun.threadID]?.recordReviewStarted(review) + 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) if let session = unregisterReviewEventSession(for: run) { @@ -571,20 +320,15 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { completedReviewEventSessionMetricsByThreadID[threadID] = metrics } } - 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) - )) + try? await appServer.cleanBackgroundTerminals(in: .init(rawValue: run.threadID)) + try? await appServer.unsubscribeThread(.init(rawValue: run.threadID)) for threadID in cleanupThreadIDs { - let _: EmptyResponse? = try? await client.send(AppServerAPI.Thread.Delete.Request( - params: .init(threadID: threadID) - )) + try? await appServer.deleteThread(.init(rawValue: threadID)) } for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) + threadsByThreadID.removeValue(forKey: threadID) } reviewThreadIDsForCleanupByThreadID.removeValue(forKey: run.threadID) } @@ -619,10 +363,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } } - package func notificationRouterMetricsForTesting() -> AppServerNotificationRouterMetrics { - notificationRouterMetrics - } - package func reviewEventSessionMetricsForTesting( threadID: String ) async -> AppServerReviewEventSessionMetrics? { @@ -639,10 +379,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 @@ -656,9 +392,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } private func reviewEventSession(for run: CodexReviewBackendModel.Review.Run) async -> AppServerReviewEventSession { - if appServer == nil { - await ensureNotificationRouterStarted() - } if let session = reviewEventSessionsByAttemptID[run.attemptID] { await session.updateRun(run) registerReviewEventSession(session, for: run) @@ -676,6 +409,15 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return session } + private func reviewThread(for run: CodexReviewBackendModel.Review.Run) -> CodexThread { + if let thread = threadsByThreadID[run.threadID] { + return thread + } + let thread = appServer.threadHandle(id: .init(rawValue: run.threadID), model: run.model) + threadsByThreadID[run.threadID] = thread + return thread + } + private func registerReviewEventSession( _ session: AppServerReviewEventSession, for run: CodexReviewBackendModel.Review.Run @@ -767,33 +509,6 @@ 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?, @@ -852,119 +567,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return merged } - private func ensureNotificationRouterStarted() async { - if notificationRouterTask != nil { - return - } - while isNotificationRouterStarting { - await Task.yield() - if notificationRouterTask != nil { - return - } - } - isNotificationRouterStarting = true - let notifications = await client.notificationStream() - notificationRouterTask = Task { [notifications] in - await self.consumeReviewNotifications(notifications) - } - isNotificationRouterStarting = false - } - - private func consumeReviewNotifications( - _ notifications: AsyncThrowingStream - ) async { - do { - for try await notification in notifications { - await routeReviewNotification(notification) - } - await finishAllReviewEventSessions(throwing: nil) - } catch { - await finishAllReviewEventSessions(throwing: error) - } - notificationRouterTask = nil - } - - private func routeReviewNotification(_ notification: JSONRPC.Notification) async { - notificationRouterMetrics.received += 1 - let method = AppServerReviewNotification.Method(rawValue: notification.method) - guard method.isReviewNotificationMethod else { - notificationRouterMetrics.ignored += 1 - return - } - guard let reviewNotification = try? AppServerReviewNotification( - method: notification.method, - paramsData: notification.params - ) else { - notificationRouterMetrics.ignored += 1 - return - } - let payload = reviewNotification.payload - notificationRouterMetrics.decoded += 1 - if let turnID = payload.resolvedTurnID, - abandonedTurnIDs.contains(turnID) { - notificationRouterMetrics.ignored += 1 - return - } - - reviewNotificationSequence += 1 - let routed = AppServerRoutedReviewNotification( - sequence: reviewNotificationSequence, - reviewNotification: reviewNotification - ) - 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 method.isThreadlessReviewBroadcast { - 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.map(\.reviewModelCatalogItem)) - 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 { @@ -980,44 +582,6 @@ package struct AppServerReviewEventSessionMetrics: Equatable, Sendable { package init() {} } -private struct AppServerRoutedReviewNotification: Sendable { - var sequence: Int - var reviewNotification: AppServerReviewNotification - - var method: String { - reviewNotification.rawMethod - } - - var payload: TurnNotificationPayload { - reviewNotification.payload - } -} - -private struct DecodedReviewNotification { - var events: [CodexReviewBackendModel.Review.Event] - var turnID: String? - var startsReviewMode: Bool - var finishesReviewMode: Bool - var hasDirectTimelineEvents: Bool - - 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 - } - -} - private struct PendingStreamedLogEntry: Sendable { struct Key: Hashable, Sendable { var kind: ReviewLogEntry.Kind @@ -1686,21 +1250,14 @@ private actor AppServerReviewEventSession { 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 var typedReviewStreamTask: Task? private var typedMessageTextByItemID: [String: String] = [:] @@ -1709,14 +1266,11 @@ private actor AppServerReviewEventSession { 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) @@ -1725,30 +1279,9 @@ private actor AppServerReviewEventSession { 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() - } - func currentRun() -> CodexReviewBackendModel.Review.Run { run } @@ -1771,32 +1304,16 @@ private actor AppServerReviewEventSession { cancellationRequestedMessage = nil } - 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 finish( cancellationMessage: String?, buffersMissingContinuation _: Bool = false ) async { cancelTypedReviewStream() - var precedingEvents = drainPendingStreamedLogEvents() + let 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) } @@ -1810,8 +1327,6 @@ private actor AppServerReviewEventSession { finished = true completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() - commandLifecycleByItemID.removeAll(keepingCapacity: true) - pendingStartupNotifications.removeAll(keepingCapacity: true) await emitPrecedingEvents(precedingEvents) if let error { await mailbox.fail(error) @@ -1828,10 +1343,8 @@ private actor AppServerReviewEventSession { finished = true completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() - commandLifecycleByItemID.removeAll(keepingCapacity: true) pendingStreamedLogEntries.removeAll(keepingCapacity: true) pendingStreamedLogIndexByKey.removeAll(keepingCapacity: true) - pendingStartupNotifications.removeAll(keepingCapacity: true) await mailbox.abandon() } @@ -1942,7 +1455,6 @@ private actor AppServerReviewEventSession { cancelTypedReviewStream() completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() - pendingStartupNotifications.removeAll(keepingCapacity: true) await emitPrecedingEvents(precedingEvents) if let cancellationMessage { _ = await emit(.cancelled(cancellationMessage)) @@ -1952,296 +1464,26 @@ private actor AppServerReviewEventSession { finished = true } - private func drainStartupNotifications() async { - guard isDrainingStartupNotifications == false else { + private func noteReviewThreadIDForCleanup(_ reviewThreadID: String?) { + guard let reviewThreadID = reviewThreadID?.nilIfEmpty, + reviewThreadID != run.threadID, + reviewThreadIDsForCleanup.contains(reviewThreadID) == false + else { return } - isDrainingStartupNotifications = true - defer { - isDrainingStartupNotifications = false - } - while finished == false, pendingStartupNotifications.isEmpty == false { - let notification = pendingStartupNotifications.removeFirst() - await process(notification) - } + reviewThreadIDsForCleanup.append(reviewThreadID) } - 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 - } - 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 - ) { - 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?.rawType == "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?.rawType == "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 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 shouldFlushPendingStreamedLogBeforeDomainEvent( followingEvents: ArraySlice, @@ -2443,47 +1685,6 @@ private extension CodexReviewBackendModel.Review.Event { } } - 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 { @@ -2523,76 +1724,6 @@ private extension CodexReviewBackendModel.Review.Event { } } -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 - } - } -} - -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 extension ReviewItemFamily { - var isCommandProgressBoundary: Bool { - switch self { - case .approval, - .contextCompaction, - .fileChange, - .message, - .plan, - .reasoning, - .search, - .tool, - .unknown: - true - case .command, - .diagnostic, - .lifecycle: - false - } - } -} - private final class ReviewCompletionCoordinator { private var pendingCompletion: CodexReviewBackendModel.Review.Event? private var finished = false @@ -2743,1909 +1874,15 @@ private extension CodexLoginHandle { } } -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") - } - } - } - - 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 typealias TurnNotificationPayload = AppServerReviewNotification.Payload -private typealias AppServerCommandAction = AppServerReviewNotification.Payload.Item.CommandAction -private typealias AppServerThreadItem = AppServerReviewNotification.Payload.Item - 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.rawType == "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.rawType == "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 - ) - ).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 - ) - ).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?.nonNullText - )] - } - 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.reviewNotification.startsReviewMode, - finishesReviewMode: notification.reviewNotification.finishesReviewMode, - hasDirectTimelineEvents: directEvents.isEmpty == false - ) -} - -private func directTimelineDomainEvents( - for notification: AppServerRoutedReviewNotification, - fallbackReviewThreadID: String -) -> [ReviewDomainEvent] { - let reviewNotification = notification.reviewNotification - return reviewNotification - .domainEvents(fallbackReviewThreadID: .init(rawValue: fallbackReviewThreadID)) - .filter { reviewNotification.allowsDirectTimelineEvent($0) } -} - -private extension AppServerReviewNotification { - 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) - } - } -} - -private extension TurnNotificationPayload { - var terminalMessage: String? { - turn?.error?.message?.nilIfEmpty - ?? error?.message?.nilIfEmpty - ?? message?.nilIfEmpty - ?? summary?.nilIfEmpty - } - - var outputDelta: String? { - if let delta, delta.isEmpty == false { - return delta - } - if let decodedBase64Output, decodedBase64Output.isEmpty == false { - return decodedBase64Output - } - return nil - } - - 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) } - } - - 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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [ReviewDomainEvent] { - guard let message = diagnosticMessage else { - return unknownEvent(method: method) - } - return [.itemUpdated(diagnosticSeed(method: method, message: message, phase: .running))] - } - - func modelReroutedEvents(method: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerReviewNotification.Method) -> [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: AppServerThreadItem, - phase: ReviewItemPhase, - content explicitContent: ReviewTimelineItem.Content? = nil - ) -> ReviewTimelineItemSeed { - ReviewTimelineItemSeed( - id: .init(rawValue: item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.rawType)), - kind: item.reviewItemKind, - family: item.family, - phase: phase, - content: explicitContent ?? item.content(fallbackDelta: delta), - startedAt: startedAt, - completedAt: completedAt, - durationMs: item.durationMs - ) - } - - private func reasoningPartSeeds(for item: AppServerThreadItem, phase: ReviewItemPhase) -> [ReviewTimelineItemSeed] { - guard item.family == .reasoning else { - return [] - } - let parentItemID = item.id.nilIfEmpty ?? itemID ?? syntheticItemID(method: item.rawType) - 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: AppServerThreadItem, - phase: ReviewItemPhase - ) -> ReviewTimelineItemSeed { - ReviewTimelineItemSeed( - id: .init(rawValue: id), - kind: item.reviewItemKind, - family: .reasoning, - phase: phase, - content: .reasoning(.init(text: text, style: style)), - startedAt: startedAt, - completedAt: completedAt, - durationMs: item.durationMs - ) - } - - private var terminalDisposition: AppServerTerminalDisposition { - switch normalizedStatus(turn?.status ?? status?.type) { - case "failed", "failure", "error", "errored": - return .failed - case "cancelled", "canceled", "interrupted", "aborted": - return .cancelled - default: - return .completed - } - } - - private func diagnosticSeed( - method: AppServerReviewNotification.Method, - 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 extension AppServerReviewNotification { - 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?.rawType == "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?.rawType == "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 reasoningSummaryGroupID(itemID: String, summaryIndex: Int) -> String { - "\(itemID):summary:\(summaryIndex)" +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 startedAt: Date? { - startedAtMs.map(Self.date(millisecondsSince1970:)) - } - - var completedAt: Date? { - completedAtMs.map(Self.date(millisecondsSince1970:)) - } - - 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 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 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 static func date(millisecondsSince1970 milliseconds: Int64) -> Date { - Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) - } -} - -private extension AppServerCommandAction { - var metadataAction: ReviewLogEntry.Metadata.CommandAction { - .init( - kind: metadataKind, - command: command, - name: name, - path: path, - query: query - ) - } - - private var metadataKind: ReviewLogEntry.Metadata.CommandAction.Kind { - switch kind { - case "read": - .read - case "listFiles": - .listFiles - case "search": - .search - default: - .unknown - } - } - - var timelineAction: ReviewTimelineItem.CommandAction { - .init( - kind: .init(rawValue: kind), - command: command, - name: name, - path: path, - query: query - ) - } -} - -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 extension AppServerThreadItem { - var reviewItemKind: ReviewItemKind { - .init(rawValue: rawType) - } - - var family: ReviewItemFamily { - switch rawType { - 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, - status: status.map(ReviewCommandStatus.init(rawValue:)), - source: source.map(ReviewCommandSource.init(rawValue:)), - processID: processID, - actions: commandActions.map(\.timelineAction), - durationMs: durationMs - )) - 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: rawType, 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 - } - - 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) } - } - } - - 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 - } - - func startedEvents( - startedAt: Date?, - lifecycle: AppServerCommandLifecycle? - ) -> [CodexReviewBackendModel.Review.Event] { - switch rawType { - 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: \(rawType).", groupID: id, replacesGroup: true)] - } - } - - func updatedEvents() -> [CodexReviewBackendModel.Review.Event] { - switch rawType { - 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 rawType { - 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: \(rawType).", 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 = rawType == "commandExecution" - let isLifecycleItem = isCommandExecution || rawType == "contextCompaction" - return .init( - sourceType: rawType, - 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 ?? rawType - } - - 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 enum AppServerTerminalDisposition { - case completed - case failed - case cancelled -} - -private func normalizedStatus(_ value: String?) -> String? { - value? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "_", with: "") - .replacingOccurrences(of: "-", with: "") - .nilIfEmpty -} - -private extension AppServerJSONValue { - var nonNullDebugText: String? { - nonNullText - } - - 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 .array, .object: - return jsonString - } - } - - 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" - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - return (try? encoder.encode(self)) - .flatMap { String(data: $0, encoding: .utf8) } - ?? fallback - } -} diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 739a47a5..c74b3cd5 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -101,6 +101,17 @@ private func eventSequence( return BackendReviewEventSequence(mailbox: attempt.events, includesDomainEvents: includingDomainEvents) } +private func makeBackend( + transport: FakeJSONRPCTransport, + threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions +) async throws -> AppServerCodexReviewBackend { + let appServer = try await CodexAppServer.testing( + transport: transport, + threadStartPermissionStrategy: threadStartPermissionStrategy + ) + return AppServerCodexReviewBackend(appServer: appServer) +} + @Suite("app-server client") struct AppServerClientTests { @Test func processTransportConfigurationResolvesCodexFromProvidedPath() throws { @@ -776,7 +787,7 @@ struct AppServerClientTests { ), for: "account/login/start" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let challenge = try await backend.startLogin(.init( nativeWebAuthenticationCallbackScheme: "lynnpd.CodexReviewMonitor.auth" @@ -802,7 +813,7 @@ struct AppServerClientTests { ), for: "account/login/start" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let challenge = try await backend.startLogin(.init()) @@ -822,7 +833,7 @@ struct AppServerClientTests { ), for: "account/login/start" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) await #expect(throws: (any Error).self) { _ = try await backend.startLogin(.init()) @@ -862,7 +873,7 @@ struct AppServerClientTests { )), for: "config/read" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let settings = try await backend.readSettings() @@ -886,7 +897,7 @@ struct AppServerClientTests { AppServerAPI.Model.List.Response(data: [makeModelCatalogItem(model: "default-model", isDefault: true)]), for: "model/list" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let settings = try await backend.readSettings() @@ -914,7 +925,7 @@ struct AppServerClientTests { ), for: "model/list" ) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let settings = try await backend.readSettings() @@ -953,7 +964,7 @@ struct AppServerClientTests { 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 backend = try await makeBackend(transport: transport) _ = try await backend.startReview(.init( jobID: "job-1", @@ -993,12 +1004,10 @@ struct AppServerClientTests { #expect(attempt.threadID == "thread-1") #expect(attempt.turnID == "turn-1") #expect(attempt.reviewThreadID == "review-thread") - #expect(await backend.notificationRouterIsRunningForTesting() == false) let storedAttempt = await backend.reviewAttemptForTesting(attempt.run) #expect(storedAttempt.threadID == "thread-1") #expect(storedAttempt.turnID == "turn-1") #expect(storedAttempt.reviewThreadID == "review-thread") - #expect(await backend.notificationRouterIsRunningForTesting() == false) let requests = await runtime.transport.recordedRequests() #expect(requests.map(\.method) == ["initialize", "thread/start", "review/start"]) @@ -1108,7 +1117,7 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } - @Test func appServerBackendInterruptUsesTypedSessionWithoutStartingRawRouter() async throws { + @Test func appServerBackendInterruptUsesTypedSession() 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") @@ -1122,11 +1131,8 @@ struct AppServerClientTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")), model: "gpt-5" )) - #expect(await backend.notificationRouterIsRunningForTesting() == false) - try await backend.interruptReview(attempt, reason: .init(message: "Stop")) - #expect(await backend.notificationRouterIsRunningForTesting() == false) let interrupt = try #require(await runtime.transport.recordedRequests().last) #expect(interrupt.method == "turn/interrupt") let params = try interrupt.decodeParams(AppServerAPI.Turn.Interrupt.Params.self) @@ -1139,8 +1145,8 @@ struct AppServerClientTests { 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 backend = try await makeBackend( + transport: transport, threadStartPermissionStrategy: .legacySandbox ) @@ -1173,7 +1179,7 @@ struct AppServerClientTests { ) 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 backend = try await makeBackend(transport: transport) _ = try await backend.startReview(.init( jobID: "job-1", @@ -1224,7 +1230,7 @@ struct AppServerClientTests { ) 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 backend = try await makeBackend(transport: transport) _ = try await backend.startReview(.init( jobID: "job-1", @@ -1257,7 +1263,7 @@ struct AppServerClientTests { ) 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 backend = try await makeBackend(transport: transport) _ = try await backend.startReview(.init( jobID: "job-1", @@ -1289,7 +1295,7 @@ struct AppServerClientTests { 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 backend = try await makeBackend(transport: transport) let run = try await backend.startReview(.init( jobID: "job-1", @@ -1316,7 +1322,7 @@ struct AppServerClientTests { "thread/start": [Data(threadStart.utf8)], "review/start": [Data(reviewStart.utf8)], ]) - let backend = AppServerCodexReviewBackend(client: .init(transport: transport)) + let backend = try await makeBackend(transport: transport) let run = try await backend.startReview(.init( jobID: "job-1", @@ -1336,50 +1342,13 @@ struct AppServerClientTests { #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 { + @Test func backendInterruptUsesDetachedReviewThreadBeforeStartedEvent() 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 backend = try await makeBackend(transport: transport) let run = try await backend.startReview(.init( jobID: "job-1", @@ -1395,4533 +1364,159 @@ struct AppServerClientTests { #expect(params.turnID == "turn-old") } - @Test func backendPreservesDetachedReviewThreadIDWhenReviewItemOmitsIt() async throws { + @Test func appServerUsesSingleNotificationStreamForConcurrentReviews() 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)) + 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 = try await makeBackend(transport: transport) - let run = try await backend.startReview(.init( + async let first = backend.startReview(.init( jobID: "job-1", sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + request: .init(cwd: "/tmp/project-1", 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 + 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 backendEmitsDomainEventsBeforeCompatibleLegacyLogEntries() async throws { + @Test func backendRecoverReviewRollsBackAndRestartsSameThread() 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.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 = try await makeBackend(transport: transport) + let run = CodexReviewBackendModel.Review.Run( + threadID: "thread-1", + turnID: "turn-1", + reviewThreadID: "thread-1", + model: "gpt-5" ) - 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") - ] - ) - ) + 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(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") + #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", ]) - - 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]) + 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 backendKeepsAgentMessageUpdateOpenForFollowingDelta() async throws { + @Test func backendRecoverReviewUsesDetachedReviewThreadBeforeStartedEvent() 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)) - + 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 = try await makeBackend(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") + 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.") ) - var iterator = events.makeAsyncIterator() - #expect(try await iterator.next() == .cancelled("Stop")) + #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 backendFlushesPendingStreamedLogsBeforeFollowingDomainEvents() async throws { + @Test func backendCleanupDeletesAllRecoveryReviewThreads() 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" - ) + 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 = try await makeBackend(transport: transport) + let run = CodexReviewBackendModel.Review.Run( + threadID: "thread-1", + turnID: "turn-1", + reviewThreadID: "review-thread-1", + model: "gpt-5" ) - #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") - ) + 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) - 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/CodexReviewKit" - ), - 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/CodexReviewKit", - 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/CodexReviewKit", - groupID: "cmd-failed", - replacesGroup: true, - metadata: .init( - sourceType: "commandExecution", - status: "inProgress", - itemID: "cmd-failed", - command: "git diff -- Sources/CodexReviewKit", - startedAt: startedAt, - commandStatus: "inProgress" - ) - )) - #expect(try await iterator.next() == .logEntry( - kind: .command, - text: "$ git diff -- Sources/CodexReviewKit", - groupID: "cmd-failed", - replacesGroup: true, - metadata: .init( - sourceType: "commandExecution", - status: "failed", - itemID: "cmd-failed", - command: "git diff -- Sources/CodexReviewKit", - 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/CodexReviewKit", - 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") - ) - ) - - 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 - } - #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 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?" - ) - )) - } - - @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( - method: "item/completed", - params: TestItemNotification( - threadID: "thread-1", - turnID: "turn-1", - item: .init( - type: "webSearch", - id: "search-result", - status: "completed", - query: "ReviewTimeline", - result: "search result" - ) - ) - ) - - 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", - turnID: "turn-1", - 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-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") - ) - ) - - 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")) - } - - @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)) - - let run = try await backend.startReview(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) - )) - - 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/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")) - ) - - 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( - method: "turn/completed", - params: TestTurnNotification( - threadID: "thread-1", - turn: .init(id: "turn-1", status: "failed", error: .init(message: "Review failed")) - ) - ) - - var failedIterator = failedEvents.makeAsyncIterator() - #expect(try await failedIterator.next() == .failed("Review failed")) - - 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) - - try await cancelledTransport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "thread-2", - turn: .init(id: "turn-2", status: "interrupted", error: .init(message: "Stopped")) - ) - ) - - var cancelledIterator = cancelledEvents.makeAsyncIterator() - #expect(try await cancelledIterator.next() == .cancelled("Stopped")) - - 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) - - try await failedWithoutMessageTransport.emitServerNotification( - method: "turn/completed", - params: TestPartialTurnNotification( - threadID: "thread-4", - turn: .init(id: "turn-4", status: "failed", error: .init()) - ) - ) - - var failedWithoutMessageIterator = failedWithoutMessageEvents.makeAsyncIterator() - #expect(try await failedWithoutMessageIterator.next() == .failed("Failed.")) - - 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) - - try await cancelledWithoutMessageTransport.emitServerNotification( - method: "turn/completed", - params: TestPartialTurnNotification( - threadID: "thread-5", - turn: .init(id: "turn-5", status: "interrupted", error: .init()) - ) - ) - - var cancelledWithoutMessageIterator = cancelledWithoutMessageEvents.makeAsyncIterator() - #expect(try await cancelledWithoutMessageIterator.next() == .cancelled("Cancellation requested.")) - - 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) - - try await retryingTransport.emitServerNotification( - method: "error", - params: TestErrorNotification(threadID: "thread-3", turnID: "turn-3", message: "Retrying request", willRetry: true) - ) - try await retryingTransport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification(threadID: "thread-3", turn: .init(id: "turn-3", status: "completed")) - ) - - 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)) - } - - @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) - - try await transport.emitServerNotification( - method: "account/updated", - params: ["accountID": "account-1"] - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification(threadID: "thread-1", turn: .init(id: "turn-1", status: "completed")) - ) - - var iterator = events.makeAsyncIterator() - let event = try await iterator.next() - #expect(event == .completed(summary: "Succeeded.", result: nil)) + 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 backendCleansThreadWhenReviewStartFailsAfterThreadStart() async throws { @@ -5929,7 +1524,7 @@ struct AppServerClientTests { 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 backend = try await makeBackend(transport: transport) await #expect(throws: JSONRPC.Error.responseError(code: -32602, message: "invalid target")) { try await backend.startReview(.init( From 5c7cdd1fd2298e5dfd46e21f9dd51237c94daafa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:35:32 +0900 Subject: [PATCH 048/359] fix(app-server): preserve session review events --- .../CodexAppServerNotificationRouter.swift | 146 ++++++++++++++++ .../CodexAppServerKit/CodexDomainTypes.swift | 12 ++ .../CodexTurnSequences.swift | 48 ++++-- .../AppServerCodexReviewBackend.swift | 70 ++++++-- .../AppServerClientTests.swift | 163 ++++++++++++++++++ 5 files changed, 412 insertions(+), 27 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index b5337368..3e7ecfe1 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -307,6 +307,17 @@ package actor CodexAppServerNotificationRouter { return .tokenUsageUpdated(usage) } return .unknown(raw) + case "item/commandExecution/outputDelta", + "command/exec/outputDelta", + "process/outputDelta", + "item/commandExecution/terminalInteraction", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/mcpToolCall/progress": + if let item = reviewLogItem(from: notification) { + return .itemUpdated(item) + } + return .unknown(raw) default: return .unknown(raw) } @@ -379,6 +390,17 @@ package actor CodexAppServerNotificationRouter { return .unknown(raw) case "thread/closed": return .closed + case "item/commandExecution/outputDelta", + "command/exec/outputDelta", + "process/outputDelta", + "item/commandExecution/terminalInteraction", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/mcpToolCall/progress": + if let item = reviewLogItem(from: notification) { + return .itemUpdated(item, turnID: context.turnID) + } + return .unknown(raw) default: return .unknown(raw) } @@ -438,6 +460,16 @@ package actor CodexAppServerNotificationRouter { return payload.item.threadItem } + private func reviewLogItem(from notification: JSONRPC.Notification) -> CodexThreadItem? { + guard let notification = try? AppServerReviewNotification( + method: notification.method, + paramsData: notification.params + ) else { + return nil + } + return notification.reviewLogItem + } + private func messageDelta(from data: Data) -> CodexMessageDelta? { guard let payload = try? decoder.decode(AgentMessageDeltaPayload.self, from: data) else { return nil @@ -841,6 +873,120 @@ private struct RawThreadItem: Decodable { } } +private extension AppServerReviewNotification { + var reviewLogItem: CodexThreadItem? { + switch method { + case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta: + return commandExecutionItem(output: payload.outputDelta) + case .commandExecutionTerminalInteraction: + return commandExecutionItem(output: payload.stdin?.nilIfEmpty) + case .fileChangeOutputDelta: + return fileChangeItem(output: payload.delta?.nilIfEmpty) + case .fileChangePatchUpdated: + return fileChangeItem(output: payload.fileChangePatchText) + case .mcpToolCallProgress: + guard let message = payload.message?.nilIfEmpty else { + return nil + } + let itemID = payload.itemID?.nilIfEmpty + ?? payload.item?.id.nilIfEmpty + ?? "mcpToolCall:\(rawMethod)" + return CodexThreadItem( + id: itemID, + kind: .mcpToolCall, + content: .toolCall(.init( + server: payload.item?.server, + name: payload.item?.tool ?? payload.item?.name, + result: message, + status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) + )), + rawPayload: rawNotification.params + ) + default: + return nil + } + } + + private func commandExecutionItem(output: String?) -> CodexThreadItem? { + guard let output else { + return nil + } + let itemID = payload.outputItemID + ?? payload.item?.id.nilIfEmpty + ?? "commandExecution:\(rawMethod)" + return CodexThreadItem( + id: itemID, + kind: .commandExecution, + content: .command(.init( + command: payload.item?.command ?? "", + cwd: payload.item?.cwd, + output: output, + exitCode: payload.item?.exitCode, + status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) + )), + rawPayload: rawNotification.params + ) + } + + private func fileChangeItem(output: String?) -> CodexThreadItem? { + guard let output else { + return nil + } + let path = payload.item?.path?.nilIfEmpty + ?? payload.changes.compactMap { $0.path?.nilIfEmpty }.first + let itemID = payload.item?.id.nilIfEmpty + ?? payload.itemID?.nilIfEmpty + ?? path + ?? "fileChange:\(rawMethod)" + return CodexThreadItem( + id: itemID, + kind: .fileChange, + content: .fileChange(.init( + path: path, + output: output, + status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) + )), + rawPayload: rawNotification.params + ) + } +} + +private extension AppServerReviewNotification.Payload { + var outputDelta: String? { + if let delta = delta?.nilIfEmpty { + return delta + } + guard let deltaBase64 = deltaBase64?.nilIfEmpty, + let data = Data(base64Encoded: deltaBase64) + else { + return nil + } + return String(data: data, encoding: .utf8)?.nilIfEmpty + } + + var outputItemID: String? { + itemID?.nilIfEmpty + ?? processID?.nilIfEmpty + ?? processHandle?.nilIfEmpty + ?? item?.id.nilIfEmpty + ?? item?.processID?.nilIfEmpty + } + + var fileChangePatchText: String? { + message?.nilIfEmpty + ?? delta?.nilIfEmpty + ?? diff?.nilIfEmpty + ?? changes.map(\.summaryText).joined(separator: "\n").nilIfEmpty + ?? "File changes updated." + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + extension KeyedDecodingContainer { fileprivate func decodeStringIfPresent(forKey key: Key) throws -> String? { if let string = try? decode(String.self, forKey: key) { diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 05272e43..5afcb8e1 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -1233,6 +1233,18 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { } return nil } + + package var startsReviewMode: Bool { + kind.rawValue == "enteredReviewMode" + } + + package var finishesReviewMode: Bool { + kind.rawValue == "exitedReviewMode" + } + + package var reviewExitResult: String? { + text.flatMap { $0.isEmpty ? nil : $0 } + } } public struct CodexReasoning: Equatable, Sendable { diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index 98830fa4..c1a9e7c8 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -194,15 +194,18 @@ public struct CodexReviewEventSequence: AsyncSequence, Sendable { private func isTerminal(_ event: CodexThreadEvent) -> Bool { switch event { case .turnCompleted(let response): - terminalTurnID.map { response.turnID == $0 } ?? true + guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { + return false + } + return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): - terminalTurnID.map { turnID == $0 } ?? true + return terminalTurnID.map { turnID == $0 } ?? true case .closed: - true + return true case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: - false + return false } } } @@ -287,15 +290,18 @@ public struct CodexReviewLogSequence: AsyncSequence, Sendable { private func isTerminal(_ event: CodexThreadEvent) -> Bool { switch event { case .turnCompleted(let response): - terminalTurnID.map { response.turnID == $0 } ?? true + guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { + return false + } + return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): - terminalTurnID.map { turnID == $0 } ?? true + return terminalTurnID.map { turnID == $0 } ?? true case .closed: - true + return true case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: - false + return false } } } @@ -405,15 +411,18 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { private func isTerminal(_ event: CodexThreadEvent) -> Bool { switch event { case .turnCompleted(let response): - terminalTurnID.map { response.turnID == $0 } ?? true + guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { + return false + } + return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): - terminalTurnID.map { turnID == $0 } ?? true + return terminalTurnID.map { turnID == $0 } ?? true case .closed: - true + return true case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: - false + return false } } } @@ -666,3 +675,18 @@ private struct CodexTranscriptAccumulator { ) } } + +private extension CodexResponse { + var shouldFinishReviewSequence: Bool { + if errorMessage != nil || status?.isFailure == true { + return true + } + if finalAnswer?.isEmpty == false + || transcript.finalAnswer?.isEmpty == false + || transcript.responseText?.isEmpty == false + { + return true + } + return false + } +} diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index bcda034c..006e8a37 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -768,6 +768,15 @@ private enum AppServerTypedReviewEventAdapter { _ item: CodexThreadItem, phase: AppServerTypedItemPhase ) -> [CodexReviewBackendModel.Review.Event] { + guard item.startsReviewMode == false else { + return [] + } + if phase == .completed, item.finishesReviewMode { + return [.completed( + summary: "Succeeded.", + result: item.reviewExitResult + )] + } guard item.kind != .userMessage, let seed = timelineSeed(for: item, phase: phase) else { @@ -1000,25 +1009,27 @@ private enum AppServerTypedReviewEventAdapter { command: CodexCommand, phase: AppServerTypedItemPhase ) -> [CodexReviewBackendModel.Review.Event] { - guard command.command.isEmpty == false else { - return [] - } - var events: [CodexReviewBackendModel.Review.Event] = [ - item.logEntry( + var events: [CodexReviewBackendModel.Review.Event] = [] + if command.command.isEmpty == false { + events.append(item.logEntry( kind: .command, text: "$ \(command.command)", phase: phase, title: nil - ), - ] - if let output = command.output?.nilIfEmpty { - events.append(item.logEntry( - kind: .commandOutput, - text: output, - phase: phase, - title: nil )) } + if let output = command.output?.nilIfEmpty { + if command.command.isEmpty { + events.append(item.commandOutputDeltaLogEntry(text: output)) + } else { + events.append(item.logEntry( + kind: .commandOutput, + text: output, + phase: phase, + title: nil + )) + } + } return events } @@ -1197,6 +1208,16 @@ private extension CodexThreadItem { ) } + func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { + .logEntry( + kind: .commandOutput, + text: text, + groupID: id, + replacesGroup: false, + metadata: reviewLogMetadata(title: "Command output", phase: .updated) + ) + } + private func reviewLogMetadata( title: String?, phase: AppServerTypedItemPhase @@ -1324,13 +1345,18 @@ private actor AppServerReviewEventSession { } cancelTypedReviewStream() let precedingEvents = drainPendingStreamedLogEvents() - finished = true - completionCoordinator.cancelPendingCompletion() cancelPendingStreamedLogFlush() await emitPrecedingEvents(precedingEvents) if let error { + finished = true + completionCoordinator.cancelPendingCompletion() await mailbox.fail(error) } else { + if await flushPendingCompletion() { + finished = true + return + } + finished = true await mailbox.finish() } } @@ -1401,6 +1427,10 @@ private actor AppServerReviewEventSession { metrics.decoded += 1 events = eventsWithTypedResultFallback(events) for event in events { + if event.shouldDeferCompletion { + completionCoordinator.deferCompletion(event) + continue + } if await emit(event, controlThreadID: converted.controlThreadID) { return } @@ -1594,6 +1624,10 @@ private actor AppServerReviewEventSession { guard let event = completionCoordinator.flushPendingCompletion() else { return false } + let events = eventsWithTypedResultFallback([event]) + guard let event = events.first else { + return false + } noteEmission(event) await mailbox.append(event) recordReviewEvent(event, controlThreadID: controlThreadID) @@ -1685,6 +1719,12 @@ private extension CodexReviewBackendModel.Review.Event { } } + var shouldDeferCompletion: Bool { + guard case .completed(_, let result) = self else { + return false + } + return result?.nilIfEmpty == nil + } } private extension Array where Element == CodexReviewBackendModel.Review.Event { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index c74b3cd5..9de49ef7 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -1117,6 +1117,169 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } + @Test func appServerBackendDefersTurnCompletionUntilReviewExitItem() 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(.init( + jobID: "job-1", + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init(id: "turn-1", status: "completed") + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: TestItemNotification( + threadID: "review-thread", + turnID: "turn-1", + item: .init( + type: "agentMessage", + id: "msg-1", + text: "Looks good." + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: TestItemNotification( + threadID: "review-thread", + turnID: "turn-1", + item: .init( + type: "exitedReviewMode", + id: "review-exit", + review: "Looks good." + ) + ) + ) + + var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() + #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + guard case .domainEvents(let messageDomainEvents, _) = try await iterator.next() else { + Issue.record("expected final message domain event before completion") + return + } + guard case .itemCompleted(let messageSeed) = try #require(messageDomainEvents.first) else { + Issue.record("expected final message item completion") + return + } + #expect(messageSeed.id.rawValue == "msg-1") + guard case .logEntry(let kind, let text, let groupID, let replacesGroup, _) = try await iterator.next() else { + Issue.record("expected final message legacy log") + return + } + #expect(kind == .agentMessage) + #expect(text == "Looks good.") + #expect(groupID == "msg-1") + #expect(replacesGroup == true) + #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) + } + + @Test func appServerBackendPreservesKnownReviewLogNotificationsFromSessionStream() 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(.init( + jobID: "job-1", + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() + + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: TestDeltaNotification( + threadID: "review-thread", + turnID: "turn-1", + itemID: "cmd-1", + delta: "building" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/fileChange/patchUpdated", + params: TestDeltaNotification( + threadID: "review-thread", + turnID: "turn-1", + itemID: "patch-1", + delta: "Sources/File.swift\n+ change" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: TestItemNotification( + threadID: "review-thread", + turnID: "turn-1", + item: .init(type: "exitedReviewMode", id: "review-exit", review: "Done") + ) + ) + + #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + guard case .domainEvents(let commandDomainEvents, _) = try await iterator.next() else { + Issue.record("expected command output domain event") + return + } + guard case .itemUpdated(let commandSeed) = try #require(commandDomainEvents.first) else { + Issue.record("expected command item update") + return + } + #expect(commandSeed.id.rawValue == "cmd-1") + guard case .command(let command) = commandSeed.content else { + Issue.record("expected command content") + return + } + #expect(command.output == "building") + + guard case .logEntry(let commandKind, let commandText, let commandGroupID, let commandReplacesGroup, let commandMetadata) = try await iterator.next() else { + Issue.record("expected command output legacy log") + return + } + #expect(commandKind == .commandOutput) + #expect(commandText == "building") + #expect(commandGroupID == "cmd-1") + #expect(commandReplacesGroup == false) + #expect(commandMetadata?.sourceType == "commandExecution") + #expect(commandMetadata?.title == "Command output") + + guard case .domainEvents(let fileDomainEvents, _) = try await iterator.next() else { + Issue.record("expected file change domain event") + return + } + guard case .itemUpdated(let fileSeed) = try #require(fileDomainEvents.first) else { + Issue.record("expected file change item update") + return + } + #expect(fileSeed.id.rawValue == "patch-1") + guard case .fileChange(let fileChange) = fileSeed.content else { + Issue.record("expected file change content") + return + } + #expect(fileChange.output == "Sources/File.swift\n+ change") + + guard case .logEntry(let fileKind, let fileText, let fileGroupID, _, let fileMetadata) = try await iterator.next() else { + Issue.record("expected file change legacy log") + return + } + #expect(fileKind == .commandOutput) + #expect(fileText == "Sources/File.swift\n+ change") + #expect(fileGroupID == "patch-1") + #expect(fileMetadata?.sourceType == "fileChange") + #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Done")) + } + @Test func appServerBackendInterruptUsesTypedSession() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") From 217f6476d6a46e8488400f66499686e96ba2c7cf Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:54:00 +0900 Subject: [PATCH 049/359] fix(app-server): finish review streams on exit --- .../CodexTurnSequences.swift | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift index c1a9e7c8..7711997c 100644 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -200,14 +200,20 @@ public struct CodexReviewEventSequence: AsyncSequence, Sendable { return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): return terminalTurnID.map { turnID == $0 } ?? true + case .itemCompleted(let item, let turnID): + return item.finishesReviewMode && matchesTerminalTurn(turnID) case .closed: return true - case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: return false } } + + private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { + terminalTurnID.map { turnID == $0 } ?? true + } } } @@ -252,6 +258,10 @@ public struct CodexReviewLogSequence: AsyncSequence, Sendable { case .itemUpdated(let item, let turnID): return .itemUpdated(item, turnID: turnID) case .itemCompleted(let item, let turnID): + if isTerminal(event) { + finished = true + return nil + } return .itemCompleted(item, turnID: turnID) case .message(let message, let turnID): let item = CodexThreadItem( @@ -296,14 +306,20 @@ public struct CodexReviewLogSequence: AsyncSequence, Sendable { return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): return terminalTurnID.map { turnID == $0 } ?? true + case .itemCompleted(let item, let turnID): + return item.finishesReviewMode && matchesTerminalTurn(turnID) case .closed: return true - case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: return false } } + + private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { + terminalTurnID.map { turnID == $0 } ?? true + } } } @@ -346,10 +362,23 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { switch event { case .turnStarted, .unknown: return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + case .itemStarted, .itemUpdated, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta: _ = accumulator.apply(event) return .init(phase: .running, transcript: accumulator.transcript, usage: usage) + case .itemCompleted(let item, let turnID): + _ = accumulator.apply(event) + if isTerminal(event) { + finished = true + let result = finalizedReviewExitResult(item: item, turnID: turnID) + return .init( + phase: .completed, + transcript: result.transcript, + usage: result.usage, + result: result + ) + } + return .init(phase: .running, transcript: accumulator.transcript, usage: usage) case .tokenUsageUpdated(let newUsage, _): usage = newUsage return .init(phase: .running, transcript: accumulator.transcript, usage: usage) @@ -408,6 +437,19 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { return result } + private func finalizedReviewExitResult( + item: CodexThreadItem, + turnID: CodexTurnID? + ) -> CodexResponse { + CodexResponse( + turnID: terminalTurnID ?? turnID ?? .init(rawValue: ""), + status: .completed, + finalAnswer: item.reviewExitResult ?? accumulator.transcript.finalAnswer, + transcript: accumulator.transcript, + usage: usage + ) + } + private func isTerminal(_ event: CodexThreadEvent) -> Bool { switch event { case .turnCompleted(let response): @@ -417,14 +459,20 @@ public struct CodexReviewProgressSequence: AsyncSequence, Sendable { return response.shouldFinishReviewSequence case .turnFailed(let turnID, _): return terminalTurnID.map { turnID == $0 } ?? true + case .itemCompleted(let item, let turnID): + return item.finishesReviewMode && matchesTerminalTurn(turnID) case .closed: return true - case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, .unknown: return false } } + + private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { + terminalTurnID.map { turnID == $0 } ?? true + } } } From 0909585391a064ff4483ab29b663cf70e8f4d74a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:09:21 +0900 Subject: [PATCH 050/359] fix(app-server): accumulate command output once --- .../CodexAppServerNotificationRouter.swift | 54 ++++++++++++++++--- .../CodexAppServerKit/CodexDomainTypes.swift | 3 ++ .../AppServerCodexReviewBackend.swift | 5 +- .../AppServerClientTests.swift | 40 ++++++++++++-- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 3e7ecfe1..5eb1eef0 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -16,6 +16,7 @@ package actor CodexAppServerNotificationRouter { private var reviewThreadIDs: Set = [] private var turnHistoryByTurnID: [CodexTurnID: [CodexTurnEvent]] = [:] private var threadHistoryByThreadID: [CodexThreadID: [CodexThreadEvent]] = [:] + private var commandOutputByItemID: [String: String] = [:] private var turnSubscribersByTurnID: [CodexTurnID: [UUID: Subscriber]] = [:] private var threadSubscribersByThreadID: [CodexThreadID: [UUID: Subscriber]] = [:] @@ -122,13 +123,25 @@ package actor CodexAppServerNotificationRouter { context.threadID = threadID } + let reviewLogItem = context.threadID != nil || context.turnID != nil + ? reviewLogItem(from: notification) + : nil + if let threadID = context.threadID { - let event = decodeThreadEvent(notification: notification, context: context) + let event = decodeThreadEvent( + notification: notification, + context: context, + reviewLogItem: reviewLogItem + ) appendThreadEvent(event, threadID: threadID) } if let turnID = context.turnID { - let event = decodeTurnEvent(notification: notification, context: context) + let event = decodeTurnEvent( + notification: notification, + context: context, + reviewLogItem: reviewLogItem + ) turnHistoryByTurnID[turnID, default: []].append(event) if let subscribers = turnSubscribersByTurnID[turnID] { for subscriber in subscribers.values { @@ -252,7 +265,8 @@ package actor CodexAppServerNotificationRouter { private func decodeTurnEvent( notification: JSONRPC.Notification, - context: NotificationContext + context: NotificationContext, + reviewLogItem: CodexThreadItem? ) -> CodexTurnEvent { let raw = CodexRawNotification( method: notification.method, @@ -314,7 +328,7 @@ package actor CodexAppServerNotificationRouter { "item/fileChange/outputDelta", "item/fileChange/patchUpdated", "item/mcpToolCall/progress": - if let item = reviewLogItem(from: notification) { + if let item = reviewLogItem { return .itemUpdated(item) } return .unknown(raw) @@ -325,7 +339,8 @@ package actor CodexAppServerNotificationRouter { private func decodeThreadEvent( notification: JSONRPC.Notification, - context: NotificationContext + context: NotificationContext, + reviewLogItem: CodexThreadItem? ) -> CodexThreadEvent { let raw = CodexRawNotification( method: notification.method, @@ -397,7 +412,7 @@ package actor CodexAppServerNotificationRouter { "item/fileChange/outputDelta", "item/fileChange/patchUpdated", "item/mcpToolCall/progress": - if let item = reviewLogItem(from: notification) { + if let item = reviewLogItem { return .itemUpdated(item, turnID: context.turnID) } return .unknown(raw) @@ -467,7 +482,31 @@ package actor CodexAppServerNotificationRouter { ) else { return nil } - return notification.reviewLogItem + guard var item = notification.reviewLogItem else { + return nil + } + switch notification.method { + case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta: + item = accumulatingCommandOutput(in: item) + default: + break + } + return item + } + + private func accumulatingCommandOutput(in item: CodexThreadItem) -> CodexThreadItem { + guard case .command(var command) = item.content, + let delta = command.outputDelta ?? command.output + else { + return item + } + var item = item + let output = (commandOutputByItemID[item.id] ?? "") + delta + commandOutputByItemID[item.id] = output + command.output = output + command.outputDelta = delta + item.content = .command(command) + return item } private func messageDelta(from data: Data) -> CodexMessageDelta? { @@ -921,6 +960,7 @@ private extension AppServerReviewNotification { command: payload.item?.command ?? "", cwd: payload.item?.cwd, output: output, + outputDelta: output, exitCode: payload.item?.exitCode, status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) )), diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 5afcb8e1..36461a5d 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -1363,6 +1363,7 @@ public struct CodexCommand: Equatable, Sendable { public var command: String public var cwd: String? public var output: String? + package var outputDelta: String? public var exitCode: Int? public var status: CodexTurnStatus? @@ -1370,12 +1371,14 @@ public struct CodexCommand: Equatable, Sendable { command: String, cwd: String? = nil, output: String? = nil, + outputDelta: String? = nil, exitCode: Int? = nil, status: CodexTurnStatus? = nil ) { self.command = command self.cwd = cwd self.output = output + self.outputDelta = outputDelta self.exitCode = exitCode self.status = status } diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 006e8a37..b544d6df 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1018,8 +1018,9 @@ private enum AppServerTypedReviewEventAdapter { title: nil )) } - if let output = command.output?.nilIfEmpty { - if command.command.isEmpty { + let output = command.outputDelta?.nilIfEmpty ?? command.output?.nilIfEmpty + if let output { + if command.outputDelta != nil || command.command.isEmpty { events.append(item.commandOutputDeltaLogEntry(text: output)) } else { events.append(item.logEntry( diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 9de49ef7..5bad316b 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -1206,7 +1206,16 @@ struct AppServerClientTests { threadID: "review-thread", turnID: "turn-1", itemID: "cmd-1", - delta: "building" + delta: "building-" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: TestDeltaNotification( + threadID: "review-thread", + turnID: "turn-1", + itemID: "cmd-1", + delta: "done" ) ) try await runtime.transport.emitServerNotification( @@ -1241,19 +1250,44 @@ struct AppServerClientTests { Issue.record("expected command content") return } - #expect(command.output == "building") + #expect(command.output == "building-") guard case .logEntry(let commandKind, let commandText, let commandGroupID, let commandReplacesGroup, let commandMetadata) = try await iterator.next() else { Issue.record("expected command output legacy log") return } #expect(commandKind == .commandOutput) - #expect(commandText == "building") + #expect(commandText == "building-") #expect(commandGroupID == "cmd-1") #expect(commandReplacesGroup == false) #expect(commandMetadata?.sourceType == "commandExecution") #expect(commandMetadata?.title == "Command output") + guard case .domainEvents(let secondCommandDomainEvents, _) = try await iterator.next() else { + Issue.record("expected second command output domain event") + return + } + guard case .itemUpdated(let secondCommandSeed) = try #require(secondCommandDomainEvents.first) else { + Issue.record("expected second command item update") + return + } + guard case .command(let secondCommand) = secondCommandSeed.content else { + Issue.record("expected second command content") + return + } + #expect(secondCommand.output == "building-done") + + guard case .logEntry(let secondCommandKind, let secondCommandText, let secondCommandGroupID, let secondCommandReplacesGroup, let secondCommandMetadata) = try await iterator.next() else { + Issue.record("expected second command output legacy log") + return + } + #expect(secondCommandKind == .commandOutput) + #expect(secondCommandText == "done") + #expect(secondCommandGroupID == "cmd-1") + #expect(secondCommandReplacesGroup == false) + #expect(secondCommandMetadata?.sourceType == "commandExecution") + #expect(secondCommandMetadata?.title == "Command output") + guard case .domainEvents(let fileDomainEvents, _) = try await iterator.next() else { Issue.record("expected file change domain event") return From 194ac24983ebaf90e8ac74216f212e9eefb1cf17 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:19:34 +0900 Subject: [PATCH 051/359] fix(app-server): scope command output by review --- .../CodexAppServerNotificationRouter.swift | 31 +++++++-- .../AppServerClientTests.swift | 64 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift index 5eb1eef0..591b94d4 100644 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -10,13 +10,19 @@ package actor CodexAppServerNotificationRouter { var turnID: CodexTurnID? } + private struct CommandOutputKey: Hashable { + var threadID: CodexThreadID? + var turnID: CodexTurnID? + var itemID: String + } + private let client: AppServerClient private var routerTask: Task? private var threadIDByTurnID: [CodexTurnID: CodexThreadID] = [:] private var reviewThreadIDs: Set = [] private var turnHistoryByTurnID: [CodexTurnID: [CodexTurnEvent]] = [:] private var threadHistoryByThreadID: [CodexThreadID: [CodexThreadEvent]] = [:] - private var commandOutputByItemID: [String: String] = [:] + private var commandOutputByKey: [CommandOutputKey: String] = [:] private var turnSubscribersByTurnID: [CodexTurnID: [UUID: Subscriber]] = [:] private var threadSubscribersByThreadID: [CodexThreadID: [UUID: Subscriber]] = [:] @@ -124,7 +130,7 @@ package actor CodexAppServerNotificationRouter { } let reviewLogItem = context.threadID != nil || context.turnID != nil - ? reviewLogItem(from: notification) + ? reviewLogItem(from: notification, context: context) : nil if let threadID = context.threadID { @@ -475,7 +481,10 @@ package actor CodexAppServerNotificationRouter { return payload.item.threadItem } - private func reviewLogItem(from notification: JSONRPC.Notification) -> CodexThreadItem? { + private func reviewLogItem( + from notification: JSONRPC.Notification, + context: NotificationContext + ) -> CodexThreadItem? { guard let notification = try? AppServerReviewNotification( method: notification.method, paramsData: notification.params @@ -487,22 +496,30 @@ package actor CodexAppServerNotificationRouter { } switch notification.method { case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta: - item = accumulatingCommandOutput(in: item) + item = accumulatingCommandOutput(in: item, context: context) default: break } return item } - private func accumulatingCommandOutput(in item: CodexThreadItem) -> CodexThreadItem { + private func accumulatingCommandOutput( + in item: CodexThreadItem, + context: NotificationContext + ) -> CodexThreadItem { guard case .command(var command) = item.content, let delta = command.outputDelta ?? command.output else { return item } var item = item - let output = (commandOutputByItemID[item.id] ?? "") + delta - commandOutputByItemID[item.id] = output + let key = CommandOutputKey( + threadID: context.threadID, + turnID: context.turnID, + itemID: item.id + ) + let output = (commandOutputByKey[key] ?? "") + delta + commandOutputByKey[key] = output command.output = output command.outputDelta = delta item.content = .command(command) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 5bad316b..27ac789f 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -1314,6 +1314,70 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Done")) } + @Test func appServerBackendScopesCommandOutputDeltasByReviewThread() 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 firstAttempt = try await backend.startReview(.init( + jobID: "job-1", + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + 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(.init( + jobID: "job-2", + sessionID: "session-2", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")), + model: "gpt-5" + )) + + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: TestDeltaNotification( + threadID: "review-thread-1", + turnID: "turn-1", + itemID: "cmd-1", + delta: "first" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: TestDeltaNotification( + threadID: "review-thread-2", + turnID: "turn-2", + itemID: "cmd-1", + delta: "second" + ) + ) + + var firstIterator = await eventSequence(backend, firstAttempt, includingDomainEvents: true).makeAsyncIterator() + #expect(try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) + guard case .domainEvents(let firstDomainEvents, _) = try await firstIterator.next(), + case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), + case .command(let firstCommand) = firstSeed.content + else { + Issue.record("expected first command output domain event") + return + } + #expect(firstCommand.output == "first") + + var secondIterator = await eventSequence(backend, secondAttempt, includingDomainEvents: true).makeAsyncIterator() + #expect(try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) + guard case .domainEvents(let secondDomainEvents, _) = try await secondIterator.next(), + case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), + case .command(let secondCommand) = secondSeed.content + else { + Issue.record("expected second command output domain event") + return + } + #expect(secondCommand.output == "second") + } + @Test func appServerBackendInterruptUsesTypedSession() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") From bc83efed3d45e255372c7719bf6b8cac490b9549 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:32:09 +0900 Subject: [PATCH 052/359] refactor(app-server): remove review compatibility fallbacks --- .../CodexAppServerKit/CodexAppServer.swift | 54 +++-- .../CodexAppServerKit/CodexDomainTypes.swift | 25 ++ .../CodexThreadOperations.swift | 13 + .../AppServerCodexReviewBackend.swift | 165 ++++--------- .../AppServerReviewControl.swift | 75 +++--- .../AppServerClientTests.swift | 227 +++++------------- 6 files changed, 228 insertions(+), 331 deletions(-) diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 8f743af1..2344a341 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -124,15 +124,6 @@ public actor CodexAppServer { private let client: AppServerClient private let router: CodexAppServerNotificationRouter - private let reviewThreadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy - - package nonisolated var appServerClient: AppServerClient { - client - } - - package nonisolated var threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy { - reviewThreadStartPermissionStrategy - } /// Starts a Codex app-server process and initializes the client session. /// @@ -158,22 +149,18 @@ public actor CodexAppServer { await router.start() self.client = client self.router = router - self.reviewThreadStartPermissionStrategy = transportConfiguration.threadStartPermissionStrategy } package init( client: AppServerClient, - router: CodexAppServerNotificationRouter, - threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + router: CodexAppServerNotificationRouter ) { self.client = client self.router = router - self.reviewThreadStartPermissionStrategy = threadStartPermissionStrategy } package init( - transport: any JSONRPC.Transport, - threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + transport: any JSONRPC.Transport ) async throws { let client = AppServerClient(transport: transport) _ = try await client.initialize( @@ -184,17 +171,12 @@ public actor CodexAppServer { await router.start() self.client = client self.router = router - self.reviewThreadStartPermissionStrategy = threadStartPermissionStrategy } package static func testing( - transport: any JSONRPC.Transport, - threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + transport: any JSONRPC.Transport ) async throws -> CodexAppServer { - try await CodexAppServer( - transport: transport, - threadStartPermissionStrategy: threadStartPermissionStrategy - ) + try await CodexAppServer(transport: transport) } /// Closes the app-server connection and stops notification routing. @@ -456,6 +438,34 @@ public actor CodexAppServer { ) } + package func updateConfiguration(_ patch: CodexConfigurationPatch) async throws { + var edits: [AppServerAPI.Config.Edit] = [] + if patch.updatesReviewModel { + edits.append(.init( + keyPath: "review_model", + value: patch.reviewModel.map(AppServerAPI.Config.Value.string) ?? .null + )) + } + if patch.updatesReasoningEffort { + edits.append(.init( + keyPath: "model_reasoning_effort", + value: patch.reasoningEffort.map { .string($0.rawValue) } ?? .null + )) + } + if patch.updatesServiceTier { + edits.append(.init( + keyPath: "service_tier", + value: patch.serviceTier.map(AppServerAPI.Config.Value.string) ?? .null + )) + } + guard edits.isEmpty == false else { + return + } + let _: AppServerAPI.Config.BatchWrite.Response = try await client.send( + AppServerAPI.Config.BatchWrite.Request(params: .init(edits: edits)) + ) + } + /// Reads Codex account rate-limit information. /// /// - Returns: Current plan type and rate-limit windows reported by the app-server. diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift index 36461a5d..67eccb52 100644 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -2056,6 +2056,31 @@ public struct CodexConfiguration: Equatable, Sendable { } } +package struct CodexConfigurationPatch: Equatable, Sendable { + package var reviewModel: String? + package var reasoningEffort: CodexReasoningEffort? + package var serviceTier: String? + package var updatesReviewModel: Bool + package var updatesReasoningEffort: Bool + package var updatesServiceTier: Bool + + package init( + reviewModel: String? = nil, + reasoningEffort: CodexReasoningEffort? = nil, + serviceTier: String? = nil, + updatesReviewModel: Bool = false, + updatesReasoningEffort: Bool = false, + updatesServiceTier: Bool = false + ) { + self.reviewModel = reviewModel + self.reasoningEffort = reasoningEffort + self.serviceTier = serviceTier + self.updatesReviewModel = updatesReviewModel + self.updatesReasoningEffort = updatesReasoningEffort + self.updatesServiceTier = updatesServiceTier + } +} + public struct CodexRateLimits: Equatable, Sendable { public var planType: String? public var windows: [CodexRateLimitWindow] diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 4dbc8e77..ba43f83e 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -165,6 +165,19 @@ extension CodexThread { ) } + @discardableResult + package func interruptActiveTurn( + expectedTurnID: CodexTurnID? = nil, + willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? = nil + ) async throws -> CodexTurnInterruption { + try await interruptCodexTurn( + threadID: id, + turnID: expectedTurnID, + client: client, + willInterruptActiveTurn: willInterruptActiveTurn + ) + } + package func startTurn( _ prompt: CodexPrompt, options: CodexGenerationOptions = .init() diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index b544d6df..b3e4a0a0 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -20,8 +20,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" private let appServer: CodexAppServer - private let client: AppServerClient - private let threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy private var controlsByThreadID: [String: AppServerReviewControl] = [:] private var reviewEventSessionsByAttemptID: [String: AppServerReviewEventSession] = [:] private var activeReviewAttemptIDByThreadID: [String: String] = [:] @@ -35,8 +33,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package init(appServer: CodexAppServer) { self.appServer = appServer - self.client = appServer.appServerClient - self.threadStartPermissionStrategy = appServer.threadStartPermissionStrategy } package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { @@ -53,12 +49,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot { - 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) - )) - } + try await appServer.updateConfiguration(Self.configurationPatch(from: change)) return try await readSettings() } @@ -106,7 +97,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - let control = AppServerReviewControl(client: client) + let control = AppServerReviewControl() let thread = try await startReviewThread(appServer: appServer, request: request) threadsByThreadID[thread.id.rawValue] = thread controlsByThreadID[thread.id.rawValue] = control @@ -120,7 +111,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) let session = AppServerReviewEventSession(run: provisionalRun, control: control) registerReviewEventSession(session, for: provisionalRun) - control.recordThreadStarted(threadID: thread.id.rawValue) + control.recordThreadStarted(thread) let review: CodexReviewSession do { @@ -150,80 +141,25 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { request: CodexReviewBackendModel.Review.Start ) async throws -> CodexThread { let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) - 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 appServer.startThread( - in: workspace, - options: reviewThreadOptions(request, sandbox: .fullAccess) - ) - } - do { - return try await appServer.startThread( - in: workspace, - options: reviewThreadOptions(request, permissions: .profile(id: Self.reviewPermissionProfileID)) - ) - } 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. - do { - return try await appServer.startThread( - in: workspace, - options: reviewThreadOptions( - request, - permissions: .profileSelection(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 appServer.startThread( - in: workspace, - options: reviewThreadOptions(request, sandbox: .fullAccess) - ) - } - } 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 appServer.startThread( - in: workspace, - options: reviewThreadOptions(request, sandbox: .fullAccess) - ) - } + return try await appServer.startThread( + in: workspace, + options: reviewThreadOptions(request) + ) } private func reviewThreadOptions( - _ request: CodexReviewBackendModel.Review.Start, - permissions: CodexThreadPermissions? = nil, - sandbox: CodexSandbox? = nil + _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { .init( model: request.model, approvalMode: .denyAll, - sandbox: sandbox, - permissions: permissions, + permissions: .profile(id: Self.reviewPermissionProfileID), ephemeral: false, 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 { guard abandonedReviewAttemptIDs.contains(run.attemptID) == false else { return @@ -272,8 +208,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { let interruptedRun = token.interruptedRun try await appServer.rollbackThread(.init(rawValue: token.rollbackThreadID), turnCount: 1) - let control = controlsByThreadID[interruptedRun.threadID] ?? AppServerReviewControl(client: client) - controlsByThreadID[interruptedRun.threadID] = control + let control = reviewControl(for: interruptedRun) let thread = reviewThread(for: interruptedRun) let attemptID = makeAppServerReviewAttemptID() let provisionalRun = CodexReviewBackendModel.Review.Run( @@ -397,24 +332,40 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { 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 control = reviewControl(for: run) let session = AppServerReviewEventSession(run: run, control: control) registerReviewEventSession(session, for: run) return session } + private func reviewControl(for run: CodexReviewBackendModel.Review.Run) -> AppServerReviewControl { + if let control = controlsByThreadID[run.threadID] { + return control + } + let control = AppServerReviewControl() + if let turnID = run.turnID { + control.recordReviewStarted(thread: turnThread(for: run), turnID: turnID) + } else { + control.recordThreadStarted(reviewThread(for: run)) + } + controlsByThreadID[run.threadID] = control + return control + } + private func reviewThread(for run: CodexReviewBackendModel.Review.Run) -> CodexThread { - if let thread = threadsByThreadID[run.threadID] { + threadHandle(id: run.threadID, model: run.model) + } + + private func turnThread(for run: CodexReviewBackendModel.Review.Run) -> CodexThread { + threadHandle(id: appServerTurnThreadID(for: run), model: run.model) + } + + private func threadHandle(id threadID: String, model: String?) -> CodexThread { + if let thread = threadsByThreadID[threadID] { return thread } - let thread = appServer.threadHandle(id: .init(rawValue: run.threadID), model: run.model) - threadsByThreadID[run.threadID] = thread + let thread = appServer.threadHandle(id: .init(rawValue: threadID), model: model) + threadsByThreadID[threadID] = thread return thread } @@ -531,11 +482,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { 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 ?? "") + let control = reviewControl(for: run) + guard let interruption = try await control.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) else { + throw CodexReviewAPI.Error.io("Review run has no interruptible app-server session.") + } + return interruption } private func cleanupThreadIDs(for run: CodexReviewBackendModel.Review.Run) -> [String] { @@ -1638,7 +1589,7 @@ private actor AppServerReviewEventSession { 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) + control.recordTurnStarted(thread: nil, turnID: turnID) case .completed, .failed, .cancelled: control.finish() appServerBackendLogger.debug( @@ -1813,27 +1764,17 @@ private final class ReviewCompletionCoordinator { } 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 - )) - } - return edits + static func configurationPatch( + from change: CodexReviewBackendModel.Settings.Change + ) -> CodexConfigurationPatch { + .init( + reviewModel: change.model, + reasoningEffort: change.reasoningEffort.map(CodexReasoningEffort.init(rawValue:)), + serviceTier: change.serviceTier, + updatesReviewModel: change.updatesModel, + updatesReasoningEffort: change.updatesReasoningEffort, + updatesServiceTier: change.updatesServiceTier + ) } } diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index 37131a24..8785caef 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -13,10 +13,10 @@ package struct AppServerReviewInterruption: Equatable, Sendable { } package final class AppServerReviewControl: @unchecked Sendable { - private enum Phase: Equatable { + private enum Phase { case preparing - case threadStarted(threadID: String) - case reviewStarted(turnThreadID: String, turnID: String) + case threadStarted(CodexThread) + case reviewStarted(thread: CodexThread?, turnID: String) case finished } @@ -25,29 +25,26 @@ package final class AppServerReviewControl: @unchecked Sendable { var reviewSession: CodexReviewSession? } - private let client: AppServerClient private let phaseLock = NSLock() private var phase: Phase = .preparing private var reviewSession: CodexReviewSession? - package init(client: AppServerClient) { - self.client = client - } + package init() {} - package func recordThreadStarted(threadID: String) { + package func recordThreadStarted(_ thread: CodexThread) { phaseLock.lock() defer { phaseLock.unlock() } - guard phase == .preparing else { + guard case .preparing = phase else { return } - phase = .threadStarted(threadID: threadID) + phase = .threadStarted(thread) reviewSession = nil } - package func recordReviewStarted(turnThreadID: String, turnID: String) { + package func recordReviewStarted(thread: CodexThread, turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } - phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID) + phase = .reviewStarted(thread: thread, turnID: turnID) reviewSession = nil } @@ -55,17 +52,23 @@ package final class AppServerReviewControl: @unchecked Sendable { phaseLock.lock() defer { phaseLock.unlock() } phase = .reviewStarted( - turnThreadID: reviewSession.reviewThreadID.rawValue, + thread: nil, turnID: reviewSession.turnID.rawValue ) self.reviewSession = reviewSession } - package func recordTurnStarted(turnThreadID: String, turnID: String) { + package func recordTurnStarted(thread: CodexThread?, turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } - phase = .reviewStarted(turnThreadID: turnThreadID, turnID: turnID) - reviewSession = nil + switch phase { + case .preparing, .finished: + return + case .threadStarted(let existingThread): + phase = .reviewStarted(thread: thread ?? existingThread, turnID: turnID) + case .reviewStarted(let existingThread, _): + phase = .reviewStarted(thread: thread ?? existingThread, turnID: turnID) + } } @discardableResult @@ -76,13 +79,13 @@ package final class AppServerReviewControl: @unchecked Sendable { switch snapshot.phase { case .preparing, .finished: return nil - case .threadStarted(let threadID): - return try await sendInterrupt( - threadID: threadID, - turnID: "", + case .threadStarted(let thread): + return try await interrupt( + thread, + expectedTurnID: nil, willInterruptActiveTurn: willInterruptActiveTurn ) - case .reviewStarted(let turnThreadID, let turnID): + case .reviewStarted(let thread, let turnID): if let reviewSession = snapshot.reviewSession { return try await interrupt( reviewSession, @@ -90,9 +93,12 @@ package final class AppServerReviewControl: @unchecked Sendable { willInterruptActiveTurn: willInterruptActiveTurn ) } - return try await sendInterrupt( - threadID: turnThreadID, - turnID: turnID, + guard let thread else { + return nil + } + return try await interrupt( + thread, + expectedTurnID: turnID.nilIfEmpty.map(CodexTurnID.init(rawValue:)), willInterruptActiveTurn: willInterruptActiveTurn ) } @@ -126,23 +132,22 @@ package final class AppServerReviewControl: @unchecked Sendable { if reviewInterruption.turnID != expectedTurnID { setPhase( .reviewStarted( - turnThreadID: reviewInterruption.threadID, + thread: nil, turnID: reviewInterruption.turnID - ) + ), + reviewSession: reviewSession ) } return reviewInterruption } - private func sendInterrupt( - threadID: String, - turnID: String, + private func interrupt( + _ thread: CodexThread, + expectedTurnID: CodexTurnID?, willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? ) async throws -> AppServerReviewInterruption { - let interruption = try await interruptCodexTurn( - threadID: .init(rawValue: threadID), - turnID: turnID.nilIfEmpty.map(CodexTurnID.init(rawValue:)), - client: client, + let interruption = try await thread.interruptActiveTurn( + expectedTurnID: expectedTurnID, willInterruptActiveTurn: { interruption in guard let willInterruptActiveTurn else { return @@ -151,8 +156,8 @@ package final class AppServerReviewControl: @unchecked Sendable { } ) let reviewInterruption = AppServerReviewInterruption(interruption) - if reviewInterruption.turnID != turnID { - setPhase(.reviewStarted(turnThreadID: threadID, turnID: reviewInterruption.turnID)) + if reviewInterruption.turnID != expectedTurnID?.rawValue { + setPhase(.reviewStarted(thread: thread, turnID: reviewInterruption.turnID)) } return reviewInterruption } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 27ac789f..afff5ddf 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -102,13 +102,9 @@ private func eventSequence( } private func makeBackend( - transport: FakeJSONRPCTransport, - threadStartPermissionStrategy: AppServerAPI.Thread.Start.PermissionStrategy = .modernPermissions + transport: FakeJSONRPCTransport ) async throws -> AppServerCodexReviewBackend { - let appServer = try await CodexAppServer.testing( - transport: transport, - threadStartPermissionStrategy: threadStartPermissionStrategy - ) + let appServer = try await CodexAppServer.testing(transport: transport) return AppServerCodexReviewBackend(appServer: appServer) } @@ -606,9 +602,14 @@ struct AppServerClientTests { let transport = FakeJSONRPCTransport() try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let client = AppServerClient(transport: transport) - let control = AppServerReviewControl(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + let control = AppServerReviewControl() - control.recordThreadStarted(threadID: "thread-1") + control.recordThreadStarted(thread) let interruption = try await control.interrupt() #expect(interruption == .init(threadID: "thread-1", turnID: "")) @@ -623,9 +624,14 @@ struct AppServerClientTests { let transport = FakeJSONRPCTransport() try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let client = AppServerClient(transport: transport) - let control = AppServerReviewControl(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + let control = AppServerReviewControl() - control.recordReviewStarted(turnThreadID: "thread-1", turnID: "turn-1") + control.recordReviewStarted(thread: thread, turnID: "turn-1") let interruption = try await control.interrupt() #expect(interruption == .init(threadID: "thread-1", turnID: "turn-1")) @@ -645,9 +651,14 @@ struct AppServerClientTests { ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let client = AppServerClient(transport: transport) - let control = AppServerReviewControl(client: client) + let thread = CodexThread( + id: "thread-1", + client: client, + router: CodexAppServerNotificationRouter(client: client) + ) + let control = AppServerReviewControl() - control.recordReviewStarted(turnThreadID: "thread-1", turnID: "turn-old") + control.recordReviewStarted(thread: thread, turnID: "turn-old") let interruption = try await control.interrupt() #expect(interruption == .init(threadID: "thread-1", turnID: "turn-new")) @@ -905,6 +916,48 @@ struct AppServerClientTests { #expect(settings.fallbackModel == "gpt-5") } + @Test func settingsApplyWritesConfigurationThroughAppServer() async throws { + let transport = FakeJSONRPCTransport() + try await enqueueInitialize(transport) + try await transport.enqueueJSON(#"{"status":"ok"}"#, for: "config/batchWrite") + try await transport.enqueue( + AppServerAPI.Config.Read.Response(config: .init( + model: "gpt-5", + reviewModel: "gpt-5.5", + modelReasoningEffort: "xhigh", + serviceTier: "flex" + )), + for: "config/read" + ) + try await transport.enqueue( + AppServerAPI.Model.List.Response(data: [makeModelCatalogItem(model: "gpt-5.5")]), + for: "model/list" + ) + let backend = try await makeBackend(transport: transport) + + let settings = try await backend.applySettings(.init( + model: "gpt-5.5", + reasoningEffort: "xhigh", + serviceTier: "flex" + )) + + let batchWrite = try #require(await transport.recordedRequests().first { + $0.method == "config/batchWrite" + }) + let params = try #require(JSONSerialization.jsonObject( + with: batchWrite.params + ) as? [String: Any]) + let edits = try #require(params["edits"] as? [[String: Any]]) + #expect(edits.compactMap { $0["keyPath"] as? String } == [ + "review_model", + "model_reasoning_effort", + "service_tier", + ]) + #expect(settings.model == "gpt-5.5") + #expect(settings.reasoningEffort == "xhigh") + #expect(settings.serviceTier == "flex") + } + @Test func settingsReadPagesThroughModelCatalog() async throws { let transport = FakeJSONRPCTransport() try await enqueueInitialize(transport) @@ -1401,156 +1454,6 @@ struct AppServerClientTests { #expect(params.turnID == "turn-1") } - @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 = try await makeBackend( - 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 = try await makeBackend(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 = try await makeBackend(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 = try await makeBackend(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) From 3e053dc34f0f60f30b2aac7f67de28dd361f8787 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:38:54 +0900 Subject: [PATCH 053/359] refactor(app-server): require session-source transport --- .../AppServerProcessTransport.swift | 79 ++----------------- .../CodexAppServerKit/AppServerRequests.swift | 7 -- .../CodexAppServerKit/CodexAppServer.swift | 19 ++--- .../AppServerClientTests.swift | 10 +-- 4 files changed, 15 insertions(+), 100 deletions(-) diff --git a/Sources/CodexAppServerKit/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift index b46d2215..92e1add1 100644 --- a/Sources/CodexAppServerKit/AppServerProcessTransport.swift +++ b/Sources/CodexAppServerKit/AppServerProcessTransport.swift @@ -10,7 +10,6 @@ package actor AppServerProcessTransport: JSONRPC.Transport { 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, @@ -23,30 +22,15 @@ package actor AppServerProcessTransport: JSONRPC.Transport { ?? 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 - ) + ?? CodexAppServerExecutable.appServerArguments() self.environment = AppServerCodexHome.environment( environment, codexHomeURL: codexHomeURL ) self.codexHomeURL = codexHomeURL - self.threadStartPermissionStrategy = - supportsSessionSource - ? .modernPermissions - : .legacySandbox } } @@ -883,7 +867,7 @@ package enum CodexAppServerExecutable { let executable = resolveExecutable(environment: environment) return .init( executable: executable, - arguments: appServerArguments(for: executable, environment: environment) + arguments: appServerArguments() ) } @@ -907,28 +891,13 @@ package enum CodexAppServerExecutable { 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 = [ + package static func appServerArguments() -> [String] { + [ "-c", fileBackedAuthConfiguration, "app-server", "--listen", "stdio://", + "--session-source", "app-server", ] - if supportsSessionSource { - arguments.append(contentsOf: ["--session-source", "app-server"]) - } - return arguments } private static func findExecutable( @@ -954,44 +923,6 @@ package enum CodexAppServerExecutable { 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) diff --git a/Sources/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift index c20f7095..54de12f7 100644 --- a/Sources/CodexAppServerKit/AppServerRequests.swift +++ b/Sources/CodexAppServerKit/AppServerRequests.swift @@ -61,13 +61,6 @@ extension AppServerAPI { } } -extension AppServerAPI.Thread.Start { - package enum PermissionStrategy: Equatable, Sendable { - case modernPermissions - case legacySandbox - } -} - extension AppServerAPI.Review.Start { package struct Params: Codable, Equatable, Sendable { package var threadID: String diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift index 2344a341..42b47c24 100644 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -47,11 +47,6 @@ public actor CodexAppServer { self.codexHomeURL = codexHomeURL ?? Self.defaultCodexHomeURL(environment: environment) } - /// A local-process configuration that uses the current process environment. - public static var `default`: LocalProcess { - LocalProcess() - } - /// Returns the default Codex home for a local app-server process. /// /// The value honors `CODEX_HOME` first. On macOS command-line runs, @@ -107,7 +102,7 @@ public actor CodexAppServer { /// - clientName: Client name sent during app-server initialization. /// - clientVersion: Client version sent during app-server initialization. public init( - localProcess: LocalProcess = .default, + localProcess: LocalProcess = .init(), clientName: String = "CodexAppServerKit", clientVersion: String = "1" ) { @@ -115,11 +110,6 @@ public actor CodexAppServer { self.clientName = clientName self.clientVersion = clientVersion } - - /// A configuration that uses the local `codex` executable and current environment. - public static var `default`: Configuration { - Configuration() - } } private let client: AppServerClient @@ -132,7 +122,7 @@ public actor CodexAppServer { /// /// - Parameter configuration: Container and local-process configuration. /// - Throws: A transport, JSON-RPC, or app-server initialization error. - public init(configuration: Configuration = .default) async throws { + public init(configuration: Configuration = .init()) async throws { let transportConfiguration = AppServerProcessTransport.Configuration( executable: configuration.localProcess.executable, arguments: configuration.localProcess.arguments, @@ -162,10 +152,11 @@ public actor CodexAppServer { package init( transport: any JSONRPC.Transport ) async throws { + let configuration = Configuration() let client = AppServerClient(transport: transport) _ = try await client.initialize( - clientName: Configuration.default.clientName, - clientVersion: Configuration.default.clientVersion + clientName: configuration.clientName, + clientVersion: configuration.clientVersion ) let router = CodexAppServerNotificationRouter(client: client) await router.start() diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index afff5ddf..b8c72ba6 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -140,7 +140,6 @@ struct AppServerClientTests { "--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") } @@ -175,7 +174,7 @@ struct AppServerClientTests { #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments()) } - @Test func processTransportConfigurationOmitsUnsupportedSessionSourceFlag() throws { + @Test func processTransportConfigurationIncludesSessionSourceWithoutProbing() throws { let directory = FileManager.default.temporaryDirectory .appending(path: "codex-review-transport-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -183,9 +182,11 @@ struct AppServerClientTests { try? FileManager.default.removeItem(at: directory) } let codex = directory.appending(path: "codex") + let probed = directory.appending(path: "probed") let script = """ #!/bin/sh if [ "$1" = "app-server" ] && [ "$2" = "--help" ]; then + touch "\(probed.path)" printf 'Usage: codex app-server --listen \\n' fi """ @@ -199,8 +200,8 @@ struct AppServerClientTests { #expect(configuration.executable == codex.path) #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments()) - #expect(configuration.arguments.contains("--session-source") == false) - #expect(configuration.threadStartPermissionStrategy == .legacySandbox) + #expect(configuration.arguments.contains("--session-source")) + #expect(FileManager.default.fileExists(atPath: probed.path) == false) } @Test func processTransportConfigurationDoesNotProbeWhenArgumentsAreExplicit() throws { @@ -228,7 +229,6 @@ struct AppServerClientTests { #expect(configuration.executable == codex.path) #expect(configuration.arguments == ["custom", "argument"]) - #expect(configuration.threadStartPermissionStrategy == .legacySandbox) #expect(FileManager.default.fileExists(atPath: probed.path) == false) } From abf8a6b7aa7665d3d9372cc8b949a2cdb7aa787a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:24:13 +0900 Subject: [PATCH 054/359] refactor(app-server): externalize CodexAppServerKit --- Package.swift | 55 +- .../CodexAppServerKit/AppServerClient.swift | 238 -- .../AppServerProcessTransport.swift | 967 ------- .../CodexAppServerKit/AppServerRequests.swift | 1982 -------------- .../AppServerReviewNotification.swift | 735 ------ .../CodexAppServerKit/CodexAppServer.swift | 725 ----- .../CodexAppServerNotificationRouter.swift | 1082 -------- .../CodexAppServerKit/CodexDomainTypes.swift | 2329 ----------------- .../CodexThreadOperations.swift | 441 ---- .../CodexTurnSequences.swift | 740 ------ Sources/CodexAppServerKit/JSONRPC.swift | 98 - Sources/CodexAppServerKit/README.md | 443 ---- .../CodexAppServerTestRuntime.swift | 586 ----- .../AppServerCodexReviewBackend.swift | 104 +- .../AppServerReviewControl.swift | 90 +- .../LiveCodexReviewStoreBackend.swift | 31 +- Sources/CodexReviewTesting/TestSupport.swift | 2 +- .../CodexAppServerKitTests.swift | 1496 ----------- .../AppServerClientTests.swift | 2039 +-------------- .../CodexReviewHostTests.swift | 654 ++++- 20 files changed, 764 insertions(+), 14073 deletions(-) delete mode 100644 Sources/CodexAppServerKit/AppServerClient.swift delete mode 100644 Sources/CodexAppServerKit/AppServerProcessTransport.swift delete mode 100644 Sources/CodexAppServerKit/AppServerRequests.swift delete mode 100644 Sources/CodexAppServerKit/AppServerReviewNotification.swift delete mode 100644 Sources/CodexAppServerKit/CodexAppServer.swift delete mode 100644 Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift delete mode 100644 Sources/CodexAppServerKit/CodexDomainTypes.swift delete mode 100644 Sources/CodexAppServerKit/CodexThreadOperations.swift delete mode 100644 Sources/CodexAppServerKit/CodexTurnSequences.swift delete mode 100644 Sources/CodexAppServerKit/JSONRPC.swift delete mode 100644 Sources/CodexAppServerKit/README.md delete mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift delete mode 100644 Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift diff --git a/Package.swift b/Package.swift index d17a029a..743438de 100644 --- a/Package.swift +++ b/Package.swift @@ -16,14 +16,6 @@ let package = Package( name: "CodexReviewHost", targets: ["CodexReviewHost"] ), - .library( - name: "CodexAppServerKit", - targets: ["CodexAppServerKit"] - ), - .library( - name: "CodexAppServerKitTesting", - targets: ["CodexAppServerKitTesting"] - ), .library( name: "ReviewUI", targets: ["ReviewUI"] @@ -37,6 +29,8 @@ 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")), + // Temporary local path for in-flight CodexKit integration; replace with a pinned remote CodexKit dependency before release. + .package(path: "/Users/kn/Dev/CodexKit"), ], targets: [ .target( @@ -48,25 +42,10 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .target( - name: "CodexAppServerKit", - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), - .target( - name: "CodexAppServerKitTesting", - dependencies: [ - "CodexAppServerKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .target( name: "CodexReviewAppServer", dependencies: [ - "CodexAppServerKit", + .product(name: "CodexAppServerKit", package: "CodexKit"), "CodexReviewKit", ], swiftSettings: [ @@ -89,7 +68,7 @@ let package = Package( .target( name: "CodexReviewHost", dependencies: [ - "CodexAppServerKit", + .product(name: "CodexAppServerKit", package: "CodexKit"), "CodexReviewKit", "CodexReviewAppServer", "CodexReviewMCPServer", @@ -101,8 +80,8 @@ let package = Package( .target( name: "CodexReviewTesting", dependencies: [ - "CodexAppServerKit", - "CodexAppServerKitTesting", + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", ], swiftSettings: [ @@ -136,16 +115,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "CodexAppServerKitTests", - dependencies: [ - "CodexAppServerKit", - "CodexAppServerKitTesting", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "CodexReviewKitTests", dependencies: ["CodexReviewKit", "CodexReviewTesting"], @@ -156,8 +125,8 @@ let package = Package( .testTarget( name: "CodexReviewAppServerTests", dependencies: [ - "CodexAppServerKit", - "CodexAppServerKitTesting", + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting", @@ -179,7 +148,13 @@ let package = Package( ), .testTarget( name: "CodexReviewHostTests", - dependencies: ["CodexAppServerKit", "CodexReviewAppServer", "CodexReviewHost", "CodexReviewTesting"], + dependencies: [ + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexReviewAppServer", + "CodexReviewHost", + "CodexReviewTesting", + ], swiftSettings: [ .swiftLanguageMode(.v6), ] diff --git a/Sources/CodexAppServerKit/AppServerClient.swift b/Sources/CodexAppServerKit/AppServerClient.swift deleted file mode 100644 index b9ae2678..00000000 --- a/Sources/CodexAppServerKit/AppServerClient.swift +++ /dev/null @@ -1,238 +0,0 @@ -import Foundation -import OSLog - -private let logger = Logger(subsystem: "CodexAppServerKit", 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 = "CodexAppServerKit", - 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/CodexAppServerKit/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift deleted file mode 100644 index 92e1add1..00000000 --- a/Sources/CodexAppServerKit/AppServerProcessTransport.swift +++ /dev/null @@ -1,967 +0,0 @@ -import Darwin -import Foundation -import OSLog - -private let logger = Logger(subsystem: "CodexAppServerKit", 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 init( - executable: String? = nil, - arguments: [String]? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment, - codexHomeURL: URL - ) { - let resolvedExecutable = - executable - ?? CodexAppServerExecutable.resolveExecutable( - environment: environment - ) - self.executable = resolvedExecutable - self.arguments = - arguments - ?? CodexAppServerExecutable.appServerArguments() - self.environment = AppServerCodexHome.environment( - environment, - codexHomeURL: codexHomeURL - ) - self.codexHomeURL = codexHomeURL - } - } - - 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) 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.CodexAppServerKit.app-server.stdout" - ) - let stderrEvents = AppServerPipeReadEventSource( - fileHandle: stderr.fileHandleForReading, - label: "com.lynnpd.CodexAppServerKit.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 environment( - _ environment: [String: String], - codexHomeURL: URL - ) -> [String: String] { - var effectiveEnvironment = environment - effectiveEnvironment["CODEX_HOME"] = codexHomeURL.path - effectiveEnvironment["CODEX_SQLITE_HOME"] = sqliteHomeURL(for: codexHomeURL).path - return effectiveEnvironment - } - - package static func sqliteHomeURL(for codexHomeURL: URL) -> URL { - codexHomeURL.appendingPathComponent("sqlite", isDirectory: true) - } - - package static func ensureScaffold(at codexHomeURL: URL) throws { - try FileManager.default.createDirectory( - at: codexHomeURL, - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: sqliteHomeURL(for: codexHomeURL), - withIntermediateDirectories: true - ) - try createEmptyFileIfMissing(at: codexHomeURL.appendingPathComponent("config.toml")) - try createEmptyFileIfMissing(at: codexHomeURL.appendingPathComponent("AGENTS.md")) - } - - private static func createEmptyFileIfMissing(at url: URL) throws { - guard FileManager.default.fileExists(atPath: url.path) == false else { - return - } - try Data().write(to: url) - } -} - -package enum CodexAppServerExecutable { - package struct Command { - package var executable: String - package var arguments: [String] - } - - package static let fileBackedAuthConfiguration = #"cli_auth_credentials_store="file""# - - package static func resolve(environment: [String: String] = ProcessInfo.processInfo.environment) - -> Command - { - let executable = resolveExecutable(environment: environment) - return .init( - executable: executable, - arguments: appServerArguments() - ) - } - - package static func resolveExecutable( - environment: [String: String] = ProcessInfo.processInfo.environment - ) -> String { - let requestedCommand = - [ - environment["CODEX_APP_SERVER_CODEX_EXECUTABLE"], - environment["CODEX_REVIEW_CODEX_EXECUTABLE"], - environment["CODEX_EXECUTABLE"], - ].compactMap(\.self).first ?? "codex" - - if let candidate = findExecutable( - requestedCommand, - environment: environment - ) { - return candidate - } - - return requestedCommand - } - - package static func appServerArguments() -> [String] { - [ - "-c", fileBackedAuthConfiguration, - "app-server", - "--listen", "stdio://", - "--session-source", "app-server", - ] - } - - private static func findExecutable( - _ requestedCommand: String, - environment: [String: String] - ) -> String? { - let trimmedCommand = requestedCommand.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedCommand.isEmpty == false else { - return nil - } - if trimmedCommand.contains("/") { - return FileManager.default.isExecutableFile(atPath: trimmedCommand) - ? trimmedCommand : nil - } - for directory in pathSearchDirectories(environment: environment) { - let candidate = URL(fileURLWithPath: directory, isDirectory: true) - .appendingPathComponent(trimmedCommand) - .path - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - package static func pathSearchDirectories(environment: [String: String]) -> [String] { - let environmentDirectories = (environment["PATH"] ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .map(String.init) - var 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/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift deleted file mode 100644 index 54de12f7..00000000 --- a/Sources/CodexAppServerKit/AppServerRequests.swift +++ /dev/null @@ -1,1982 +0,0 @@ -import Foundation - -package enum AppServerAPI { - package enum Initialize {} - package enum Thread { - package enum Start {} - package enum Resume {} - package enum Fork {} - package enum List {} - package enum Read {} - package enum Archive {} - package enum Unarchive {} - package enum Name { - package enum Set {} - } - package enum Compact { - package enum Start {} - } - package enum Rollback {} - package enum Delete {} - package enum Unsubscribe {} - package enum BackgroundTerminals { - package enum Clean {} - } - } - package enum Review { - package enum Start {} - } - package enum Turn { - package enum Start {} - package enum Steer {} - package enum Interrupt {} - } - package enum Config { - package enum Read {} - package enum BatchWrite {} - } - package enum Model { - package enum List {} - } - package enum Auth { - package enum Read {} - } - package enum Account { - package enum Read {} - package enum Logout {} - package enum RateLimits { - package enum Read {} - } - package enum Login { - package enum Start {} - package enum Complete {} - package enum Cancel {} - } - } -} - -extension AppServerAPI { - package enum RequestScope: Hashable, Sendable { - case thread(String) - } -} - -extension AppServerAPI.Review.Start { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var target: CodexReviewTarget - package var delivery: CodexReviewDelivery - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case target - case delivery - } - - package init( - threadID: String, - target: CodexReviewTarget, - delivery: CodexReviewDelivery = .inline - ) { - self.threadID = threadID - self.target = target - self.delivery = delivery - } - } -} - -extension AppServerAPI.Review.Start { - package struct Response: Codable, Equatable, Sendable { - package var 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) - } - } -} - -extension AppServerAPI.Review.Start { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Review.Start.Response - - package static let method = "review/start" - package var params: AppServerAPI.Review.Start.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Review.Start.Params) { - self.params = params - } - } -} - -extension AppServerAPI { - package protocol Request: Sendable { - associatedtype Params: Encodable & Sendable - associatedtype Response: Decodable & Sendable - - static var method: String { get } - var params: Params { get } - var scope: AppServerAPI.RequestScope? { get } - } -} - -extension AppServerAPI.Request { - package var scope: AppServerAPI.RequestScope? { nil } -} - -package enum AppServerJSONValue: Codable, Equatable, Sendable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case array([AppServerJSONValue]) - case object([String: AppServerJSONValue]) - case null - - package init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([AppServerJSONValue].self) { - self = .array(value) - } else { - self = .object(try container.decode([String: AppServerJSONValue].self)) - } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .double(let value): - try container.encode(value) - case .bool(let value): - try container.encode(value) - case .array(let value): - try container.encode(value) - case .object(let value): - try container.encode(value) - case .null: - try container.encodeNil() - } - } -} - -extension AppServerAPI.Initialize { - package struct ClientInfo: Codable, Equatable, Sendable { - package var name: String - package var title: String? - package var version: String - - package init(name: String, title: String? = nil, version: String) { - self.name = name - self.title = title - self.version = version - } - } -} - -extension AppServerAPI.Initialize { - package struct Capabilities: Codable, Equatable, Sendable { - package var experimentalAPI: Bool - - enum CodingKeys: String, CodingKey { - case experimentalAPI = "experimentalApi" - } - - package init(experimentalAPI: Bool = true) { - self.experimentalAPI = experimentalAPI - } - } -} - -extension AppServerAPI.Initialize { - package struct Params: Codable, Equatable, Sendable { - package var clientInfo: AppServerAPI.Initialize.ClientInfo - package var capabilities: AppServerAPI.Initialize.Capabilities - - enum CodingKeys: String, CodingKey { - case clientInfo - case capabilities - } - - package init(clientName: String, clientVersion: String) { - self.clientInfo = .init(name: clientName, version: clientVersion) - self.capabilities = .init() - } - } -} - -extension AppServerAPI.Initialize { - package struct Response: Codable, Equatable, Sendable { - package var codexHome: String? - package var userAgent: String? - - package init(codexHome: String? = nil, userAgent: String? = nil) { - self.codexHome = codexHome - self.userAgent = userAgent - } - } -} - -extension AppServerAPI.Initialize { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Initialize.Response - - package static let method = "initialize" - package var params: AppServerAPI.Initialize.Params - - package init(params: AppServerAPI.Initialize.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Start { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String? - package var cwd: String? - package var model: String? - package var modelProvider: String? - package var ephemeral: Bool? - package var baseInstructions: String? - package var developerInstructions: String? - package var approvalPolicy: String? - package var approvalsReviewer: String? - package var sandbox: String? - package var serviceName: String? - package var serviceTier: String? - package var personality: String? - package var config: [String: AppServerJSONValue]? - package var permissions: AppServerAPI.Thread.Start.Permissions? - // Session start source drives lifecycle hooks; thread source is analytics classification. - package var sessionStartSource: AppServerAPI.Thread.Start.Source? - package var threadSource: AppServerAPI.Thread.Source? - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case cwd - case model - case modelProvider - case ephemeral - case baseInstructions - case developerInstructions - case approvalPolicy - case approvalsReviewer - case sandbox - case serviceName - case serviceTier - case personality - case config - case permissions - case sessionStartSource - case threadSource - } - - package init( - threadID: String? = nil, - cwd: String? = nil, - model: String? = nil, - modelProvider: String? = nil, - ephemeral: Bool? = nil, - baseInstructions: String? = nil, - developerInstructions: String? = nil, - approvalPolicy: String? = nil, - approvalsReviewer: String? = nil, - sandbox: String? = nil, - serviceName: String? = nil, - serviceTier: String? = nil, - personality: String? = nil, - config: [String: AppServerJSONValue]? = nil, - permissions: AppServerAPI.Thread.Start.Permissions? = nil, - sessionStartSource: AppServerAPI.Thread.Start.Source? = nil, - threadSource: AppServerAPI.Thread.Source? = nil - ) { - self.threadID = threadID - self.cwd = cwd - self.model = model - self.modelProvider = modelProvider - self.ephemeral = ephemeral - self.baseInstructions = baseInstructions - self.developerInstructions = developerInstructions - self.approvalPolicy = approvalPolicy - self.approvalsReviewer = approvalsReviewer - self.sandbox = sandbox - self.serviceName = serviceName - self.serviceTier = serviceTier - self.personality = personality - self.config = config - self.permissions = permissions - self.sessionStartSource = sessionStartSource - self.threadSource = threadSource - } - } -} - -extension AppServerAPI.Thread.Start { - package enum Source: String, Codable, Equatable, Sendable { - case startup - case clear - } -} - -extension AppServerAPI.Thread { - package struct Source: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - package var rawValue: String - - package init(rawValue: String) { - self.rawValue = rawValue - } - - package init(stringLiteral value: String) { - self.rawValue = value - } - - package static let user = Self(rawValue: "user") - package static let subagent = Self(rawValue: "subagent") - package static let memoryConsolidation = Self(rawValue: "memory_consolidation") - } -} - -extension AppServerAPI.Thread.Start { - package enum Permissions: Codable, Equatable, Sendable { - case profileID(String) - case profileSelection(AppServerAPI.Thread.Start.PermissionProfileSelection) - - package init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - if let profileID = try? container.decode(String.self) { - self = .profileID(profileID) - return - } - if let profileSelection = try? container.decode( - AppServerAPI.Thread.Start.PermissionProfileSelection.self) - { - self = .profileSelection(profileSelection) - return - } - throw DecodingError.typeMismatch( - AppServerAPI.Thread.Start.Permissions.self, - .init( - codingPath: decoder.codingPath, - debugDescription: - "Expected a permissions profile ID or profile selection object." - ) - ) - } - - package func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .profileID(let profileID): - try container.encode(profileID) - case .profileSelection(let profileSelection): - try container.encode(profileSelection) - } - } - } -} - -extension AppServerAPI.Thread.Start { - package struct PermissionProfileSelection: Codable, Equatable, Sendable { - package var type: String - package var id: String - - package init(id: String, type: String = "profile") { - self.type = type - self.id = id - } - } -} - -extension AppServerAPI.Thread.Start { - package struct Response: Codable, Equatable, Sendable { - package var threadID: String - package var model: String? - - enum CodingKeys: String, CodingKey { - case thread - case model - } - - private struct Thread: Codable, Equatable, Sendable { - var id: String - } - - package init(threadID: String, model: String? = nil) { - self.threadID = threadID - self.model = model - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.threadID = try container.decode(Thread.self, forKey: .thread).id - self.model = try container.decodeIfPresent(String.self, forKey: .model) - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Thread(id: threadID), forKey: .thread) - try container.encodeIfPresent(model, forKey: .model) - } - } -} - -extension AppServerAPI.Thread.Start { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Start.Response - - package static let method = "thread/start" - package var params: AppServerAPI.Thread.Start.Params - - package init(params: AppServerAPI.Thread.Start.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread { - package struct Snapshot: Codable, Equatable, Sendable { - package var id: String - package var cwd: String? - package var name: String? - package var preview: String? - package var modelProvider: String? - package var createdAt: Int? - package var updatedAt: Int? - package var ephemeral: Bool? - package var turns: [AppServerAPI.Turn.Payload]? - - enum CodingKeys: String, CodingKey { - case id - case cwd - case name - case preview - case modelProvider - case createdAt - case updatedAt - case ephemeral - case turns - } - - package init( - id: String, - cwd: String? = nil, - name: String? = nil, - preview: String? = nil, - modelProvider: String? = nil, - createdAt: Int? = nil, - updatedAt: Int? = nil, - ephemeral: Bool? = nil, - turns: [AppServerAPI.Turn.Payload]? = nil - ) { - self.id = id - self.cwd = cwd - self.name = name - self.preview = preview - self.modelProvider = modelProvider - self.createdAt = createdAt - self.updatedAt = updatedAt - self.ephemeral = ephemeral - self.turns = turns - } - } -} - -extension AppServerAPI.Thread.Resume { - package typealias Params = AppServerAPI.Thread.Start.Params - - package struct Response: Codable, Equatable, Sendable { - package var thread: AppServerAPI.Thread.Snapshot - - package init(thread: AppServerAPI.Thread.Snapshot) { - self.thread = thread - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Resume.Response - - package static let method = "thread/resume" - package var params: AppServerAPI.Thread.Resume.Params - package var scope: AppServerAPI.RequestScope? { - params.threadID.map(AppServerAPI.RequestScope.thread) - } - - package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { - var scopedParams = params - scopedParams.threadID = threadID - self.params = scopedParams - } - } -} - -extension AppServerAPI.Thread.Fork { - package typealias Params = AppServerAPI.Thread.Start.Params - - package struct Response: Codable, Equatable, Sendable { - package var thread: AppServerAPI.Thread.Snapshot - - package init(thread: AppServerAPI.Thread.Snapshot) { - self.thread = thread - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Fork.Response - - package static let method = "thread/fork" - package var params: AppServerAPI.Thread.Fork.Params - package var scope: AppServerAPI.RequestScope? { - params.threadID.map(AppServerAPI.RequestScope.thread) - } - - package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { - var scopedParams = params - scopedParams.threadID = threadID - self.params = scopedParams - } - } -} - -extension AppServerAPI.Turn { - package struct Payload: Codable, Equatable, Sendable { - package var id: String - package var status: String? - package var error: AppServerAPI.Turn.Error? - package var startedAt: Int? - package var completedAt: Int? - package var durationMS: Int? - - enum CodingKeys: String, CodingKey { - case id - case status - case error - case startedAt - case completedAt - case durationMS = "durationMs" - } - - package init( - id: String, - status: String? = nil, - error: AppServerAPI.Turn.Error? = nil, - startedAt: Int? = nil, - completedAt: Int? = nil, - durationMS: Int? = nil - ) { - self.id = id - self.status = status - self.error = error - self.startedAt = startedAt - self.completedAt = completedAt - self.durationMS = durationMS - } - } -} - -extension AppServerAPI.Thread.List { - package struct Params: Codable, Equatable, Sendable { - package var archived: Bool? - package var cursor: String? - package var cwd: AppServerAPI.Thread.List.CWDFilter? - package var limit: Int? - package var modelProviders: [String]? - package var searchTerm: String? - package var sortDirection: String? - package var sortKey: String? - package var sourceKinds: [String]? - package var useStateDbOnly: Bool? - - package init( - archived: Bool? = nil, - cursor: String? = nil, - cwd: AppServerAPI.Thread.List.CWDFilter? = nil, - limit: Int? = nil, - modelProviders: [String]? = nil, - searchTerm: String? = nil, - sortDirection: String? = nil, - sortKey: String? = nil, - sourceKinds: [String]? = nil, - useStateDbOnly: Bool? = nil - ) { - self.archived = archived - self.cursor = cursor - self.cwd = cwd - self.limit = limit - self.modelProviders = modelProviders - self.searchTerm = searchTerm - self.sortDirection = sortDirection - self.sortKey = sortKey - self.sourceKinds = sourceKinds - self.useStateDbOnly = useStateDbOnly - } - } - - package enum CWDFilter: Codable, Equatable, Sendable { - case path(String) - case paths([String]) - - package init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let path = try? container.decode(String.self) { - self = .path(path) - } else { - self = .paths(try container.decode([String].self)) - } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .path(let path): - try container.encode(path) - case .paths(let paths): - try container.encode(paths) - } - } - } - - package struct Response: Codable, Equatable, Sendable { - package var data: [AppServerAPI.Thread.Snapshot] - package var nextCursor: String? - package var backwardsCursor: String? - - package init( - data: [AppServerAPI.Thread.Snapshot], - nextCursor: String? = nil, - backwardsCursor: String? = nil - ) { - self.data = data - self.nextCursor = nextCursor - self.backwardsCursor = backwardsCursor - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.List.Response - - package static let method = "thread/list" - package var params: AppServerAPI.Thread.List.Params - - package init(params: AppServerAPI.Thread.List.Params = .init()) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Read { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var includeTurns: Bool? - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case includeTurns - } - - package init(threadID: String, includeTurns: Bool? = nil) { - self.threadID = threadID - self.includeTurns = includeTurns - } - } - - package struct Response: Codable, Equatable, Sendable { - package var thread: AppServerAPI.Thread.Snapshot - - package init(thread: AppServerAPI.Thread.Snapshot) { - self.thread = thread - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Read.Response - - package static let method = "thread/read" - package var params: AppServerAPI.Thread.Read.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Read.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Archive { - package typealias Response = EmptyResponse - - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Archive.Response - - package static let method = "thread/archive" - package var params: AppServerAPI.Thread.Archive.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Archive.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Unarchive { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } - - package struct Response: Codable, Equatable, Sendable { - package var thread: AppServerAPI.Thread.Snapshot - - package init(thread: AppServerAPI.Thread.Snapshot) { - self.thread = thread - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Unarchive.Response - - package static let method = "thread/unarchive" - package var params: AppServerAPI.Thread.Unarchive.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Unarchive.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Name.Set { - package typealias Response = EmptyResponse - - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var name: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case name - } - - package init(threadID: String, name: String) { - self.threadID = threadID - self.name = name - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Name.Set.Response - - package static let method = "thread/name/set" - package var params: AppServerAPI.Thread.Name.Set.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Name.Set.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Compact.Start { - package typealias Response = EmptyResponse - - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Compact.Start.Response - - package static let method = "thread/compact/start" - package var params: AppServerAPI.Thread.Compact.Start.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Compact.Start.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Turn { - package struct Error: Codable, Equatable, Sendable { - package var message: String - - package init(message: String) { - self.message = message - } - } -} - -extension AppServerAPI { - package enum UserInput: Codable, Equatable, Sendable { - case text(String) - case image(url: String) - case localImage(path: String) - case skill(name: String, path: String) - case mention(name: String, path: String) - - private enum CodingKeys: String, CodingKey { - case type - case text - case url - case path - case name - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch try container.decode(String.self, forKey: .type) { - case "text": - self = .text(try container.decode(String.self, forKey: .text)) - case "image": - self = .image(url: try container.decode(String.self, forKey: .url)) - case "localImage": - self = .localImage(path: try container.decode(String.self, forKey: .path)) - case "skill": - self = .skill( - name: try container.decode(String.self, forKey: .name), - path: try container.decode(String.self, forKey: .path) - ) - case "mention": - self = .mention( - name: try container.decode(String.self, forKey: .name), - path: try container.decode(String.self, forKey: .path) - ) - case let type: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unsupported app-server input type: \(type)" - ) - } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .text(let text): - try container.encode("text", forKey: .type) - try container.encode(text, forKey: .text) - case .image(let url): - try container.encode("image", forKey: .type) - try container.encode(url, forKey: .url) - case .localImage(let path): - try container.encode("localImage", forKey: .type) - try container.encode(path, forKey: .path) - case .skill(let name, let path): - try container.encode("skill", forKey: .type) - try container.encode(name, forKey: .name) - try container.encode(path, forKey: .path) - case .mention(let name, let path): - try container.encode("mention", forKey: .type) - try container.encode(name, forKey: .name) - try container.encode(path, forKey: .path) - } - } - } -} - -extension AppServerAPI.Turn { - package enum SandboxPolicy: Codable, Equatable, Sendable { - case readOnly(networkAccess: Bool) - case workspaceWrite( - writableRoots: [String], - networkAccess: Bool, - excludeTmpdirEnvVar: Bool, - excludeSlashTmp: Bool - ) - case dangerFullAccess - - private enum CodingKeys: String, CodingKey { - case type - case writableRoots - case networkAccess - case excludeTmpdirEnvVar - case excludeSlashTmp - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch try container.decode(String.self, forKey: .type) { - case "readOnly": - self = .readOnly( - networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) - ?? false) - case "workspaceWrite": - self = .workspaceWrite( - writableRoots: try container.decodeIfPresent( - [String].self, forKey: .writableRoots) ?? [], - networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) - ?? false, - excludeTmpdirEnvVar: try container.decodeIfPresent( - Bool.self, forKey: .excludeTmpdirEnvVar) ?? false, - excludeSlashTmp: try container.decodeIfPresent( - Bool.self, forKey: .excludeSlashTmp) ?? false - ) - case "dangerFullAccess": - self = .dangerFullAccess - case let type: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unsupported sandbox policy type: \(type)" - ) - } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .readOnly(let networkAccess): - try container.encode("readOnly", forKey: .type) - try container.encode(networkAccess, forKey: .networkAccess) - case .workspaceWrite( - let writableRoots, let networkAccess, let excludeTmpdirEnvVar, let excludeSlashTmp): - try container.encode("workspaceWrite", forKey: .type) - try container.encode(writableRoots, forKey: .writableRoots) - try container.encode(networkAccess, forKey: .networkAccess) - try container.encode(excludeTmpdirEnvVar, forKey: .excludeTmpdirEnvVar) - try container.encode(excludeSlashTmp, forKey: .excludeSlashTmp) - case .dangerFullAccess: - try container.encode("dangerFullAccess", forKey: .type) - } - } - } -} - -extension AppServerAPI.Turn.Start { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var input: [AppServerAPI.UserInput] - package var approvalPolicy: String? - package var approvalsReviewer: String? - package var clientUserMessageID: String? - package var cwd: String? - package var effort: String? - package var model: String? - package var outputSchema: AppServerJSONValue? - package var personality: String? - package var sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? - package var serviceTier: String? - package var summary: String? - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case input - case approvalPolicy - case approvalsReviewer - case clientUserMessageID = "clientUserMessageId" - case cwd - case effort - case model - case outputSchema - case personality - case sandboxPolicy - case serviceTier - case summary - } - - package init( - threadID: String, - input: [AppServerAPI.UserInput], - approvalPolicy: String? = nil, - approvalsReviewer: String? = nil, - clientUserMessageID: String? = nil, - cwd: String? = nil, - effort: String? = nil, - model: String? = nil, - outputSchema: AppServerJSONValue? = nil, - personality: String? = nil, - sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? = nil, - serviceTier: String? = nil, - summary: String? = nil - ) { - self.threadID = threadID - self.input = input - self.approvalPolicy = approvalPolicy - self.approvalsReviewer = approvalsReviewer - self.clientUserMessageID = clientUserMessageID - self.cwd = cwd - self.effort = effort - self.model = model - self.outputSchema = outputSchema - self.personality = personality - self.sandboxPolicy = sandboxPolicy - self.serviceTier = serviceTier - self.summary = summary - } - } - - package struct Response: Codable, Equatable, Sendable { - package var turn: AppServerAPI.Turn.Payload - - package init(turn: AppServerAPI.Turn.Payload) { - self.turn = turn - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Turn.Start.Response - - package static let method = "turn/start" - package var params: AppServerAPI.Turn.Start.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Turn.Start.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Turn.Steer { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var expectedTurnID: String - package var input: [AppServerAPI.UserInput] - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case expectedTurnID = "expectedTurnId" - case input - } - - package init(threadID: String, expectedTurnID: String, input: [AppServerAPI.UserInput]) { - self.threadID = threadID - self.expectedTurnID = expectedTurnID - self.input = input - } - } - - package struct Response: Codable, Equatable, Sendable { - package var turnID: String - - enum CodingKeys: String, CodingKey { - case turnID = "turnId" - } - - package init(turnID: String) { - self.turnID = turnID - } - } - - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Turn.Steer.Response - - package static let method = "turn/steer" - package var params: AppServerAPI.Turn.Steer.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Turn.Steer.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Turn.Interrupt { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var turnID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - } - - package init(threadID: String, turnID: String) { - self.threadID = threadID - self.turnID = turnID - } - } -} - -extension AppServerAPI.Turn.Interrupt { - package struct Request: AppServerAPI.Request { - package typealias Response = EmptyResponse - - package static let method = "turn/interrupt" - package var params: AppServerAPI.Turn.Interrupt.Params - - package init(params: AppServerAPI.Turn.Interrupt.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Rollback { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - package var numTurns: Int - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case numTurns - } - - package init(threadID: String, numTurns: Int) { - self.threadID = threadID - self.numTurns = numTurns - } - } -} - -extension AppServerAPI.Thread.Rollback { - package struct Request: AppServerAPI.Request { - package typealias Response = EmptyResponse - - package static let method = "thread/rollback" - package var params: AppServerAPI.Thread.Rollback.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Rollback.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Delete { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } -} - -extension AppServerAPI.Thread.Delete { - package struct Request: AppServerAPI.Request { - package typealias Response = EmptyResponse - - package static let method = "thread/delete" - package var params: AppServerAPI.Thread.Delete.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Delete.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.Unsubscribe { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } -} - -extension AppServerAPI.Thread.Unsubscribe { - package enum Status: String, Codable, Equatable, Sendable { - case notLoaded - case notSubscribed - case unsubscribed - } -} - -extension AppServerAPI.Thread.Unsubscribe { - package struct Response: Codable, Equatable, Sendable { - package var status: AppServerAPI.Thread.Unsubscribe.Status - - package init(status: AppServerAPI.Thread.Unsubscribe.Status) { - self.status = status - } - } -} - -extension AppServerAPI.Thread.Unsubscribe { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Thread.Unsubscribe.Response - - package static let method = "thread/unsubscribe" - package var params: AppServerAPI.Thread.Unsubscribe.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.Unsubscribe.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Thread.BackgroundTerminals.Clean { - package struct Params: Codable, Equatable, Sendable { - package var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } - - package init(threadID: String) { - self.threadID = threadID - } - } -} - -extension AppServerAPI.Thread.BackgroundTerminals.Clean { - package struct Request: AppServerAPI.Request { - package typealias Response = EmptyResponse - - package static let method = "thread/backgroundTerminals/clean" - package var params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params - package var scope: AppServerAPI.RequestScope? { - .thread(params.threadID) - } - - package init(params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Config.Read { - package struct Response: Codable, Equatable, Sendable { - package var config: AppServerAPI.Config.Snapshot - - package init(config: AppServerAPI.Config.Snapshot) { - self.config = config - } - } -} - -extension AppServerAPI.Config { - package struct Snapshot: Codable, Equatable, Sendable { - package var model: String? - package var reviewModel: String? - package var modelReasoningEffort: String? - package var serviceTier: String? - - enum CodingKeys: String, CodingKey { - case model - case reviewModel = "review_model" - case modelReasoningEffort = "model_reasoning_effort" - case serviceTier = "service_tier" - } - - package init( - model: String? = nil, - reviewModel: String? = nil, - modelReasoningEffort: String? = nil, - serviceTier: String? = nil - ) { - self.model = model - self.reviewModel = reviewModel - self.modelReasoningEffort = modelReasoningEffort - self.serviceTier = serviceTier - } - } -} - -extension AppServerAPI.Config.Read { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Config.Read.Response - - package static let method = "config/read" - package var params: EmptyResponse - - package init() { - self.params = .init() - } - } -} - -extension AppServerAPI.Config { - package enum Value: Encodable, Equatable, Sendable { - case string(String) - case null - - package func encode(to encoder: Encoder) throws { - switch self { - case .string(let value): - var container = encoder.singleValueContainer() - try container.encode(value) - case .null: - var container = encoder.singleValueContainer() - try container.encodeNil() - } - } - } -} - -extension AppServerAPI.Config { - package enum MergeStrategy: String, Codable, Equatable, Sendable { - case replace - case upsert - } -} - -extension AppServerAPI.Config { - package struct Edit: Encodable, Equatable, Sendable { - package var keyPath: String - package var value: AppServerAPI.Config.Value - package var mergeStrategy: AppServerAPI.Config.MergeStrategy - - package init( - keyPath: String, - value: AppServerAPI.Config.Value, - mergeStrategy: AppServerAPI.Config.MergeStrategy = .replace - ) { - self.keyPath = keyPath - self.value = value - self.mergeStrategy = mergeStrategy - } - } -} - -extension AppServerAPI.Config.BatchWrite { - package struct Params: Encodable, Equatable, Sendable { - package var edits: [AppServerAPI.Config.Edit] - package var filePath: String? - package var expectedVersion: String? - package var reloadUserConfig: Bool - - package init( - edits: [AppServerAPI.Config.Edit], - filePath: String? = nil, - expectedVersion: String? = nil, - reloadUserConfig: Bool = true - ) { - self.edits = edits - self.filePath = filePath - self.expectedVersion = expectedVersion - self.reloadUserConfig = reloadUserConfig - } - } -} - -extension AppServerAPI.Config.BatchWrite { - package struct Response: Decodable, Equatable, Sendable { - package var status: String - package var version: String? - package var filePath: String? - } -} - -extension AppServerAPI.Config.BatchWrite { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Config.BatchWrite.Response - - package static let method = "config/batchWrite" - package var params: AppServerAPI.Config.BatchWrite.Params - - package init(params: AppServerAPI.Config.BatchWrite.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Model.List { - package struct Params: Codable, Equatable, Sendable { - package var cursor: String? - package var limit: Int? - package var includeHidden: Bool? - - package init( - cursor: String? = nil, - limit: Int? = nil, - includeHidden: Bool? = nil - ) { - self.cursor = cursor - self.limit = limit - self.includeHidden = includeHidden - } - } -} - -extension AppServerAPI.Model.List { - package struct Response: Codable, Equatable, Sendable { - package var data: [CodexModel] - package var nextCursor: String? - - package init( - data: [CodexModel], - nextCursor: String? = nil - ) { - self.data = data - self.nextCursor = nextCursor - } - } -} - -extension AppServerAPI.Model.List { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Model.List.Response - - package static let method = "model/list" - package var params: AppServerAPI.Model.List.Params - - package init(params: AppServerAPI.Model.List.Params = .init(includeHidden: true)) { - self.params = params - } - } -} - -extension AppServerAPI.Auth.Read { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.Read.Response - - package static let method = "account/read" - package var params: AppServerAPI.Account.Read.Params - - package init() { - self.params = .init(refreshToken: false) - } - } -} - -extension AppServerAPI.Account.Read { - package struct Params: Codable, Equatable, Sendable { - package var refreshToken: Bool - - package init(refreshToken: Bool) { - self.refreshToken = refreshToken - } - } -} - -extension AppServerAPI.Account.Read { - package struct Response: Codable, Equatable, Sendable { - package var account: AppServerAPI.Account.Snapshot? - package var requiresOpenAIAuth: Bool - - enum CodingKeys: String, CodingKey { - case account - case requiresOpenAIAuth = "requiresOpenaiAuth" - } - - package init( - account: AppServerAPI.Account.Snapshot? = nil, requiresOpenAIAuth: Bool = false - ) { - self.account = account - self.requiresOpenAIAuth = requiresOpenAIAuth - } - } -} - -extension AppServerAPI.Account.Read { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.Read.Response - - package static let method = "account/read" - package var params: AppServerAPI.Account.Read.Params - - package init(params: AppServerAPI.Account.Read.Params = .init(refreshToken: false)) { - self.params = params - } - } -} - -extension AppServerAPI.Account.RateLimits.Read { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.RateLimits.Response - - package static let method = "account/rateLimits/read" - package var params: EmptyResponse - - package init() { - self.params = .init() - } - } -} - -extension AppServerAPI.Account.RateLimits { - package struct Response: Codable, Equatable, Sendable { - package var rateLimits: AppServerAPI.Account.RateLimits.Snapshot - package var rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? - - enum CodingKeys: String, CodingKey { - case rateLimits - case rateLimitsByLimitID = "rateLimitsByLimitId" - } - - package init( - rateLimits: AppServerAPI.Account.RateLimits.Snapshot, - rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? = nil - ) { - self.rateLimits = rateLimits - self.rateLimitsByLimitID = rateLimitsByLimitID - } - } -} - -extension AppServerAPI.Account.RateLimits { - package struct Snapshot: Codable, Equatable, Sendable { - package var limitID: String? - package var primary: AppServerAPI.Account.RateLimits.Window? - package var secondary: AppServerAPI.Account.RateLimits.Window? - package var planType: String? - - enum CodingKeys: String, CodingKey { - case limitID = "limitId" - case primary - case secondary - case planType - } - - package init( - limitID: String? = nil, - primary: AppServerAPI.Account.RateLimits.Window? = nil, - secondary: AppServerAPI.Account.RateLimits.Window? = nil, - planType: String? = nil - ) { - self.limitID = limitID - self.primary = primary - self.secondary = secondary - self.planType = planType - } - } -} - -extension AppServerAPI.Account.RateLimits { - package struct Window: Codable, Equatable, Sendable { - package var usedPercent: Int - package var windowDurationMins: Int? - package var resetsAt: Int64? - - package init( - usedPercent: Int, - windowDurationMins: Int? = nil, - resetsAt: Int64? = nil - ) { - self.usedPercent = usedPercent - self.windowDurationMins = windowDurationMins - self.resetsAt = resetsAt - } - } -} - -extension AppServerAPI.Account.RateLimits.Response { - package var codexRateLimitWindows: - [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] - { - Self.rateLimitWindows(from: codexSnapshot) - } - - package var codexPlanType: String? { - codexSnapshot?.planType - } - - private var codexSnapshot: AppServerAPI.Account.RateLimits.Snapshot? { - if let codexSnapshot = rateLimitsByLimitID?["codex"] { - return codexSnapshot - } - if let codexSnapshot = rateLimitsByLimitID?.first(where: { limitID, snapshot in - Self.isCodexRateLimit(limitID) || Self.isCodexRateLimit(snapshot.limitID) - })?.value { - return codexSnapshot - } - if Self.isCodexRateLimit(rateLimits.limitID) { - return rateLimits - } - return nil - } - - private static func rateLimitWindows( - from snapshot: AppServerAPI.Account.RateLimits.Snapshot? - ) -> [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] { - [snapshot?.primary, snapshot?.secondary].compactMap { window in - guard let window, - let duration = window.windowDurationMins - else { - return nil - } - return ( - windowDurationMinutes: duration, - usedPercent: window.usedPercent, - resetsAt: window.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } - ) - } - } - - package static func isCodexRateLimit(_ limitID: String?) -> Bool { - let trimmedLimitID = limitID?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedLimitID = - if let trimmedLimitID, trimmedLimitID.isEmpty == false { - trimmedLimitID - } else { - "codex" - } - return normalizedLimitID == "codex" || normalizedLimitID.hasPrefix("codex_") - } -} - -extension AppServerAPI.Account { - package struct Snapshot: Codable, Equatable, Sendable { - package enum Kind: String, Codable, Equatable, Sendable { - case chatGPT = "chatgpt" - case apiKey - case amazonBedrock - } - - package var id: String - package var kind: Kind - package var label: String - package var planType: String? - - private enum CodingKeys: String, CodingKey { - case type - case email - case planType - } - - package init( - kind: Kind, - id: String, - label: String, - planType: String? = nil - ) { - self.id = id - self.kind = kind - self.label = label - self.planType = planType - } - - package init(email: String, planType: String) { - self.init( - kind: .chatGPT, - id: Self.normalizedAccountID(email), - label: email, - planType: planType - ) - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(Kind.self, forKey: .type) - switch kind { - case .apiKey: - self.init(kind: .apiKey, id: "api-key", label: "API Key") - case .chatGPT: - let email = try container.decode(String.self, forKey: .email) - let normalizedEmail = Self.normalizedAccountID(email) - guard normalizedEmail.isEmpty == false else { - throw DecodingError.dataCorruptedError( - forKey: .email, - in: container, - debugDescription: "ChatGPT account email must not be empty." - ) - } - self.init( - kind: .chatGPT, - id: normalizedEmail, - label: email, - planType: try container.decodeIfPresent(String.self, forKey: .planType) - ) - case .amazonBedrock: - self.init(kind: .amazonBedrock, id: "amazon-bedrock", label: "Amazon Bedrock") - } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(kind, forKey: .type) - switch kind { - case .apiKey, .amazonBedrock: - break - case .chatGPT: - try container.encode(label, forKey: .email) - try container.encodeIfPresent(planType, forKey: .planType) - } - } - - private static func normalizedAccountID(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - } - } -} - -extension AppServerAPI.Account.Login { - package struct Params: Codable, Equatable, Sendable { - package var type: String - package var apiKey: String? - package var codexStreamlinedLogin: Bool - package var nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication? - - package init( - type: String = "chatgpt", - apiKey: String? = nil, - codexStreamlinedLogin: Bool = true, - nativeWebAuthentication: AppServerAPI.Account.Login.NativeWebAuthentication? = nil - ) { - self.type = type - self.apiKey = apiKey - self.codexStreamlinedLogin = codexStreamlinedLogin - self.nativeWebAuthentication = nativeWebAuthentication - } - } -} - -extension AppServerAPI.Account.Login { - package struct NativeWebAuthentication: Codable, Equatable, Sendable { - package var callbackURLScheme: String - - enum CodingKeys: String, CodingKey { - case callbackURLScheme = "callbackUrlScheme" - } - - package init(callbackURLScheme: String) { - self.callbackURLScheme = callbackURLScheme - } - } -} - -extension AppServerAPI.Account.Login { - package 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) - } - } - } -} - -extension AppServerAPI.Account.Login.Start { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.Login.Response - - package static let method = "account/login/start" - package var params: AppServerAPI.Account.Login.Params - - package init(params: AppServerAPI.Account.Login.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Account.Login.Complete { - package 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 - } - } -} - -extension AppServerAPI.Account.Login.Complete { - package struct Response: Codable, Equatable, Sendable { - package init() {} - } -} - -extension AppServerAPI.Account.Login.Complete { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.Login.Complete.Response - - package static let method = "account/login/complete" - package var params: AppServerAPI.Account.Login.Complete.Params - - package init(params: AppServerAPI.Account.Login.Complete.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Account.Login.Cancel { - package struct Params: Codable, Equatable, Sendable { - package var loginID: String - - enum CodingKeys: String, CodingKey { - case loginID = "loginId" - } - - package init(loginID: String) { - self.loginID = loginID - } - } -} - -extension AppServerAPI.Account.Login.Cancel { - package struct Response: Codable, Equatable, Sendable { - package var status: String - - package init(status: String = "canceled") { - self.status = status - } - } -} - -extension AppServerAPI.Account.Login.Cancel { - package struct Request: AppServerAPI.Request { - package typealias Response = AppServerAPI.Account.Login.Cancel.Response - - package static let method = "account/login/cancel" - package var params: AppServerAPI.Account.Login.Cancel.Params - - package init(params: AppServerAPI.Account.Login.Cancel.Params) { - self.params = params - } - } -} - -extension AppServerAPI.Account.Logout { - package struct Request: AppServerAPI.Request { - package typealias Response = EmptyResponse - - package static let method = "account/logout" - package var params: EmptyResponse - - package init() { - self.params = .init() - } - } -} diff --git a/Sources/CodexAppServerKit/AppServerReviewNotification.swift b/Sources/CodexAppServerKit/AppServerReviewNotification.swift deleted file mode 100644 index 2f669bd3..00000000 --- a/Sources/CodexAppServerKit/AppServerReviewNotification.swift +++ /dev/null @@ -1,735 +0,0 @@ -import Foundation - -package struct AppServerReviewNotification: Decodable, Equatable, Sendable { - package var method: Method - package var payload: Payload - package var rawPayload: AppServerJSONValue? - package var rawNotification: CodexRawNotification - - package var rawMethod: String { - method.rawValue - } - - package init(method: String, paramsData: Data) throws { - let rawPayload = try JSONDecoder().decode(AppServerJSONValue.self, from: paramsData) - let payload: Payload - if case .object = rawPayload, - let decodedPayload = try? JSONDecoder().decode(Payload.self, from: paramsData) { - payload = decodedPayload - } else { - payload = Payload(rawValue: rawPayload) - } - - self.method = Method(rawValue: method) - self.payload = payload - self.rawPayload = rawPayload - self.rawNotification = CodexRawNotification( - method: method, - params: paramsData, - threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), - turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) - ) - } - - enum CodingKeys: String, CodingKey { - case method - case payload = "params" - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let rawMethod = try container.decode(String.self, forKey: .method) - let rawPayload = container.contains(.payload) - ? try container.decode(AppServerJSONValue.self, forKey: .payload) - : nil - let payload: Payload - if case .object? = rawPayload, - let decodedPayload = try? container.decodeIfPresent(Payload.self, forKey: .payload) { - payload = decodedPayload - } else { - payload = Payload(rawValue: rawPayload) - } - - self.method = Method(rawValue: rawMethod) - self.payload = payload - self.rawPayload = rawPayload - self.rawNotification = CodexRawNotification( - method: rawMethod, - params: rawPayload.flatMap { try? JSONEncoder().encode($0) } ?? Data("null".utf8), - threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), - turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) - ) - } - - package var startsReviewMode: Bool { - method == .itemStarted && payload.item?.resolvedType == "enteredReviewMode" - } - - package var finishesReviewMode: Bool { - method == .itemCompleted && payload.item?.resolvedType == "exitedReviewMode" - } -} - -extension AppServerReviewNotification { - package struct Method: RawRepresentable, Hashable, ExpressibleByStringLiteral, CustomStringConvertible, Sendable { - package var rawValue: String - - package init(rawValue: String) { - self.rawValue = rawValue - } - - package init(stringLiteral value: String) { - self.rawValue = value - } - - package var description: String { - rawValue - } - - package static let turnStarted: Self = "turn/started" - package static let turnCompleted: Self = "turn/completed" - package static let turnFailed: Self = "turn/failed" - package static let turnCancelled: Self = "turn/cancelled" - package static let turnAborted: Self = "turn/aborted" - package static let turnDiffUpdated: Self = "turn/diff/updated" - package static let turnPlanUpdated: Self = "turn/plan/updated" - package static let itemStarted: Self = "item/started" - package static let itemUpdated: Self = "item/updated" - package static let itemCompleted: Self = "item/completed" - package static let agentMessageDelta: Self = "item/agentMessage/delta" - package static let planDelta: Self = "item/plan/delta" - package static let reasoningSummaryTextDelta: Self = "item/reasoning/summaryTextDelta" - package static let reasoningSummaryPartAdded: Self = "item/reasoning/summaryPartAdded" - package static let reasoningTextDelta: Self = "item/reasoning/textDelta" - package static let commandExecutionOutputDelta: Self = "item/commandExecution/outputDelta" - package static let commandExecOutputDelta: Self = "command/exec/outputDelta" - package static let processOutputDelta: Self = "process/outputDelta" - package static let commandExecutionTerminalInteraction: Self = - "item/commandExecution/terminalInteraction" - package static let fileChangeOutputDelta: Self = "item/fileChange/outputDelta" - package static let fileChangePatchUpdated: Self = "item/fileChange/patchUpdated" - package static let mcpToolCallProgress: Self = "item/mcpToolCall/progress" - package static let autoApprovalReviewStarted: Self = "item/autoApprovalReview/started" - package static let autoApprovalReviewCompleted: Self = "item/autoApprovalReview/completed" - package static let agentMessage: Self = "agent/message" - package static let log: Self = "log" - package static let error: Self = "error" - package static let warning: Self = "warning" - package static let guardianWarning: Self = "guardianWarning" - package static let deprecationNotice: Self = "deprecationNotice" - package static let configWarning: Self = "configWarning" - package static let diagnostic: Self = "diagnostic" - package static let modelRerouted: Self = "model/rerouted" - package static let modelVerification: Self = "model/verification" - package static let threadCompacted: Self = "thread/compacted" - package static let threadClosed: Self = "thread/closed" - package static let threadStatusChanged: Self = "thread/status/changed" - - package var isReviewNotificationMethod: Bool { - switch self { - case .threadClosed, - .threadStatusChanged, - .turnStarted, - .turnCompleted, - .turnFailed, - .turnCancelled, - .turnAborted, - .turnDiffUpdated, - .turnPlanUpdated, - .itemStarted, - .itemUpdated, - .itemCompleted, - .autoApprovalReviewStarted, - .autoApprovalReviewCompleted, - .agentMessageDelta, - .planDelta, - .reasoningSummaryTextDelta, - .reasoningSummaryPartAdded, - .reasoningTextDelta, - .commandExecutionOutputDelta, - .commandExecutionTerminalInteraction, - .commandExecOutputDelta, - .processOutputDelta, - .fileChangeOutputDelta, - .fileChangePatchUpdated, - .mcpToolCallProgress, - .agentMessage, - .log, - .error, - .modelRerouted, - .modelVerification, - .threadCompacted, - .warning, - .guardianWarning, - .deprecationNotice, - .configWarning, - .diagnostic: - true - default: - false - } - } - - package var isThreadlessReviewBroadcast: Bool { - switch self { - case .warning, .deprecationNotice, .configWarning, .error: - true - default: - false - } - } - } -} - -extension AppServerReviewNotification { - package struct Payload: Decodable, Equatable, Sendable { - package var threadID: String? - package var turn: Turn? - package var turnID: String? - package var reviewThreadID: String? - package var model: String? - package var fromModel: String? - package var toModel: String? - package var reason: String? - package var itemID: String? - package var processID: String? - package var processHandle: String? - package var delta: String? - package var deltaBase64: String? - package var stdin: String? - package var message: String? - package var summary: String? - package var details: String? - package var diff: String? - package var result: AppServerJSONValue? - package var error: ErrorPayload? - package var willRetry: Bool? - package var status: Status? - package var startedAtMs: Int64? - package var completedAtMs: Int64? - package var summaryIndex: Int? - package var contentIndex: Int? - package var plan: [PlanStep] - package var verifications: [String] - package var changes: [FileChange] - package var item: Item? - package var rawValue: AppServerJSONValue? - - package var rawFields: [String: AppServerJSONValue] { - if case .object(let value) = rawValue { - return value - } - return [:] - } - - package var resolvedTurnID: String? { - guard let turnID = turn?.id, turnID.isEmpty == false else { - return self.turnID - } - return turnID - } - - package 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: AppServerJSONValue? = 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: AppServerJSONValue? = 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 - } - - package init(from decoder: Decoder) throws { - let rawValue = try? AppServerJSONValue(from: decoder) - guard case .object? = rawValue else { - self.threadID = nil - self.turn = nil - self.turnID = nil - self.reviewThreadID = nil - self.model = nil - self.fromModel = nil - self.toModel = nil - self.reason = nil - self.itemID = nil - self.processID = nil - self.processHandle = nil - self.delta = nil - self.deltaBase64 = nil - self.stdin = nil - self.message = nil - self.summary = nil - self.details = nil - self.diff = nil - self.result = nil - self.error = nil - self.willRetry = nil - self.status = nil - self.startedAtMs = nil - self.completedAtMs = nil - self.summaryIndex = nil - self.contentIndex = nil - self.plan = [] - self.verifications = [] - self.changes = [] - self.item = nil - self.rawValue = rawValue - return - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - self.threadID = container.decodeReviewStringIfPresent(forKey: .threadID) - self.turn = try? container.decodeIfPresent(Turn.self, forKey: .turn) - self.turnID = container.decodeReviewStringIfPresent(forKey: .turnID) - self.reviewThreadID = container.decodeReviewStringIfPresent(forKey: .reviewThreadID) - self.model = container.decodeReviewStringIfPresent(forKey: .model) - self.fromModel = container.decodeReviewStringIfPresent(forKey: .fromModel) - self.toModel = container.decodeReviewStringIfPresent(forKey: .toModel) - self.reason = container.decodeReviewStringIfPresent(forKey: .reason) - self.itemID = container.decodeReviewStringIfPresent(forKey: .itemID) - self.processID = container.decodeReviewStringIfPresent(forKey: .processID) - self.processHandle = container.decodeReviewStringIfPresent(forKey: .processHandle) - self.delta = container.decodeReviewStringIfPresent(forKey: .delta) - self.deltaBase64 = container.decodeReviewStringIfPresent(forKey: .deltaBase64) - self.stdin = container.decodeReviewStringIfPresent(forKey: .stdin) - self.message = container.decodeReviewStringIfPresent(forKey: .message) - self.summary = container.decodeReviewStringIfPresent(forKey: .summary) - self.details = container.decodeReviewStringIfPresent(forKey: .details) - self.diff = container.decodeReviewStringIfPresent(forKey: .diff) - self.result = try? container.decodeIfPresent(AppServerJSONValue.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) - self.rawValue = rawValue - } - - package struct ErrorPayload: Decodable, Equatable, Sendable { - package var message: String? - package var rawValue: AppServerJSONValue? - - enum CodingKeys: String, CodingKey { - case message - } - - package init(message: String? = nil, rawValue: AppServerJSONValue? = nil) { - self.message = message - self.rawValue = rawValue - } - - package init(from decoder: Decoder) throws { - rawValue = try? AppServerJSONValue(from: decoder) - let singleValue = try decoder.singleValueContainer() - if singleValue.decodeNil() { - message = nil - } else if let value = try? singleValue.decode(String.self) { - message = value - } else if let container = try? decoder.container(keyedBy: CodingKeys.self) { - message = container.decodeReviewStringIfPresent(forKey: .message) - } else { - message = nil - } - } - } - - package struct Turn: Decodable, Equatable, Sendable { - package var id: String - package var status: String? - package var error: ErrorPayload? - package var rawValue: AppServerJSONValue? - - enum CodingKeys: String, CodingKey { - case id - case status - case error - } - - package init( - id: String, - status: String? = nil, - error: ErrorPayload? = nil, - rawValue: AppServerJSONValue? = nil - ) { - self.id = id - self.status = status - self.error = error - self.rawValue = rawValue - } - - package init(from decoder: Decoder) throws { - rawValue = try? AppServerJSONValue(from: decoder) - let container = try decoder.container(keyedBy: CodingKeys.self) - id = container.decodeReviewStringIfPresent(forKey: .id) ?? "" - status = container.decodeReviewStringIfPresent(forKey: .status) - error = try? container.decodeIfPresent(ErrorPayload.self, forKey: .error) - } - } - - package struct Status: Decodable, Equatable, Sendable { - package var type: String - package var rawValue: AppServerJSONValue? - - enum CodingKeys: String, CodingKey { - case type - } - - package init(type: String, rawValue: AppServerJSONValue? = nil) { - self.type = type - self.rawValue = rawValue - } - - package init(from decoder: Decoder) throws { - rawValue = try? AppServerJSONValue(from: decoder) - let container = try decoder.container(keyedBy: CodingKeys.self) - type = container.decodeReviewStringIfPresent(forKey: .type) ?? "" - } - } - - package struct PlanStep: Decodable, Equatable, Sendable { - package var step: String - package var status: String - - package init(step: String, status: String) { - self.step = step - self.status = status - } - } - - package struct FileChange: Decodable, Equatable, Sendable { - package var path: String? - package var kind: String? - package var diff: String? - - package var summaryText: String { - [kind, path, diff].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") - } - - enum CodingKeys: String, CodingKey { - case path - case kind - case diff - } - - package init(path: String? = nil, kind: String? = nil, diff: String? = nil) { - self.path = path - self.kind = kind - self.diff = diff - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - path = container.decodeReviewStringIfPresent(forKey: .path) - kind = container.decodeReviewStringIfPresent(forKey: .kind) - diff = container.decodeReviewStringIfPresent(forKey: .diff) - } - } - } -} - -extension AppServerReviewNotification.Payload { - package struct Item: Decodable, Equatable, Sendable { - package var id: String - package var type: String? - package var kind: String? - package var text: String? - package var review: String? - package var phase: String? - package var command: String? - package var cwd: String? - package var processID: String? - package var source: String? - package var aggregatedOutput: String? - package var hasAggregatedOutputField: Bool - package var output: String? - package var exitCode: Int? - package var durationMs: Int? - package var commandActions: [CommandAction] - package var status: String? - package var path: String? - package var namespace: String? - package var server: String? - package var tool: String? - package var name: String? - package var query: String? - package var prompt: String? - package var summary: [String] - package var summaryFragments: [TextFragment] - package var content: [String] - package var contentFragments: [TextFragment] - package var fragments: [TextFragment] - package var arguments: AppServerJSONValue? - package var input: AppServerJSONValue? - package var result: AppServerJSONValue? - package var error: AppServerJSONValue? - package var success: Bool? - package var rawValue: AppServerJSONValue? - - package var resolvedType: String? { - type ?? kind - } - - package var rawType: String { - resolvedType ?? "unknown" - } - - enum CodingKeys: String, CodingKey { - case id - case type - case kind - case text - case review - case phase - case command - case cwd - case processID = "processId" - case source - case aggregatedOutput - case output - case exitCode - case durationMs - case commandActions - case status - case path - case namespace - case server - case tool - case name - case query - case prompt - case summary - case content - case fragments - case arguments - case input - case result - case error - case success - } - - package init(from decoder: Decoder) throws { - rawValue = try? AppServerJSONValue(from: decoder) - let container = try decoder.container(keyedBy: CodingKeys.self) - id = container.decodeReviewStringIfPresent(forKey: .id) ?? "" - type = container.decodeReviewStringIfPresent(forKey: .type) - kind = container.decodeReviewStringIfPresent(forKey: .kind) - text = container.decodeReviewStringIfPresent(forKey: .text) - review = container.decodeReviewStringIfPresent(forKey: .review) - phase = container.decodeReviewStringIfPresent(forKey: .phase) - command = container.decodeReviewStringIfPresent(forKey: .command) - cwd = container.decodeReviewStringIfPresent(forKey: .cwd) - processID = container.decodeReviewStringIfPresent(forKey: .processID) - source = container.decodeReviewStringIfPresent(forKey: .source) - hasAggregatedOutputField = container.contains(.aggregatedOutput) - aggregatedOutput = container.decodeReviewStringIfPresent(forKey: .aggregatedOutput)?.nilIfEmpty - output = container.decodeReviewStringIfPresent(forKey: .output) - exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) - durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs) - commandActions = (try? container.decodeIfPresent([CommandAction].self, forKey: .commandActions)) ?? [] - status = container.decodeReviewStringIfPresent(forKey: .status) - path = container.decodeReviewStringIfPresent(forKey: .path) - namespace = container.decodeReviewStringIfPresent(forKey: .namespace) - server = container.decodeReviewStringIfPresent(forKey: .server) - tool = container.decodeReviewStringIfPresent(forKey: .tool) - name = container.decodeReviewStringIfPresent(forKey: .name) - query = container.decodeReviewStringIfPresent(forKey: .query) - prompt = container.decodeReviewStringIfPresent(forKey: .prompt) - summary = (try? container.decodeIfPresent([String].self, forKey: .summary)) ?? [] - summaryFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .summary)) ?? [] - content = (try? container.decodeIfPresent([String].self, forKey: .content)) ?? [] - contentFragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .content)) ?? [] - fragments = (try? container.decodeIfPresent([TextFragment].self, forKey: .fragments)) ?? [] - arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) - input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) - result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) - error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) - success = try? container.decodeIfPresent(Bool.self, forKey: .success) - } - - package struct CommandAction: Decodable, Equatable, Sendable { - package var kind: String - package var command: String? - package var name: String? - package var path: String? - package var query: String? - - enum CodingKeys: String, CodingKey { - case type - case kind - case command - case name - case path - case query - } - - package init( - kind: String, - 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 - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - kind = container.decodeReviewStringIfPresent(forKey: .type) - ?? container.decodeReviewStringIfPresent(forKey: .kind) - ?? "unknown" - command = container.decodeReviewStringIfPresent(forKey: .command) - name = container.decodeReviewStringIfPresent(forKey: .name) - path = container.decodeReviewStringIfPresent(forKey: .path) - query = container.decodeReviewStringIfPresent(forKey: .query) - } - } - - package struct TextFragment: Decodable, Equatable, Sendable { - package var text: String? - - enum CodingKeys: String, CodingKey { - case text - } - - package init(text: String? = nil) { - self.text = text - } - - package init(from decoder: Decoder) throws { - let singleValueContainer = try decoder.singleValueContainer() - if singleValueContainer.decodeNil() { - text = nil - return - } - if let text = try? singleValueContainer.decode(String.self) { - self.text = text - return - } - let container = try decoder.container(keyedBy: CodingKeys.self) - text = container.decodeReviewStringIfPresent(forKey: .text) - } - } - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} - -private extension KeyedDecodingContainer { - func decodeReviewStringIfPresent(forKey key: Key) -> String? { - if let string = try? decode(String.self, forKey: key) { - return string - } - if let int = try? decode(Int.self, forKey: key) { - return String(int) - } - if let double = try? decode(Double.self, forKey: key) { - return String(double) - } - if let bool = try? decode(Bool.self, forKey: key) { - return bool ? "true" : "false" - } - return nil - } -} diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift deleted file mode 100644 index 42b47c24..00000000 --- a/Sources/CodexAppServerKit/CodexAppServer.swift +++ /dev/null @@ -1,725 +0,0 @@ -import Foundation - -/// A live connection to a Codex app-server process. -/// -/// `CodexAppServer` owns the app-server transport, performs the initial -/// JSON-RPC handshake, and routes server notifications to thread, turn, and -/// login domain objects. -public actor CodexAppServer { - /// Options for creating a Codex app-server container. - public struct Configuration: Sendable { - /// Options for launching a local `codex app-server` process. - public struct LocalProcess: Sendable { - /// The `codex` executable path or command name. - /// - /// Set this when the executable is not available through the process - /// environment. When `nil`, the default transport command is used. - public var executable: String? - - /// Command-line arguments passed to the app-server executable. - /// - /// When `nil`, the transport uses the default arguments for starting - /// `codex app-server`. - public var arguments: [String]? - - /// Environment variables supplied to the app-server process. - public var environment: [String: String] - - /// The Codex home directory used by the app-server process. - public var codexHomeURL: URL - - /// Creates a configuration for launching a local app-server process. - /// - /// - Parameters: - /// - executable: The `codex` executable path or command name. - /// - arguments: Command-line arguments for the app-server process. - /// - environment: Environment variables for the app-server process. - /// - codexHomeURL: Codex home directory, or `nil` to use the local-process default. - public init( - executable: String? = nil, - arguments: [String]? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment, - codexHomeURL: URL? = nil - ) { - self.executable = executable - self.arguments = arguments - self.environment = environment - self.codexHomeURL = codexHomeURL ?? Self.defaultCodexHomeURL(environment: environment) - } - - /// Returns the default Codex home for a local app-server process. - /// - /// The value honors `CODEX_HOME` first. On macOS command-line runs, - /// it then matches the Codex CLI convention of `~/.codex`. Other - /// Apple platform environments prefer Application Support so the - /// default stays inside the app container when this API is compiled - /// for a non-command-line host. - public static func defaultCodexHomeURL( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectoryForCurrentUser: URL = FileManager.default.homeDirectoryForCurrentUser, - applicationSupportDirectory: URL? = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first - ) -> URL { - if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters( - in: .whitespacesAndNewlines - ), - codexHome.isEmpty == false { - return URL(fileURLWithPath: codexHome, isDirectory: true) - } -#if os(macOS) - if let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), - home.isEmpty == false { - return URL(fileURLWithPath: home, isDirectory: true) - .appendingPathComponent(".codex", isDirectory: true) - } -#endif - if let applicationSupportDirectory { - return applicationSupportDirectory - .appendingPathComponent("Codex", isDirectory: true) - } - return homeDirectoryForCurrentUser - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Application Support", isDirectory: true) - .appendingPathComponent("Codex", isDirectory: true) - } - } - - /// Local process launch settings for the app-server runtime. - public var localProcess: LocalProcess - - /// The client name sent in the app-server `initialize` request. - public var clientName: String - - /// The client version sent in the app-server `initialize` request. - public var clientVersion: String - - /// Creates a configuration for a Codex app-server container. - /// - /// - Parameters: - /// - localProcess: Local process launch settings. - /// - clientName: Client name sent during app-server initialization. - /// - clientVersion: Client version sent during app-server initialization. - public init( - localProcess: LocalProcess = .init(), - clientName: String = "CodexAppServerKit", - clientVersion: String = "1" - ) { - self.localProcess = localProcess - self.clientName = clientName - self.clientVersion = clientVersion - } - } - - private let client: AppServerClient - private let router: CodexAppServerNotificationRouter - - /// Starts a Codex app-server process and initializes the client session. - /// - /// The initializer completes after the app-server has accepted the - /// `initialize` request and notification routing is ready. - /// - /// - Parameter configuration: Container and local-process configuration. - /// - Throws: A transport, JSON-RPC, or app-server initialization error. - public init(configuration: Configuration = .init()) async throws { - let transportConfiguration = AppServerProcessTransport.Configuration( - executable: configuration.localProcess.executable, - arguments: configuration.localProcess.arguments, - environment: configuration.localProcess.environment, - codexHomeURL: configuration.localProcess.codexHomeURL - ) - let client = AppServerClient( - transport: try AppServerProcessTransport(configuration: transportConfiguration)) - _ = try await client.initialize( - clientName: configuration.clientName, - clientVersion: configuration.clientVersion - ) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - self.client = client - self.router = router - } - - package init( - client: AppServerClient, - router: CodexAppServerNotificationRouter - ) { - self.client = client - self.router = router - } - - package init( - transport: any JSONRPC.Transport - ) async throws { - let configuration = Configuration() - let client = AppServerClient(transport: transport) - _ = try await client.initialize( - clientName: configuration.clientName, - clientVersion: configuration.clientVersion - ) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - self.client = client - self.router = router - } - - package static func testing( - transport: any JSONRPC.Transport - ) async throws -> CodexAppServer { - try await CodexAppServer(transport: transport) - } - - /// Closes the app-server connection and stops notification routing. - /// - /// Call this when the container is no longer needed. Closing is idempotent - /// from the perspective of public callers. - public func close() async { - await router.stop() - await client.close() - } - - package func notificationStream() async -> AsyncThrowingStream { - await client.notificationStream() - } - - /// Returns account-related app-server notifications as typed domain events. - /// - /// The stream includes login completion, account update, and Codex - /// rate-limit update notifications. Notifications with newer account - /// methods are preserved as `.unknown`; malformed known notifications are - /// reported as `.malformed` without terminating the stream. - /// - /// - Returns: A stream of account domain events. - public func accountEvents() async -> AsyncThrowingStream { - let notifications = await client.notificationStream() - return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in - let task = Task { - do { - for try await notification in notifications { - guard let event = Self.accountEvent(from: notification) else { - continue - } - continuation.yield(event) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - - /// Creates a new Codex thread in a workspace. - /// - /// - Parameters: - /// - workspace: The workspace directory for the thread. - /// - instructions: Optional base and developer instructions. - /// - options: Thread creation options, including model, approval, and sandbox settings. - /// - Returns: A domain handle for the created thread. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func startThread( - in workspace: URL, - instructions: CodexInstructions? = nil, - options: CodexThread.Options = .init() - ) async throws -> CodexThread { - let approvalMode = options.approvalMode ?? .autoReview - let response = try await client.send( - AppServerAPI.Thread.Start.Request( - params: .init( - cwd: workspace.path, - model: options.model, - modelProvider: options.modelProvider, - ephemeral: options.ephemeral, - baseInstructions: instructions?.base, - developerInstructions: instructions?.developer, - approvalPolicy: approvalMode.approvalPolicy, - approvalsReviewer: approvalMode.approvalsReviewer, - sandbox: options.sandbox?.threadSandboxValue, - serviceName: options.serviceName, - serviceTier: options.serviceTier, - personality: options.personality?.rawValue, - config: options.config?.mapValues(\.appServerJSONValue), - permissions: options.permissions?.appServerPermissions, - sessionStartSource: options.sessionStartSource?.appServerSource, - threadSource: options.threadSource?.appServerSource - ) - )) - return CodexThread( - id: .init(rawValue: response.threadID), - workspace: workspace, - model: response.model ?? options.model, - client: client, - router: router - ) - } - - /// Resumes an existing Codex thread. - /// - /// - Parameters: - /// - id: The thread identifier to resume. - /// - options: Resume options that may override the stored thread context. - /// - Returns: A domain handle for the resumed thread. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func resumeThread( - _ id: CodexThreadID, - options: CodexThread.ResumeOptions = .init() - ) async throws -> CodexThread { - let response = try await client.send( - AppServerAPI.Thread.Resume.Request( - threadID: id.rawValue, - params: threadStartParams(options: options) - )) - return thread(from: response.thread) - } - - /// Forks an existing Codex thread into a new thread. - /// - /// - Parameters: - /// - id: The source thread identifier. - /// - options: Options for the forked thread. - /// - Returns: A domain handle for the forked thread. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func forkThread( - _ id: CodexThreadID, - options: CodexThread.Options = .init() - ) async throws -> CodexThread { - let response = try await client.send( - AppServerAPI.Thread.Fork.Request( - threadID: id.rawValue, - params: threadStartParams(options: options) - )) - return thread(from: response.thread) - } - - /// Restores an archived Codex thread. - /// - /// - Parameter id: The archived thread identifier. - /// - Returns: A domain handle for the restored thread. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func unarchiveThread(_ id: CodexThreadID) async throws -> CodexThread { - let response = try await client.send( - AppServerAPI.Thread.Unarchive.Request( - params: .init(threadID: id.rawValue) - )) - return thread(from: response.thread) - } - - /// Permanently deletes a Codex thread. - /// - /// - Parameter id: The thread identifier to delete. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func deleteThread(_ id: CodexThreadID) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Delete.Request( - params: .init(threadID: id.rawValue) - )) - } - - package nonisolated func threadHandle( - id: CodexThreadID, - workspace: URL? = nil, - model: String? = nil - ) -> CodexThread { - CodexThread( - id: id, - workspace: workspace, - model: model, - client: client, - router: router - ) - } - - package func rollbackThread(_ id: CodexThreadID, turnCount: Int = 1) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Rollback.Request( - params: .init(threadID: id.rawValue, numTurns: turnCount) - )) - } - - package func cleanBackgroundTerminals(in id: CodexThreadID) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.BackgroundTerminals.Clean.Request( - params: .init(threadID: id.rawValue) - )) - } - - package func unsubscribeThread(_ id: CodexThreadID) async throws { - let _: AppServerAPI.Thread.Unsubscribe.Response = try await client.send( - AppServerAPI.Thread.Unsubscribe.Request( - params: .init(threadID: id.rawValue) - )) - } - - /// Lists Codex threads visible to the app-server account. - /// - /// - Parameter query: Paging and filtering options. - /// - Returns: A page of thread snapshots. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func listThreads(_ query: CodexThreadQuery = .init()) async throws -> CodexThreadPage { - let response = try await client.send( - AppServerAPI.Thread.List.Request( - params: .init( - archived: query.archived, - cursor: query.cursor, - cwd: query.workspace.map { .path($0.path) }, - limit: query.limit, - modelProviders: query.modelProviders, - searchTerm: query.searchTerm, - sortDirection: query.sortDirection?.rawValue, - sortKey: query.sortKey?.rawValue, - sourceKinds: query.sourceKinds?.map(\.rawValue), - useStateDbOnly: query.useStateDBOnly - ))) - return .init( - threads: response.data.map(Self.threadSnapshot), - nextCursor: response.nextCursor, - backwardsCursor: response.backwardsCursor - ) - } - - /// Lists available Codex models. - /// - /// - Parameter includeHidden: Whether hidden models should be included. - /// - Returns: The complete model list across all app-server result pages. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func models(includeHidden: Bool = false) async throws -> [CodexModel] { - var cursor: String? - var models: [CodexModel] = [] - repeat { - let response = try await client.send( - AppServerAPI.Model.List.Request( - params: .init(cursor: cursor, includeHidden: includeHidden) - )) - models.append(contentsOf: response.data) - cursor = response.nextCursor - } while cursor != nil - return models - } - - /// Reads the active Codex account. - /// - /// - Parameter refreshToken: Whether the app-server should refresh token state before returning. - /// - Returns: The active account, or `nil` when no account is signed in. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func account(refreshToken: Bool = false) async throws -> CodexAccount? { - let response = try await client.send( - AppServerAPI.Account.Read.Request(params: .init(refreshToken: refreshToken)) - ) - return response.account.map(Self.account) - } - - /// Reads the app-server configuration visible to Codex clients. - /// - /// - Returns: Model, reasoning, review model, and service-tier settings. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func configuration() async throws -> CodexConfiguration { - let response = try await client.send(AppServerAPI.Config.Read.Request()) - let reasoningEffort = response.config.modelReasoningEffort.map { - CodexReasoningEffort(rawValue: $0) - } - return .init( - model: response.config.model, - reviewModel: response.config.reviewModel, - reasoningEffort: reasoningEffort, - serviceTier: response.config.serviceTier - ) - } - - package func updateConfiguration(_ patch: CodexConfigurationPatch) async throws { - var edits: [AppServerAPI.Config.Edit] = [] - if patch.updatesReviewModel { - edits.append(.init( - keyPath: "review_model", - value: patch.reviewModel.map(AppServerAPI.Config.Value.string) ?? .null - )) - } - if patch.updatesReasoningEffort { - edits.append(.init( - keyPath: "model_reasoning_effort", - value: patch.reasoningEffort.map { .string($0.rawValue) } ?? .null - )) - } - if patch.updatesServiceTier { - edits.append(.init( - keyPath: "service_tier", - value: patch.serviceTier.map(AppServerAPI.Config.Value.string) ?? .null - )) - } - guard edits.isEmpty == false else { - return - } - let _: AppServerAPI.Config.BatchWrite.Response = try await client.send( - AppServerAPI.Config.BatchWrite.Request(params: .init(edits: edits)) - ) - } - - /// Reads Codex account rate-limit information. - /// - /// - Returns: Current plan type and rate-limit windows reported by the app-server. - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func rateLimits() async throws -> CodexRateLimits { - let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) - return .init(appServer: response) - } - - /// Starts an API-key login flow. - /// - /// - Parameter apiKey: The OpenAI API key to register with Codex. - /// - Returns: The login handle reported by the app-server. - /// - Throws: A transport, JSON-RPC, or app-server login error. - @discardableResult - public func loginAPIKey(_ apiKey: String) async throws -> CodexLoginHandle { - let response = try await client.send( - AppServerAPI.Account.Login.Start.Request( - params: .init(type: "apiKey", apiKey: apiKey) - )) - return try Self.loginHandle(from: response) - } - - /// Starts a ChatGPT browser login flow. - /// - /// - Parameter callbackURLScheme: Optional native callback URL scheme for completing login. - /// - Returns: A login handle containing the next browser or callback step. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func loginChatGPT(callbackURLScheme: String? = nil) async throws -> CodexLoginHandle { - let response = try await client.send( - AppServerAPI.Account.Login.Start.Request( - params: .init( - type: "chatgpt", - nativeWebAuthentication: callbackURLScheme.map { - .init(callbackURLScheme: $0) - } - ) - )) - return try Self.loginHandle(from: response) - } - - /// Starts a ChatGPT device-code login flow. - /// - /// - Returns: A login handle containing device-code instructions. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func loginChatGPTDeviceCode() async throws -> CodexLoginHandle { - let response = try await client.send( - AppServerAPI.Account.Login.Start.Request( - params: .init(type: "chatgptDeviceCode") - )) - return try Self.loginHandle(from: response) - } - - /// Cancels a pending login flow. - /// - /// Handles without an app-server login identifier are treated as already complete. - /// - /// - Parameter handle: The login handle returned from a login-start method. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func cancelLogin(_ handle: CodexLoginHandle) async throws { - guard let id = handle.id else { - return - } - try await cancelLogin(id: id) - } - - /// Cancels a pending login flow by identifier. - /// - /// - Parameter id: The app-server login identifier. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func cancelLogin(id: CodexLoginHandle.ID) async throws { - let _: AppServerAPI.Account.Login.Cancel.Response = try await client.send( - AppServerAPI.Account.Login.Cancel.Request(params: .init(loginID: id.rawValue)) - ) - } - - /// Completes a native ChatGPT browser login flow. - /// - /// Handles without an app-server login identifier are treated as already complete. - /// - /// - Parameters: - /// - handle: The login handle returned from `loginChatGPT(callbackURLScheme:)`. - /// - callbackURL: The callback URL received by the client application. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func completeLogin(_ handle: CodexLoginHandle, callbackURL: URL) async throws { - guard let id = handle.id else { - return - } - try await completeLogin(id: id, callbackURL: callbackURL) - } - - /// Completes a native ChatGPT browser login flow by identifier. - /// - /// - Parameters: - /// - id: The app-server login identifier. - /// - callbackURL: The callback URL received by the client application. - /// - Throws: A transport, JSON-RPC, or app-server login error. - public func completeLogin(id: CodexLoginHandle.ID, callbackURL: URL) async throws { - let _: AppServerAPI.Account.Login.Complete.Response = try await client.send( - AppServerAPI.Account.Login.Complete.Request( - params: .init( - loginID: id.rawValue, - callbackURL: callbackURL.absoluteString - )) - ) - } - - /// Logs out of the active Codex account. - /// - /// - Throws: A transport, JSON-RPC, or app-server request error. - public func logout() async throws { - let _: EmptyResponse = try await client.send(AppServerAPI.Account.Logout.Request()) - } - - private func threadStartParams(options: CodexThread.Options) -> AppServerAPI.Thread.Start.Params { - .init( - model: options.model, - modelProvider: options.modelProvider, - ephemeral: options.ephemeral, - approvalPolicy: options.approvalMode?.approvalPolicy, - approvalsReviewer: options.approvalMode?.approvalsReviewer, - sandbox: options.sandbox?.threadSandboxValue, - serviceName: options.serviceName, - serviceTier: options.serviceTier, - personality: options.personality?.rawValue, - config: options.config?.mapValues(\.appServerJSONValue), - permissions: options.permissions?.appServerPermissions, - sessionStartSource: options.sessionStartSource?.appServerSource, - threadSource: options.threadSource?.appServerSource - ) - } - - private func thread(from snapshot: AppServerAPI.Thread.Snapshot) -> CodexThread { - CodexThread( - id: .init(rawValue: snapshot.id), - workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, - client: client, - router: router - ) - } - - package nonisolated static func threadSnapshot( - from snapshot: AppServerAPI.Thread.Snapshot - ) -> CodexThreadSnapshot { - .init( - id: .init(rawValue: snapshot.id), - workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, - name: snapshot.name, - preview: snapshot.preview, - turns: (snapshot.turns ?? []).map { - CodexTurnSnapshot( - id: .init(rawValue: $0.id), - status: $0.status.map(CodexTurnStatus.init(rawValue:)), - errorMessage: $0.error?.message - ) - } - ) - } - - private nonisolated static func account(from snapshot: AppServerAPI.Account.Snapshot) -> CodexAccount { - .init( - id: snapshot.id, - kind: .init(rawValue: snapshot.kind.rawValue) ?? .chatGPT, - label: snapshot.label, - planType: snapshot.planType - ) - } - - private nonisolated static func loginHandle( - from response: AppServerAPI.Account.Login.Response - ) throws -> CodexLoginHandle { - switch response { - case .apiKey: - return .apiKey - case .chatgpt(let loginID, let authURL, _): - return try .chatGPT( - id: .init(rawValue: loginID), - authenticationURL: webAuthenticationURL(authURL, field: "authUrl") - ) - case .chatgptDeviceCode(let loginID, let verificationURL, let userCode): - return .chatGPTDeviceCode( - id: .init(rawValue: loginID), - verificationURL: try webAuthenticationURL(verificationURL, field: "verificationUrl"), - userCode: userCode - ) - case .chatgptAuthTokens: - return .apiKey - } - } - - private nonisolated 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 CodexAppServerError.jsonRPC( - code: -32602, - message: "Invalid ChatGPT authentication URL in \(field)." - ) - } - return url - } - - private nonisolated static func accountEvent( - from notification: JSONRPC.Notification - ) -> CodexAccountEvent? { - switch notification.method { - case "account/login/completed": - do { - let payload = try JSONDecoder().decode( - AppServerAccountLoginCompletedNotification.self, - from: notification.params - ) - return .loginCompleted(.init( - loginID: payload.loginID.map(CodexLoginHandle.ID.init(rawValue:)), - success: payload.success, - error: payload.error - )) - } catch { - return .malformed(method: notification.method, message: error.localizedDescription) - } - case "account/updated": - return .accountUpdated - case "account/rateLimits/updated": - do { - let payload = try JSONDecoder().decode( - AppServerAccountRateLimitsUpdatedNotification.self, - from: notification.params - ) - guard AppServerAPI.Account.RateLimits.Response - .isCodexRateLimit(payload.rateLimits.limitID) - else { - return nil - } - return .rateLimitsUpdated(.init( - appServer: .init(rateLimits: payload.rateLimits) - )) - } catch { - return .malformed(method: notification.method, message: error.localizedDescription) - } - case let method where method.hasPrefix("account/"): - return .unknown(.init(method: notification.method, params: notification.params)) - default: - return nil - } - } - -} - -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 AppServerAccountRateLimitsUpdatedNotification: Decodable, Equatable, Sendable { - var rateLimits: AppServerAPI.Account.RateLimits.Snapshot -} diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift deleted file mode 100644 index 591b94d4..00000000 --- a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift +++ /dev/null @@ -1,1082 +0,0 @@ -import Foundation - -package actor CodexAppServerNotificationRouter { - private struct Subscriber { - var continuation: AsyncThrowingStream.Continuation - } - - private struct NotificationContext { - var threadID: CodexThreadID? - var turnID: CodexTurnID? - } - - private struct CommandOutputKey: Hashable { - var threadID: CodexThreadID? - var turnID: CodexTurnID? - var itemID: String - } - - private let client: AppServerClient - private var routerTask: Task? - private var threadIDByTurnID: [CodexTurnID: CodexThreadID] = [:] - private var reviewThreadIDs: Set = [] - private var turnHistoryByTurnID: [CodexTurnID: [CodexTurnEvent]] = [:] - private var threadHistoryByThreadID: [CodexThreadID: [CodexThreadEvent]] = [:] - private var commandOutputByKey: [CommandOutputKey: String] = [:] - private var turnSubscribersByTurnID: [CodexTurnID: [UUID: Subscriber]] = [:] - private var threadSubscribersByThreadID: [CodexThreadID: [UUID: Subscriber]] = - [:] - private let decoder = JSONDecoder() - - package init(client: AppServerClient) { - self.client = client - } - - package func start() async { - guard routerTask == nil else { - return - } - let notifications = await client.notificationStream() - routerTask = Task { - do { - for try await notification in notifications { - self.route(notification) - } - } catch { - self.finishAll(throwing: error) - } - } - } - - package func events(for turnID: CodexTurnID) -> AsyncThrowingStream { - AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in - let subscriptionID = UUID() - Task { - self.addTurnSubscriber( - subscriptionID, turnID: turnID, continuation: continuation) - } - continuation.onTermination = { _ in - Task { await self.removeTurnSubscriber(subscriptionID, turnID: turnID) } - } - } - } - - package func events(for threadID: CodexThreadID) -> AsyncThrowingStream< - CodexThreadEvent, Error - > { - AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in - let subscriptionID = UUID() - Task { - self.addThreadSubscriber( - subscriptionID, threadID: threadID, continuation: continuation) - } - continuation.onTermination = { _ in - Task { await self.removeThreadSubscriber(subscriptionID, threadID: threadID) } - } - } - } - - package func seedReviewTurn(_ turnID: CodexTurnID, reviewThreadID: CodexThreadID) { - threadIDByTurnID[turnID] = reviewThreadID - reviewThreadIDs.insert(reviewThreadID) - guard let turnHistory = turnHistoryByTurnID[turnID] else { - return - } - for turnEvent in turnHistory { - let threadEvent = Self.threadEvent( - from: turnEvent, - turnID: turnID, - threadID: reviewThreadID - ) - if (threadHistoryByThreadID[reviewThreadID] ?? []).contains(threadEvent) { - continue - } - appendThreadEvent(threadEvent, threadID: reviewThreadID) - } - } - - package func stop() { - routerTask?.cancel() - routerTask = nil - finishAll(throwing: JSONRPC.Error.closed) - } - - package func turnSubscriberCountForTesting(for turnID: CodexTurnID) -> Int { - turnSubscribersByTurnID[turnID]?.count ?? 0 - } - - package func threadSubscriberCountForTesting(for threadID: CodexThreadID) -> Int { - threadSubscribersByThreadID[threadID]?.count ?? 0 - } - - private func route(_ notification: JSONRPC.Notification) { - let reviewNotification = try? AppServerReviewNotification( - method: notification.method, - paramsData: notification.params - ) - if let reviewNotification, - reviewNotification.method.isThreadlessReviewBroadcast, - reviewNotification.payload.threadID == nil, - reviewNotification.payload.resolvedTurnID == nil, - routeReviewBroadcast(reviewNotification) { - return - } - - var context = Self.context(from: notification.params, reviewNotification: reviewNotification) - if let threadID = context.threadID, let turnID = context.turnID { - threadIDByTurnID[turnID] = threadID - } else if let turnID = context.turnID, let threadID = threadIDByTurnID[turnID] { - context.threadID = threadID - } - - let reviewLogItem = context.threadID != nil || context.turnID != nil - ? reviewLogItem(from: notification, context: context) - : nil - - if let threadID = context.threadID { - let event = decodeThreadEvent( - notification: notification, - context: context, - reviewLogItem: reviewLogItem - ) - appendThreadEvent(event, threadID: threadID) - } - - if let turnID = context.turnID { - let event = decodeTurnEvent( - notification: notification, - context: context, - reviewLogItem: reviewLogItem - ) - turnHistoryByTurnID[turnID, default: []].append(event) - if let subscribers = turnSubscribersByTurnID[turnID] { - for subscriber in subscribers.values { - subscriber.continuation.yield(event) - } - } - if Self.isTerminalTurnEvent(event) { - finishTurnSubscribers(turnID: turnID) - } - } - } - - private func routeReviewBroadcast(_ notification: AppServerReviewNotification) -> Bool { - guard reviewThreadIDs.isEmpty == false else { - return false - } - for threadID in reviewThreadIDs { - var raw = notification.rawNotification - raw.threadID = threadID - appendThreadEvent(.unknown(raw), threadID: threadID) - } - return true - } - - private func appendThreadEvent(_ event: CodexThreadEvent, threadID: CodexThreadID) { - threadHistoryByThreadID[threadID, default: []].append(event) - if let subscribers = threadSubscribersByThreadID[threadID] { - for subscriber in subscribers.values { - subscriber.continuation.yield(event) - } - } - if case .closed = event { - finishThreadSubscribers(threadID: threadID) - } - } - - private func addTurnSubscriber( - _ subscriptionID: UUID, - turnID: CodexTurnID, - continuation: AsyncThrowingStream.Continuation - ) { - let history = turnHistoryByTurnID[turnID] ?? [] - for event in history { - continuation.yield(event) - } - if history.contains(where: Self.isTerminalTurnEvent) { - continuation.finish() - return - } - turnSubscribersByTurnID[turnID, default: [:]][subscriptionID] = .init( - continuation: continuation) - } - - private func addThreadSubscriber( - _ subscriptionID: UUID, - threadID: CodexThreadID, - continuation: AsyncThrowingStream.Continuation - ) { - let history = threadHistoryByThreadID[threadID] ?? [] - for event in history { - continuation.yield(event) - } - if history.contains(where: Self.isTerminalThreadEvent) { - continuation.finish() - return - } - threadSubscribersByThreadID[threadID, default: [:]][subscriptionID] = .init( - continuation: continuation) - } - - private nonisolated static func isTerminalTurnEvent(_ event: CodexTurnEvent) -> Bool { - switch event { - case .completed, .failed: - true - case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .unknown: - false - } - } - - private nonisolated static func isTerminalThreadEvent(_ event: CodexThreadEvent) -> Bool { - if case .closed = event { - return true - } - return false - } - - private nonisolated static func threadEvent( - from event: CodexTurnEvent, - turnID: CodexTurnID, - threadID: CodexThreadID - ) -> CodexThreadEvent { - switch event { - case .started(let turnID): - return .turnStarted(turnID) - case .itemStarted(let item): - return .itemStarted(item, turnID: turnID) - case .itemUpdated(let item): - return .itemUpdated(item, turnID: turnID) - case .itemCompleted(let item): - return .itemCompleted(item, turnID: turnID) - case .messageDelta(let delta): - return .messageDelta(delta, turnID: turnID) - case .reasoningSummaryPartAdded(let part): - return .reasoningSummaryPartAdded(part, turnID: turnID) - case .reasoningDelta(let delta): - return .reasoningDelta(delta, turnID: turnID) - case .tokenUsageUpdated(let usage): - return .tokenUsageUpdated(usage, turnID: turnID) - case .completed(let response): - return .turnCompleted(response) - case .failed(let message): - return .turnFailed(turnID: turnID, message: message) - case .unknown(let raw): - var raw = raw - raw.threadID = threadID - raw.turnID = turnID - return .unknown(raw) - } - } - - private func decodeTurnEvent( - notification: JSONRPC.Notification, - context: NotificationContext, - reviewLogItem: CodexThreadItem? - ) -> CodexTurnEvent { - let raw = CodexRawNotification( - method: notification.method, - params: notification.params, - threadID: context.threadID, - turnID: context.turnID - ) - switch notification.method { - case "turn/started": - return .started(context.turnID ?? .init(rawValue: "")) - case "turn/completed": - return .completed(turnResult(from: notification.params, context: context)) - case "turn/failed", "turn/cancelled": - return .failed(turnFailureMessage(from: notification.params) ?? notification.method) - case "item/started": - if let item = item(from: notification.params) { - return .itemStarted(item) - } - return .unknown(raw) - case "item/updated": - if let item = item(from: notification.params) { - return .itemUpdated(item) - } - return .unknown(raw) - case "item/completed": - if let item = item(from: notification.params) { - return .itemCompleted(item) - } - return .unknown(raw) - case "item/agentMessage/delta": - if let delta = messageDelta(from: notification.params) { - return .messageDelta(delta) - } - return .unknown(raw) - case "item/reasoning/summaryPartAdded": - if let part = reasoningSummaryPart(from: notification.params) { - return .reasoningSummaryPartAdded(part) - } - return .unknown(raw) - case "item/reasoning/summaryTextDelta": - if let delta = reasoningSummaryDelta(from: notification.params) { - return .reasoningDelta(delta) - } - return .unknown(raw) - case "item/reasoning/textDelta": - if let delta = reasoningTextDelta(from: notification.params) { - return .reasoningDelta(delta) - } - return .unknown(raw) - case "thread/tokenUsage/updated": - if let usage = tokenUsage(from: notification.params) { - return .tokenUsageUpdated(usage) - } - return .unknown(raw) - case "item/commandExecution/outputDelta", - "command/exec/outputDelta", - "process/outputDelta", - "item/commandExecution/terminalInteraction", - "item/fileChange/outputDelta", - "item/fileChange/patchUpdated", - "item/mcpToolCall/progress": - if let item = reviewLogItem { - return .itemUpdated(item) - } - return .unknown(raw) - default: - return .unknown(raw) - } - } - - private func decodeThreadEvent( - notification: JSONRPC.Notification, - context: NotificationContext, - reviewLogItem: CodexThreadItem? - ) -> CodexThreadEvent { - let raw = CodexRawNotification( - method: notification.method, - params: notification.params, - threadID: context.threadID, - turnID: context.turnID - ) - switch notification.method { - case "turn/started": - return .turnStarted(context.turnID ?? .init(rawValue: "")) - case "turn/completed": - return .turnCompleted(turnResult(from: notification.params, context: context)) - case "turn/failed", "turn/cancelled": - return .turnFailed( - turnID: context.turnID, - message: turnFailureMessage(from: notification.params) ?? notification.method - ) - case "item/started": - if let item = item(from: notification.params) { - return .itemStarted(item, turnID: context.turnID) - } - return .unknown(raw) - case "item/updated": - if let item = item(from: notification.params) { - return .itemUpdated(item, turnID: context.turnID) - } - return .unknown(raw) - case "item/completed": - if let item = item(from: notification.params) { - return .itemCompleted(item, turnID: context.turnID) - } - return .unknown(raw) - case "item/agentMessage/delta": - if let delta = messageDelta(from: notification.params) { - return .messageDelta(delta, turnID: context.turnID) - } - return .unknown(raw) - case "item/reasoning/summaryPartAdded": - if let part = reasoningSummaryPart(from: notification.params) { - return .reasoningSummaryPartAdded(part, turnID: context.turnID) - } - return .unknown(raw) - case "item/reasoning/summaryTextDelta": - if let delta = reasoningSummaryDelta(from: notification.params) { - return .reasoningDelta(delta, turnID: context.turnID) - } - return .unknown(raw) - case "item/reasoning/textDelta": - if let delta = reasoningTextDelta(from: notification.params) { - return .reasoningDelta(delta, turnID: context.turnID) - } - return .unknown(raw) - case "thread/tokenUsage/updated": - if let usage = tokenUsage(from: notification.params) { - return .tokenUsageUpdated(usage, turnID: context.turnID) - } - return .unknown(raw) - case "thread/status/changed": - if let status = threadStatus(from: notification.params) { - return .statusChanged(status) - } - return .unknown(raw) - case "thread/closed": - return .closed - case "item/commandExecution/outputDelta", - "command/exec/outputDelta", - "process/outputDelta", - "item/commandExecution/terminalInteraction", - "item/fileChange/outputDelta", - "item/fileChange/patchUpdated", - "item/mcpToolCall/progress": - if let item = reviewLogItem { - return .itemUpdated(item, turnID: context.turnID) - } - return .unknown(raw) - default: - return .unknown(raw) - } - } - - private func removeTurnSubscriber(_ subscriptionID: UUID, turnID: CodexTurnID) { - turnSubscribersByTurnID[turnID]?.removeValue(forKey: subscriptionID) - if turnSubscribersByTurnID[turnID]?.isEmpty == true { - turnSubscribersByTurnID.removeValue(forKey: turnID) - } - } - - private func removeThreadSubscriber(_ subscriptionID: UUID, threadID: CodexThreadID) { - threadSubscribersByThreadID[threadID]?.removeValue(forKey: subscriptionID) - if threadSubscribersByThreadID[threadID]?.isEmpty == true { - threadSubscribersByThreadID.removeValue(forKey: threadID) - } - } - - private func finishTurnSubscribers(turnID: CodexTurnID) { - let subscribers = - turnSubscribersByTurnID.removeValue(forKey: turnID).map { - Array($0.values) - } ?? [] - for subscriber in subscribers { - subscriber.continuation.finish() - } - } - - private func finishThreadSubscribers(threadID: CodexThreadID) { - let subscribers = - threadSubscribersByThreadID.removeValue(forKey: threadID).map { - Array($0.values) - } ?? [] - for subscriber in subscribers { - subscriber.continuation.finish() - } - } - - private func finishAll(throwing error: Error) { - let turnSubscribers = turnSubscribersByTurnID.values.flatMap(\.values) - let threadSubscribers = threadSubscribersByThreadID.values.flatMap(\.values) - turnSubscribersByTurnID.removeAll() - threadSubscribersByThreadID.removeAll() - for subscriber in turnSubscribers { - subscriber.continuation.finish(throwing: error) - } - for subscriber in threadSubscribers { - subscriber.continuation.finish(throwing: error) - } - } - - private func item(from data: Data) -> CodexThreadItem? { - guard let payload = try? decoder.decode(ItemPayload.self, from: data) else { - return nil - } - return payload.item.threadItem - } - - private func reviewLogItem( - from notification: JSONRPC.Notification, - context: NotificationContext - ) -> CodexThreadItem? { - guard let notification = try? AppServerReviewNotification( - method: notification.method, - paramsData: notification.params - ) else { - return nil - } - guard var item = notification.reviewLogItem else { - return nil - } - switch notification.method { - case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta: - item = accumulatingCommandOutput(in: item, context: context) - default: - break - } - return item - } - - private func accumulatingCommandOutput( - in item: CodexThreadItem, - context: NotificationContext - ) -> CodexThreadItem { - guard case .command(var command) = item.content, - let delta = command.outputDelta ?? command.output - else { - return item - } - var item = item - let key = CommandOutputKey( - threadID: context.threadID, - turnID: context.turnID, - itemID: item.id - ) - let output = (commandOutputByKey[key] ?? "") + delta - commandOutputByKey[key] = output - command.output = output - command.outputDelta = delta - item.content = .command(command) - return item - } - - private func messageDelta(from data: Data) -> CodexMessageDelta? { - guard let payload = try? decoder.decode(AgentMessageDeltaPayload.self, from: data) else { - return nil - } - let text = payload.delta ?? payload.text ?? "" - guard text.isEmpty == false else { - return nil - } - return .init( - text: text, - itemID: payload.itemID, - phase: payload.phase.map(CodexMessagePhase.init(rawValue:)) - ) - } - - private func reasoningSummaryPart(from data: Data) -> CodexReasoningPart? { - guard let payload = try? decoder.decode( - ReasoningSummaryPartPayload.self, - from: data - ) else { - return nil - } - return .init(itemID: payload.itemID, kind: .summary, index: payload.summaryIndex) - } - - private func reasoningSummaryDelta(from data: Data) -> CodexReasoningDelta? { - guard let payload = try? decoder.decode( - ReasoningSummaryTextDeltaPayload.self, - from: data - ) else { - return nil - } - let part = CodexReasoningPart( - itemID: payload.itemID, - kind: .summary, - index: payload.summaryIndex - ) - return .init(part: part, delta: payload.delta) - } - - private func reasoningTextDelta(from data: Data) -> CodexReasoningDelta? { - guard let payload = try? decoder.decode( - ReasoningTextDeltaPayload.self, - from: data - ) else { - return nil - } - let part = CodexReasoningPart( - itemID: payload.itemID, - kind: .text, - index: payload.contentIndex - ) - return .init(part: part, delta: payload.delta) - } - - private func tokenUsage(from data: Data) -> CodexTokenUsage? { - guard let payload = try? decoder.decode(TokenUsagePayload.self, from: data) else { - return nil - } - return payload.tokenUsage.codexUsage - } - - private func threadStatus(from data: Data) -> CodexThreadStatus? { - guard - let payload = try? decoder.decode(ThreadStatusPayload.self, from: data), - let type = payload.status?.type - else { - return nil - } - return .init(rawValue: type) - } - - private func turnResult(from data: Data, context: NotificationContext) -> CodexResponse { - let payload = try? decoder.decode(TurnCompletedPayload.self, from: data) - let turn = payload?.turn - let status = turn?.status.map(CodexTurnStatus.init(rawValue:)) - return .init( - turnID: turn.map { .init(rawValue: $0.id) } ?? context.turnID ?? .init(rawValue: ""), - status: status, - errorMessage: turn?.error?.message, - startedAt: turn?.startedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, - completedAt: turn?.completedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, - duration: turn?.durationMS.map { .milliseconds(Int64($0)) } - ) - } - - private func turnFailureMessage(from data: Data) -> String? { - if let payload = try? decoder.decode(TurnCompletedPayload.self, from: data) { - return payload.turn.error?.message - } - if let payload = try? decoder.decode(ErrorPayload.self, from: data) { - return payload.error?.message ?? payload.message - } - return nil - } - - private static func context( - from data: Data, - reviewNotification: AppServerReviewNotification? = nil - ) -> NotificationContext { - if let reviewNotification { - let payload = reviewNotification.payload - if payload.threadID != nil || payload.resolvedTurnID != nil { - return .init( - threadID: payload.threadID.map(CodexThreadID.init(rawValue:)), - turnID: payload.resolvedTurnID.map(CodexTurnID.init(rawValue:)) - ) - } - } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return .init() - } - let threadID = - stringValue(named: "threadId", in: object) - ?? objectValue(named: "thread", in: object).flatMap { stringValue(named: "id", in: $0) } - let turnID = - stringValue(named: "turnId", in: object) - ?? objectValue(named: "turn", in: object).flatMap { stringValue(named: "id", in: $0) } - return .init( - threadID: threadID.map(CodexThreadID.init(rawValue:)), - turnID: turnID.map(CodexTurnID.init(rawValue:)) - ) - } - - private static func stringValue(named name: String, in object: [String: Any]) -> String? { - object[name] as? String - } - - private static func objectValue(named name: String, in object: [String: Any]) -> [String: Any]? { - object[name] as? [String: Any] - } -} - -private struct TurnCompletedPayload: Decodable { - var turn: AppServerAPI.Turn.Payload -} - -private struct ErrorPayload: Decodable { - struct ErrorBody: Decodable { - var message: String? - } - - var message: String? - var error: ErrorBody? -} - -private struct AgentMessageDeltaPayload: Decodable { - var itemID: String? - var delta: String? - var text: String? - var phase: String? - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case delta - case text - case phase - } -} - -private struct ReasoningSummaryPartPayload: Decodable { - var itemID: String - var summaryIndex: Int - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case summaryIndex - } -} - -private struct ReasoningSummaryTextDeltaPayload: Decodable { - var itemID: String - var summaryIndex: Int - var delta: String - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case summaryIndex - case delta - } -} - -private struct ReasoningTextDeltaPayload: Decodable { - var itemID: String - var contentIndex: Int - var delta: String - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case contentIndex - case delta - } -} - -private struct ItemPayload: Decodable { - var item: RawThreadItem -} - -private struct ThreadStatusPayload: Decodable { - struct Status: Decodable { - var type: String? - } - - var status: Status? -} - -private struct TokenUsagePayload: Decodable { - var tokenUsage: RawTokenUsage -} - -private struct RawTokenUsage: Decodable { - var total: RawTokenUsageBreakdown? - var modelContextWindow: Int? - - var codexUsage: CodexTokenUsage { - .init( - inputTokens: total?.inputTokens, - outputTokens: total?.outputTokens, - totalTokens: total?.totalTokens, - cachedInputTokens: total?.cachedInputTokens, - reasoningOutputTokens: total?.reasoningOutputTokens, - modelContextWindow: modelContextWindow - ) - } -} - -private struct RawTokenUsageBreakdown: Decodable { - var cachedInputTokens: Int? - var inputTokens: Int? - var outputTokens: Int? - var reasoningOutputTokens: Int? - var totalTokens: Int? -} - -private struct RawThreadItem: Decodable { - var id: String? - var type: String? - var kind: String? - var text: String? - var review: String? - var phase: String? - var command: String? - var cwd: String? - var aggregatedOutput: String? - var output: String? - var exitCode: Int? - var status: String? - var path: String? - var namespace: String? - var server: String? - var tool: String? - var name: String? - var query: String? - var prompt: String? - var summary: [String]? - var content: [String]? - var arguments: AppServerJSONValue? - var input: AppServerJSONValue? - var result: AppServerJSONValue? - var error: AppServerJSONValue? - var rawValue: AppServerJSONValue? - - enum CodingKeys: String, CodingKey { - case id - case type - case kind - case text - case review - case phase - case command - case cwd - case aggregatedOutput - case output - case exitCode - case status - case path - case namespace - case server - case tool - case name - case query - case prompt - case summary - case content - case arguments - case input - case result - case error - } - - init(from decoder: Decoder) throws { - rawValue = try? AppServerJSONValue(from: decoder) - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeStringIfPresent(forKey: .id) - type = try container.decodeStringIfPresent(forKey: .type) - kind = try container.decodeStringIfPresent(forKey: .kind) - text = try container.decodeStringIfPresent(forKey: .text) - review = try container.decodeStringIfPresent(forKey: .review) - phase = try container.decodeStringIfPresent(forKey: .phase) - command = try container.decodeStringIfPresent(forKey: .command) - cwd = try container.decodeStringIfPresent(forKey: .cwd) - aggregatedOutput = try container.decodeStringIfPresent(forKey: .aggregatedOutput) - output = try container.decodeStringIfPresent(forKey: .output) - exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) - status = try container.decodeStringIfPresent(forKey: .status) - path = try container.decodeStringIfPresent(forKey: .path) - namespace = try container.decodeStringIfPresent(forKey: .namespace) - server = try container.decodeStringIfPresent(forKey: .server) - tool = try container.decodeStringIfPresent(forKey: .tool) - name = try container.decodeStringIfPresent(forKey: .name) - query = try container.decodeStringIfPresent(forKey: .query) - prompt = try container.decodeStringIfPresent(forKey: .prompt) - summary = try? container.decodeIfPresent([String].self, forKey: .summary) - content = try? container.decodeIfPresent([String].self, forKey: .content) - arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) - input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) - result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) - error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) - } - - var threadItem: CodexThreadItem { - let rawType = type ?? kind ?? "unknown" - let kind = CodexThreadItem.Kind(rawValue: rawType) - let itemID = id ?? UUID().uuidString - return .init( - id: itemID, - kind: kind, - content: content(kind: kind, id: itemID, rawType: rawType), - rawPayload: rawPayload - ) - } - - private func content( - kind: CodexThreadItem.Kind, - id: String, - rawType: String - ) -> CodexThreadItem.Content { - switch kind { - case .userMessage: - return .message(.init(id: id, role: .user, text: messageText)) - case .agentMessage: - return .message( - .init( - id: id, - role: .assistant, - phase: phase.map(CodexMessagePhase.init(rawValue:)), - text: messageText - )) - case .plan: - return .plan(messageText) - case .reasoning: - let summary = summary ?? [] - let content = content ?? [] - if summary.isEmpty && content.isEmpty { - return .reasoning(.init(summary: messageText)) - } - return .reasoning(.init(summary: summary, content: content)) - case .commandExecution: - return .command( - .init( - command: command ?? "", - cwd: cwd, - output: aggregatedOutput ?? output ?? text, - exitCode: exitCode, - status: status.map(CodexTurnStatus.init(rawValue:)) - )) - case .fileChange: - return .fileChange( - .init( - path: path, - output: aggregatedOutput ?? output ?? text, - status: status.map(CodexTurnStatus.init(rawValue:)) - )) - case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .subAgentActivity, - .webSearch, .imageView, .sleep, .imageGeneration: - return .toolCall( - .init( - namespace: namespace, - server: server, - name: tool ?? name ?? query ?? path, - arguments: arguments?.displayText ?? input?.displayText, - result: result?.displayText ?? text, - error: error?.displayText, - status: status.map(CodexTurnStatus.init(rawValue:)) - )) - case .contextCompaction: - return .contextCompaction(status ?? text) - case .diagnostic, .error: - return .diagnostic(messageText) - case .unknown: - return .unknown(.init(rawType: rawType, text: messageText, payload: rawPayload)) - } - } - - private var messageText: String { - text ?? review ?? content?.joined(separator: "\n") ?? "" - } - - private var rawPayload: Data? { - rawValue.flatMap { try? JSONEncoder().encode($0) } - } -} - -private extension AppServerReviewNotification { - var reviewLogItem: CodexThreadItem? { - switch method { - case .commandExecutionOutputDelta, .commandExecOutputDelta, .processOutputDelta: - return commandExecutionItem(output: payload.outputDelta) - case .commandExecutionTerminalInteraction: - return commandExecutionItem(output: payload.stdin?.nilIfEmpty) - case .fileChangeOutputDelta: - return fileChangeItem(output: payload.delta?.nilIfEmpty) - case .fileChangePatchUpdated: - return fileChangeItem(output: payload.fileChangePatchText) - case .mcpToolCallProgress: - guard let message = payload.message?.nilIfEmpty else { - return nil - } - let itemID = payload.itemID?.nilIfEmpty - ?? payload.item?.id.nilIfEmpty - ?? "mcpToolCall:\(rawMethod)" - return CodexThreadItem( - id: itemID, - kind: .mcpToolCall, - content: .toolCall(.init( - server: payload.item?.server, - name: payload.item?.tool ?? payload.item?.name, - result: message, - status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) - )), - rawPayload: rawNotification.params - ) - default: - return nil - } - } - - private func commandExecutionItem(output: String?) -> CodexThreadItem? { - guard let output else { - return nil - } - let itemID = payload.outputItemID - ?? payload.item?.id.nilIfEmpty - ?? "commandExecution:\(rawMethod)" - return CodexThreadItem( - id: itemID, - kind: .commandExecution, - content: .command(.init( - command: payload.item?.command ?? "", - cwd: payload.item?.cwd, - output: output, - outputDelta: output, - exitCode: payload.item?.exitCode, - status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) - )), - rawPayload: rawNotification.params - ) - } - - private func fileChangeItem(output: String?) -> CodexThreadItem? { - guard let output else { - return nil - } - let path = payload.item?.path?.nilIfEmpty - ?? payload.changes.compactMap { $0.path?.nilIfEmpty }.first - let itemID = payload.item?.id.nilIfEmpty - ?? payload.itemID?.nilIfEmpty - ?? path - ?? "fileChange:\(rawMethod)" - return CodexThreadItem( - id: itemID, - kind: .fileChange, - content: .fileChange(.init( - path: path, - output: output, - status: payload.item?.status.map(CodexTurnStatus.init(rawValue:)) - )), - rawPayload: rawNotification.params - ) - } -} - -private extension AppServerReviewNotification.Payload { - var outputDelta: String? { - if let delta = delta?.nilIfEmpty { - return delta - } - guard let deltaBase64 = deltaBase64?.nilIfEmpty, - let data = Data(base64Encoded: deltaBase64) - else { - return nil - } - return String(data: data, encoding: .utf8)?.nilIfEmpty - } - - var outputItemID: String? { - itemID?.nilIfEmpty - ?? processID?.nilIfEmpty - ?? processHandle?.nilIfEmpty - ?? item?.id.nilIfEmpty - ?? item?.processID?.nilIfEmpty - } - - var fileChangePatchText: String? { - message?.nilIfEmpty - ?? delta?.nilIfEmpty - ?? diff?.nilIfEmpty - ?? changes.map(\.summaryText).joined(separator: "\n").nilIfEmpty - ?? "File changes updated." - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} - -extension KeyedDecodingContainer { - fileprivate func decodeStringIfPresent(forKey key: Key) throws -> String? { - if let string = try? decode(String.self, forKey: key) { - return string - } - if let int = try? decode(Int.self, forKey: key) { - return String(int) - } - if let double = try? decode(Double.self, forKey: key) { - return String(double) - } - if let bool = try? decode(Bool.self, forKey: key) { - return bool ? "true" : "false" - } - return nil - } -} - -extension AppServerJSONValue { - fileprivate var displayText: String? { - switch self { - case .string(let value): - value - case .int(let value): - String(value) - case .double(let value): - String(value) - case .bool(let value): - value ? "true" : "false" - case .array, .object: - (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } - case .null: - nil - } - } -} diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift deleted file mode 100644 index 67eccb52..00000000 --- a/Sources/CodexAppServerKit/CodexDomainTypes.swift +++ /dev/null @@ -1,2329 +0,0 @@ -import Foundation - -public struct CodexPrompt: ExpressibleByStringLiteral, Equatable, Sendable { - public var parts: [Part] - - public init(parts: [Part]) { - self.parts = parts - } - - public init(@CodexPromptBuilder _ content: () throws -> CodexPrompt) rethrows { - self = try content() - } - - public init(stringLiteral value: String) { - self.parts = [.text(value)] - } - - public init(_ text: String) { - self.parts = [.text(text)] - } - - public enum Part: Equatable, Sendable { - case text(String) - case imageURL(URL) - case localImage(URL) - case skill(name: String, path: URL) - case mention(name: String, path: URL) - } -} - -@resultBuilder -public enum CodexPromptBuilder { - public static func buildBlock(_ components: CodexPrompt...) -> CodexPrompt { - .init(parts: components.flatMap(\.parts)) - } - - public static func buildExpression(_ expression: CodexPrompt) -> CodexPrompt { - expression - } - - public static func buildExpression(_ expression: CodexPrompt.Part) -> CodexPrompt { - .init(parts: [expression]) - } - - public static func buildExpression(_ expression: String) -> CodexPrompt { - .init(expression) - } - - public static func buildOptional(_ component: CodexPrompt?) -> CodexPrompt { - component ?? .init(parts: []) - } - - public static func buildEither(first component: CodexPrompt) -> CodexPrompt { - component - } - - public static func buildEither(second component: CodexPrompt) -> CodexPrompt { - component - } - - public static func buildArray(_ components: [CodexPrompt]) -> CodexPrompt { - .init(parts: components.flatMap(\.parts)) - } - - public static func buildLimitedAvailability(_ component: CodexPrompt) -> CodexPrompt { - component - } -} - -public struct CodexInstructions: Equatable, Sendable { - public var base: String? - public var developer: String? - - public init(base: String? = nil, developer: String? = nil) { - self.base = base - self.developer = developer - } - - public init(_ developer: String) { - self.init(developer: developer) - } - - public init(@CodexInstructionsBuilder _ developer: () throws -> String) rethrows { - self.init(developer: try developer()) - } - - public static func base(_ text: String) -> Self { - .init(base: text) - } - - public static func developer(_ text: String) -> Self { - .init(developer: text) - } -} - -/// A JSON value used for app-server configuration and structured output schema. -public enum CodexJSONValue: Codable, Equatable, Sendable, ExpressibleByStringLiteral { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case array([CodexJSONValue]) - case object([String: CodexJSONValue]) - case null - - public init(stringLiteral value: String) { - self = .string(value) - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([CodexJSONValue].self) { - self = .array(value) - } else { - self = .object(try container.decode([String: CodexJSONValue].self)) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .double(let value): - try container.encode(value) - case .bool(let value): - try container.encode(value) - case .array(let value): - try container.encode(value) - case .object(let value): - try container.encode(value) - case .null: - try container.encodeNil() - } - } -} - -extension CodexJSONValue { - package var appServerJSONValue: AppServerJSONValue { - switch self { - case .string(let value): - .string(value) - case .int(let value): - .int(value) - case .double(let value): - .double(value) - case .bool(let value): - .bool(value) - case .array(let value): - .array(value.map(\.appServerJSONValue)) - case .object(let value): - .object(value.mapValues(\.appServerJSONValue)) - case .null: - .null - } - } -} - -@resultBuilder -public enum CodexInstructionsBuilder { - public static func buildBlock(_ components: String...) -> String { - components.filter { $0.isEmpty == false }.joined(separator: "\n") - } - - public static func buildExpression(_ expression: String) -> String { - expression - } - - public static func buildOptional(_ component: String?) -> String { - component ?? "" - } - - public static func buildEither(first component: String) -> String { - component - } - - public static func buildEither(second component: String) -> String { - component - } - - public static func buildArray(_ components: [String]) -> String { - components.filter { $0.isEmpty == false }.joined(separator: "\n") - } - - public static func buildLimitedAvailability(_ component: String) -> String { - component - } -} - -public enum CodexSandbox: String, Codable, Equatable, Sendable { - case readOnly - case workspaceWrite - case fullAccess - - package var threadSandboxValue: String { - switch self { - case .readOnly: - "read-only" - case .workspaceWrite: - "workspace-write" - case .fullAccess: - "danger-full-access" - } - } - - package var turnSandboxPolicy: AppServerAPI.Turn.SandboxPolicy { - switch self { - case .readOnly: - .readOnly(networkAccess: false) - case .workspaceWrite: - .workspaceWrite( - writableRoots: [], - networkAccess: false, - excludeTmpdirEnvVar: false, - excludeSlashTmp: false - ) - case .fullAccess: - .dangerFullAccess - } - } -} - -/// Permission profile selection for a newly created Codex thread. -public struct CodexThreadPermissions: Equatable, Sendable { - package enum Kind: Equatable, Sendable { - case profileID(String) - case profileSelection(String) - } - - package var kind: Kind - - package init(kind: Kind) { - self.kind = kind - } - - public static func profile(id: String) -> Self { - .init(kind: .profileID(id)) - } - - package static func profileSelection(id: String) -> Self { - .init(kind: .profileSelection(id)) - } - - package var appServerPermissions: AppServerAPI.Thread.Start.Permissions { - switch kind { - case .profileID(let id): - .profileID(id) - case .profileSelection(let id): - .profileSelection(.init(id: id)) - } - } -} - -public enum CodexApprovalMode: String, Codable, Equatable, Sendable { - case autoReview - case denyAll - - package var approvalPolicy: String { - switch self { - case .autoReview: - "on-request" - case .denyAll: - "never" - } - } - - package var approvalsReviewer: String? { - switch self { - case .autoReview: - "auto_review" - case .denyAll: - nil - } - } -} - -/// The amount of model reasoning requested for a Codex turn. -public struct CodexReasoningEffort: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - - public static let none = Self(rawValue: "none") - public static let minimal = Self(rawValue: "minimal") - public static let low = Self(rawValue: "low") - public static let medium = Self(rawValue: "medium") - public static let high = Self(rawValue: "high") - public static let xhigh = Self(rawValue: "xhigh") -} - -extension CodexReasoningEffort: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - rawValue = try container.decode(String.self) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } -} - -/// The reasoning summary mode requested for a Codex turn. -public struct CodexReasoningSummary: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - - public static let none = Self(rawValue: "none") - public static let auto = Self(rawValue: "auto") - public static let concise = Self(rawValue: "concise") - public static let detailed = Self(rawValue: "detailed") -} - -extension CodexReasoningSummary: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - rawValue = try container.decode(String.self) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } -} - -/// The app-server personality to apply to a thread or turn. -public struct CodexPersonality: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - - public static let none = Self(rawValue: "none") - public static let friendly = Self(rawValue: "friendly") - public static let pragmatic = Self(rawValue: "pragmatic") -} - -/// The source that created a new app-server session. -public enum CodexThreadStartSource: String, Codable, Equatable, Sendable { - case startup - case clear - - package var appServerSource: AppServerAPI.Thread.Start.Source { - switch self { - case .startup: - .startup - case .clear: - .clear - } - } -} - -/// Client-supplied analytics classification for a Codex thread. -public struct CodexThreadSource: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - - public static let user = Self(rawValue: "user") - public static let subagent = Self(rawValue: "subagent") - public static let memoryConsolidation = Self(rawValue: "memory_consolidation") - - package var appServerSource: AppServerAPI.Thread.Source { - .init(rawValue: rawValue) - } -} - -public struct CodexGenerationOptions: Equatable, Sendable { - public var model: String? - public var approvalMode: CodexApprovalMode? - public var sandbox: CodexSandbox? - public var cwd: URL? - public var effort: CodexReasoningEffort? - public var serviceTier: String? - public var summary: CodexReasoningSummary? - public var outputSchema: CodexJSONValue? - public var personality: CodexPersonality? - public var clientUserMessageID: String? - public var transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy - - public init( - model: String? = nil, - approvalMode: CodexApprovalMode? = nil, - sandbox: CodexSandbox? = nil, - cwd: URL? = nil, - effort: CodexReasoningEffort? = nil, - serviceTier: String? = nil, - summary: CodexReasoningSummary? = nil, - outputSchema: CodexJSONValue? = nil, - personality: CodexPersonality? = nil, - clientUserMessageID: String? = nil, - transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript - ) { - self.model = model - self.approvalMode = approvalMode - self.sandbox = sandbox - self.cwd = cwd - self.effort = effort - self.serviceTier = serviceTier - self.summary = summary - self.outputSchema = outputSchema - self.personality = personality - self.clientUserMessageID = clientUserMessageID - self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy - } -} - -public struct CodexTranscriptErrorHandlingPolicy: Equatable, Sendable { - private enum Kind: Equatable, Sendable { - case preserveTranscript - case revertTranscript - } - - private var kind: Kind - - private init(kind: Kind) { - self.kind = kind - } - - public static let preserveTranscript = Self(kind: .preserveTranscript) - public static let revertTranscript = Self(kind: .revertTranscript) - - package var shouldRevertTranscript: Bool { - kind == .revertTranscript - } -} - -public struct CodexThreadID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } -} - -public struct CodexThread: Identifiable, Sendable { - public struct Options: Equatable, Sendable { - public var model: String? - public var modelProvider: String? - public var approvalMode: CodexApprovalMode? - public var sandbox: CodexSandbox? - public var permissions: CodexThreadPermissions? - public var serviceTier: String? - public var ephemeral: Bool? - public var config: [String: CodexJSONValue]? - public var personality: CodexPersonality? - public var serviceName: String? - public var sessionStartSource: CodexThreadStartSource? - public var threadSource: CodexThreadSource? - - public init( - model: String? = nil, - modelProvider: String? = nil, - approvalMode: CodexApprovalMode? = nil, - sandbox: CodexSandbox? = nil, - permissions: CodexThreadPermissions? = nil, - serviceTier: String? = nil, - ephemeral: Bool? = nil, - config: [String: CodexJSONValue]? = nil, - personality: CodexPersonality? = nil, - serviceName: String? = nil, - sessionStartSource: CodexThreadStartSource? = nil, - threadSource: CodexThreadSource? = nil - ) { - self.model = model - self.modelProvider = modelProvider - self.approvalMode = approvalMode - self.sandbox = sandbox - self.permissions = permissions - self.serviceTier = serviceTier - self.ephemeral = ephemeral - self.config = config - self.personality = personality - self.serviceName = serviceName - self.sessionStartSource = sessionStartSource - self.threadSource = threadSource - } - } - - public typealias ResumeOptions = Options - - public let id: CodexThreadID - public let workspace: URL? - public let model: String? - - package let client: AppServerClient - package let router: CodexAppServerNotificationRouter - - package init( - id: CodexThreadID, - workspace: URL? = nil, - model: String? = nil, - client: AppServerClient, - router: CodexAppServerNotificationRouter - ) { - self.id = id - self.workspace = workspace - self.model = model - self.client = client - self.router = router - } -} - -/// The target that `codex app-server` should review. -public enum CodexReviewTarget: Codable, Hashable, Sendable { - /// Review the current uncommitted working tree changes. - case uncommittedChanges - - /// Review changes relative to a base branch. - case baseBranch(String) - - /// Review a specific commit. - /// - /// `title` is optional metadata that the app-server may use for display. - case commit(sha: String, title: String? = nil) - - /// Review using custom app-server instructions. - case custom(instructions: String) - - private enum CodingKeys: String, CodingKey { - case type - case branch - case sha - case title - case instructions - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - switch type { - case "uncommittedChanges": - self = .uncommittedChanges - case "baseBranch": - self = .baseBranch(try container.decode(String.self, forKey: .branch)) - case "commit": - self = .commit( - sha: try container.decode(String.self, forKey: .sha), - title: try container.decodeIfPresent(String.self, forKey: .title) - ) - case "custom": - self = .custom(instructions: try container.decode(String.self, forKey: .instructions)) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unknown review target type: \(type)" - ) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .uncommittedChanges: - try container.encode("uncommittedChanges", forKey: .type) - case .baseBranch(let branch): - try container.encode("baseBranch", forKey: .type) - try container.encode(branch, forKey: .branch) - case .commit(let sha, let title): - try container.encode("commit", forKey: .type) - try container.encode(sha, forKey: .sha) - try container.encodeIfPresent(title, forKey: .title) - case .custom(let instructions): - try container.encode("custom", forKey: .type) - try container.encode(instructions, forKey: .instructions) - } - } -} - -/// How `review/start` should deliver review work. -public enum CodexReviewDelivery: String, Codable, Equatable, Sendable { - /// Run the review in the current thread. - case inline - - /// Let the app-server create a detached review thread when supported. - case detached -} - -/// Review-scoped events emitted for a `CodexReviewSession`. -public enum CodexReviewEvent: Equatable, Sendable { - case turnStarted(CodexTurnID) - case turnCompleted(CodexResponse) - case turnFailed(turnID: CodexTurnID?, message: String) - case itemStarted(CodexThreadItem, turnID: CodexTurnID?) - case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) - case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) - case message(CodexMessage, turnID: CodexTurnID?) - case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) - case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) - case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) - case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) - case statusChanged(CodexThreadStatus) - case closed - case unknown(CodexRawNotification) - - package init(_ event: CodexThreadEvent) { - switch event { - case .turnStarted(let turnID): - self = .turnStarted(turnID) - case .turnCompleted(let response): - self = .turnCompleted(response) - case .turnFailed(let turnID, let message): - self = .turnFailed(turnID: turnID, message: message) - case .itemStarted(let item, let turnID): - self = .itemStarted(item, turnID: turnID) - case .itemUpdated(let item, let turnID): - self = .itemUpdated(item, turnID: turnID) - case .itemCompleted(let item, let turnID): - self = .itemCompleted(item, turnID: turnID) - case .message(let message, let turnID): - self = .message(message, turnID: turnID) - case .messageDelta(let delta, let turnID): - self = .messageDelta(delta, turnID: turnID) - case .reasoningSummaryPartAdded(let part, let turnID): - self = .reasoningSummaryPartAdded(part, turnID: turnID) - case .reasoningDelta(let delta, let turnID): - self = .reasoningDelta(delta, turnID: turnID) - case .tokenUsageUpdated(let usage, let turnID): - self = .tokenUsageUpdated(usage, turnID: turnID) - case .statusChanged(let status): - self = .statusChanged(status) - case .closed: - self = .closed - case .unknown(let raw): - self = .unknown(raw) - } - } -} - -/// A log-oriented review event suitable for review output views. -public enum CodexReviewLogEntry: Identifiable, Equatable, Sendable { - public typealias Phase = CodexThreadLogEntry.Phase - - case itemStarted(CodexThreadItem, turnID: CodexTurnID?) - case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) - case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) - case messageDelta(CodexMessageDelta, turnID: CodexTurnID?, id: String) - case reasoningPartStarted(CodexReasoningPart, turnID: CodexTurnID?) - case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) - - package init(_ entry: CodexThreadLogEntry) { - switch entry { - case .itemStarted(let item, let turnID): - self = .itemStarted(item, turnID: turnID) - case .itemUpdated(let item, let turnID): - self = .itemUpdated(item, turnID: turnID) - case .itemCompleted(let item, let turnID): - self = .itemCompleted(item, turnID: turnID) - case .messageDelta(let delta, let turnID, let id): - self = .messageDelta(delta, turnID: turnID, id: id) - case .reasoningPartStarted(let part, let turnID): - self = .reasoningPartStarted(part, turnID: turnID) - case .reasoningDelta(let delta, let turnID): - self = .reasoningDelta(delta, turnID: turnID) - } - } - - public var id: String { - switch self { - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - item.id - case .messageDelta(_, _, let id): - id - case .reasoningPartStarted(let part, _): - part.id - case .reasoningDelta(let delta, _): - delta.id - } - } - - public var turnID: CodexTurnID? { - switch self { - case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), - .itemCompleted(_, let turnID), .messageDelta(_, let turnID, _), - .reasoningPartStarted(_, let turnID), .reasoningDelta(_, let turnID): - turnID - } - } - - public var phase: Phase { - switch self { - case .itemStarted, .reasoningPartStarted: - .started - case .itemUpdated: - .updated - case .itemCompleted: - .completed - case .messageDelta, .reasoningDelta: - .delta - } - } - - public var item: CodexThreadItem? { - switch self { - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - item - case .reasoningPartStarted(let part, _): - .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)) - case .messageDelta, .reasoningDelta: - nil - } - } - - public var messageDelta: CodexMessageDelta? { - if case .messageDelta(let delta, _, _) = self { - return delta - } - return nil - } - - public var reasoningDelta: CodexReasoningDelta? { - if case .reasoningDelta(let delta, _) = self { - return delta - } - return nil - } -} - -/// Incremental review progress derived from review-domain events. -public struct CodexReviewProgress: Equatable, Sendable { - public enum Phase: Equatable, Sendable { - case running - case completed - case failed(CodexAppServerError) - } - - public var phase: Phase - public var transcript: CodexTranscript - public var usage: CodexTokenUsage? - public var result: CodexResponse? - - public init( - phase: Phase, - transcript: CodexTranscript = .init(), - usage: CodexTokenUsage? = nil, - result: CodexResponse? = nil - ) { - self.phase = phase - self.transcript = transcript - self.usage = usage - self.result = result - } -} - -/// A review run started by `codex app-server`. -public struct CodexReviewSession: Identifiable, Sendable { - /// The response turn identifier, used as the stable session identity. - public var id: CodexTurnID { - turnID - } - - /// The thread where `startReview(target:delivery:transcriptErrorHandlingPolicy:)` was called. - public let threadID: CodexThreadID - - /// The app-server turn that is producing the review response. - public let turnID: CodexTurnID - - /// The thread that emits review events and logs. - /// - /// This equals `threadID` for inline reviews and may differ for detached - /// reviews. - public let reviewThreadID: CodexThreadID - - /// The live response stream for the review turn. - public let response: CodexResponseStream - - private let eventThread: CodexThread - - package init( - threadID: CodexThreadID, - turnID: CodexTurnID, - reviewThreadID: CodexThreadID, - response: CodexResponseStream, - eventThread: CodexThread - ) { - self.threadID = threadID - self.turnID = turnID - self.reviewThreadID = reviewThreadID - self.response = response - self.eventThread = eventThread - } - - /// Review-scoped events emitted by the review thread. - public var events: CodexReviewEventSequence { - .init(events: eventThread.events, terminalTurnID: turnID) - } - - /// Agent messages emitted by the review thread. - public var messages: CodexThreadMessageSequence { - eventThread.messages - } - - /// Incremental transcript snapshots for the review thread. - public var transcriptUpdates: CodexThreadTranscriptSequence { - eventThread.transcriptUpdates - } - - /// Log-oriented review entries. - /// - /// Review log views can be built from this sequence without depending on - /// raw JSON-RPC notifications. - public var logEntries: CodexReviewLogSequence { - .init(events: eventThread.events, terminalTurnID: turnID) - } - - /// Incremental progress snapshots for the review thread. - public var progress: CodexReviewProgressSequence { - .init(events: eventThread.events, terminalTurnID: turnID) - } - - /// Collects the review response until the turn finishes. - public func collect() async throws -> CodexResponse { - try await response.collect() - } - - /// Interrupts the running review turn. - /// - /// - Returns: The turn that the app-server actually interrupted. This can - /// differ from `turnID` when the app-server reports a newer active turn. - @discardableResult - public func interrupt() async throws -> CodexTurnInterruption { - try await response.interrupt() - } - - @discardableResult - package func interrupt( - willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? - ) async throws -> CodexTurnInterruption { - try await response.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) - } - - /// Sends additional input to the running review turn. - public func steer(with prompt: CodexPrompt) async throws { - try await response.steer(with: prompt) - } - - /// Sends additional text input to the running review turn. - public func steer(with prompt: String) async throws { - try await response.steer(with: prompt) - } -} - -public struct CodexTurnID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } -} - -package struct CodexTurn: Identifiable, Sendable { - package let id: CodexTurnID - package let threadID: CodexThreadID - - package let client: AppServerClient - package let router: CodexAppServerNotificationRouter - - package init( - id: CodexTurnID, - threadID: CodexThreadID, - client: AppServerClient, - router: CodexAppServerNotificationRouter - ) { - self.id = id - self.threadID = threadID - self.client = client - self.router = router - } -} - -public struct CodexThreadSnapshot: Identifiable, Equatable, Sendable { - public var id: CodexThreadID - public var workspace: URL? - public var name: String? - public var preview: String? - public var turns: [CodexTurnSnapshot] - - public init( - id: CodexThreadID, - workspace: URL? = nil, - name: String? = nil, - preview: String? = nil, - turns: [CodexTurnSnapshot] = [] - ) { - self.id = id - self.workspace = workspace - self.name = name - self.preview = preview - self.turns = turns - } -} - -public struct CodexTurnSnapshot: Identifiable, Equatable, Sendable { - public var id: CodexTurnID - public var status: CodexTurnStatus? - public var errorMessage: String? - - public init( - id: CodexTurnID, - status: CodexTurnStatus? = nil, - errorMessage: String? = nil - ) { - self.id = id - self.status = status - self.errorMessage = errorMessage - } -} - -public struct CodexThreadQuery: Equatable, Sendable { - public var archived: Bool? - public var cursor: String? - public var workspace: URL? - public var limit: Int? - public var searchTerm: String? - public var modelProviders: [String]? - public var sortDirection: CodexSortDirection? - public var sortKey: CodexThreadSortKey? - public var sourceKinds: [CodexThreadSourceKind]? - public var useStateDBOnly: Bool? - - public init( - archived: Bool? = nil, - cursor: String? = nil, - workspace: URL? = nil, - limit: Int? = nil, - searchTerm: String? = nil, - modelProviders: [String]? = nil, - sortDirection: CodexSortDirection? = nil, - sortKey: CodexThreadSortKey? = nil, - sourceKinds: [CodexThreadSourceKind]? = nil, - useStateDBOnly: Bool? = nil - ) { - self.archived = archived - self.cursor = cursor - self.workspace = workspace - self.limit = limit - self.searchTerm = searchTerm - self.modelProviders = modelProviders - self.sortDirection = sortDirection - self.sortKey = sortKey - self.sourceKinds = sourceKinds - self.useStateDBOnly = useStateDBOnly - } -} - -/// Sort direction for thread list queries. -public enum CodexSortDirection: String, Codable, Equatable, Sendable { - case ascending = "asc" - case descending = "desc" -} - -/// Sort key for thread list queries. -public enum CodexThreadSortKey: String, Codable, Equatable, Sendable { - case createdAt = "created_at" - case updatedAt = "updated_at" - case recencyAt = "recency_at" -} - -/// Source-kind filter for thread list queries. -public struct CodexThreadSourceKind: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - - public static let cli = Self(rawValue: "cli") - public static let vscode = Self(rawValue: "vscode") - public static let exec = Self(rawValue: "exec") - public static let appServer = Self(rawValue: "appServer") - public static let subAgent = Self(rawValue: "subAgent") - public static let subAgentReview = Self(rawValue: "subAgentReview") - public static let subAgentCompact = Self(rawValue: "subAgentCompact") - public static let subAgentThreadSpawn = Self(rawValue: "subAgentThreadSpawn") - public static let subAgentOther = Self(rawValue: "subAgentOther") - public static let unknown = Self(rawValue: "unknown") -} - -extension CodexThreadSourceKind: Codable {} - -public struct CodexThreadPage: Equatable, Sendable { - public var threads: [CodexThreadSnapshot] - public var nextCursor: String? - public var backwardsCursor: String? - - public init( - threads: [CodexThreadSnapshot], - nextCursor: String? = nil, - backwardsCursor: String? = nil - ) { - self.threads = threads - self.nextCursor = nextCursor - self.backwardsCursor = backwardsCursor - } -} - -public struct CodexTranscript: Equatable, Sendable { - public var items: [CodexThreadItem] - - public init(items: [CodexThreadItem] = []) { - self.items = items - } - - public var messages: [CodexMessage] { - items.compactMap(\.message) - } - - public var finalAnswer: String? { - var fallback: String? - for message in messages.reversed() where message.role == .assistant { - if message.phase == .finalAnswer { - return message.text - } - if message.phase == nil, fallback == nil { - fallback = message.text - } - } - return fallback - } - - public var responseText: String? { - messages.reversed().first { $0.role == .assistant }?.text - } -} - -public struct CodexThreadItem: Identifiable, Equatable, Sendable { - public enum Kind: Equatable, Sendable { - case userMessage - case agentMessage - case plan - case reasoning - case commandExecution - case fileChange - case mcpToolCall - case dynamicToolCall - case collabAgentToolCall - case subAgentActivity - case webSearch - case imageView - case sleep - case imageGeneration - case contextCompaction - case diagnostic - case error - case unknown(String) - - public init(rawValue: String) { - switch rawValue { - case "userMessage": - self = .userMessage - case "agentMessage": - self = .agentMessage - case "plan": - self = .plan - case "reasoning": - self = .reasoning - case "commandExecution": - self = .commandExecution - case "fileChange": - self = .fileChange - case "mcpToolCall": - self = .mcpToolCall - case "dynamicToolCall": - self = .dynamicToolCall - case "collabAgentToolCall": - self = .collabAgentToolCall - case "subAgentActivity": - self = .subAgentActivity - case "webSearch": - self = .webSearch - case "imageView": - self = .imageView - case "sleep": - self = .sleep - case "imageGeneration": - self = .imageGeneration - case "contextCompaction": - self = .contextCompaction - case "diagnostic": - self = .diagnostic - case "error": - self = .error - case let rawValue: - self = .unknown(rawValue) - } - } - - public var rawValue: String { - switch self { - case .userMessage: - "userMessage" - case .agentMessage: - "agentMessage" - case .plan: - "plan" - case .reasoning: - "reasoning" - case .commandExecution: - "commandExecution" - case .fileChange: - "fileChange" - case .mcpToolCall: - "mcpToolCall" - case .dynamicToolCall: - "dynamicToolCall" - case .collabAgentToolCall: - "collabAgentToolCall" - case .subAgentActivity: - "subAgentActivity" - case .webSearch: - "webSearch" - case .imageView: - "imageView" - case .sleep: - "sleep" - case .imageGeneration: - "imageGeneration" - case .contextCompaction: - "contextCompaction" - case .diagnostic: - "diagnostic" - case .error: - "error" - case .unknown(let rawValue): - rawValue - } - } - } - - public enum Content: Equatable, Sendable { - case message(CodexMessage) - case plan(String) - case reasoning(CodexReasoning) - case command(CodexCommand) - case fileChange(CodexFileChange) - case toolCall(CodexToolCall) - case contextCompaction(String?) - case diagnostic(String) - case log(String) - case unknown(CodexRawItem) - } - - public var id: String - public var kind: Kind - public var content: Content - public var rawPayload: Data? - - public init( - id: String, - kind: Kind, - content: Content, - rawPayload: Data? = nil - ) { - self.id = id - self.kind = kind - self.content = content - self.rawPayload = rawPayload - } - - public var text: String? { - switch content { - case .message(let message): - message.text - case .plan(let text), .diagnostic(let text), .log(let text): - text - case .reasoning(let reasoning): - reasoning.text - case .command(let command): - command.output ?? command.command - case .fileChange(let fileChange): - fileChange.output ?? fileChange.path - case .toolCall(let toolCall): - toolCall.result ?? toolCall.error ?? toolCall.name - case .contextCompaction(let text): - text - case .unknown(let raw): - raw.text - } - } - - public var message: CodexMessage? { - if case .message(let message) = content { - return message - } - return nil - } - - package var startsReviewMode: Bool { - kind.rawValue == "enteredReviewMode" - } - - package var finishesReviewMode: Bool { - kind.rawValue == "exitedReviewMode" - } - - package var reviewExitResult: String? { - text.flatMap { $0.isEmpty ? nil : $0 } - } -} - -public struct CodexReasoning: Equatable, Sendable { - public var summary: [String] - public var content: [String] - - public static let empty = Self(summary: [], content: []) - - public init(summary: [String] = [], content: [String] = []) { - self.summary = summary - self.content = content - } - - public init(summary: String, content: String? = nil) { - self.summary = [summary] - self.content = content.map { [$0] } ?? [] - } - - public init(content: String) { - self.summary = [] - self.content = [content] - } - - public var text: String { - let preferred = summary.isEmpty ? content : summary - return preferred.joined(separator: "\n") - } -} - -public struct CodexMessage: Identifiable, Equatable, Sendable { - public enum Role: Equatable, Sendable { - case user - case assistant - case system - case tool - case unknown(String) - - public init(rawValue: String) { - switch rawValue { - case "user": - self = .user - case "assistant", "agent": - self = .assistant - case "system": - self = .system - case "tool": - self = .tool - case let rawValue: - self = .unknown(rawValue) - } - } - - public var rawValue: String { - switch self { - case .user: - "user" - case .assistant: - "assistant" - case .system: - "system" - case .tool: - "tool" - case .unknown(let rawValue): - rawValue - } - } - } - - public var id: String - public var role: Role - public var phase: CodexMessagePhase? - public var text: String - - public init( - id: String, - role: Role, - phase: CodexMessagePhase? = nil, - text: String - ) { - self.id = id - self.role = role - self.phase = phase - self.text = text - } -} - -public enum CodexMessagePhase: Equatable, Sendable { - case commentary - case finalAnswer - case unknown(String) - - public init(rawValue: String) { - switch rawValue { - case "commentary": - self = .commentary - case "final_answer", "finalAnswer": - self = .finalAnswer - case let rawValue: - self = .unknown(rawValue) - } - } - - public var rawValue: String { - switch self { - case .commentary: - "commentary" - case .finalAnswer: - "final_answer" - case .unknown(let rawValue): - rawValue - } - } -} - -public struct CodexCommand: Equatable, Sendable { - public var command: String - public var cwd: String? - public var output: String? - package var outputDelta: String? - public var exitCode: Int? - public var status: CodexTurnStatus? - - public init( - command: String, - cwd: String? = nil, - output: String? = nil, - outputDelta: String? = nil, - exitCode: Int? = nil, - status: CodexTurnStatus? = nil - ) { - self.command = command - self.cwd = cwd - self.output = output - self.outputDelta = outputDelta - self.exitCode = exitCode - self.status = status - } -} - -public struct CodexFileChange: Equatable, Sendable { - public var path: String? - public var output: String? - public var status: CodexTurnStatus? - - public init(path: String? = nil, output: String? = nil, status: CodexTurnStatus? = nil) { - self.path = path - self.output = output - self.status = status - } -} - -public struct CodexToolCall: Equatable, Sendable { - public var namespace: String? - public var server: String? - public var name: String? - public var arguments: String? - public var result: String? - public var error: String? - public var status: CodexTurnStatus? - - public init( - namespace: String? = nil, - server: String? = nil, - name: String? = nil, - arguments: String? = nil, - result: String? = nil, - error: String? = nil, - status: CodexTurnStatus? = nil - ) { - self.namespace = namespace - self.server = server - self.name = name - self.arguments = arguments - self.result = result - self.error = error - self.status = status - } -} - -public struct CodexRawItem: Equatable, Sendable { - public var rawType: String - public var text: String? - public var payload: Data? - - public init(rawType: String, text: String? = nil, payload: Data? = nil) { - self.rawType = rawType - self.text = text - self.payload = payload - } -} - -public enum CodexTurnStatus: Equatable, Sendable { - case running - case completed - case failed - case interrupted - case cancelled - case unknown(String) - - public init(rawValue: String) { - switch rawValue { - case "running", "inProgress", "started": - self = .running - case "completed", "succeeded", "success": - self = .completed - case "failed", "failure", "error": - self = .failed - case "interrupted": - self = .interrupted - case "cancelled", "canceled", "aborted": - self = .cancelled - case let rawValue: - self = .unknown(rawValue) - } - } - - public var rawValue: String { - switch self { - case .running: - "running" - case .completed: - "completed" - case .failed: - "failed" - case .interrupted: - "interrupted" - case .cancelled: - "cancelled" - case .unknown(let rawValue): - rawValue - } - } - - public var isFailure: Bool { - switch self { - case .failed, .interrupted, .cancelled: - true - case .running, .completed, .unknown: - false - } - } -} - -public struct CodexResponse: Identifiable, Equatable, Sendable { - public var turnID: CodexTurnID - public var status: CodexTurnStatus? - public var errorMessage: String? - public var finalAnswer: String? - public var transcript: CodexTranscript - public var usage: CodexTokenUsage? - public var startedAt: Date? - public var completedAt: Date? - public var duration: Duration? - - public var id: CodexTurnID { - turnID - } - - public init( - turnID: CodexTurnID, - status: CodexTurnStatus? = nil, - errorMessage: String? = nil, - finalAnswer: String? = nil, - transcript: CodexTranscript = .init(), - usage: CodexTokenUsage? = nil, - startedAt: Date? = nil, - completedAt: Date? = nil, - duration: Duration? = nil - ) { - self.turnID = turnID - self.status = status - self.errorMessage = errorMessage - self.finalAnswer = finalAnswer - self.transcript = transcript - self.usage = usage - self.startedAt = startedAt - self.completedAt = completedAt - self.duration = duration - } -} - -/// The turn interrupted by an app-server control request. -public struct CodexTurnInterruption: Equatable, Sendable { - /// The thread that owns the interrupted turn. - public var threadID: CodexThreadID - - /// The interrupted turn, when the app-server reported one. - public var turnID: CodexTurnID? - - public init(threadID: CodexThreadID, turnID: CodexTurnID?) { - self.threadID = threadID - self.turnID = turnID - } - - package init(threadID: String, turnID: String?) { - self.threadID = .init(rawValue: threadID) - self.turnID = turnID.flatMap { value in - value.isEmpty ? nil : CodexTurnID(rawValue: value) - } - } -} - -public struct CodexResponseStream: AsyncSequence, Sendable { - public enum SubmissionMode: Equatable, Sendable { - case queueAfterCurrentResponse - case interruptCurrentResponse - } - - public struct Snapshot: Equatable, Sendable { - public var turnID: CodexTurnID - public var content: String? - public var transcript: CodexTranscript - public var usage: CodexTokenUsage? - public var response: CodexResponse? - - public init( - turnID: CodexTurnID, - content: String? = nil, - transcript: CodexTranscript = .init(), - usage: CodexTokenUsage? = nil, - response: CodexResponse? = nil - ) { - self.turnID = turnID - self.content = content - self.transcript = transcript - self.usage = usage - self.response = response - } - } - - private let turn: CodexTurn - private let transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy - - package init( - turn: CodexTurn, - transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy - ) { - self.turn = turn - self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy - } - - public func makeAsyncIterator() -> Iterator { - Iterator( - turn: turn, - transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, - progress: turn.progress.makeAsyncIterator() - ) - } - - public func collect() async throws -> CodexResponse { - try await withTaskCancellationHandler { - do { - return try await turn.result() - } catch { - try await handleFailure() - throw error - } - } onCancel: { - let turn = turn - Task { - try? await turn.interrupt() - } - } - } - - /// Interrupts the running response. - /// - /// - Returns: The turn that the app-server actually interrupted. This can - /// differ from the stream's original turn when the app-server reports a - /// newer active turn. - @discardableResult - public func interrupt() async throws -> CodexTurnInterruption { - try await turn.interrupt() - } - - @discardableResult - package func interrupt( - willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? - ) async throws -> CodexTurnInterruption { - try await turn.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) - } - - public func steer(with prompt: CodexPrompt) async throws { - try await turn.steer(with: prompt) - } - - public func steer(with prompt: String) async throws { - try await steer(with: CodexPrompt(prompt)) - } - - public func steer(@CodexPromptBuilder prompt: () throws -> CodexPrompt) async throws { - try await steer(with: try prompt()) - } - - public func submit( - _ prompt: CodexPrompt, - mode: SubmissionMode, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponseStream { - switch mode { - case .queueAfterCurrentResponse: - _ = try await collect() - return try await startFollowUp(to: prompt, options: options) - case .interruptCurrentResponse: - try await interrupt() - try await waitForInterruptedResponse() - return try await startFollowUp(to: prompt, options: options) - } - } - - public func submit( - _ prompt: String, - mode: SubmissionMode, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponseStream { - try await submit(CodexPrompt(prompt), mode: mode, options: options) - } - - public func submit( - mode: SubmissionMode, - options: CodexGenerationOptions = .init(), - @CodexPromptBuilder prompt: () throws -> CodexPrompt - ) async throws -> CodexResponseStream { - try await submit(try prompt(), mode: mode, options: options) - } - - private func startFollowUp( - to prompt: CodexPrompt, - options: CodexGenerationOptions - ) async throws -> CodexResponseStream { - let turn = try await startCodexTurn( - threadID: turn.threadID, - prompt: prompt, - options: options, - client: turn.client, - router: turn.router - ) - return .init( - turn: turn, - transcriptErrorHandlingPolicy: options.transcriptErrorHandlingPolicy - ) - } - - private func waitForInterruptedResponse() async throws { - for try await event in turn.events { - switch event { - case .completed(let response): - if let message = response.errorMessage { - throw CodexAppServerError.turnFailed(message) - } - switch response.status { - case .interrupted, .cancelled: - return - case .failed: - throw CodexAppServerError.turnFailed(CodexTurnStatus.failed.rawValue) - case .running, .completed, .unknown, nil: - return - } - case .failed(let message): - throw CodexAppServerError.turnFailed(message) - case .started, .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .unknown: - continue - } - } - throw CodexAppServerError.transportClosed - } - - private func handleFailure() async throws { - guard transcriptErrorHandlingPolicy.shouldRevertTranscript else { - return - } - let _: EmptyResponse = try await turn.client.send( - AppServerAPI.Thread.Rollback.Request( - params: .init(threadID: turn.threadID.rawValue, numTurns: 1) - )) - } - - public struct Iterator: AsyncIteratorProtocol { - private let turn: CodexTurn - private let transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy - private var progress: CodexTurnProgressSequence.Iterator - private var pendingError: Error? - - fileprivate init( - turn: CodexTurn, - transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy, - progress: CodexTurnProgressSequence.Iterator - ) { - self.turn = turn - self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy - self.progress = progress - } - - public mutating func next() async throws -> Snapshot? { - if let error = pendingError { - pendingError = nil - throw error - } - guard let progress = try await progress.next() else { - return nil - } - if case .failed(let error) = progress.phase { - try await handleFailure() - if let response = progress.result { - pendingError = error - return Snapshot( - turnID: turn.id, - content: response.finalAnswer ?? response.transcript.responseText, - transcript: response.transcript, - usage: response.usage, - response: response - ) - } - throw error - } - return Snapshot( - turnID: turn.id, - content: progress.result?.finalAnswer ?? progress.transcript.responseText, - transcript: progress.transcript, - usage: progress.result?.usage, - response: progress.result - ) - } - - private func handleFailure() async throws { - guard transcriptErrorHandlingPolicy.shouldRevertTranscript else { - return - } - let _: EmptyResponse = try await turn.client.send( - AppServerAPI.Thread.Rollback.Request( - params: .init(threadID: turn.threadID.rawValue, numTurns: 1) - )) - } - } -} - -public struct CodexTokenUsage: Equatable, Sendable { - public var inputTokens: Int? - public var outputTokens: Int? - public var totalTokens: Int? - public var cachedInputTokens: Int? - public var reasoningOutputTokens: Int? - public var modelContextWindow: Int? - - public init( - inputTokens: Int? = nil, - outputTokens: Int? = nil, - totalTokens: Int? = nil, - cachedInputTokens: Int? = nil, - reasoningOutputTokens: Int? = nil, - modelContextWindow: Int? = nil - ) { - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.totalTokens = totalTokens - self.cachedInputTokens = cachedInputTokens - self.reasoningOutputTokens = reasoningOutputTokens - self.modelContextWindow = modelContextWindow - } -} - -public struct CodexMessageDelta: Equatable, Sendable { - public var text: String - public var itemID: String? - public var phase: CodexMessagePhase? - - public init(text: String, itemID: String? = nil, phase: CodexMessagePhase? = nil) { - self.text = text - self.itemID = itemID - self.phase = phase - } -} - -/// A reasoning summary or raw reasoning text part emitted by app-server. -public struct CodexReasoningPart: Identifiable, Equatable, Sendable { - public enum Kind: Equatable, Sendable { - case summary - case text - } - - public var itemID: String - public var kind: Kind - public var index: Int - - public var id: String { - switch kind { - case .summary: - "\(itemID):summary:\(index)" - case .text: - "\(itemID):content:\(index)" - } - } - - public init(itemID: String, kind: Kind, index: Int) { - self.itemID = itemID - self.kind = kind - self.index = index - } -} - -/// Incremental text for a reasoning summary or raw reasoning text part. -public struct CodexReasoningDelta: Identifiable, Equatable, Sendable { - public var part: CodexReasoningPart - public var delta: String - - public var id: String { - part.id - } - - public init(part: CodexReasoningPart, delta: String) { - self.part = part - self.delta = delta - } -} - -package enum CodexTurnEvent: Equatable, Sendable { - case started(CodexTurnID) - case itemStarted(CodexThreadItem) - case itemUpdated(CodexThreadItem) - case itemCompleted(CodexThreadItem) - case messageDelta(CodexMessageDelta) - case reasoningSummaryPartAdded(CodexReasoningPart) - case reasoningDelta(CodexReasoningDelta) - case tokenUsageUpdated(CodexTokenUsage) - case completed(CodexResponse) - case failed(String) - case unknown(CodexRawNotification) -} - -public enum CodexThreadEvent: Equatable, Sendable { - case turnStarted(CodexTurnID) - case turnCompleted(CodexResponse) - case turnFailed(turnID: CodexTurnID?, message: String) - case itemStarted(CodexThreadItem, turnID: CodexTurnID?) - case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) - case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) - case message(CodexMessage, turnID: CodexTurnID?) - case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) - case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) - case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) - case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) - case statusChanged(CodexThreadStatus) - case closed - case unknown(CodexRawNotification) -} - -public enum CodexThreadLogEntry: Identifiable, Equatable, Sendable { - public enum Phase: Equatable, Sendable { - case started - case updated - case completed - case delta - } - - case itemStarted(CodexThreadItem, turnID: CodexTurnID?) - case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) - case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) - case messageDelta(CodexMessageDelta, turnID: CodexTurnID?, id: String) - case reasoningPartStarted(CodexReasoningPart, turnID: CodexTurnID?) - case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) - - public var id: String { - switch self { - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - item.id - case .messageDelta(_, _, let id): - id - case .reasoningPartStarted(let part, _): - part.id - case .reasoningDelta(let delta, _): - delta.id - } - } - - public var turnID: CodexTurnID? { - switch self { - case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), - .itemCompleted(_, let turnID), .messageDelta(_, let turnID, _), - .reasoningPartStarted(_, let turnID), .reasoningDelta(_, let turnID): - turnID - } - } - - public var phase: Phase { - switch self { - case .itemStarted, .reasoningPartStarted: - .started - case .itemUpdated: - .updated - case .itemCompleted: - .completed - case .messageDelta, .reasoningDelta: - .delta - } - } - - public var item: CodexThreadItem? { - switch self { - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - item - case .reasoningPartStarted(let part, _): - .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)) - case .messageDelta, .reasoningDelta: - nil - } - } - - public var messageDelta: CodexMessageDelta? { - if case .messageDelta(let delta, _, _) = self { - return delta - } - return nil - } - - public var reasoningDelta: CodexReasoningDelta? { - if case .reasoningDelta(let delta, _) = self { - return delta - } - return nil - } -} - -public enum CodexThreadStatus: Equatable, Sendable { - case running - case closed - case unknown(String) - - public init(rawValue: String) { - switch rawValue { - case "running", "loaded": - self = .running - case "closed", "notLoaded": - self = .closed - case let rawValue: - self = .unknown(rawValue) - } - } - - public var rawValue: String { - switch self { - case .running: - "running" - case .closed: - "closed" - case .unknown(let rawValue): - rawValue - } - } -} - -package struct CodexTurnProgress: Equatable, Sendable { - package enum Phase: Equatable, Sendable { - case running - case completed - case failed(CodexAppServerError) - } - - package var phase: Phase - package var transcript: CodexTranscript - package var result: CodexResponse? - - package init( - phase: Phase, - transcript: CodexTranscript = .init(), - result: CodexResponse? = nil - ) { - self.phase = phase - self.transcript = transcript - self.result = result - } -} - -public struct CodexRawNotification: Equatable, Sendable { - public var method: String - public var params: Data - public var threadID: CodexThreadID? - public var turnID: CodexTurnID? - - public init( - method: String, - params: Data, - threadID: CodexThreadID? = nil, - turnID: CodexTurnID? = nil - ) { - self.method = method - self.params = params - self.threadID = threadID - self.turnID = turnID - } -} - -public struct CodexConfiguration: Equatable, Sendable { - public var model: String? - public var reviewModel: String? - public var reasoningEffort: CodexReasoningEffort? - public var serviceTier: String? - - public init( - model: String? = nil, - reviewModel: String? = nil, - reasoningEffort: CodexReasoningEffort? = nil, - serviceTier: String? = nil - ) { - self.model = model - self.reviewModel = reviewModel - self.reasoningEffort = reasoningEffort - self.serviceTier = serviceTier - } -} - -package struct CodexConfigurationPatch: Equatable, Sendable { - package var reviewModel: String? - package var reasoningEffort: CodexReasoningEffort? - package var serviceTier: String? - package var updatesReviewModel: Bool - package var updatesReasoningEffort: Bool - package var updatesServiceTier: Bool - - package init( - reviewModel: String? = nil, - reasoningEffort: CodexReasoningEffort? = nil, - serviceTier: String? = nil, - updatesReviewModel: Bool = false, - updatesReasoningEffort: Bool = false, - updatesServiceTier: Bool = false - ) { - self.reviewModel = reviewModel - self.reasoningEffort = reasoningEffort - self.serviceTier = serviceTier - self.updatesReviewModel = updatesReviewModel - self.updatesReasoningEffort = updatesReasoningEffort - self.updatesServiceTier = updatesServiceTier - } -} - -public struct CodexRateLimits: Equatable, Sendable { - public var planType: String? - public var windows: [CodexRateLimitWindow] - - public init(planType: String? = nil, windows: [CodexRateLimitWindow] = []) { - self.planType = planType - self.windows = windows - } -} - -public struct CodexRateLimitWindow: Equatable, Sendable { - public var windowDurationMinutes: Int - public var usedPercent: Int - public var resetsAt: Date? - - public init(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date? = nil) { - self.windowDurationMinutes = windowDurationMinutes - self.usedPercent = usedPercent - self.resetsAt = resetsAt - } -} - -package extension CodexRateLimits { - init(appServer response: AppServerAPI.Account.RateLimits.Response) { - self.init( - planType: response.codexPlanType, - windows: response.codexRateLimitWindows.map { - .init( - windowDurationMinutes: $0.windowDurationMinutes, - usedPercent: $0.usedPercent, - resetsAt: $0.resetsAt - ) - } - ) - } -} - -public struct CodexModel: Codable, Identifiable, Equatable, Sendable { - public struct ReasoningOption: Codable, Equatable, Sendable { - public var reasoningEffort: CodexReasoningEffort - public var description: String - - public init(reasoningEffort: CodexReasoningEffort, description: String) { - self.reasoningEffort = reasoningEffort - self.description = description - } - } - - private struct ServiceTier: Decodable { - let id: String - } - - public var id: String - public var model: String - public var displayName: String - public var hidden: Bool - public var supportedReasoningEfforts: [ReasoningOption] - public var defaultReasoningEffort: CodexReasoningEffort? - public var supportedServiceTiers: [String] - public var isDefault: Bool - - private enum CodingKeys: String, CodingKey { - case id - case model - case displayName - case hidden - case supportedReasoningEfforts - case defaultReasoningEffort - case supportedServiceTiers = "additionalSpeedTiers" - case serviceTiers - case isDefault - } - - public init( - id: String, - model: String, - displayName: String, - hidden: Bool = false, - supportedReasoningEfforts: [ReasoningOption] = [], - defaultReasoningEffort: CodexReasoningEffort? = nil, - supportedServiceTiers: [String] = [], - isDefault: Bool = false - ) { - self.id = id - self.model = model - self.displayName = displayName - self.hidden = hidden - self.supportedReasoningEfforts = supportedReasoningEfforts - self.defaultReasoningEffort = defaultReasoningEffort - self.supportedServiceTiers = supportedServiceTiers - self.isDefault = isDefault - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.model = try container.decode(String.self, forKey: .model) - self.displayName = try container.decode(String.self, forKey: .displayName) - self.hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) ?? false - self.supportedReasoningEfforts = - try container.decodeIfPresent( - [ReasoningOption].self, - forKey: .supportedReasoningEfforts - ) ?? [] - self.defaultReasoningEffort = try container.decodeIfPresent( - CodexReasoningEffort.self, forKey: .defaultReasoningEffort) - let additionalSpeedTiers = - try container.decodeIfPresent([String].self, forKey: .supportedServiceTiers) ?? [] - let serviceTierIDs = - try container.decodeIfPresent([ServiceTier].self, forKey: .serviceTiers)?.map(\.id) - ?? [] - self.supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTierIDs)).sorted() - self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(model, forKey: .model) - try container.encode(displayName, forKey: .displayName) - try container.encode(hidden, forKey: .hidden) - try container.encode(supportedReasoningEfforts, forKey: .supportedReasoningEfforts) - try container.encodeIfPresent(defaultReasoningEffort, forKey: .defaultReasoningEffort) - try container.encode(supportedServiceTiers, forKey: .supportedServiceTiers) - try container.encode(isDefault, forKey: .isDefault) - } -} - -public struct CodexAccount: Identifiable, Equatable, Sendable { - public enum Kind: String, Codable, Equatable, Sendable { - case chatGPT = "chatgpt" - case apiKey - case amazonBedrock - } - - public var id: String - public var kind: Kind - public var label: String - public var planType: String? - - public init(id: String, kind: Kind, label: String, planType: String? = nil) { - self.id = id - self.kind = kind - self.label = label - self.planType = planType - } -} - -/// The result of an app-server account login completion notification. -public struct CodexLoginCompletion: Equatable, Sendable { - /// The app-server login identifier, when the notification is scoped to a login flow. - public var loginID: CodexLoginHandle.ID? - - /// Whether the login completed successfully. - public var success: Bool - - /// The server-provided failure message when `success` is false. - public var error: String? - - public init(loginID: CodexLoginHandle.ID? = nil, success: Bool, error: String? = nil) { - self.loginID = loginID - self.success = success - self.error = error - } -} - -/// A typed account-related notification emitted by Codex app-server. -public enum CodexAccountEvent: Equatable, Sendable { - /// A login flow reached a terminal state. - case loginCompleted(CodexLoginCompletion) - - /// The active account changed or was refreshed. - case accountUpdated - - /// Account rate-limit information changed. - case rateLimitsUpdated(CodexRateLimits) - - /// A known account notification arrived with a shape this SDK could not decode. - case malformed(method: String, message: String) - - /// A notification outside the current account event surface. - case unknown(CodexRawNotification) -} - -public enum CodexLoginHandle: Equatable, Sendable { - public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(stringLiteral value: String) { - self.rawValue = value - } - } - - case apiKey - case chatGPT(id: ID, authenticationURL: URL) - case chatGPTDeviceCode(id: ID, verificationURL: URL, userCode: String) - - public var id: ID? { - switch self { - case .apiKey: - nil - case .chatGPT(let id, _), .chatGPTDeviceCode(let id, _, _): - id - } - } -} - -public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { - case transportClosed - case jsonRPC(code: Int, message: String) - case serverBusy(String) - case retryLimitExceeded - case malformedNotification(String) - case turnFailed(String) - case turnFailedWithResponse(CodexResponse) - - public var errorDescription: String? { - switch self { - case .transportClosed: - "The Codex app-server connection is closed." - case .jsonRPC(_, let message): - message - case .serverBusy(let message): - message - case .retryLimitExceeded: - "The Codex app-server remained busy after all retry attempts." - case .malformedNotification(let message): - message - case .turnFailed(let message): - message - case .turnFailedWithResponse(let response): - response.errorMessage ?? response.status?.rawValue ?? "Turn failed." - } - } - - public var response: CodexResponse? { - if case .turnFailedWithResponse(let response) = self { - return response - } - return nil - } -} diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift deleted file mode 100644 index ba43f83e..00000000 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ /dev/null @@ -1,441 +0,0 @@ -import Foundation - -extension CodexThread { - /// Thread-scoped events emitted by the app-server. - /// - /// The sequence replays buffered events for this thread before yielding - /// live notifications. It finishes when the app-server reports the thread - /// as closed or when the app-server connection closes. - public var events: CodexThreadEventSequence { - .init { - AsyncThrowingStream { continuation in - let task = Task { - let stream = await router.events(for: id) - do { - for try await event in stream { - continuation.yield(event) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - } - - /// Agent messages emitted by this thread. - /// - /// This sequence is derived from `events` and is useful when callers only - /// need the conversational output rather than every item lifecycle event. - public var messages: CodexThreadMessageSequence { - .init(events: events) - } - - /// Incremental transcript snapshots derived from this thread's events. - public var transcriptUpdates: CodexThreadTranscriptSequence { - .init(events: events) - } - - /// Log-oriented item events for this thread. - /// - /// This includes command, tool, file-change, diagnostic, and unknown - /// app-server items that are useful for review logs or progress views. - public var logEntries: CodexThreadLogSequence { - .init(events: events) - } - - /// Sends a prompt to the thread and waits for the final response. - /// - /// Use `streamResponse(to:options:)` when the caller needs incremental - /// progress, transcript updates, steering, or interruption. - /// - /// - Parameters: - /// - prompt: The structured prompt to send. - /// - options: Per-turn generation options. - /// - Returns: The completed response collected from app-server events. - public func respond( - to prompt: CodexPrompt, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponse { - try await streamResponse(to: prompt, options: options).collect() - } - - /// Sends a text prompt to the thread and waits for the final response. - public func respond( - to prompt: String, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponse { - try await respond(to: CodexPrompt(prompt), options: options) - } - - /// Builds a structured prompt, sends it to the thread, and waits for the final response. - public func respond( - options: CodexGenerationOptions = .init(), - @CodexPromptBuilder prompt: () throws -> CodexPrompt - ) async throws -> CodexResponse { - try await respond(to: try prompt(), options: options) - } - - /// Sends a prompt and returns a live response stream. - /// - /// The returned stream exposes events, progress, transcript updates, the - /// collected result, and Codex-specific controls such as steer and - /// interrupt. - /// - /// - Parameters: - /// - prompt: The structured prompt to send. - /// - options: Per-turn generation options. - /// - Returns: A live response stream for the started turn. - public func streamResponse( - to prompt: CodexPrompt, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponseStream { - let turn = try await startTurn(prompt, options: options) - return .init( - turn: turn, - transcriptErrorHandlingPolicy: options.transcriptErrorHandlingPolicy - ) - } - - /// Sends a text prompt and returns a live response stream. - public func streamResponse( - to prompt: String, - options: CodexGenerationOptions = .init() - ) async throws -> CodexResponseStream { - try await streamResponse(to: CodexPrompt(prompt), options: options) - } - - /// Builds a structured prompt, sends it, and returns a live response stream. - public func streamResponse( - options: CodexGenerationOptions = .init(), - @CodexPromptBuilder prompt: () throws -> CodexPrompt - ) async throws -> CodexResponseStream { - try await streamResponse(to: try prompt(), options: options) - } - - /// Starts a Codex code review in this thread. - /// - /// The returned session exposes both the response stream for the review - /// turn and thread-level event streams, including log entries. When the - /// app-server uses a detached review thread, those event streams are bound - /// to that review thread. - /// - /// - Parameters: - /// - target: The repository changes or custom instructions to review. - /// - delivery: Whether the app-server should run the review inline or in a detached review thread. - /// - transcriptErrorHandlingPolicy: How collection should treat transcript errors. - /// - Returns: A live review session. - public func startReview( - target: CodexReviewTarget, - delivery: CodexReviewDelivery = .inline, - transcriptErrorHandlingPolicy: CodexTranscriptErrorHandlingPolicy = .preserveTranscript - ) async throws -> CodexReviewSession { - let response = try await client.send(AppServerAPI.Review.Start.Request( - params: .init(threadID: id.rawValue, target: target, delivery: delivery) - )) - let reviewThreadID = response.reviewThreadID.map(CodexThreadID.init(rawValue:)) ?? id - let turnID = CodexTurnID(rawValue: response.turnID) - await router.seedReviewTurn(turnID, reviewThreadID: reviewThreadID) - let turn = CodexTurn( - id: turnID, - threadID: reviewThreadID, - client: client, - router: router - ) - let eventThread = CodexThread( - id: reviewThreadID, - workspace: workspace, - model: model, - client: client, - router: router - ) - return .init( - threadID: id, - turnID: turn.id, - reviewThreadID: reviewThreadID, - response: .init( - turn: turn, - transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy - ), - eventThread: eventThread - ) - } - - @discardableResult - package func interruptActiveTurn( - expectedTurnID: CodexTurnID? = nil, - willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? = nil - ) async throws -> CodexTurnInterruption { - try await interruptCodexTurn( - threadID: id, - turnID: expectedTurnID, - client: client, - willInterruptActiveTurn: willInterruptActiveTurn - ) - } - - package func startTurn( - _ prompt: CodexPrompt, - options: CodexGenerationOptions = .init() - ) async throws -> CodexTurn { - try await startCodexTurn( - threadID: id, - prompt: prompt, - options: options, - client: client, - router: router - ) - } - - /// Reads the current thread snapshot. - /// - /// - Parameter includeTurns: Whether to include turn summaries in the snapshot. - /// - Returns: The current app-server snapshot for this thread. - public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot { - let response = try await client.send( - AppServerAPI.Thread.Read.Request( - params: .init(threadID: id.rawValue, includeTurns: includeTurns) - )) - return .init( - id: .init(rawValue: response.thread.id), - workspace: response.thread.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, - name: response.thread.name, - preview: response.thread.preview, - turns: (response.thread.turns ?? []).map { - CodexTurnSnapshot( - id: .init(rawValue: $0.id), - status: $0.status.map(CodexTurnStatus.init(rawValue:)), - errorMessage: $0.error?.message - ) - } - ) - } - - /// Renames this thread. - /// - /// - Parameter name: The new user-visible thread name. - public func rename(to name: String) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Name.Set.Request( - params: .init(threadID: id.rawValue, name: name) - )) - } - - /// Starts app-server context compaction for this thread. - public func compact() async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Compact.Start.Request( - params: .init(threadID: id.rawValue) - )) - } - - /// Archives this thread. - public func archive() async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Archive.Request( - params: .init(threadID: id.rawValue) - )) - } - - /// Restores this thread from the archive. - /// - /// - Returns: The restored thread snapshot. - public func unarchive() async throws -> CodexThreadSnapshot { - let response = try await client.send( - AppServerAPI.Thread.Unarchive.Request( - params: .init(threadID: id.rawValue) - )) - return CodexAppServer.threadSnapshot(from: response.thread) - } - - /// Rolls this thread back by the specified number of turns. - /// - /// - Parameter turnCount: The number of latest turns to remove. - public func rollback(turnCount: Int = 1) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Rollback.Request( - params: .init(threadID: id.rawValue, numTurns: turnCount) - )) - } - - /// Permanently deletes this thread. - public func delete() async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Thread.Delete.Request( - params: .init(threadID: id.rawValue) - )) - } -} - -package func startCodexTurn( - threadID: CodexThreadID, - prompt: CodexPrompt, - options: CodexGenerationOptions = .init(), - client: AppServerClient, - router: CodexAppServerNotificationRouter -) async throws -> CodexTurn { - let response = try await client.send( - AppServerAPI.Turn.Start.Request( - params: .init( - threadID: threadID.rawValue, - input: prompt.appServerInput, - approvalPolicy: options.approvalMode?.approvalPolicy, - approvalsReviewer: options.approvalMode?.approvalsReviewer, - clientUserMessageID: options.clientUserMessageID, - cwd: options.cwd?.path, - effort: options.effort?.rawValue, - model: options.model, - outputSchema: options.outputSchema?.appServerJSONValue, - personality: options.personality?.rawValue, - sandboxPolicy: options.sandbox?.turnSandboxPolicy, - serviceTier: options.serviceTier, - summary: options.summary?.rawValue - ) - )) - return CodexTurn( - id: .init(rawValue: response.turn.id), - threadID: threadID, - client: client, - router: router - ) -} - -extension CodexTurn { - package var events: CodexTurnEventSequence { - .init { - AsyncThrowingStream { continuation in - let task = Task { - let stream = await router.events(for: id) - do { - for try await event in stream { - continuation.yield(event) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - } - - package var progress: CodexTurnProgressSequence { - .init(events: events) - } - - package func result() async throws -> CodexResponse { - try await CodexResponseCollector.collect(from: events) - } - - package func steer(with prompt: CodexPrompt) async throws { - let _: AppServerAPI.Turn.Steer.Response = try await client.send( - AppServerAPI.Turn.Steer.Request( - params: .init( - threadID: threadID.rawValue, - expectedTurnID: id.rawValue, - input: prompt.appServerInput - ) - )) - } - - package func steer(with prompt: String) async throws { - try await steer(with: CodexPrompt(prompt)) - } - - @discardableResult - package func interrupt() async throws -> CodexTurnInterruption { - try await interruptCodexTurn(threadID: threadID, turnID: id, client: client) - } - - @discardableResult - package func interrupt( - willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? - ) async throws -> CodexTurnInterruption { - try await interruptCodexTurn( - threadID: threadID, - turnID: id, - client: client, - willInterruptActiveTurn: willInterruptActiveTurn - ) - } -} - -@discardableResult -package func interruptCodexTurn( - threadID: CodexThreadID, - turnID: CodexTurnID?, - client: AppServerClient, - willInterruptActiveTurn: (@Sendable (CodexTurnInterruption) async -> Void)? = nil -) async throws -> CodexTurnInterruption { - do { - try await sendInterrupt(threadID: threadID, turnID: turnID, client: client) - return .init(threadID: threadID, turnID: turnID) - } catch { - guard let activeTurnID = activeTurnID(from: error), - activeTurnID != turnID?.rawValue - else { - throw error - } - let activeTurn = CodexTurnID(rawValue: activeTurnID) - let interruption = CodexTurnInterruption(threadID: threadID, turnID: activeTurn) - if let willInterruptActiveTurn { - await willInterruptActiveTurn(interruption) - } - try await sendInterrupt(threadID: threadID, turnID: activeTurn, client: client) - return interruption - } -} - -private func sendInterrupt( - threadID: CodexThreadID, - turnID: CodexTurnID?, - client: AppServerClient -) async throws { - let _: EmptyResponse = try await client.send( - AppServerAPI.Turn.Interrupt.Request( - params: .init(threadID: threadID.rawValue, turnID: turnID?.rawValue ?? "") - )) -} - -private 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)) - .nonEmpty -} - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} - -extension CodexPrompt { - package var appServerInput: [AppServerAPI.UserInput] { - parts.map { part in - switch part { - case .text(let text): - .text(text) - case .imageURL(let url): - .image(url: url.absoluteString) - case .localImage(let url): - .localImage(path: url.path) - case .skill(let name, let path): - .skill(name: name, path: path.path) - case .mention(let name, let path): - .mention(name: name, path: path.path) - } - } - } -} diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift deleted file mode 100644 index 7711997c..00000000 --- a/Sources/CodexAppServerKit/CodexTurnSequences.swift +++ /dev/null @@ -1,740 +0,0 @@ -import Foundation - -public struct CodexThreadEventSequence: AsyncSequence, Sendable { - public typealias Element = CodexThreadEvent - - private let makeStream: @Sendable () -> AsyncThrowingStream - - package init( - makeStream: @escaping @Sendable () -> AsyncThrowingStream - ) { - self.makeStream = makeStream - } - - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { - makeStream().makeAsyncIterator() - } -} - -public struct CodexThreadMessageSequence: AsyncSequence, Sendable { - public typealias Element = CodexMessage - - private let events: CodexThreadEventSequence - - package init(events: CodexThreadEventSequence) { - self.events = events - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - - fileprivate init(events: AsyncThrowingStream.Iterator) { - self.events = events - } - - public mutating func next() async throws -> CodexMessage? { - while let event = try await events.next() { - switch event { - case .message(let message, _): - return message - case .itemCompleted(let item, _): - if let message = item.message { - return message - } - case .itemStarted, .itemUpdated, .messageDelta: - continue - case .turnStarted, .turnCompleted, .turnFailed, .reasoningSummaryPartAdded, - .reasoningDelta, .tokenUsageUpdated, .statusChanged, .closed, .unknown: - continue - } - } - return nil - } - } -} - -public struct CodexThreadTranscriptSequence: AsyncSequence, Sendable { - public typealias Element = CodexTranscript - - private let events: CodexThreadEventSequence - - package init(events: CodexThreadEventSequence) { - self.events = events - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private var accumulator = CodexTranscriptAccumulator() - - fileprivate init(events: AsyncThrowingStream.Iterator) { - self.events = events - } - - public mutating func next() async throws -> CodexTranscript? { - while let event = try await events.next() { - if accumulator.apply(event) { - return accumulator.transcript - } - } - return nil - } - } -} - -public struct CodexThreadLogSequence: AsyncSequence, Sendable { - public typealias Element = CodexThreadLogEntry - - private let events: CodexThreadEventSequence - - package init(events: CodexThreadEventSequence) { - self.events = events - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private var logEntryIndex = 0 - - fileprivate init(events: AsyncThrowingStream.Iterator) { - self.events = events - } - - public mutating func next() async throws -> CodexThreadLogEntry? { - while let event = try await events.next() { - switch event { - case .itemStarted(let item, let turnID): - return .itemStarted(item, turnID: turnID) - case .itemUpdated(let item, let turnID): - return .itemUpdated(item, turnID: turnID) - case .itemCompleted(let item, let turnID): - return .itemCompleted(item, turnID: turnID) - case .message(let message, let turnID): - let item = CodexThreadItem( - id: message.id, - kind: message.role == .user ? .userMessage : .agentMessage, - content: .message(message) - ) - return .itemCompleted(item, turnID: turnID) - case .messageDelta(let delta, let turnID): - return .messageDelta(delta, turnID: turnID, id: nextDeltaLogEntryID(for: delta)) - case .reasoningSummaryPartAdded(let part, let turnID): - return .reasoningPartStarted(part, turnID: turnID) - case .reasoningDelta(let delta, let turnID): - return .reasoningDelta(delta, turnID: turnID) - case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, - .closed, .unknown: - continue - } - } - return nil - } - - private mutating func nextDeltaLogEntryID(for delta: CodexMessageDelta) -> String { - defer { - logEntryIndex += 1 - } - return "\(delta.itemID ?? "agent-message-delta"):\(logEntryIndex)" - } - } -} - -/// Review-scoped event stream for a `CodexReviewSession`. -public struct CodexReviewEventSequence: AsyncSequence, Sendable { - public typealias Element = CodexReviewEvent - - private let events: CodexThreadEventSequence - private let terminalTurnID: CodexTurnID? - - package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private let terminalTurnID: CodexTurnID? - private var finished = false - - fileprivate init( - events: AsyncThrowingStream.Iterator, - terminalTurnID: CodexTurnID? - ) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public mutating func next() async throws -> CodexReviewEvent? { - guard finished == false else { - return nil - } - guard let event = try await events.next() else { - return nil - } - if isTerminal(event) { - finished = true - } - return CodexReviewEvent(event) - } - - private func isTerminal(_ event: CodexThreadEvent) -> Bool { - switch event { - case .turnCompleted(let response): - guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { - return false - } - return response.shouldFinishReviewSequence - case .turnFailed(let turnID, _): - return terminalTurnID.map { turnID == $0 } ?? true - case .itemCompleted(let item, let turnID): - return item.finishesReviewMode && matchesTerminalTurn(turnID) - case .closed: - return true - case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, - .unknown: - return false - } - } - - private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { - terminalTurnID.map { turnID == $0 } ?? true - } - } -} - -/// Review-scoped log stream for a `CodexReviewSession`. -public struct CodexReviewLogSequence: AsyncSequence, Sendable { - public typealias Element = CodexReviewLogEntry - - private let events: CodexThreadEventSequence - private let terminalTurnID: CodexTurnID? - - package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private let terminalTurnID: CodexTurnID? - private var logEntryIndex = 0 - private var finished = false - - fileprivate init( - events: AsyncThrowingStream.Iterator, - terminalTurnID: CodexTurnID? - ) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public mutating func next() async throws -> CodexReviewLogEntry? { - guard finished == false else { - return nil - } - while let event = try await events.next() { - switch event { - case .itemStarted(let item, let turnID): - return .itemStarted(item, turnID: turnID) - case .itemUpdated(let item, let turnID): - return .itemUpdated(item, turnID: turnID) - case .itemCompleted(let item, let turnID): - if isTerminal(event) { - finished = true - return nil - } - return .itemCompleted(item, turnID: turnID) - case .message(let message, let turnID): - let item = CodexThreadItem( - id: message.id, - kind: message.role == .user ? .userMessage : .agentMessage, - content: .message(message) - ) - return .itemCompleted(item, turnID: turnID) - case .messageDelta(let delta, let turnID): - return .messageDelta(delta, turnID: turnID, id: nextDeltaLogEntryID(for: delta)) - case .reasoningSummaryPartAdded(let part, let turnID): - return .reasoningPartStarted(part, turnID: turnID) - case .reasoningDelta(let delta, let turnID): - return .reasoningDelta(delta, turnID: turnID) - case .turnCompleted, .turnFailed, .closed: - if isTerminal(event) { - finished = true - return nil - } - continue - case .turnStarted, .tokenUsageUpdated, .statusChanged, .unknown: - continue - } - } - finished = true - return nil - } - - private mutating func nextDeltaLogEntryID(for delta: CodexMessageDelta) -> String { - defer { - logEntryIndex += 1 - } - return "\(delta.itemID ?? "agent-message-delta"):\(logEntryIndex)" - } - - private func isTerminal(_ event: CodexThreadEvent) -> Bool { - switch event { - case .turnCompleted(let response): - guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { - return false - } - return response.shouldFinishReviewSequence - case .turnFailed(let turnID, _): - return terminalTurnID.map { turnID == $0 } ?? true - case .itemCompleted(let item, let turnID): - return item.finishesReviewMode && matchesTerminalTurn(turnID) - case .closed: - return true - case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, - .unknown: - return false - } - } - - private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { - terminalTurnID.map { turnID == $0 } ?? true - } - } -} - -/// Incremental progress stream for a `CodexReviewSession`. -public struct CodexReviewProgressSequence: AsyncSequence, Sendable { - public typealias Element = CodexReviewProgress - - private let events: CodexThreadEventSequence - private let terminalTurnID: CodexTurnID? - - package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) - } - - public struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private let terminalTurnID: CodexTurnID? - private var accumulator = CodexTranscriptAccumulator() - private var usage: CodexTokenUsage? - private var finished = false - - fileprivate init( - events: AsyncThrowingStream.Iterator, - terminalTurnID: CodexTurnID? - ) { - self.events = events - self.terminalTurnID = terminalTurnID - } - - public mutating func next() async throws -> CodexReviewProgress? { - guard finished == false else { - return nil - } - while let event = try await events.next() { - switch event { - case .turnStarted, .unknown: - return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .itemStarted, .itemUpdated, .message, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta: - _ = accumulator.apply(event) - return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .itemCompleted(let item, let turnID): - _ = accumulator.apply(event) - if isTerminal(event) { - finished = true - let result = finalizedReviewExitResult(item: item, turnID: turnID) - return .init( - phase: .completed, - transcript: result.transcript, - usage: result.usage, - result: result - ) - } - return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .tokenUsageUpdated(let newUsage, _): - usage = newUsage - return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .turnCompleted(var result): - guard isTerminal(event) else { - continue - } - finished = true - result = finalizedResult(result) - if result.errorMessage != nil || result.status?.isFailure == true { - return .init( - phase: .failed(.turnFailedWithResponse(result)), - transcript: result.transcript, - usage: result.usage, - result: result - ) - } - return .init( - phase: .completed, - transcript: result.transcript, - usage: result.usage, - result: result - ) - case .turnFailed(_, let message): - guard isTerminal(event) else { - continue - } - finished = true - return .init( - phase: .failed(.turnFailed(message)), - transcript: accumulator.transcript, - usage: usage - ) - case .statusChanged: - return .init(phase: .running, transcript: accumulator.transcript, usage: usage) - case .closed: - finished = true - return nil - } - } - finished = true - return nil - } - - private func finalizedResult(_ result: CodexResponse) -> CodexResponse { - var result = result - if result.finalAnswer == nil { - result.finalAnswer = accumulator.transcript.finalAnswer - } - if result.transcript.items.isEmpty { - result.transcript = accumulator.transcript - } - if result.usage == nil { - result.usage = usage - } - return result - } - - private func finalizedReviewExitResult( - item: CodexThreadItem, - turnID: CodexTurnID? - ) -> CodexResponse { - CodexResponse( - turnID: terminalTurnID ?? turnID ?? .init(rawValue: ""), - status: .completed, - finalAnswer: item.reviewExitResult ?? accumulator.transcript.finalAnswer, - transcript: accumulator.transcript, - usage: usage - ) - } - - private func isTerminal(_ event: CodexThreadEvent) -> Bool { - switch event { - case .turnCompleted(let response): - guard terminalTurnID.map({ response.turnID == $0 }) ?? true else { - return false - } - return response.shouldFinishReviewSequence - case .turnFailed(let turnID, _): - return terminalTurnID.map { turnID == $0 } ?? true - case .itemCompleted(let item, let turnID): - return item.finishesReviewMode && matchesTerminalTurn(turnID) - case .closed: - return true - case .turnStarted, .itemStarted, .itemUpdated, .message, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta, .tokenUsageUpdated, .statusChanged, - .unknown: - return false - } - } - - private func matchesTerminalTurn(_ turnID: CodexTurnID?) -> Bool { - terminalTurnID.map { turnID == $0 } ?? true - } - } -} - -package struct CodexTurnEventSequence: AsyncSequence, Sendable { - package typealias Element = CodexTurnEvent - - private let makeStream: @Sendable () -> AsyncThrowingStream - - package init(makeStream: @escaping @Sendable () -> AsyncThrowingStream) { - self.makeStream = makeStream - } - - package func makeAsyncIterator() -> AsyncThrowingStream.Iterator { - makeStream().makeAsyncIterator() - } -} - -package struct CodexTurnProgressSequence: AsyncSequence, Sendable { - package typealias Element = CodexTurnProgress - - private let events: CodexTurnEventSequence - - package init(events: CodexTurnEventSequence) { - self.events = events - } - - package func makeAsyncIterator() -> Iterator { - Iterator(events: events.makeAsyncIterator()) - } - - package struct Iterator: AsyncIteratorProtocol { - private var events: AsyncThrowingStream.Iterator - private var accumulator = CodexTranscriptAccumulator() - private var usage: CodexTokenUsage? - - fileprivate init(events: AsyncThrowingStream.Iterator) { - self.events = events - } - - package mutating func next() async throws -> CodexTurnProgress? { - guard let event = try await events.next() else { - return nil - } - switch event { - case .started, .unknown: - return .init(phase: .running, transcript: accumulator.transcript) - case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta: - _ = accumulator.apply(event) - return .init(phase: .running, transcript: accumulator.transcript) - case .tokenUsageUpdated(let newUsage): - usage = newUsage - return .init(phase: .running, transcript: accumulator.transcript) - case .completed(var result): - result = finalizedResult(result) - if result.errorMessage != nil { - return .init( - phase: .failed(.turnFailedWithResponse(result)), - transcript: result.transcript, - result: result - ) - } - if result.status?.isFailure == true { - return .init( - phase: .failed(.turnFailedWithResponse(result)), - transcript: result.transcript, - result: result - ) - } - return .init(phase: .completed, transcript: result.transcript, result: result) - case .failed(let message): - return .init( - phase: .failed(.turnFailed(message)), - transcript: accumulator.transcript - ) - } - } - - private func finalizedResult(_ result: CodexResponse) -> CodexResponse { - var result = result - if result.finalAnswer == nil { - result.finalAnswer = accumulator.transcript.finalAnswer - } - if result.transcript.items.isEmpty { - result.transcript = accumulator.transcript - } - if result.usage == nil { - result.usage = usage - } - return result - } - } -} - -package struct CodexResponseCollector { - static func collect(from events: CodexTurnEventSequence) async throws -> CodexResponse { - var accumulator = CodexTranscriptAccumulator() - var usage: CodexTokenUsage? - for try await event in events { - switch event { - case .started, .unknown: - continue - case .itemStarted, .itemUpdated, .itemCompleted, .messageDelta, - .reasoningSummaryPartAdded, .reasoningDelta: - _ = accumulator.apply(event) - case .tokenUsageUpdated(let newUsage): - usage = newUsage - case .completed(var result): - if result.finalAnswer == nil { - result.finalAnswer = accumulator.transcript.finalAnswer - } - if result.transcript.items.isEmpty { - result.transcript = accumulator.transcript - } - if result.usage == nil { - result.usage = usage - } - if result.errorMessage != nil { - throw CodexAppServerError.turnFailedWithResponse(result) - } - if result.status?.isFailure == true { - throw CodexAppServerError.turnFailedWithResponse(result) - } - return result - case .failed(let message): - throw CodexAppServerError.turnFailed(message) - } - } - throw CodexAppServerError.transportClosed - } -} - -private struct CodexTranscriptAccumulator { - private var items: [CodexThreadItem] = [] - private var itemIndexesByID: [String: Int] = [:] - private var messageDeltaTextByItemID: [String: String] = [:] - private var reasoningDeltaTextByPartID: [String: String] = [:] - - var transcript: CodexTranscript { - .init(items: items) - } - - mutating func apply(_ event: CodexTurnEvent) -> Bool { - switch event { - case .itemStarted(let item), .itemUpdated(let item), .itemCompleted(let item): - upsert(item) - return true - case .messageDelta(let delta): - append(delta) - return true - case .reasoningSummaryPartAdded(let part): - start(part) - return true - case .reasoningDelta(let delta): - append(delta) - return true - case .started, .tokenUsageUpdated, .completed, .failed, .unknown: - return false - } - } - - mutating func apply(_ event: CodexThreadEvent) -> Bool { - switch event { - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - upsert(item) - return true - case .message(let message, _): - upsert( - .init( - id: message.id, - kind: message.role == .user ? .userMessage : .agentMessage, - content: .message(message) - )) - return true - case .messageDelta(let delta, _): - append(delta) - return true - case .reasoningSummaryPartAdded(let part, _): - start(part) - return true - case .reasoningDelta(let delta, _): - append(delta) - return true - case .turnStarted, .turnCompleted, .turnFailed, .tokenUsageUpdated, .statusChanged, - .closed, .unknown: - return false - } - } - - private mutating func upsert(_ item: CodexThreadItem) { - if item.kind == .reasoning && item.id.contains(":summary:") == false - && item.id.contains(":content:") == false - { - removeReasoningParts(parentItemID: item.id) - } - if let index = itemIndexesByID[item.id] { - items[index] = item - } else { - itemIndexesByID[item.id] = items.count - items.append(item) - } - } - - private mutating func append(_ delta: CodexMessageDelta) { - let itemID = delta.itemID ?? "agent-message-delta" - let text = (messageDeltaTextByItemID[itemID] ?? "") + delta.text - messageDeltaTextByItemID[itemID] = text - let message = CodexMessage( - id: itemID, - role: .assistant, - phase: delta.phase, - text: text - ) - upsert(.init(id: itemID, kind: .agentMessage, content: .message(message))) - } - - private mutating func start(_ part: CodexReasoningPart) { - upsert(.init( - id: part.id, - kind: .reasoning, - content: .reasoning(.empty) - )) - } - - private mutating func append(_ delta: CodexReasoningDelta) { - let text = (reasoningDeltaTextByPartID[delta.id] ?? "") + delta.delta - reasoningDeltaTextByPartID[delta.id] = text - let reasoning: CodexReasoning - switch delta.part.kind { - case .summary: - reasoning = .init(summary: text) - case .text: - reasoning = .init(content: text) - } - upsert(.init(id: delta.id, kind: .reasoning, content: .reasoning(reasoning))) - } - - private mutating func removeReasoningParts(parentItemID: String) { - let prefixes = ["\(parentItemID):summary:", "\(parentItemID):content:"] - items.removeAll { item in - prefixes.contains { item.id.hasPrefix($0) } - } - reasoningDeltaTextByPartID = reasoningDeltaTextByPartID.filter { id, _ in - prefixes.contains { id.hasPrefix($0) } == false - } - itemIndexesByID = Dictionary( - uniqueKeysWithValues: items.enumerated().map { index, item in (item.id, index) } - ) - } -} - -private extension CodexResponse { - var shouldFinishReviewSequence: Bool { - if errorMessage != nil || status?.isFailure == true { - return true - } - if finalAnswer?.isEmpty == false - || transcript.finalAnswer?.isEmpty == false - || transcript.responseText?.isEmpty == false - { - return true - } - return false - } -} diff --git a/Sources/CodexAppServerKit/JSONRPC.swift b/Sources/CodexAppServerKit/JSONRPC.swift deleted file mode 100644 index c7314648..00000000 --- a/Sources/CodexAppServerKit/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/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md deleted file mode 100644 index d2f8a9ad..00000000 --- a/Sources/CodexAppServerKit/README.md +++ /dev/null @@ -1,443 +0,0 @@ -# CodexAppServerKit - -CodexAppServerKit is a Swift library for working with a local -`codex app-server` process from macOS apps and tools. - -The package hides JSON-RPC framing and app-server DTOs behind Swift domain -types. Callers work with an app-server container, sessions, prompts, -responses, response streams, transcript items, log entries, models, accounts, -and login handles. - -## Container - -Create one `CodexAppServer` for the lifetime of the app-server connection: - -```swift -import CodexAppServerKit - -let appServer = try await CodexAppServer() -let thread = try await appServer.startThread(in: workspaceURL) - -let response = try await thread.respond(to: "Review this workspace.") -print(response.finalAnswer ?? "") - -await appServer.close() -``` - -`CodexAppServer()` uses the local `codex` executable over stdio. It performs -`initialize` / `initialized`, manages the process transport, routes -notifications, retries app-server overload responses, and preserves schema-new -notifications as unknown domain events. - -## Configuration - -`CodexAppServer.Configuration` owns the container identity and local-process -runtime settings. The default local process resolves Codex home from -`CODEX_HOME`, then `HOME/.codex` on macOS command-line runs, then Application -Support for container-style environments. Pass `localProcess.codexHomeURL` when -an app wants an isolated runtime directory. - -```swift -let configuration = CodexAppServer.Configuration( - localProcess: .init( - codexHomeURL: appSupportURL.appendingPathComponent("Codex", isDirectory: true) - ) -) -let appServer = try await CodexAppServer(configuration: configuration) -``` - -## Threads - -`CodexThread` is the long-lived session handle for a Codex conversation in a -workspace. It mirrors the Foundation Models session style: use `respond` for a -single final response, or `streamResponse` when the UI needs partial snapshots. - -```swift -let thread = try await appServer.startThread( - in: workspaceURL, - instructions: .init(developer: "Keep responses concise."), - options: .init(model: "gpt-5", approvalMode: .autoReview) -) - -let response = try await thread.respond { - "Run the checks." - "Focus on failing tests." -} -``` - -Thread management is exposed without requiring raw request DTOs: - -```swift -let snapshot = try await thread.read(includeTurns: true) -try await thread.rename(to: "Release review") -try await thread.compact() -try await thread.archive() -let restored = try await thread.unarchive() -try await thread.rollback(turnCount: 1) -try await thread.delete() -``` - -## Streaming - -Use `streamResponse` for the Foundation Models-style streaming surface. The -stream yields snapshots of the accumulated response state and can be collected -into the final `CodexResponse`. - -```swift -let stream = try await thread.streamResponse(to: "Summarize the changes.") - -for try await snapshot in stream { - render(snapshot.transcript.items) -} - -let response = try await stream.collect() -``` - -Codex also supports explicit interruption for an in-flight response. Foundation -Models normally relies on task cancellation, but app-server has real -`turn/steer` and `turn/interrupt` control paths, so `CodexResponseStream` -exposes them directly: - -```swift -let stream = try await thread.streamResponse(to: "Run the slow checks.") -try await stream.steer(with: "Prefer the smallest fix.") -try await stream.interrupt() -``` - -If the task awaiting `stream.collect()` is cancelled, the stream also sends the -same interrupt request to app-server. - -When a UI needs to accept another prompt while a response is in flight, submit -it with an explicit follow-up mode: - -```swift -let next = try await stream.submit( - "Now update the tests.", - mode: .queueAfterCurrentResponse -) - -let urgent = try await stream.submit( - "Stop and try the shorter path.", - mode: .interruptCurrentResponse -) -``` - -Use `steer(with:)` when the new input should modify the current turn. -`.queueAfterCurrentResponse` waits for the current response to finish before -starting the next turn. `.interruptCurrentResponse` sends `turn/interrupt`, -waits for app-server's terminal event, and then starts the next turn in the -same thread. - -`CodexGenerationOptions` includes `transcriptErrorHandlingPolicy`, matching the -Foundation Models policy shape: - -```swift -let stream = try await thread.streamResponse( - to: "Try the risky change.", - options: .init(transcriptErrorHandlingPolicy: .revertTranscript) -) -``` - -It also exposes reasoning controls with domain values instead of raw strings: - -```swift -let response = try await thread.respond( - to: "Find the risky part of this change.", - options: .init( - effort: .high, - summary: .detailed, - personality: .pragmatic - ) -) - -print(response.usage?.reasoningOutputTokens ?? 0) -``` - -Structured final answers can be constrained with a JSON schema: - -```swift -let response = try await thread.respond( - to: "Summarize the change as JSON.", - options: .init(outputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "summary": .object(["type": .string("string")]), - "risk": .object(["type": .string("string")]), - ]), - "required": .array([.string("summary"), .string("risk")]), - ])) -) -``` - -Threads also expose async sequences for chat, transcript updates, and log-style -consumers. This is the API surface intended for higher-level products that need -to render Codex output continuously outside a single response stream. - -```swift -for try await message in thread.messages { - print(message.text) -} -``` - -```swift -for try await transcript in thread.transcriptUpdates { - render(transcript.items) -} -``` - -```swift -for try await entry in thread.logEntries { - switch entry { - case .reasoningDelta(let delta, _): - renderReasoningDelta(delta) - case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): - switch item.content { - case .message(let message): - renderMessage(message) - case .reasoning(let reasoning): - renderReasoning(summary: reasoning.summary, content: reasoning.content) - case .command(let command): - renderCommand(command.command, output: command.output) - case .toolCall(let tool): - renderToolCall(tool.name, result: tool.result, error: tool.error) - case .fileChange(let fileChange): - renderFileChange(fileChange.path, output: fileChange.output) - default: - break - } - case .messageDelta(let delta, _, _): - renderMessageDelta(delta.text) - default: - break - } -} -``` - -`CodexThread.events` is the full thread event stream. It includes turn -lifecycle, item lifecycle, message deltas, token usage, thread status, and -unknown notifications: - -```swift -for try await event in thread.events { - switch event { - case .reasoningDelta(let delta, _): - renderReasoningDelta(delta) - case .tokenUsageUpdated(let usage, _): - updateUsage(usage.totalTokens) - case .unknown(let raw): - logUnknownNotification(raw.method) - default: - break - } -} -``` - -This lets review clients build logs from -CodexAppServerKit domain events instead of parsing JSON-RPC notifications or -string logs directly. - -Known `CodexThreadItem` values keep their high-level `content` projection and -the original `rawPayload`. Use the raw payload when a product needs -full-fidelity rendering for app-server fields that the current Kit version does -not yet model directly. - -## Reviews - -`review/start` is part of the app-server surface, so CodexAppServerKit exposes -it as a thread operation. A review session provides the review turn response and -review-domain streams. If app-server detaches the review into a separate -thread, `events`, `logEntries`, `progress`, and `transcriptUpdates` are bound to -that review thread automatically. Detached review notification routing is owned -by CodexAppServerKit, so callers do not need to parse JSON-RPC notifications or -track app-server thread event details. - -```swift -let review = try await thread.startReview(target: .baseBranch("main")) - -for try await entry in review.logEntries { - switch entry { - case .itemCompleted(let item, _): - renderReviewLog(item) - case .reasoningDelta(let delta, _): - renderReasoningDelta(delta) - default: - break - } -} - -for try await progress in review.progress { - renderReviewTranscript(progress.transcript) - if progress.phase == .completed { - break - } -} - -let response = try await review.collect() -print(response.finalAnswer ?? "") -``` - -Review targets are Swift domain values: - -```swift -try await thread.startReview(target: .uncommittedChanges) -try await thread.startReview(target: .commit(sha: sha, title: title)) -try await thread.startReview(target: .custom(instructions: instructions)) -``` - -`CodexReviewSession.events` yields `CodexReviewEvent`, preserving unknown -schema-new notifications as `CodexRawNotification`. `logEntries` yields -`CodexReviewLogEntry` values for command, reasoning, tool, file-change, message, -and delta output. `progress` yields `CodexReviewProgress` snapshots until the -review turn completes or fails. `transcriptUpdates` remains the transcript -sequence for the review thread itself, which is useful when a UI wants -thread-bound transcript snapshots rather than review progress phases. - -## Responses - -`CodexResponse` is the final result from `respond` or `ResponseStream.collect()`. -It carries the final answer, transcript, status, token usage, and `turnID`. - -Final answers are derived from assistant messages whose phase is -`.finalAnswer`. If no final-answer phase is present, the last normal assistant -message is used as a fallback. - -## Prompts - -`CodexPrompt` accepts text and structured parts: - -```swift -let prompt: CodexPrompt = .init(parts: [ - .text("Explain this screenshot."), - .localImage(screenshotURL), - .mention(name: "repo", path: workspaceURL), -]) -``` - -String literals are supported for simple prompts: - -```swift -try await thread.respond(to: "What changed?") -``` - -For dynamic prompts, use the result-builder initializer or the builder overloads -on `respond` and `streamResponse`: - -```swift -let response = try await thread.respond { - "Explain this screenshot." - CodexPrompt.Part.localImage(screenshotURL) - if includeRepository { - CodexPrompt.Part.mention(name: "repo", path: workspaceURL) - } -} -``` - -## Models, Account, And Login - -```swift -let models = try await appServer.models() -let account = try await appServer.account(refreshToken: true) -let configuration = try await appServer.configuration() -let rateLimits = try await appServer.rateLimits() -``` - -Login flows return typed handles: - -```swift -let handle = try await appServer.loginChatGPT(callbackURLScheme: "myapp") -try await appServer.completeLogin(handle, callbackURL: callbackURL) -``` - -API key and device-code login are also available: - -```swift -try await appServer.loginAPIKey(apiKey) -let deviceCode = try await appServer.loginChatGPTDeviceCode() -``` - -## Testing - -Use `CodexAppServerKitTesting` to exercise `CodexAppServer` without launching a -real `codex app-server` process. The test runtime uses an in-memory transport, -enqueues the startup `initialize` response, records requests, and lets tests -emit server notifications explicitly. - -```swift -import CodexAppServerKit -import CodexAppServerKitTesting -import Testing - -@Test func startsThread() async throws { - let runtime = try await CodexAppServerTestRuntime.start() - try await runtime.transport.enqueueThreadStart( - threadID: "thread-test", - model: "gpt-5" - ) - - let thread = try await runtime.server.startThread( - in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), - options: .init(model: "gpt-5") - ) - - #expect(thread.id.rawValue == "thread-test") - #expect(await runtime.transport.recordedRequests().map(\.method) == [ - "initialize", - "thread/start", - ]) -} -``` - -For concurrency-sensitive tests, hold a request with -`CodexAppServerTestGate` and release it explicitly. This avoids depending on -sleep duration or repeated `Task.yield()` calls. - -```swift -try await runtime.transport.enqueueTurnStart(turnID: "turn-1") -try await runtime.transport.enqueueEmpty(for: "turn/interrupt") - -let stream = try await thread.streamResponse(to: "Run checks.") -let gate = CodexAppServerTestGate() -await runtime.transport.holdNext(method: "turn/interrupt", gate: gate) - -let interruptTask = Task { - try await stream.interrupt() -} - -await runtime.transport.waitForRequest(method: "turn/interrupt") -await gate.open() -try await interruptTask.value -``` - -## Boundary - -Public users should not need to import or call JSON-RPC or `AppServerAPI` -request DTOs. Those remain package-level implementation details. - -The public boundary is: - -- `CodexAppServer` -- `CodexThreadID` -- `CodexTurnID` -- `CodexThread` -- `CodexReviewTarget` -- `CodexReviewSession` -- `CodexReviewEvent` -- `CodexReviewLogEntry` -- `CodexReviewProgress` -- `CodexReviewEventSequence` -- `CodexReviewLogSequence` -- `CodexReviewProgressSequence` -- `CodexResponse` -- `CodexResponseStream` -- `CodexGenerationOptions` -- `CodexTranscriptErrorHandlingPolicy` -- `CodexPrompt` -- `CodexTranscript` -- `CodexThreadItem` -- `CodexThreadEvent` -- `CodexModel` -- `CodexAccount` -- `CodexLoginHandle` - -Unknown notifications and unknown item kinds are preserved so clients can keep -running when app-server adds new schema. diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift deleted file mode 100644 index 48866ef6..00000000 --- a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift +++ /dev/null @@ -1,586 +0,0 @@ -import Foundation -import CodexAppServerKit - -/// A Codex app-server test runtime backed by an in-memory transport. -/// -/// This type does not launch `codex` or any external process. Tests enqueue -/// responses and emit notifications through ``transport`` while exercising the -/// same public ``CodexAppServer`` API that production code uses. -public struct CodexAppServerTestRuntime: Sendable { - /// The app-server domain container under test. - public var server: CodexAppServer - - /// The in-memory transport used by ``server``. - public var transport: CodexAppServerTestTransport - - /// Creates a runtime from an already initialized app-server container and transport. - public init(server: CodexAppServer, transport: CodexAppServerTestTransport) { - self.server = server - self.transport = transport - } - - /// Creates a test runtime without launching a real app-server process. - /// - /// The runtime automatically enqueues the `initialize` response required by - /// ``CodexAppServer`` startup. - /// - /// - Parameters: - /// - transport: The in-memory transport to use. - /// - codexHome: Optional Codex home path returned by initialization. - /// - userAgent: Optional user-agent returned by initialization. - /// - Returns: A started test runtime. - public static func start( - transport: CodexAppServerTestTransport = CodexAppServerTestTransport(), - codexHome: String? = nil, - userAgent: String? = nil - ) async throws -> CodexAppServerTestRuntime { - try await transport.enqueueInitialize(codexHome: codexHome, userAgent: userAgent) - let server = try await CodexAppServer.testing(transport: transport) - return .init(server: server, transport: transport) - } -} - -/// A recorded JSON-RPC request sent by ``CodexAppServer``. -public struct CodexAppServerRecordedRequest: Equatable, Sendable { - /// The JSON-RPC request identifier. - public var id: Int - - /// The app-server method name. - public var method: String - - /// The encoded request parameters. - public var params: Data - - /// Creates a recorded request value. - public init(id: Int, method: String, params: Data) { - self.id = id - self.method = method - self.params = params - } - - /// Decodes the request parameters as `Value`. - public func decodeParams(_ type: Value.Type = Value.self) throws -> Value { - try JSONDecoder().decode(type, from: params) - } -} - -/// A recorded JSON-RPC notification sent by ``CodexAppServer``. -public struct CodexAppServerRecordedNotification: Equatable, Sendable { - /// The app-server notification method name. - public var method: String - - /// The encoded notification parameters. - public var params: Data - - /// Creates a recorded notification value. - public init(method: String, params: Data) { - self.method = method - self.params = params - } - - /// Decodes the notification parameters as `Value`. - public func decodeParams(_ type: Value.Type = Value.self) throws -> Value { - try JSONDecoder().decode(type, from: params) - } -} - -/// A deterministic gate for app-server concurrency tests. -/// -/// Use this to hold a request at a known point and release it explicitly, -/// instead of depending on sleeps or repeated `Task.yield()` calls. -public actor CodexAppServerTestGate { - private var isOpen = false - private var waiters: [UUID: CheckedContinuation] = [:] - - /// Creates a closed gate. - public init() {} - - /// Suspends until the gate opens, or until the waiting task is cancelled. - public func wait() async { - if isOpen { - return - } - let waiterID = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if isOpen || Task.isCancelled { - continuation.resume() - } else { - waiters[waiterID] = continuation - } - } - } onCancel: { - Task { - await self.cancelWaiter(id: waiterID) - } - } - } - - /// Suspends until the gate opens, ignoring task cancellation while waiting. - public func waitIgnoringCancellation() async { - if isOpen { - return - } - let waiterID = UUID() - await withCheckedContinuation { continuation in - if isOpen { - continuation.resume() - } else { - waiters[waiterID] = continuation - } - } - } - - /// Opens the gate and resumes all suspended waiters. - public 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() - } -} - -/// An in-memory app-server transport for tests. -public actor CodexAppServerTestTransport { - private struct RequestGate: Sendable { - var gate: CodexAppServerTestGate - 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 requestMethodWaiters: [(String, Int, CheckedContinuation)] = [] - private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] - private var closed = false - - /// Creates an in-memory app-server transport. - public init() {} - - package init(responses: [String: [Data]]) { - self.responses = responses.mapValues { $0.map(QueuedResponse.success) } - } - - /// Enqueues a raw Encodable response for `method`. - public func enqueue( - _ response: Response, - for method: String - ) throws { - responses[method, default: []].append(.success(try JSONEncoder().encode(response))) - } - - /// Enqueues a JSON string response for `method`. - public func enqueueJSON(_ json: String, for method: String) throws { - responses[method, default: []].append(.success(Data(json.utf8))) - } - - /// Enqueues a JSON-RPC response error for `method`. - public func enqueueFailure(code: Int, message: String, for method: String) { - responses[method, default: []].append(.failure(.responseError( - code: code, - message: message - ))) - } - - package func enqueueFailure(_ error: JSONRPC.Error, for method: String) { - responses[method, default: []].append(.failure(error)) - } - - /// Enqueues an empty JSON object response for `method`. - public func enqueueEmpty(for method: String) throws { - try enqueue(EmptyResponse(), for: method) - } - - /// Enqueues a thread-start response. - public func enqueueThreadStart(threadID: String, model: String? = nil) throws { - try enqueue( - AppServerAPI.Thread.Start.Response(threadID: threadID, model: model), - for: "thread/start" - ) - } - - /// Enqueues a turn-start response. - public func enqueueTurnStart(turnID: String, status: String? = nil) throws { - try enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: turnID, status: status)), - for: "turn/start" - ) - } - - /// Enqueues a review-start response. - public func enqueueReviewStart(turnID: String, reviewThreadID: String? = nil) throws { - try enqueue( - AppServerAPI.Review.Start.Response(turnID: turnID, reviewThreadID: reviewThreadID), - for: "review/start" - ) - } - - /// Enqueues a model-list response. - public func enqueueModels(_ models: [CodexModel], nextCursor: String? = nil) throws { - try enqueue( - AppServerAPI.Model.List.Response(data: models, nextCursor: nextCursor), - for: "model/list" - ) - } - - /// Enqueues an account-read response. - public func enqueueAccount(_ account: CodexAccount?, requiresOpenAIAuth: Bool = false) throws { - let snapshot = account.map { - AppServerAPI.Account.Snapshot( - kind: .init(rawValue: $0.kind.rawValue) ?? .chatGPT, - id: $0.id, - label: $0.label, - planType: $0.planType - ) - } - try enqueue( - AppServerAPI.Account.Read.Response( - account: snapshot, - requiresOpenAIAuth: requiresOpenAIAuth - ), - for: "account/read" - ) - } - - /// Enqueues a config-read response. - public func enqueueConfiguration(_ configuration: CodexConfiguration) throws { - try enqueue( - AppServerAPI.Config.Read.Response(config: .init( - model: configuration.model, - reviewModel: configuration.reviewModel, - modelReasoningEffort: configuration.reasoningEffort?.rawValue, - serviceTier: configuration.serviceTier - )), - for: "config/read" - ) - } - - /// Enqueues an account rate-limit response. - public func enqueueRateLimits(_ rateLimits: CodexRateLimits) throws { - let windows = rateLimits.windows - let primary = windows.first.map(Self.window) - let secondary = windows.dropFirst().first.map(Self.window) - try enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: primary, - secondary: secondary, - planType: rateLimits.planType - )), - for: "account/rateLimits/read" - ) - } - - /// Enqueues a ChatGPT browser login response. - public func enqueueChatGPTLogin( - loginID: String, - authenticationURL: URL, - callbackURLScheme: String? = nil - ) throws { - try enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: loginID, - authURL: authenticationURL.absoluteString, - nativeWebAuthentication: callbackURLScheme.map { - .init(callbackURLScheme: $0) - } - ), - for: "account/login/start" - ) - } - - /// Enqueues a ChatGPT device-code login response. - public func enqueueChatGPTDeviceCodeLogin( - loginID: String, - verificationURL: URL, - userCode: String - ) throws { - try enqueue( - AppServerAPI.Account.Login.Response.chatgptDeviceCode( - loginID: loginID, - verificationURL: verificationURL.absoluteString, - userCode: userCode - ), - for: "account/login/start" - ) - } - - /// Enqueues an API-key login response. - public func enqueueAPIKeyLogin() throws { - try enqueue(AppServerAPI.Account.Login.Response.apiKey, for: "account/login/start") - } - - /// Holds every request for `method` until `gate` opens. - public func hold(method: String, gate: CodexAppServerTestGate) { - gatesByMethod[method] = .init(gate: gate, ignoresCancellation: false) - } - - /// Holds the next request for `method` until `gate` opens. - public func holdNext(method: String, gate: CodexAppServerTestGate) { - oneShotGatesByMethod[method, default: []].append(.init( - gate: gate, - ignoresCancellation: false - )) - } - - /// Holds the next request for `method` and ignores task cancellation while waiting. - public func holdNextIgnoringCancellation(method: String, gate: CodexAppServerTestGate) { - oneShotGatesByMethod[method, default: []].append(.init( - gate: gate, - ignoresCancellation: true - )) - } - - /// Returns all requests sent so far. - public func recordedRequests() -> [CodexAppServerRecordedRequest] { - requests.map { .init(id: $0.id, method: $0.method, params: $0.params) } - } - - /// Returns all requests sent so far for `method`. - public func recordedRequests(method: String) -> [CodexAppServerRecordedRequest] { - recordedRequests().filter { $0.method == method } - } - - /// Returns all client notifications sent so far. - public func recordedNotifications() -> [CodexAppServerRecordedNotification] { - notifications.map { .init(method: $0.method, params: $0.params) } - } - - /// Suspends until at least `count` requests have been sent. - public func waitForRequestCount(_ count: Int) async { - if requests.count >= count { - return - } - await withCheckedContinuation { continuation in - if requests.count >= count { - continuation.resume() - } else { - requestCountWaiters.append((count, continuation)) - } - } - } - - /// Suspends until at least `count` requests for `method` have been sent. - public func waitForRequest(method: String, count: Int = 1) async { - if requests.filter({ $0.method == method }).count >= count { - return - } - await withCheckedContinuation { continuation in - if requests.filter({ $0.method == method }).count >= count { - continuation.resume() - } else { - requestMethodWaiters.append((method, count, continuation)) - } - } - } - - /// Suspends until at least `count` notification stream consumers are attached. - public 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)) - } - } - } - - /// Returns the maximum number of in-flight requests observed for `method`. - public func maxActiveCount(for method: String) -> Int { - maxActiveByMethod[method] ?? 0 - } - - package func notificationStreamCount() -> Int { - serverNotificationContinuations.count - } - - package func isClosedForTesting() -> Bool { - closed - } - - /// Emits a server notification to all attached app-server notification streams. - public 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) - } - } - - /// Emits a server notification from a raw JSON object string. - public func emitServerNotificationJSON(method: String, json: String) { - let notification = JSONRPC.Notification( - method: method, - params: Data(json.utf8) - ) - for continuation in serverNotificationContinuations { - continuation.yield(notification) - } - } - - /// Finishes all attached notification streams with `error`. - public func finishNotificationStreams(throwing error: any Error) { - for continuation in serverNotificationContinuations { - continuation.finish(throwing: error) - } - serverNotificationContinuations.removeAll() - } - - package func enqueueInitialize(codexHome: String?, userAgent: String?) throws { - try enqueue( - AppServerAPI.Initialize.Response(codexHome: codexHome, userAgent: userAgent), - for: "initialize" - ) - } - - private func dequeueResponse(for method: String) -> QueuedResponse? { - guard var queued = responses[method], queued.isEmpty == false else { - return nil - } - let response = queued.removeFirst() - responses[method] = queued - return response - } - - private func dequeueOneShotGate(for method: String) -> RequestGate? { - guard var gates = oneShotGatesByMethod[method], gates.isEmpty == false else { - return nil - } - let gate = gates.removeFirst() - oneShotGatesByMethod[method] = gates - return gate - } - - private func resumeRequestCountWaiters() { - var remaining: [(Int, CheckedContinuation)] = [] - for waiter in requestCountWaiters { - if requests.count >= waiter.0 { - waiter.1.resume() - } else { - remaining.append(waiter) - } - } - requestCountWaiters = remaining - } - - private func resumeRequestMethodWaiters() { - var remaining: [(String, Int, CheckedContinuation)] = [] - for waiter in requestMethodWaiters { - let count = requests.filter { $0.method == waiter.0 }.count - if count >= waiter.1 { - waiter.2.resume() - } else { - remaining.append(waiter) - } - } - requestMethodWaiters = remaining - } - - private func resumeNotificationStreamCountWaiters() { - var remaining: [(Int, CheckedContinuation)] = [] - for waiter in notificationStreamCountWaiters { - if serverNotificationContinuations.count >= waiter.0 { - waiter.1.resume() - } else { - remaining.append(waiter) - } - } - notificationStreamCountWaiters = remaining - } - - private static func window( - from window: CodexRateLimitWindow - ) -> AppServerAPI.Account.RateLimits.Window { - .init( - usedPercent: window.usedPercent, - windowDurationMins: window.windowDurationMinutes, - resetsAt: window.resetsAt.map { Int64($0.timeIntervalSince1970 * 1000) } - ) - } -} - -extension CodexAppServerTestTransport: JSONRPC.Transport { - package func send(_ request: JSONRPC.Request) async throws -> Data { - guard closed == false else { - throw JSONRPC.Error.closed - } - requests.append(request) - resumeRequestCountWaiters() - resumeRequestMethodWaiters() - activeByMethod[request.method, default: 0] += 1 - maxActiveByMethod[request.method] = max( - maxActiveByMethod[request.method] ?? 0, - activeByMethod[request.method] ?? 0 - ) - let queuedResponse = dequeueResponse(for: request.method) - if let gate = dequeueOneShotGate(for: request.method) ?? gatesByMethod[request.method] { - 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()) - } - - 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() - } -} diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index b3e4a0a0..dce5f709 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -8,10 +8,6 @@ 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 } @@ -49,7 +45,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot { - try await appServer.updateConfiguration(Self.configurationPatch(from: change)) + guard change.updatesModel == false, + change.updatesReasoningEffort == false, + change.updatesServiceTier == false + else { + throw CodexReviewAPI.Error.io( + "Updating app-server review settings requires a public CodexKit configuration update API." + ) + } return try await readSettings() } @@ -65,13 +68,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { try await appServer.rateLimits() } - package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { - let handle = try await appServer.loginChatGPT( - callbackURLScheme: request.nativeWebAuthenticationCallbackScheme - ) - return try handle.backendChallenge( - nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme - ) + package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { + let handle = try await appServer.loginChatGPT() + return try handle.backendChallenge(nativeWebAuthenticationCallbackScheme: nil) } package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { @@ -83,9 +82,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { guard let url = URL(string: callbackURL) else { throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") } - try await appServer.completeLogin( - id: .init(rawValue: response.challengeID), - callbackURL: url + _ = url + throw CodexReviewAPI.Error.io( + "Completing native ChatGPT authentication callbacks requires a public CodexKit login completion API." ) } return try await readAuth() @@ -111,7 +110,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) let session = AppServerReviewEventSession(run: provisionalRun, control: control) registerReviewEventSession(session, for: provisionalRun) - control.recordThreadStarted(thread) + control.recordThreadStarted() let review: CodexReviewSession do { @@ -206,10 +205,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { let interruptedRun = token.interruptedRun - try await appServer.rollbackThread(.init(rawValue: token.rollbackThreadID), turnCount: 1) + let rollbackThread = try await threadHandle(id: token.rollbackThreadID, model: interruptedRun.model) + try await rollbackThread.rollback(turnCount: 1) let control = reviewControl(for: interruptedRun) - let thread = reviewThread(for: interruptedRun) + let thread = try await reviewThread(for: interruptedRun) let attemptID = makeAppServerReviewAttemptID() let provisionalRun = CodexReviewBackendModel.Review.Run( attemptID: attemptID, @@ -255,10 +255,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { completedReviewEventSessionMetricsByThreadID[threadID] = metrics } } - try? await appServer.cleanBackgroundTerminals(in: .init(rawValue: run.threadID)) - try? await appServer.unsubscribeThread(.init(rawValue: run.threadID)) for threadID in cleanupThreadIDs { - try? await appServer.deleteThread(.init(rawValue: threadID)) + guard let thread = try? await threadHandle(id: threadID, model: run.model) else { + continue + } + try? await thread.delete() } for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) @@ -344,27 +345,26 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } let control = AppServerReviewControl() if let turnID = run.turnID { - control.recordReviewStarted(thread: turnThread(for: run), turnID: turnID) + control.recordReviewStarted(turnID: turnID) } else { - control.recordThreadStarted(reviewThread(for: run)) + control.recordThreadStarted() } controlsByThreadID[run.threadID] = control return control } - private func reviewThread(for run: CodexReviewBackendModel.Review.Run) -> CodexThread { - threadHandle(id: run.threadID, model: run.model) - } - - private func turnThread(for run: CodexReviewBackendModel.Review.Run) -> CodexThread { - threadHandle(id: appServerTurnThreadID(for: run), model: run.model) + private func reviewThread(for run: CodexReviewBackendModel.Review.Run) async throws -> CodexThread { + try await threadHandle(id: run.threadID, model: run.model) } - private func threadHandle(id threadID: String, model: String?) -> CodexThread { + private func threadHandle(id threadID: String, model: String?) async throws -> CodexThread { if let thread = threadsByThreadID[threadID] { return thread } - let thread = appServer.threadHandle(id: .init(rawValue: threadID), model: model) + let thread = try await appServer.resumeThread( + .init(rawValue: threadID), + options: .init(model: model) + ) threadsByThreadID[threadID] = thread return thread } @@ -719,13 +719,13 @@ private enum AppServerTypedReviewEventAdapter { _ item: CodexThreadItem, phase: AppServerTypedItemPhase ) -> [CodexReviewBackendModel.Review.Event] { - guard item.startsReviewMode == false else { + guard item.kind.rawValue != "enteredReviewMode" else { return [] } - if phase == .completed, item.finishesReviewMode { + if phase == .completed, item.kind.rawValue == "exitedReviewMode" { return [.completed( summary: "Succeeded.", - result: item.reviewExitResult + result: item.text?.nilIfEmpty )] } guard item.kind != .userMessage, @@ -969,9 +969,9 @@ private enum AppServerTypedReviewEventAdapter { title: nil )) } - let output = command.outputDelta?.nilIfEmpty ?? command.output?.nilIfEmpty + let output = command.output?.nilIfEmpty if let output { - if command.outputDelta != nil || command.command.isEmpty { + if command.command.isEmpty { events.append(item.commandOutputDeltaLogEntry(text: output)) } else { events.append(item.logEntry( @@ -1589,7 +1589,7 @@ private actor AppServerReviewEventSession { private func recordReviewEvent(_ event: CodexReviewBackendModel.Review.Event, controlThreadID: String? = nil) { switch event { case .started(let turnID, _, _): - control.recordTurnStarted(thread: nil, turnID: turnID) + control.recordTurnStarted(turnID: turnID) case .completed, .failed, .cancelled: control.finish() appServerBackendLogger.debug( @@ -1763,21 +1763,6 @@ private final class ReviewCompletionCoordinator { } } -private extension AppServerCodexReviewBackend { - static func configurationPatch( - from change: CodexReviewBackendModel.Settings.Change - ) -> CodexConfigurationPatch { - .init( - reviewModel: change.model, - reasoningEffort: change.reasoningEffort.map(CodexReasoningEffort.init(rawValue:)), - serviceTier: change.serviceTier, - updatesReviewModel: change.updatesModel, - updatesReasoningEffort: change.updatesReasoningEffort, - updatesServiceTier: change.updatesServiceTier - ) - } -} - private extension CodexAppServerKit.CodexAccount { var backendAccount: CodexReviewBackendModel.Account.Snapshot { let backendKind: CodexReviewBackendModel.Account.Kind = @@ -1793,21 +1778,6 @@ private extension CodexAppServerKit.CodexAccount { } } -private extension AppServerAPI.Account.Snapshot { - var backendAccount: CodexReviewBackendModel.Account.Snapshot { - 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: backendKind.capabilities - ) - } -} - private extension CodexModel { var reviewModelCatalogItem: CodexReviewSettings.ModelCatalogItem { let reasoningOptions = supportedReasoningEfforts.compactMap { option in diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index 8785caef..1ba9875f 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -15,8 +15,8 @@ package struct AppServerReviewInterruption: Equatable, Sendable { package final class AppServerReviewControl: @unchecked Sendable { private enum Phase { case preparing - case threadStarted(CodexThread) - case reviewStarted(thread: CodexThread?, turnID: String) + case threadStarted + case reviewStarted(turnID: String) case finished } @@ -31,43 +31,40 @@ package final class AppServerReviewControl: @unchecked Sendable { package init() {} - package func recordThreadStarted(_ thread: CodexThread) { + package func recordThreadStarted() { phaseLock.lock() defer { phaseLock.unlock() } guard case .preparing = phase else { return } - phase = .threadStarted(thread) + phase = .threadStarted reviewSession = nil } - package func recordReviewStarted(thread: CodexThread, turnID: String) { + package func recordReviewStarted(turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } - phase = .reviewStarted(thread: thread, turnID: turnID) + phase = .reviewStarted(turnID: turnID) reviewSession = nil } package func recordReviewStarted(_ reviewSession: CodexReviewSession) { phaseLock.lock() defer { phaseLock.unlock() } - phase = .reviewStarted( - thread: nil, - turnID: reviewSession.turnID.rawValue - ) + phase = .reviewStarted(turnID: reviewSession.turnID.rawValue) self.reviewSession = reviewSession } - package func recordTurnStarted(thread: CodexThread?, turnID: String) { + package func recordTurnStarted(turnID: String) { phaseLock.lock() defer { phaseLock.unlock() } switch phase { case .preparing, .finished: return - case .threadStarted(let existingThread): - phase = .reviewStarted(thread: thread ?? existingThread, turnID: turnID) - case .reviewStarted(let existingThread, _): - phase = .reviewStarted(thread: thread ?? existingThread, turnID: turnID) + case .threadStarted: + phase = .reviewStarted(turnID: turnID) + case .reviewStarted: + phase = .reviewStarted(turnID: turnID) } } @@ -79,26 +76,15 @@ package final class AppServerReviewControl: @unchecked Sendable { switch snapshot.phase { case .preparing, .finished: return nil - case .threadStarted(let thread): - return try await interrupt( - thread, - expectedTurnID: nil, - willInterruptActiveTurn: willInterruptActiveTurn - ) - case .reviewStarted(let thread, let turnID): - if let reviewSession = snapshot.reviewSession { - return try await interrupt( - reviewSession, - expectedTurnID: turnID, - willInterruptActiveTurn: willInterruptActiveTurn - ) - } - guard let thread else { + case .threadStarted: + return nil + case .reviewStarted(let turnID): + guard let reviewSession = snapshot.reviewSession else { return nil } return try await interrupt( - thread, - expectedTurnID: turnID.nilIfEmpty.map(CodexTurnID.init(rawValue:)), + reviewSession, + expectedTurnID: turnID, willInterruptActiveTurn: willInterruptActiveTurn ) } @@ -122,42 +108,14 @@ package final class AppServerReviewControl: @unchecked Sendable { expectedTurnID: String, willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? ) async throws -> AppServerReviewInterruption { - let interruption = try await reviewSession.interrupt { interruption in - guard let willInterruptActiveTurn else { - return - } - await willInterruptActiveTurn(AppServerReviewInterruption(interruption)) - } + let interruption = try await reviewSession.interrupt() let reviewInterruption = AppServerReviewInterruption(interruption) - if reviewInterruption.turnID != expectedTurnID { - setPhase( - .reviewStarted( - thread: nil, - turnID: reviewInterruption.turnID - ), - reviewSession: reviewSession - ) + if reviewInterruption.turnID != expectedTurnID, + let willInterruptActiveTurn { + await willInterruptActiveTurn(reviewInterruption) } - return reviewInterruption - } - - private func interrupt( - _ thread: CodexThread, - expectedTurnID: CodexTurnID?, - willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? - ) async throws -> AppServerReviewInterruption { - let interruption = try await thread.interruptActiveTurn( - expectedTurnID: expectedTurnID, - willInterruptActiveTurn: { interruption in - guard let willInterruptActiveTurn else { - return - } - await willInterruptActiveTurn(AppServerReviewInterruption(interruption)) - } - ) - let reviewInterruption = AppServerReviewInterruption(interruption) - if reviewInterruption.turnID != expectedTurnID?.rawValue { - setPhase(.reviewStarted(thread: thread, turnID: reviewInterruption.turnID)) + if reviewInterruption.turnID != expectedTurnID { + setPhase(.reviewStarted(turnID: reviewInterruption.turnID), reviewSession: reviewSession) } return reviewInterruption } diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index df9075c7..2752434e 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -130,7 +130,7 @@ public extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, - transport: any JSONRPC.Transport + appServer: CodexAppServer ) -> CodexReviewStore { makeLiveStoreForTesting( environment: environment, @@ -143,7 +143,7 @@ public extension CodexReviewStore { shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, - transportFactory: { _ in transport } + appServerFactory: { _ in appServer } ) } @@ -162,7 +162,7 @@ public extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, - transportFactory: @escaping @MainActor @Sendable (URL) async throws -> any JSONRPC.Transport + appServerFactory: @escaping @MainActor @Sendable (URL) async throws -> CodexAppServer ) -> CodexReviewStore { CodexReviewStore( backend: LiveCodexReviewStoreBackend( @@ -176,9 +176,7 @@ public extension CodexReviewStore { mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, shutdownCleanupTimeout: shutdownCleanupTimeout, appServerRuntimeFactory: { codexHomeURL in - let appServer = try await CodexAppServer( - transport: try await transportFactory(codexHomeURL) - ) + let appServer = try await appServerFactory(codexHomeURL) return .init( appServer: appServer, backend: AppServerCodexReviewBackend(appServer: appServer) @@ -494,8 +492,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { reason: ReviewCancellation, timeoutWarning: String ) async { - let didInterrupt = await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) { - await appServerBackend.interruptActiveReviewsForShutdown(reason: .init(message: reason.message)) + let recoveryRunsForCleanup = store.reviewRecoveryWaitingJobIDs + .sorted() + .compactMap { store.activeRuns[$0] } + let didCleanup = await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) { + await appServerBackend.cleanupActiveReviewsForShutdown(reason: .init(message: reason.message)) + for run in recoveryRunsForCleanup { + await appServerBackend.cleanupReview(run) + } } let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( reason: reason, @@ -505,7 +509,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let didDrainReviewWorkers = await store.drainReviewWorkersForRuntimeStop( timeout: shutdownCleanupTimeout ) - if didInterrupt == false || didDrainReviewWorkers == false { + if didCleanup == false || didDrainReviewWorkers == false { logger.warning("\(timeoutWarning, privacy: .public)") } } @@ -827,14 +831,19 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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) } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 4d195e8b..5570d335 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -830,4 +830,4 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { } } -package typealias FakeJSONRPCTransport = CodexAppServerTestTransport +package typealias FakeCodexAppServerTransport = CodexAppServerTestTransport diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift deleted file mode 100644 index 06190cbe..00000000 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ /dev/null @@ -1,1496 +0,0 @@ -import Foundation -import Testing - -import CodexAppServerKitTesting -@testable import CodexAppServerKit - -@Suite("CodexAppServerKit") -struct CodexAppServerKitTests { - @Test func localProcessConfigurationOwnsDefaultCodexHome() { - let fromHome = CodexAppServer.Configuration.LocalProcess(environment: [ - "HOME": "/tmp/user-home", - ]) - #expect(fromHome.codexHomeURL.path == "/tmp/user-home/.codex") - - let fromCodexHome = CodexAppServer.Configuration.LocalProcess(environment: [ - "CODEX_HOME": "/tmp/codex-home", - "HOME": "/tmp/user-home", - ]) - #expect(fromCodexHome.codexHomeURL.path == "/tmp/codex-home") - - let appSupport = URL(fileURLWithPath: "/tmp/app-support", isDirectory: true) - let containerDefault = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( - environment: [:], - homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), - applicationSupportDirectory: appSupport - ) - #expect(containerDefault.path == "/tmp/app-support/Codex") - - let homeFallback = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( - environment: [:], - homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), - applicationSupportDirectory: nil - ) - #expect(homeFallback.path == "/tmp/home/Library/Application Support/Codex") - } - - @Test func testRuntimeStartsAppServerWithoutLaunchingProcess() async throws { - let runtime = try await CodexAppServerTestRuntime.start(codexHome: "/tmp/codex") - try await runtime.transport.enqueueThreadStart(threadID: "thread-test", model: "gpt-5") - - let thread = try await runtime.server.startThread( - in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), - options: .init(model: "gpt-5") - ) - - #expect(thread.id == "thread-test") - #expect(await runtime.transport.recordedRequests().map(\.method) == [ - "initialize", - "thread/start", - ]) - #expect(await runtime.transport.recordedNotifications().map(\.method) == [ - "initialized" - ]) - } - - @Test func testTransportHoldsRequestsAtExplicitGate() async throws { - let transport = CodexAppServerTestTransport() - let gate = CodexAppServerTestGate() - await transport.holdNext(method: "ping", gate: gate) - let client = AppServerClient(transport: transport) - - let task = Task { - let _: EmptyResponse = try await client.send( - method: "ping", - params: EmptyResponse(), - responseType: EmptyResponse.self - ) - } - - await transport.waitForRequest(method: "ping") - #expect(await transport.maxActiveCount(for: "ping") == 1) - - await gate.open() - try await task.value - } - - @Test func initializeSendsHandshakeAndInitializedNotification() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize") - let client = AppServerClient(transport: transport) - - let response = try await client.initialize(clientName: "TestClient", clientVersion: "1") - - #expect(response.codexHome == "/tmp/codex") - #expect(await transport.recordedRequests().map(\.method) == ["initialize"]) - #expect(await transport.recordedNotifications().map(\.method) == ["initialized"]) - let params = try #require(await transport.recordedRequests().first?.params) - let decoded = try JSONDecoder().decode(AppServerAPI.Initialize.Params.self, from: params) - #expect(decoded.clientInfo.name == "TestClient") - #expect(decoded.clientInfo.version == "1") - } - - @Test func appServerStartThreadSerializesDomainOptions() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), - for: "thread/start" - ) - let client = AppServerClient(transport: transport) - let server = CodexAppServer( - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) - - let thread = try await server.startThread( - in: workspace, - instructions: .init(base: "Base", developer: "Developer"), - options: .init( - model: "gpt-5", - sandbox: .workspaceWrite, - ephemeral: true, - config: ["experimental": .bool(true)], - personality: .pragmatic, - serviceName: "review-monitor", - sessionStartSource: .startup, - threadSource: "automation" - ) - ) - - #expect(thread.id == "thread-1") - let request = try #require(await transport.recordedRequests().first) - #expect(request.method == "thread/start") - let params = try JSONDecoder().decode( - AppServerAPI.Thread.Start.Params.self, from: request.params) - #expect(params.cwd == workspace.path) - #expect(params.model == "gpt-5") - #expect(params.ephemeral == true) - #expect(params.baseInstructions == "Base") - #expect(params.developerInstructions == "Developer") - #expect(params.approvalPolicy == "on-request") - #expect(params.approvalsReviewer == "auto_review") - #expect(params.sandbox == "workspace-write") - #expect(params.config == ["experimental": .bool(true)]) - #expect(params.personality == "pragmatic") - #expect(params.serviceName == "review-monitor") - #expect(params.sessionStartSource == .startup) - #expect(params.threadSource?.rawValue == "automation") - } - - @Test func appServerListThreadsSerializesQueryOptions() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Thread.List.Response(data: [], nextCursor: "next"), - for: "thread/list" - ) - let client = AppServerClient(transport: transport) - let server = CodexAppServer( - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) - - let page = try await server.listThreads(.init( - archived: false, - cursor: "cursor", - workspace: workspace, - limit: 10, - searchTerm: "review", - modelProviders: ["openai"], - sortDirection: .descending, - sortKey: .recencyAt, - sourceKinds: [.appServer, .subAgentReview], - useStateDBOnly: true - )) - - #expect(page.nextCursor == "next") - let request = try #require(await transport.recordedRequests().first) - #expect(request.method == "thread/list") - let params = try JSONDecoder().decode( - AppServerAPI.Thread.List.Params.self, - from: request.params - ) - #expect(params.archived == false) - #expect(params.cursor == "cursor") - #expect(params.cwd == .path(workspace.path)) - #expect(params.limit == 10) - #expect(params.searchTerm == "review") - #expect(params.modelProviders == ["openai"]) - #expect(params.sortDirection == "desc") - #expect(params.sortKey == "recency_at") - #expect(params.sourceKinds == ["appServer", "subAgentReview"]) - #expect(params.useStateDbOnly == true) - } - - @Test func reviewNotificationMethodsClassifyReviewEvents() { - #expect(AppServerReviewNotification.Method(rawValue: "turn/started").isReviewNotificationMethod) - #expect(AppServerReviewNotification.Method(rawValue: "item/started").isReviewNotificationMethod) - #expect( - AppServerReviewNotification.Method(rawValue: "item/commandExecution/outputDelta") - .isReviewNotificationMethod - ) - #expect(AppServerReviewNotification.Method(rawValue: "thread/status/changed").isReviewNotificationMethod) - #expect(AppServerReviewNotification.Method(rawValue: "model/verification").isReviewNotificationMethod) - #expect(AppServerReviewNotification.Method(rawValue: "review/futureEvent").isReviewNotificationMethod == false) - } - - @Test func reviewNotificationMethodsClassifyThreadlessBroadcasts() { - #expect(AppServerReviewNotification.Method(rawValue: "warning").isThreadlessReviewBroadcast) - #expect(AppServerReviewNotification.Method(rawValue: "deprecationNotice").isThreadlessReviewBroadcast) - #expect(AppServerReviewNotification.Method(rawValue: "configWarning").isThreadlessReviewBroadcast) - #expect(AppServerReviewNotification.Method(rawValue: "error").isThreadlessReviewBroadcast) - #expect(AppServerReviewNotification.Method(rawValue: "diagnostic").isThreadlessReviewBroadcast == false) - #expect(AppServerReviewNotification.Method(rawValue: "turn/started").isThreadlessReviewBroadcast == false) - } - - @Test func reviewNotificationDecodePreservesUnknownRawPayloads() throws { - let objectData = Data(""" - { - "threadId": "thread-review", - "turnId": "turn-review", - "future": { "enabled": true }, - "count": 2 - } - """.utf8) - let objectNotification = try AppServerReviewNotification( - method: "review/futureEvent", - paramsData: objectData - ) - - #expect(objectNotification.method.rawValue == "review/futureEvent") - #expect(objectNotification.payload.threadID == "thread-review") - #expect(objectNotification.payload.turnID == "turn-review") - #expect(objectNotification.rawNotification.threadID == "thread-review") - #expect(objectNotification.rawNotification.turnID == "turn-review") - #expect(objectNotification.rawNotification.params == objectData) - #expect(objectNotification.rawPayload == .object([ - "threadId": .string("thread-review"), - "turnId": .string("turn-review"), - "future": .object(["enabled": .bool(true)]), - "count": .int(2), - ])) - - let nonObjectData = Data(#""future schema""#.utf8) - let nonObjectNotification = try AppServerReviewNotification( - method: "review/futureEvent", - paramsData: nonObjectData - ) - - #expect(nonObjectNotification.payload.threadID == nil) - #expect(nonObjectNotification.payload.turnID == nil) - #expect(nonObjectNotification.rawPayload == .string("future schema")) - #expect(nonObjectNotification.rawNotification.params == nonObjectData) - } - - @Test func reviewNotificationDecodeReadsOfficialItemPayload() throws { - let notification = try AppServerReviewNotification( - method: "item/completed", - paramsData: Data(""" - { - "threadId": "thread-review", - "turnId": "turn-review", - "item": { - "id": "command-1", - "type": "commandExecution", - "command": "swift test", - "aggregatedOutput": "passed", - "status": "completed" - } - } - """.utf8) - ) - - #expect(notification.method.isReviewNotificationMethod) - #expect(notification.payload.threadID == "thread-review") - #expect(notification.payload.resolvedTurnID == "turn-review") - #expect(notification.payload.item?.id == "command-1") - #expect(notification.payload.item?.resolvedType == "commandExecution") - #expect(notification.payload.item?.command == "swift test") - #expect(notification.payload.item?.aggregatedOutput == "passed") - #expect(notification.payload.item?.status == "completed") - } - - @Test func threadStartReviewSerializesTargetAndStreamsTypedReviewLogs() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Review.Start.Response( - turnID: "turn-review", - reviewThreadID: "thread-review" - ), - for: "review/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let review = try await thread.startReview( - target: .baseBranch("main"), - delivery: .detached - ) - - #expect(review.threadID == "thread-1") - #expect(review.turnID == "turn-review") - #expect(review.reviewThreadID == "thread-review") - - let request = try #require(await transport.recordedRequests().first) - #expect(request.method == "review/start") - let params = try JSONDecoder().decode( - AppServerAPI.Review.Start.Params.self, - from: request.params - ) - #expect(params.threadID == "thread-1") - #expect(params.target == .baseBranch("main")) - #expect(params.delivery == .detached) - - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-review", - turnID: "turn-review", - item: .init( - id: "command-1", - type: "commandExecution", - command: "swift test", - aggregatedOutput: "passed", - status: "completed" - ) - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-review", - turnID: "turn-review", - item: .init( - id: "reasoning-1", - type: "reasoning", - summary: ["Checked the diff"], - content: ["trace"] - ) - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-review", - turnID: "turn-review", - item: .init( - id: "tool-1", - type: "mcpToolCall", - text: "ok", - status: "completed", - tool: "review_read" - ) - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-review", - turnID: "turn-review", - item: .init( - id: "file-1", - type: "fileChange", - text: "updated", - status: "completed", - path: "Sources/File.swift" - ) - ) - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-review") - ) - - let logs = try await collect(review.logEntries) - #expect(logs.count == 4) - #expect(logs.first?.turnID == "turn-review") - #expect(logs.first?.item?.kind == .commandExecution) - #expect(logs.first?.item?.text == "passed") - #expect(logs.contains { - if case .reasoning(let reasoning) = $0.item?.content { - reasoning.summary == ["Checked the diff"] - } else { - false - } - }) - #expect(logs.contains { - if case .toolCall(let toolCall) = $0.item?.content { - toolCall.name == "review_read" - } else { - false - } - }) - #expect(logs.contains { - if case .fileChange(let fileChange) = $0.item?.content { - fileChange.path == "Sources/File.swift" - } else { - false - } - }) - } - - @Test func reviewStartSeedsDetachedTurnRoutingForTurnOnlyTerminalNotifications() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Review.Start.Response( - turnID: "turn-review", - reviewThreadID: "thread-review" - ), - for: "review/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let review = try await thread.startReview( - target: .baseBranch("main"), - delivery: .detached - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-review", status: "completed")) - ) - - var eventIterator = review.events.makeAsyncIterator() - let event = try await eventIterator.next() - if case .turnCompleted(let response) = event { - #expect(response.turnID == "turn-review") - } else { - Issue.record("Expected review.events to receive turn-only completion.") - } - #expect(try await eventIterator.next() == nil) - - var logIterator = review.logEntries.makeAsyncIterator() - #expect(try await logIterator.next() == nil) - - var progressIterator = review.progress.makeAsyncIterator() - let progress = try #require(try await progressIterator.next()) - #expect(progress.phase == .completed) - #expect(progress.result?.turnID == "turn-review") - #expect(try await progressIterator.next() == nil) - } - - @Test func reviewEventsPreserveUnknownNotificationsAsRawDomainEvents() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Review.Start.Response( - turnID: "turn-review", - reviewThreadID: "thread-review" - ), - for: "review/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let review = try await thread.startReview( - target: .baseBranch("main"), - delivery: .detached - ) - try await transport.emitServerNotification( - method: "review/futureEvent", - params: TurnIDParams(turnID: "turn-review") - ) - - var iterator = review.events.makeAsyncIterator() - let event = try await iterator.next() - if case .unknown(let raw) = event { - #expect(raw.method == "review/futureEvent") - #expect(raw.threadID == "thread-review") - #expect(raw.turnID == "turn-review") - } else { - Issue.record("Expected unknown review notification to be preserved.") - } - } - - @Test func reviewEventsRouteThreadlessBroadcastsToSeededReviewThread() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Review.Start.Response( - turnID: "turn-review", - reviewThreadID: "thread-review" - ), - for: "review/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let review = try await thread.startReview( - target: .baseBranch("main"), - delivery: .detached - ) - try await transport.emitServerNotification( - method: "warning", - params: ReviewWarningParams(message: "model warning") - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-review") - ) - - let events = try await collect(review.events) - #expect( - events.contains { - if case .unknown(let raw) = $0 { - let text = String(data: raw.params, encoding: .utf8) ?? "" - return raw.method == "warning" - && raw.threadID == "thread-review" - && text.contains("model warning") - } - return false - }) - } - - @Test func reviewEventsRouteIdentifiedBroadcastMethodsThroughTurnSeed() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Review.Start.Response( - turnID: "turn-review", - reviewThreadID: "thread-review" - ), - for: "review/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let review = try await thread.startReview( - target: .baseBranch("main"), - delivery: .detached - ) - try await transport.emitServerNotification( - method: "error", - params: ReviewErrorParams(turnID: "turn-review", message: "recoverable") - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-review") - ) - - let events = try await collect(review.events) - #expect( - events.contains { - if case .unknown(let raw) = $0 { - return raw.method == "error" - && raw.threadID == "thread-review" - && raw.turnID == "turn-review" - } - return false - }) - } - - @Test func promptPartsEncodeToAppServerInputItems() { - let prompt = CodexPrompt(parts: [ - .text("Describe these files."), - .imageURL(URL(string: "https://example.test/diagram.png")!), - .localImage(URL(fileURLWithPath: "/tmp/screenshot.png")), - .skill(name: "checks", path: URL(fileURLWithPath: "/tmp/skills/checks")), - .mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")), - ]) - - #expect( - prompt.appServerInput == [ - .text("Describe these files."), - .image(url: "https://example.test/diagram.png"), - .localImage(path: "/tmp/screenshot.png"), - .skill(name: "checks", path: "/tmp/skills/checks"), - .mention(name: "repo", path: "/tmp/repo"), - ]) - } - - @Test func clientRetriesOverloadedRequestsThenSucceeds() async throws { - let transport = CodexAppServerTestTransport() - await transport.enqueueFailure(code: -32001, message: "server busy", for: "ping") - try await transport.enqueue(EmptyResponse(), for: "ping") - let client = AppServerClient( - transport: transport, - overloadRetryDelay: { $0 == 0 ? .zero : nil }, - retrySleep: { _ in } - ) - - let _: EmptyResponse = try await client.send( - method: "ping", - params: EmptyResponse(), - responseType: EmptyResponse.self - ) - - #expect(await transport.recordedRequests().map(\.method) == ["ping", "ping"]) - } - - @Test func turnResultReplaysEarlyNotificationsAndKeepsUnknownEvents() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - try await transport.emitServerNotification( - method: "future/notification", - params: TurnIDParams(turnID: "turn-1") - ) - try await transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TurnDeltaParams(turnID: "turn-1", delta: "Done") - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) - ) - let turn = CodexTurn( - id: "turn-1", - threadID: "thread-1", - client: client, - router: router - ) - - let events = try await collect(turn.events) - #expect( - events.contains { - if case .unknown(let raw) = $0 { - raw.method == "future/notification" - } else { - false - } - }) - let result = try await CodexResponseCollector.collect( - from: .init { - AsyncThrowingStream { continuation in - for event in events { - continuation.yield(event) - } - continuation.finish() - } - }) - #expect(result.turnID == "turn-1") - #expect(result.status == .completed) - #expect(result.finalAnswer == "Done") - } - - @Test func threadEventStreamCancellationRemovesRouterSubscriber() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let consumer = Task { - var iterator = thread.events.makeAsyncIterator() - _ = try await iterator.next() - } - - #expect(await eventually { - await router.threadSubscriberCountForTesting(for: "thread-1") == 1 - }) - consumer.cancel() - let removed = await eventually { - await router.threadSubscriberCountForTesting(for: "thread-1") == 0 - } - #expect(removed) - if removed { - try await consumer.value - } - } - - @Test func turnEventStreamCancellationRemovesRouterSubscriber() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let turn = CodexTurn( - id: "turn-1", - threadID: "thread-1", - client: client, - router: router - ) - - let consumer = Task { - var iterator = turn.events.makeAsyncIterator() - _ = try await iterator.next() - } - - #expect(await eventually { - await router.turnSubscriberCountForTesting(for: "turn-1") == 1 - }) - consumer.cancel() - let removed = await eventually { - await router.turnSubscriberCountForTesting(for: "turn-1") == 0 - } - #expect(removed) - if removed { - try await consumer.value - } - } - - @Test func threadStreamsReplayMessagesTranscriptLogsAndUsage() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-1", - turnID: "turn-1", - item: .init( - id: "message-1", - type: "agentMessage", - text: "Interim", - phase: "commentary" - ) - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-1", - turnID: "turn-1", - item: .init( - id: "message-2", - type: "agentMessage", - text: "Final", - phase: "final_answer" - ) - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-1", - turnID: "turn-1", - item: .init( - id: "command-1", - type: "commandExecution", - command: "swift test", - aggregatedOutput: "passed", - status: "completed" - ) - ) - ) - try await transport.emitServerNotification( - method: "thread/tokenUsage/updated", - params: TokenUsageParams( - threadID: "thread-1", - turnID: "turn-1", - tokenUsage: .init( - total: .init(inputTokens: 1, outputTokens: 2, totalTokens: 3) - ) - ) - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-1") - ) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let messages = try await collect(thread.messages) - #expect(messages.map(\.text) == ["Interim", "Final"]) - #expect(messages.last?.phase == .finalAnswer) - - let transcripts = try await collect(thread.transcriptUpdates) - #expect(transcripts.last?.finalAnswer == "Final") - #expect(transcripts.last?.items.count == 3) - - let logs = try await collect(thread.logEntries) - #expect(logs.contains { $0.item?.kind == .commandExecution }) - #expect(logs.contains { $0.item?.text == "Final" }) - - let events = try await collect(thread.events) - #expect( - events.contains { - if case .tokenUsageUpdated(let usage, let turnID) = $0 { - turnID == "turn-1" && usage.totalTokens == 3 - } else { - false - } - }) - } - - @Test func responseStreamYieldsSnapshotsAndCollectsFinalResponse() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse { - "Summarize this." - CodexPrompt.Part.mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")) - } - - try await transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TurnDeltaParams(turnID: "turn-1", delta: "Final") - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) - ) - - var iterator = stream.makeAsyncIterator() - let first = try await iterator.next() - #expect(first?.turnID == "turn-1") - #expect(first?.content == "Final") - - let response = try await stream.collect() - #expect(response.turnID == "turn-1") - #expect(response.finalAnswer == "Final") - - let request = try #require(await transport.recordedRequests().first) - let params = try JSONDecoder().decode( - AppServerAPI.Turn.Start.Params.self, from: request.params) - #expect( - params.input == [ - .text("Summarize this."), - .mention(name: "repo", path: "/tmp/repo"), - ]) - } - - @Test func responseStreamFailureCarriesPartialResponse() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - let stream = try await thread.streamResponse(to: "Try this.") - try await transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TurnDeltaParams(turnID: "turn-1", delta: "Partial") - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init( - id: "turn-1", - status: "failed", - error: .init(message: "Tool failed."), - startedAt: 1_700_000_000, - completedAt: 1_700_000_001, - durationMS: 1_000 - )) - ) - - var iterator = stream.makeAsyncIterator() - let first = try await iterator.next() - #expect(first?.content == "Partial") - let terminal = try await iterator.next() - #expect(terminal?.response?.status == .failed) - #expect(terminal?.response?.errorMessage == "Tool failed.") - #expect(terminal?.response?.transcript.responseText == "Partial") - #expect(terminal?.response?.startedAt == Date(timeIntervalSince1970: 1_700_000_000)) - #expect(terminal?.response?.duration == .milliseconds(1_000)) - do { - _ = try await iterator.next() - Issue.record("Expected failed stream to throw after terminal snapshot.") - } catch let error as CodexAppServerError { - #expect(error.response?.status == .failed) - #expect(error.response?.transcript.responseText == "Partial") - } - - do { - _ = try await stream.collect() - Issue.record("Expected collect() to throw for failed turn.") - } catch let error as CodexAppServerError { - #expect(error.response?.status == .failed) - #expect(error.response?.transcript.responseText == "Partial") - } - } - - @Test func responseStreamSerializesReasoningOptions() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let thread = CodexThread(id: "thread-1", client: client, router: router) - - _ = try await thread.streamResponse( - to: "Explain the patch.", - options: .init( - effort: .high, - summary: .detailed, - outputSchema: .object([ - "type": .string("object"), - "properties": .object(["summary": .object(["type": .string("string")])]), - ]), - personality: .pragmatic, - clientUserMessageID: "client-message-1" - ) - ) - - let request = try #require(await transport.recordedRequests().first) - let params = try JSONDecoder().decode( - AppServerAPI.Turn.Start.Params.self, - from: request.params - ) - #expect(params.effort == "high") - #expect(params.summary == "detailed") - #expect(params.outputSchema == .object([ - "type": .string("object"), - "properties": .object(["summary": .object(["type": .string("string")])]), - ])) - #expect(params.personality == "pragmatic") - #expect(params.clientUserMessageID == "client-message-1") - } - - @Test func messageDeltaLogEntriesUseUniqueEntryIDs() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - try await transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TurnDeltaParams(threadID: "thread-1", turnID: "turn-1", delta: "First") - ) - try await transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TurnDeltaParams(threadID: "thread-1", turnID: "turn-1", delta: "Second") - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-1") - ) - - let thread = CodexThread(id: "thread-1", client: client, router: router) - let logs = try await collect(thread.logEntries) - #expect(logs.map(\.id) == ["agent-message-delta:0", "agent-message-delta:1"]) - #expect(logs.compactMap(\.messageDelta).map(\.text) == ["First", "Second"]) - } - - @Test func reasoningNotificationsRouteAsTypedEventsLogsAndTranscript() async throws { - let transport = CodexAppServerTestTransport() - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - - try await transport.emitServerNotification( - method: "item/reasoning/summaryPartAdded", - params: ReasoningSummaryPartParams( - threadID: "thread-1", - turnID: "turn-1", - itemID: "reasoning-1", - summaryIndex: 0 - ) - ) - try await transport.emitServerNotification( - method: "item/reasoning/summaryTextDelta", - params: ReasoningSummaryDeltaParams( - threadID: "thread-1", - turnID: "turn-1", - itemID: "reasoning-1", - summaryIndex: 0, - delta: "Checking" - ) - ) - try await transport.emitServerNotification( - method: "item/reasoning/textDelta", - params: ReasoningTextDeltaParams( - threadID: "thread-1", - turnID: "turn-1", - itemID: "reasoning-1", - contentIndex: 1, - delta: "Raw trace" - ) - ) - try await transport.emitServerNotification( - method: "item/completed", - params: ThreadItemParams( - threadID: "thread-1", - turnID: "turn-1", - item: .init( - id: "reasoning-1", - type: "reasoning", - summary: ["Final summary"], - content: ["Final raw"] - ) - ) - ) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) - ) - try await transport.emitServerNotification( - method: "thread/closed", - params: ThreadIDParams(threadID: "thread-1") - ) - - let thread = CodexThread(id: "thread-1", client: client, router: router) - let events = try await collect(thread.events) - #expect( - events.contains { - if case .reasoningSummaryPartAdded(let part, let turnID) = $0 { - part.id == "reasoning-1:summary:0" && turnID == "turn-1" - } else { - false - } - }) - #expect( - events.contains { - if case .reasoningDelta(let delta, let turnID) = $0 { - delta.id == "reasoning-1:content:1" - && delta.delta == "Raw trace" - && turnID == "turn-1" - } else { - false - } - }) - - let logs = try await collect(thread.logEntries) - #expect(logs.contains { $0.id == "reasoning-1:summary:0" && $0.phase == .started }) - #expect( - logs.contains { - $0.reasoningDelta?.id == "reasoning-1:summary:0" - && $0.reasoningDelta?.delta == "Checking" - }) - #expect( - logs.contains { - $0.reasoningDelta?.id == "reasoning-1:content:1" - && $0.reasoningDelta?.delta == "Raw trace" - }) - - let transcripts = try await collect(thread.transcriptUpdates) - let finalTranscript = try #require(transcripts.last) - #expect(finalTranscript.items.map(\.id) == ["reasoning-1"]) - #expect(finalTranscript.items.first?.content == .reasoning( - .init(summary: ["Final summary"], content: ["Final raw"]) - )) - } - - @Test func modelAndConfigurationDecodeReasoningTypes() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueueJSON( - """ - { - "data": [ - { - "id": "gpt-5-codex", - "model": "gpt-5-codex", - "displayName": "GPT-5 Codex", - "hidden": false, - "supportedReasoningEfforts": [ - {"reasoningEffort": "medium", "description": "Balanced"}, - {"reasoningEffort": "xhigh", "description": "Maximum"} - ], - "defaultReasoningEffort": "xhigh", - "additionalSpeedTiers": [], - "isDefault": true - } - ], - "nextCursor": null - } - """, - for: "model/list" - ) - try await transport.enqueueJSON( - """ - { - "config": { - "model": "gpt-5-codex", - "model_reasoning_effort": "high", - "service_tier": "flex" - } - } - """, - for: "config/read" - ) - let client = AppServerClient(transport: transport) - let server = CodexAppServer( - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - - let models = try await server.models() - let reasoningEfforts = models.first?.supportedReasoningEfforts.map(\.reasoningEffort) - #expect(reasoningEfforts == [.medium, .xhigh]) - #expect(models.first?.defaultReasoningEffort == .xhigh) - - let configuration = try await server.configuration() - #expect(configuration.reasoningEffort == .high) - } - - @Test func responseStreamInterruptSendsTurnInterrupt() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse(to: "Run the slow checks.") - try await stream.interrupt() - - #expect( - await transport.recordedRequests().map(\.method) == [ - "turn/start", - "turn/interrupt", - ]) - let request = try #require(await transport.recordedRequests().last) - let params = try JSONDecoder().decode( - AppServerAPI.Turn.Interrupt.Params.self, from: request.params) - #expect(params.threadID == "thread-1") - #expect(params.turnID == "turn-1") - } - - @Test func responseStreamInterruptRetriesWithCurrentActiveTurnID() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-old", status: "running")), - for: "turn/start" - ) - await transport.enqueueFailure( - code: -32602, - message: "expected active turn id turn-old but found turn-new", - for: "turn/interrupt" - ) - try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse(to: "Run the slow checks.") - let interruption = try await stream.interrupt() - - #expect(interruption.threadID == "thread-1") - #expect(interruption.turnID == "turn-new") - let interruptRequests = await transport.recordedRequests().filter { - $0.method == "turn/interrupt" - } - let turnIDs = try interruptRequests.map { request in - try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID - } - #expect(turnIDs == ["turn-old", "turn-new"]) - } - - @Test func responseStreamSteerSubmitsInputToCurrentTurn() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - try await transport.enqueue( - AppServerAPI.Turn.Steer.Response(turnID: "turn-1"), - for: "turn/steer" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse(to: "Run the slow checks.") - try await stream.steer(with: "Prefer the smallest fix.") - - #expect( - await transport.recordedRequests().map(\.method) == [ - "turn/start", - "turn/steer", - ]) - let request = try #require(await transport.recordedRequests().last) - let params = try JSONDecoder().decode( - AppServerAPI.Turn.Steer.Params.self, from: request.params) - #expect(params.threadID == "thread-1") - #expect(params.expectedTurnID == "turn-1") - #expect(params.input == [.text("Prefer the smallest fix.")]) - } - - @Test func responseStreamQueueStartsFollowUpAfterCurrentResponse() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-2", status: "running")), - for: "turn/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse(to: "First request.") - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) - ) - - _ = try await stream.submit( - "Second request.", - mode: .queueAfterCurrentResponse, - options: .init(model: "gpt-5") - ) - - #expect( - await transport.recordedRequests().map(\.method) == [ - "turn/start", - "turn/start", - ]) - let request = try #require(await transport.recordedRequests().last) - let params = try JSONDecoder().decode( - AppServerAPI.Turn.Start.Params.self, from: request.params) - #expect(params.threadID == "thread-1") - #expect(params.input == [.text("Second request.")]) - #expect(params.model == "gpt-5") - } - - @Test func responseStreamInterruptStartsFollowUpAfterServerTerminalEvent() async throws { - let transport = CodexAppServerTestTransport() - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), - for: "turn/start" - ) - try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") - try await transport.enqueue( - AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-2", status: "running")), - for: "turn/start" - ) - let client = AppServerClient(transport: transport) - let router = CodexAppServerNotificationRouter(client: client) - await router.start() - await transport.waitForNotificationStreamCount(1) - let thread = CodexThread( - id: "thread-1", - client: client, - router: router - ) - - let stream = try await thread.streamResponse(to: "Long request.") - let followUpTask = Task { - try await stream.submit( - "Use the shorter path.", - mode: .interruptCurrentResponse - ) - } - await transport.waitForRequestCount(2) - try await transport.emitServerNotification( - method: "turn/completed", - params: TurnCompletedParams(turn: .init(id: "turn-1", status: "interrupted")) - ) - _ = try await followUpTask.value - - #expect( - await transport.recordedRequests().map(\.method) == [ - "turn/start", - "turn/interrupt", - "turn/start", - ]) - let interruptRequest = try #require(await transport.recordedRequests().dropLast().last) - let interruptParams = try JSONDecoder().decode( - AppServerAPI.Turn.Interrupt.Params.self, from: interruptRequest.params) - #expect(interruptParams.threadID == "thread-1") - #expect(interruptParams.turnID == "turn-1") - - let followUpRequest = try #require(await transport.recordedRequests().last) - let followUpParams = try JSONDecoder().decode( - AppServerAPI.Turn.Start.Params.self, from: followUpRequest.params) - #expect(followUpParams.threadID == "thread-1") - #expect(followUpParams.input == [.text("Use the shorter path.")]) - } -} - -private func collect( - _ sequence: Sequence -) async throws -> [Sequence.Element] { - var elements: [Sequence.Element] = [] - for try await element in sequence { - elements.append(element) - } - return elements -} - -private struct TurnIDParams: Encodable, Sendable { - var turnID: String - - enum CodingKeys: String, CodingKey { - case turnID = "turnId" - } -} - -private struct ThreadIDParams: Encodable, Sendable { - var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } -} - -private struct ReviewWarningParams: Encodable, Sendable { - var message: String -} - -private struct ReviewErrorParams: Encodable, Sendable { - var turnID: String - var message: String - - enum CodingKeys: String, CodingKey { - case turnID = "turnId" - case message - } -} - -private struct TurnDeltaParams: Encodable, Sendable { - var threadID: String? = nil - var turnID: String - var delta: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case delta - } -} - -private struct TurnCompletedParams: Encodable, Sendable { - var turn: AppServerAPI.Turn.Payload -} - -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? - var command: String? - var aggregatedOutput: String? - var status: String? - var path: String? - var tool: String? - var summary: [String]? - var content: [String]? - - init( - id: String, - type: String, - text: String? = nil, - phase: String? = nil, - command: String? = nil, - aggregatedOutput: String? = nil, - status: String? = nil, - path: String? = nil, - tool: String? = nil, - summary: [String]? = nil, - content: [String]? = nil - ) { - self.id = id - self.type = type - self.text = text - self.phase = phase - self.command = command - self.aggregatedOutput = aggregatedOutput - self.status = status - self.path = path - self.tool = tool - self.summary = summary - self.content = content - } - } -} - -private struct ReasoningSummaryPartParams: Encodable, Sendable { - var threadID: String - var turnID: String - var itemID: String - var summaryIndex: Int - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case itemID = "itemId" - case summaryIndex - } -} - -private struct ReasoningSummaryDeltaParams: Encodable, Sendable { - var threadID: String - var turnID: String - var itemID: String - var summaryIndex: Int - var delta: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case itemID = "itemId" - case summaryIndex - case delta - } -} - -private struct ReasoningTextDeltaParams: Encodable, Sendable { - var threadID: String - var turnID: String - var itemID: String - var contentIndex: Int - var delta: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case itemID = "itemId" - case contentIndex - case delta - } -} - -private struct TokenUsageParams: Encodable, Sendable { - var threadID: String - var turnID: String - var tokenUsage: TokenUsage - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case tokenUsage - } - - struct TokenUsage: Encodable, Sendable { - var total: Breakdown - var modelContextWindow: Int? - } - - struct Breakdown: Encodable, Sendable { - var cachedInputTokens: Int = 0 - var inputTokens: Int - var outputTokens: Int - var reasoningOutputTokens: Int = 0 - var totalTokens: Int - } -} - -private func eventually( - attempts: Int = 50, - _ condition: () async -> Bool -) async -> Bool { - for _ in 0.. 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 includesDomainEvents == false { + switch event { + case .domainEvents, + .suppressNextLegacyTimelineProjection, + .suppressNextTerminalFailureLogTimelineProjection: + continue + case .started, + .message, + .messageDelta, + .log, + .logEntry, + .completed, + .failed, + .cancelled: + break + } } if case .suppressNextTerminalFailureLogTimelineProjection = event { continue @@ -84,999 +46,50 @@ private struct BackendReviewEventSequence: AsyncSequence { } } -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) -} - -private func makeBackend( - transport: FakeJSONRPCTransport -) async throws -> AppServerCodexReviewBackend { - let appServer = try await CodexAppServer.testing(transport: transport) - return AppServerCodexReviewBackend(appServer: appServer) -} - -@Suite("app-server client") +@Suite("AppServerClientTests") 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"], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - ) - - #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""#)) - 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", - ], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - ) - - #expect(configuration.executable == codex.path) - #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments()) - } - - @Test func processTransportConfigurationIncludesSessionSourceWithoutProbing() 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 - if [ "$1" = "app-server" ] && [ "$2" = "--help" ]; then - touch "\(probed.path)" - 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"], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - ) - - #expect(configuration.executable == codex.path) - #expect(configuration.arguments == CodexAppServerExecutable.appServerArguments()) - #expect(configuration.arguments.contains("--session-source")) - #expect(FileManager.default.fileExists(atPath: probed.path) == false) - } - - @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"], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - ) - - #expect(configuration.executable == codex.path) - #expect(configuration.arguments == ["custom", "argument"]) - #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 processTransportConfigurationUsesConfiguredCodexHome() throws { - let codexHomeURL = URL(fileURLWithPath: "/tmp/app-server-home", isDirectory: true) - let configuration = AppServerProcessTransport.Configuration( - environment: [ - "PATH": "/usr/bin", - "HOME": "/tmp/review-home", - "CODEX_SQLITE_HOME": "/tmp/main-codex-sqlite", - ], - codexHomeURL: codexHomeURL - ) - - #expect(configuration.codexHomeURL == codexHomeURL) - #expect(configuration.environment["CODEX_HOME"] == "/tmp/app-server-home") - #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/app-server-home/sqlite") - } - - @Test func processTransportConfigurationOverridesCodexHomeEnvironment() throws { - let codexHomeURL = URL(fileURLWithPath: "/tmp/configured-codex-home", isDirectory: true) - 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", - ], - codexHomeURL: codexHomeURL - ) - - #expect(configuration.codexHomeURL == codexHomeURL) - #expect(configuration.environment["CODEX_HOME"] == "/tmp/configured-codex-home") - #expect(configuration.environment["CODEX_SQLITE_HOME"] == "/tmp/configured-codex-home/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", - ], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - )) - 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", - ], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - )) - - 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", - ], - codexHomeURL: directory.appendingPathComponent("codex-home", isDirectory: true) - )) - - _ = 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 thread = CodexThread( - id: "thread-1", - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - let control = AppServerReviewControl() - - control.recordThreadStarted(thread) - 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 thread = CodexThread( - id: "thread-1", - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - let control = AppServerReviewControl() - - control.recordReviewStarted(thread: thread, 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 thread = CodexThread( - id: "thread-1", - client: client, - router: CodexAppServerNotificationRouter(client: client) - ) - let control = AppServerReviewControl() - - control.recordReviewStarted(thread: thread, 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 == "CodexAppServerKit") - #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") - } - - @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(bedrockResponse.account?.id == .init("amazon-bedrock")) - #expect(bedrockResponse.account?.kind == .amazonBedrock) - #expect(bedrockResponse.account?.label == "Amazon Bedrock") - } - - @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 = try await makeBackend(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 = try await makeBackend(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 = try await makeBackend(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 = try await makeBackend(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 = try await makeBackend(transport: transport) - - let settings = try await backend.readSettings() - - #expect(settings.model == nil) - #expect(settings.fallbackModel == "gpt-5") - } - - @Test func settingsApplyWritesConfigurationThroughAppServer() async throws { - let transport = FakeJSONRPCTransport() - try await enqueueInitialize(transport) - try await transport.enqueueJSON(#"{"status":"ok"}"#, for: "config/batchWrite") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init( - model: "gpt-5", - reviewModel: "gpt-5.5", - modelReasoningEffort: "xhigh", - serviceTier: "flex" - )), - for: "config/read" - ) - try await transport.enqueue( - AppServerAPI.Model.List.Response(data: [makeModelCatalogItem(model: "gpt-5.5")]), - for: "model/list" - ) - let backend = try await makeBackend(transport: transport) - - let settings = try await backend.applySettings(.init( - model: "gpt-5.5", - reasoningEffort: "xhigh", - serviceTier: "flex" - )) - - let batchWrite = try #require(await transport.recordedRequests().first { - $0.method == "config/batchWrite" - }) - let params = try #require(JSONSerialization.jsonObject( - with: batchWrite.params - ) as? [String: Any]) - let edits = try #require(params["edits"] as? [[String: Any]]) - #expect(edits.compactMap { $0["keyPath"] as? String } == [ - "review_model", - "model_reasoning_effort", - "service_tier", - ]) - #expect(settings.model == "gpt-5.5") - #expect(settings.reasoningEffort == "xhigh") - #expect(settings.serviceTier == "flex") - } - - @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 = try await makeBackend(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) + @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 _: AppServerAPI.Review.Start.Response = try await client.send(AppServerAPI.Review.Start.Request( - params: .init(threadID: "thread-1", target: .baseBranch("main")) - )) + let attempt = try await backend.startReview(makeReviewStart(target: .uncommittedChanges)) - 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") - } + #expect(attempt.run.threadID == "thread-1") + #expect(attempt.run.turnID == "turn-1") + #expect(attempt.run.reviewThreadID == "thread-1") - @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 = try await makeBackend(transport: transport) + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == ["initialize", "thread/start", "review/start"]) - _ = try await backend.startReview(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) - )) + 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 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) + 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 appServerBackendConsumesTypedReviewSessionStream() async throws { + @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(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) - - #expect(attempt.threadID == "thread-1") - #expect(attempt.turnID == "turn-1") - #expect(attempt.reviewThreadID == "review-thread") - let storedAttempt = await backend.reviewAttemptForTesting(attempt.run) - #expect(storedAttempt.threadID == "thread-1") - #expect(storedAttempt.turnID == "turn-1") - #expect(storedAttempt.reviewThreadID == "review-thread") - - 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 threadStart.decodeParams(AppServerAPI.Thread.Start.Params.self) - #expect(threadParams.cwd == "/tmp/project") - #expect(threadParams.model == "gpt-5") - #expect(threadParams.approvalPolicy == "never") - #expect(threadParams.permissions == .profileID(":danger-full-access")) - #expect(threadParams.sessionStartSource == .startup) - #expect(threadParams.threadSource == .user) - - let reviewStart = try #require(requests.first { $0.method == "review/start" }) - let reviewParams = try reviewStart.decodeParams(AppServerAPI.Review.Start.Params.self) - #expect(reviewParams.threadID == "thread-1") - #expect(reviewParams.target == .baseBranch("main")) + let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) + var iterator = eventSequence(attempt, includingDomainEvents: true).makeAsyncIterator() try await runtime.transport.emitServerNotification( method: "item/completed", @@ -1105,289 +118,80 @@ struct AppServerClientTests { method: "turn/completed", params: TestTurnNotification( threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) - ) - - var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() - #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() else { - Issue.record("expected typed command domain event") - return - } - #expect(commandSuppressionCount == 2) - guard case .itemCompleted(let commandSeed) = try #require(commandDomainEvents.first) else { - Issue.record("expected completed command seed") - return - } - #expect(commandSeed.id.rawValue == "cmd-1") - guard case .command(let command) = commandSeed.content else { - Issue.record("expected command content") - return - } - #expect(command.command == "swift test") - #expect(command.output == "passed") - - #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: "passed", - groupID: "cmd-1", - replacesGroup: true, - metadata: .init( - sourceType: "commandExecution", - status: "completed", - itemID: "cmd-1", - command: "swift test", - commandStatus: "completed" - ) - )) - guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() else { - Issue.record("expected typed message delta domain event") - return - } - #expect(messageSuppressionCount == 1) - guard case .textDelta(let itemID, _, let family, _, let delta) = try #require(messageDomainEvents.first) else { - Issue.record("expected message text delta") - return - } - #expect(itemID.rawValue == "msg-1") - #expect(family == .message) - #expect(delta == "Looks good.") - #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) - #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) - } - - @Test func appServerBackendDefersTurnCompletionUntilReviewExitItem() 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(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) - - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) - ) - try await runtime.transport.emitServerNotification( - method: "item/completed", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - type: "agentMessage", - id: "msg-1", - text: "Looks good." - ) - ) - ) - try await runtime.transport.emitServerNotification( - method: "item/completed", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - type: "exitedReviewMode", - id: "review-exit", - review: "Looks good." - ) - ) - ) - - var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() - #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let messageDomainEvents, _) = try await iterator.next() else { - Issue.record("expected final message domain event before completion") - return - } - guard case .itemCompleted(let messageSeed) = try #require(messageDomainEvents.first) else { - Issue.record("expected final message item completion") - return - } - #expect(messageSeed.id.rawValue == "msg-1") - guard case .logEntry(let kind, let text, let groupID, let replacesGroup, _) = try await iterator.next() else { - Issue.record("expected final message legacy log") - return - } - #expect(kind == .agentMessage) - #expect(text == "Looks good.") - #expect(groupID == "msg-1") - #expect(replacesGroup == true) - #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) - } - - @Test func appServerBackendPreservesKnownReviewLogNotificationsFromSessionStream() 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(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) - var iterator = await eventSequence(backend, attempt, includingDomainEvents: true).makeAsyncIterator() - - try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", - params: TestDeltaNotification( - threadID: "review-thread", - turnID: "turn-1", - itemID: "cmd-1", - delta: "building-" - ) - ) - try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", - params: TestDeltaNotification( - threadID: "review-thread", - turnID: "turn-1", - itemID: "cmd-1", - delta: "done" - ) - ) - try await runtime.transport.emitServerNotification( - method: "item/fileChange/patchUpdated", - params: TestDeltaNotification( - threadID: "review-thread", - turnID: "turn-1", - itemID: "patch-1", - delta: "Sources/File.swift\n+ change" - ) - ) - try await runtime.transport.emitServerNotification( - method: "item/completed", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init(type: "exitedReviewMode", id: "review-exit", review: "Done") - ) - ) - - #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let commandDomainEvents, _) = try await iterator.next() else { - Issue.record("expected command output domain event") - return - } - guard case .itemUpdated(let commandSeed) = try #require(commandDomainEvents.first) else { - Issue.record("expected command item update") - return - } - #expect(commandSeed.id.rawValue == "cmd-1") - guard case .command(let command) = commandSeed.content else { - Issue.record("expected command content") - return - } - #expect(command.output == "building-") - - guard case .logEntry(let commandKind, let commandText, let commandGroupID, let commandReplacesGroup, let commandMetadata) = try await iterator.next() else { - Issue.record("expected command output legacy log") - return - } - #expect(commandKind == .commandOutput) - #expect(commandText == "building-") - #expect(commandGroupID == "cmd-1") - #expect(commandReplacesGroup == false) - #expect(commandMetadata?.sourceType == "commandExecution") - #expect(commandMetadata?.title == "Command output") - - guard case .domainEvents(let secondCommandDomainEvents, _) = try await iterator.next() else { - Issue.record("expected second command output domain event") - return - } - guard case .itemUpdated(let secondCommandSeed) = try #require(secondCommandDomainEvents.first) else { - Issue.record("expected second command item update") - return - } - guard case .command(let secondCommand) = secondCommandSeed.content else { - Issue.record("expected second command content") - return - } - #expect(secondCommand.output == "building-done") + turn: .init(id: "turn-1", status: "completed") + ) + ) - guard case .logEntry(let secondCommandKind, let secondCommandText, let secondCommandGroupID, let secondCommandReplacesGroup, let secondCommandMetadata) = try await iterator.next() else { - Issue.record("expected second command output legacy log") + #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() else { + Issue.record("expected typed command domain event") return } - #expect(secondCommandKind == .commandOutput) - #expect(secondCommandText == "done") - #expect(secondCommandGroupID == "cmd-1") - #expect(secondCommandReplacesGroup == false) - #expect(secondCommandMetadata?.sourceType == "commandExecution") - #expect(secondCommandMetadata?.title == "Command output") - - guard case .domainEvents(let fileDomainEvents, _) = try await iterator.next() else { - Issue.record("expected file change domain event") + #expect(commandSuppressionCount == 2) + guard case .itemCompleted(let commandSeed) = try #require(commandDomainEvents.first) else { + Issue.record("expected completed command seed") return } - guard case .itemUpdated(let fileSeed) = try #require(fileDomainEvents.first) else { - Issue.record("expected file change item update") + #expect(commandSeed.id.rawValue == "cmd-1") + guard case .command(let command) = commandSeed.content else { + Issue.record("expected command content") return } - #expect(fileSeed.id.rawValue == "patch-1") - guard case .fileChange(let fileChange) = fileSeed.content else { - Issue.record("expected file change content") + #expect(command.command == "swift test") + #expect(command.output == "passed") + #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: "passed", + groupID: "cmd-1", + replacesGroup: true, + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd-1", + command: "swift test", + commandStatus: "completed" + ) + )) + guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() else { + Issue.record("expected typed message delta domain event") return } - #expect(fileChange.output == "Sources/File.swift\n+ change") - - guard case .logEntry(let fileKind, let fileText, let fileGroupID, _, let fileMetadata) = try await iterator.next() else { - Issue.record("expected file change legacy log") + #expect(messageSuppressionCount == 1) + guard case .textDelta(let itemID, _, let family, _, let delta) = try #require(messageDomainEvents.first) else { + Issue.record("expected message text delta") return } - #expect(fileKind == .commandOutput) - #expect(fileText == "Sources/File.swift\n+ change") - #expect(fileGroupID == "patch-1") - #expect(fileMetadata?.sourceType == "fileChange") - #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Done")) + #expect(itemID.rawValue == "msg-1") + #expect(family == .message) + #expect(delta == "Looks good.") + #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) + #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } - @Test func appServerBackendScopesCommandOutputDeltasByReviewThread() async throws { + @Test func backendScopesCommandOutputDeltasByReviewThread() 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 firstAttempt = try await backend.startReview(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) + let firstAttempt = try await backend.startReview(makeReviewStart(jobID: "job-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(.init( - jobID: "job-2", - sessionID: "session-2", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) + let secondAttempt = try await backend.startReview(makeReviewStart(jobID: "job-2", sessionID: "session-2")) try await runtime.transport.emitServerNotification( method: "item/commandExecution/outputDelta", @@ -1408,7 +212,7 @@ struct AppServerClientTests { ) ) - var firstIterator = await eventSequence(backend, firstAttempt, includingDomainEvents: true).makeAsyncIterator() + var firstIterator = eventSequence(firstAttempt, includingDomainEvents: true).makeAsyncIterator() #expect(try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) guard case .domainEvents(let firstDomainEvents, _) = try await firstIterator.next(), case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), @@ -1419,7 +223,7 @@ struct AppServerClientTests { } #expect(firstCommand.output == "first") - var secondIterator = await eventSequence(backend, secondAttempt, includingDomainEvents: true).makeAsyncIterator() + var secondIterator = eventSequence(secondAttempt, includingDomainEvents: true).makeAsyncIterator() #expect(try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) guard case .domainEvents(let secondDomainEvents, _) = try await secondIterator.next(), case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), @@ -1431,346 +235,55 @@ struct AppServerClientTests { #expect(secondCommand.output == "second") } - @Test func appServerBackendInterruptUsesTypedSession() async throws { + @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() 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: "turn/interrupt") - await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) - - let attempt = try await backend.startReview(.init( - jobID: "job-1", - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - model: "gpt-5" - )) - try await backend.interruptReview(attempt, reason: .init(message: "Stop")) - - let interrupt = try #require(await runtime.transport.recordedRequests().last) - #expect(interrupt.method == "turn/interrupt") - let params = try interrupt.decodeParams(AppServerAPI.Turn.Interrupt.Params.self) - #expect(params.threadID == "review-thread") - #expect(params.turnID == "turn-1") - } - - @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 = try await makeBackend(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 = try await makeBackend(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 backendInterruptUsesDetachedReviewThreadBeforeStartedEvent() 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 = try await makeBackend(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 appServerUsesSingleNotificationStreamForConcurrentReviews() 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 = try await makeBackend(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 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 = try await makeBackend(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 backendRecoverReviewUsesDetachedReviewThreadBeforeStartedEvent() 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 = try await makeBackend(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 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 = try await makeBackend(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.") + try await runtime.transport.enqueueJSON( + #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, + for: "thread/resume" ) - 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 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 = try await makeBackend(transport: transport) - - 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) - )) - } - - let methods = await transport.recordedRequests().map(\.method) - #expect(methods == [ - "initialize", - "thread/start", - "review/start", - "thread/backgroundTerminals/clean", - "thread/unsubscribe", - "thread/delete", - ]) - } + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueEmpty(for: "thread/delete") + let backend = AppServerCodexReviewBackend(appServer: runtime.server) - 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)) - } - return true - } + let attempt = try await backend.startReview(makeReviewStart()) + await backend.cleanupReview(attempt.run) - 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)) - } - return true + 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"]) } } -private func enqueueInitialize(_ transport: FakeJSONRPCTransport) async throws { - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") +private func eventSequence( + _ attempt: BackendReviewAttempt, + includingDomainEvents: Bool = false +) -> BackendReviewEventSequence { + BackendReviewEventSequence(mailbox: attempt.events, includesDomainEvents: includingDomainEvents) } -private func makeModelCatalogItem( - model: String, - isDefault: Bool = false -) -> CodexModel { +private func makeReviewStart( + jobID: String = "job-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 + jobID: jobID, + 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" @@ -1778,44 +291,9 @@ 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 {} - -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" - } } private struct TestDeltaNotification: Encodable, Sendable { @@ -1823,133 +301,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 } } @@ -1960,24 +329,9 @@ private struct TestItem: Encodable, Sendable { 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, @@ -1986,24 +340,9 @@ private struct TestItem: Encodable, Sendable { 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 @@ -2011,118 +350,8 @@ private struct TestItem: Encodable, Sendable { 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 } } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index b8aa1771..86c34b35 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -3,12 +3,91 @@ import AppKit import AuthenticationServices import Testing 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, + transport: FakeCodexAppServerTransport + ) -> CodexReviewStore { + makeLiveStoreForTesting( + environment: environment, + runtimePreferences: runtimePreferences, + nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, + webAuthenticationSessionFactory: webAuthenticationSessionFactory, + externalURLOpener: externalURLOpener, + mcpPortOwnerResolver: mcpPortOwnerResolver, + mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, + shutdownCleanupTimeout: shutdownCleanupTimeout, + networkMonitor: networkMonitor, + networkRecoveryPolicy: networkRecoveryPolicy, + 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 + ) -> 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 -> 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, + 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 { @@ -170,7 +249,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( @@ -196,7 +275,7 @@ struct CodexReviewHostTests { @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( @@ -263,7 +342,7 @@ struct CodexReviewHostTests { }, transportFactory: { _ in didLaunchAppServer = true - return FakeJSONRPCTransport() + return FakeCodexAppServerTransport() } ) @@ -300,7 +379,7 @@ struct CodexReviewHostTests { }, transportFactory: { _ in didLaunchAppServer = true - return FakeJSONRPCTransport() + return FakeCodexAppServerTransport() } ) @@ -338,7 +417,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 { @@ -380,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 { @@ -402,7 +481,7 @@ struct CodexReviewHostTests { } @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")), @@ -433,8 +512,8 @@ struct CodexReviewHostTests { ]) } - @Test func liveStoreCancelsLoginWhenAuthenticationSessionIsClosed() async throws { - let transport = FakeJSONRPCTransport() + @Test func liveStoreOpensBrowserLoginWhenNativeCompletionIsUnavailable() 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( @@ -450,8 +529,8 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) - try await transport.enqueue(AppServerAPI.Account.Login.Cancel.Response(), for: "account/login/cancel") let sessions = FakeWebAuthenticationSessions() + let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": try temporaryHome().path], nativeAuthenticationConfiguration: .init( @@ -460,21 +539,22 @@ struct CodexReviewHostTests { presentationAnchorProvider: { NSWindow() } ), webAuthenticationSessionFactory: sessions.makeSession, + externalURLOpener: externalURLOpener.open, transport: transport ) await store.start(forceRestartIfNeeded: true) 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(sessions.createdSessionCount == 0) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) #expect(store.auth.selectedAccount == nil) + 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", @@ -482,12 +562,12 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", - "account/login/cancel", ]) + await store.stop() } - @Test func liveStoreCancelsLoginWhenAuthenticationSessionSetupFails() async throws { - let transport = FakeJSONRPCTransport() + @Test func liveStoreDoesNotCreateNativeAuthenticationSessionWithoutCompletionAPI() 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( @@ -503,7 +583,8 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) - 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( @@ -512,30 +593,35 @@ 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(didCreateNativeSession == false) + #expect(store.auth.isAuthenticating) + #expect(failedMessage(from: store.auth.phase) == nil) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) #expect(await transport.recordedRequests().map(\.method) == [ "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( @@ -556,7 +642,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( @@ -579,7 +665,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") @@ -631,16 +717,14 @@ 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 == []) + #expect(sessions.createdSessionCount == 0) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) 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") + #expect(loginParams.nativeWebAuthentication == nil) try await authTransport.emitServerNotification( method: "account/updated", params: EmptyResponse() @@ -703,7 +787,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")), @@ -722,7 +806,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")), @@ -768,7 +852,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( @@ -784,7 +868,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" @@ -796,7 +879,7 @@ struct CodexReviewHostTests { )), for: "account/rateLimits/read" ) - let sessions = FakeWebAuthenticationSessions() + let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], nativeAuthenticationConfiguration: .init( @@ -804,7 +887,8 @@ struct CodexReviewHostTests { browserSessionPolicy: .ephemeral, presentationAnchorProvider: { NSWindow() } ), - webAuthenticationSessionFactory: sessions.makeSession, + webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, + externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return transport @@ -816,9 +900,15 @@ 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")!) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + 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 @@ -836,13 +926,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 liveStoreAddAccountFallsBackToBrowserWhenNativeSessionSetupWouldFail() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( @@ -850,7 +939,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")), @@ -868,7 +957,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( @@ -878,8 +967,8 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) - 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( @@ -890,6 +979,7 @@ struct CodexReviewHostTests { webAuthenticationSessionFactory: { _, _, _, _ in throw CodexReviewAPI.Error.io("Authentication presentation failed.") }, + externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -903,25 +993,24 @@ 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(failedMessage(from: store.auth.phase) == nil) #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - await waitUntil { - FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false - } + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + await store.stop() #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) #expect(await loginTransport.recordedRequests().map(\.method) == [ "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")), @@ -984,7 +1073,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")), @@ -1006,7 +1095,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")), @@ -1063,7 +1152,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")), @@ -1087,7 +1176,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( @@ -1140,7 +1229,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")), @@ -1178,7 +1267,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") @@ -1228,21 +1317,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") @@ -1281,15 +1367,10 @@ struct CodexReviewHostTests { #expect(await transport.recordedRequests().map(\.method).contains("turn/interrupt")) } - @Test func liveStoreStopDrainsRecoveryWaitingWorkerCleanupBeforeDroppingBackend() async throws { + @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutLegacyCleanup() 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( @@ -1334,26 +1415,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("turn/interrupt")) #expect(methods.contains("thread/delete")) } @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( @@ -1369,7 +1443,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 @@ -1393,7 +1467,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")), @@ -1404,7 +1478,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( @@ -1414,7 +1488,7 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) - let sessions = FakeWebAuthenticationSessions() + let externalURLOpener = FakeExternalURLOpener() var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1423,7 +1497,8 @@ struct CodexReviewHostTests { browserSessionPolicy: .ephemeral, presentationAnchorProvider: { NSWindow() } ), - webAuthenticationSessionFactory: sessions.makeSession, + webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, + externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -1437,30 +1512,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 == [testAuthenticationURL]) - 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 { @@ -1474,7 +1540,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")), @@ -1494,7 +1560,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( @@ -1543,7 +1609,7 @@ struct CodexReviewHostTests { transportFactory: { codexHomeURL in isolatedCodexHomeURL = codexHomeURL try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) - return FakeJSONRPCTransport() + return FakeCodexAppServerTransport() } ) @@ -1562,7 +1628,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")), @@ -1573,10 +1639,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? @@ -1606,7 +1673,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( @@ -1614,7 +1681,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")), @@ -1625,7 +1692,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( @@ -1635,11 +1702,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], @@ -1648,7 +1711,8 @@ struct CodexReviewHostTests { browserSessionPolicy: .ephemeral, presentationAnchorProvider: { NSWindow() } ), - webAuthenticationSessionFactory: sessions.makeSession, + webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, + externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -1661,18 +1725,26 @@ 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 == [testAuthenticationURL]) + 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", ]) } @@ -1685,7 +1757,7 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transport: FakeJSONRPCTransport() + transport: FakeCodexAppServerTransport() ) store.auth.applyPersistedAccountStates([savedAccountPayload(from: account)]) @@ -1711,7 +1783,7 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transport: FakeJSONRPCTransport() + transport: FakeCodexAppServerTransport() ) store.auth.applyPersistedAccountStates([ savedAccountPayload(from: dotAccount), @@ -1765,6 +1837,346 @@ 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 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() {} + } + } + + enum Complete { + struct Response: Codable, Equatable, Sendable { + init() {} + } + } + } + } +} + @MainActor private final class FakeWebAuthenticationSessions { private var session: FakeWebAuthenticationSession? From 3e159594fd6a03f1bdd4399e15bcb4c883a85e8a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:12:27 +0900 Subject: [PATCH 055/359] fix(app-server): use external CodexKit auth settings APIs --- .../AppServerCodexReviewBackend.swift | 33 +++-- .../CodexReviewHostTests.swift | 137 ++++++++++++------ 2 files changed, 115 insertions(+), 55 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index dce5f709..31c3a829 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -45,14 +45,17 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot { - guard change.updatesModel == false, - change.updatesReasoningEffort == false, - change.updatesServiceTier == false - else { - throw CodexReviewAPI.Error.io( - "Updating app-server review settings requires a public CodexKit configuration update API." - ) + 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() } @@ -68,9 +71,13 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { try await appServer.rateLimits() } - package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { - let handle = try await appServer.loginChatGPT() - return try handle.backendChallenge(nativeWebAuthenticationCallbackScheme: nil) + package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { + let handle = try await appServer.loginChatGPT( + callbackURLScheme: request.nativeWebAuthenticationCallbackScheme + ) + return try handle.backendChallenge( + nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme + ) } package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { @@ -82,9 +89,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { guard let url = URL(string: callbackURL) else { throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") } - _ = url - throw CodexReviewAPI.Error.io( - "Completing native ChatGPT authentication callbacks requires a public CodexKit login completion API." + try await appServer.completeLogin( + id: .init(rawValue: response.challengeID), + callbackURL: url ) } return try await readAuth() diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 86c34b35..9e467964 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -512,7 +512,7 @@ struct CodexReviewHostTests { ]) } - @Test func liveStoreOpensBrowserLoginWhenNativeCompletionIsUnavailable() async throws { + @Test func liveStoreUsesNativeLoginCompletionWhenCallbackIsAvailable() 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") @@ -529,6 +529,21 @@ 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" + ) + 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( @@ -545,16 +560,25 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await store.addAccount() - await transport.waitForRequestCount(5) + let session = await sessions.waitForSession() + await session.waitUntilWaitingForCallback() #expect(store.auth.isAuthenticating) - #expect(sessions.createdSessionCount == 0) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - #expect(store.auth.selectedAccount == nil) + session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) + await transport.waitForRequestCount(7) + #expect(sessions.createdSessionCount == 1) + #expect(externalURLOpener.openedURLs == []) + await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" } 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) + #expect(loginParams.nativeWebAuthentication?.callbackURLScheme == "lynnpd.CodexReviewMonitor.auth") + let completeRequest = try #require(await transport.recordedRequests().first { + $0.method == "account/login/complete" + }) + let completeParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Complete.Params.self, from: completeRequest.params) + #expect(completeParams.loginID == "login-1") + #expect(completeParams.callbackURL == "lynnpd.CodexReviewMonitor.auth://callback?code=abc") let methods = await transport.recordedRequests().map(\.method) #expect(methods == [ "initialize", @@ -562,11 +586,14 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", + "account/login/complete", + "account/read", + "account/rateLimits/read", ]) await store.stop() } - @Test func liveStoreDoesNotCreateNativeAuthenticationSessionWithoutCompletionAPI() async throws { + @Test func liveStoreReportsNativeAuthenticationSessionCreationFailure() 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") @@ -583,6 +610,10 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) + try await transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) var didCreateNativeSession = false let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( @@ -602,18 +633,19 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await store.addAccount() - await transport.waitForRequestCount(5) + await transport.waitForRequestCount(6) - #expect(didCreateNativeSession == false) - #expect(store.auth.isAuthenticating) - #expect(failedMessage(from: store.auth.phase) == nil) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + #expect(didCreateNativeSession) + #expect(store.auth.isAuthenticating == false) + #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") + #expect(externalURLOpener.openedURLs == []) #expect(await transport.recordedRequests().map(\.method) == [ "initialize", "account/read", "config/read", "model/list", "account/login/start", + "account/login/cancel", ]) await store.stop() } @@ -652,6 +684,10 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) + try await authTransport.enqueue( + AppServerAPI.Account.Login.Complete.Response(), + for: "account/login/complete" + ) try await authTransport.enqueue( AppServerAPI.Account.Read.Response( account: .init(email: "new@example.com", planType: "plus") @@ -718,26 +754,17 @@ struct CodexReviewHostTests { await store.addAccount() await authTransport.waitForNotificationStreamCount(1) - #expect(sessions.createdSessionCount == 0) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + let session = await sessions.waitForSession() + await session.waitUntilWaitingForCallback() + session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) + #expect(sessions.createdSessionCount == 1) + #expect(externalURLOpener.openedURLs == []) 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 == nil) - try await authTransport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() - ) - try await authTransport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-2", success: true) - ) - try await authTransport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() - ) - await authTransport.waitForRequestCount(4) + #expect(loginParams.nativeWebAuthentication?.callbackURLScheme == "lynnpd.CodexReviewMonitor.auth") + await authTransport.waitForRequestCount(5) await waitUntil { store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" } && store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25 @@ -752,6 +779,7 @@ struct CodexReviewHostTests { #expect(await authTransport.recordedRequests().map(\.method) == [ "initialize", "account/login/start", + "account/login/complete", "account/read", "account/rateLimits/read", ]) @@ -868,6 +896,10 @@ 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" @@ -880,6 +912,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) let externalURLOpener = FakeExternalURLOpener() + let sessions = FakeWebAuthenticationSessions() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], nativeAuthenticationConfiguration: .init( @@ -887,7 +920,7 @@ struct CodexReviewHostTests { browserSessionPolicy: .ephemeral, presentationAnchorProvider: { NSWindow() } ), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, + webAuthenticationSessionFactory: sessions.makeSession, externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) @@ -900,7 +933,9 @@ struct CodexReviewHostTests { #expect(store.auth.persistedAccounts.map(\.accountKey) == ["existing@example.com"]) await store.addAccount() - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + let session = await sessions.waitForSession() + await session.waitUntilWaitingForCallback() + session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) try await transport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification(loginID: "login-new", success: true) @@ -926,12 +961,14 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", + "account/login/complete", "account/read", "account/rateLimits/read", ]) + #expect(externalURLOpener.openedURLs == []) } - @Test func liveStoreAddAccountFallsBackToBrowserWhenNativeSessionSetupWouldFail() async throws { + @Test func liveStoreAddAccountReportsNativeSessionSetupFailure() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( @@ -967,6 +1004,10 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) + try await loginTransport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) var isolatedCodexHomeURL: URL? let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( @@ -996,16 +1037,17 @@ struct CodexReviewHostTests { await loginTransport.waitForRequestCount(2) let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - #expect(store.auth.authenticationFailureCount == previousFailureCount) - #expect(store.auth.isAuthenticating) - #expect(failedMessage(from: store.auth.phase) == nil) + #expect(store.auth.authenticationFailureCount == previousFailureCount + 1) + #expect(store.auth.isAuthenticating == false) + #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + #expect(externalURLOpener.openedURLs == []) await store.stop() #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) #expect(await loginTransport.recordedRequests().map(\.method) == [ "initialize", "account/login/start", + "account/login/cancel", ]) } @@ -1514,7 +1556,7 @@ struct CodexReviewHostTests { await store.addAccount() let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path)) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + #expect(externalURLOpener.openedURLs == []) await mainTransport.finishNotificationStreams(throwing: TestTransportClosedError()) await waitUntil { @@ -1726,7 +1768,7 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await store.addAccount() await loginTransport.waitForNotificationStreamCount(1) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + #expect(externalURLOpener.openedURLs == []) try await loginTransport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification( @@ -2091,6 +2133,22 @@ private enum AppServerAPI { } } + 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( @@ -2168,11 +2226,6 @@ private enum AppServerAPI { } } - enum Complete { - struct Response: Codable, Equatable, Sendable { - init() {} - } - } } } } From 491fcf28566e92e6a2d1adcbb0f2dd65886cd70d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:22:37 +0900 Subject: [PATCH 056/359] fix(auth): drain duplicate native login notifications --- .../LiveCodexReviewStoreBackend.swift | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 2752434e..d14666a6 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -211,6 +211,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private var authenticationTask: Task? private var authNotificationTask: Task? private var loginNotificationTask: Task? + // Native completeLogin returns the account before app-server notification echoes settle. + private var nativeLoginCompletionInFlightID: String? + private var nativeCompletedLoginIDAwaitingNotificationDrain: String? + private var didDrainNativeLoginAccountUpdateDuringCompletion = false + private var ignoresNextNativeLoginAccountUpdate = false private var settingsSnapshot = CodexReviewSettings.Snapshot() private let codexHomeURL: URL private let mcpHTTPServerConfiguration: CodexReviewMCPHTTPServer.Configuration @@ -799,6 +804,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func startLogin(auth: CodexReviewAuthModel, activation: LoginActivation) async { var isolatedLoginAppServer: CodexAppServer? var isolatedLoginCodexHomeURL: URL? + resetNativeLoginNotificationDrain() do { let runtime = try await loginRuntime(for: activation) let appServerBackend = runtime.backend @@ -826,6 +832,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.loginCodexHomeURL = loginCodexHomeURL loginActivation = activation isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() if let loginAppServer { observeLoginNotifications(appServer: loginAppServer, backend: appServerBackend, auth: auth) } @@ -855,6 +862,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { try? await appServerBackend.cancelLogin(challenge) loginChallenge = nil loginBackend = nil + resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil updateAuthenticationFailure( @@ -890,6 +898,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil loginBackend = nil isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() let loginAppServer = loginAppServer ?? isolatedLoginAppServer self.loginAppServer = nil let loginCodexHomeURL = loginCodexHomeURL ?? isolatedLoginCodexHomeURL @@ -929,6 +938,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let loginBackend else { return } + resetNativeLoginNotificationDrain() + nativeLoginCompletionInFlightID = challenge.id let snapshot = try await loginBackend.completeLogin(.init( challengeID: challenge.id, callbackURL: callbackURL.absoluteString @@ -939,6 +950,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil self.loginBackend = nil isWaitingForLoginAccountUpdate = false + nativeLoginCompletionInFlightID = nil + nativeCompletedLoginIDAwaitingNotificationDrain = didDrainNativeLoginAccountUpdateDuringCompletion + ? nil + : challenge.id + if didDrainNativeLoginAccountUpdateDuringCompletion { + ignoresNextNativeLoginAccountUpdate = false + } + didDrainNativeLoginAccountUpdateDuringCompletion = false self.loginAppServer = nil self.loginCodexHomeURL = nil activeAuthenticationSession = nil @@ -967,7 +986,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } catch CodexReviewNativeAuthenticationError.cancelled { await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth) } catch { - guard loginChallenge?.id == challenge.id else { + guard loginChallenge?.id == challenge.id || nativeLoginCompletionInFlightID == challenge.id else { return } logger.error("ChatGPT login failed to complete: \(error.localizedDescription, privacy: .public)") @@ -976,6 +995,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil self.loginBackend = nil isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil activeAuthenticationSession = nil @@ -1004,6 +1024,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } + private func resetNativeLoginNotificationDrain() { + nativeLoginCompletionInFlightID = nil + nativeCompletedLoginIDAwaitingNotificationDrain = nil + didDrainNativeLoginAccountUpdateDuringCompletion = false + ignoresNextNativeLoginAccountUpdate = false + } + private func loginRuntime(for activation: LoginActivation) async throws -> LoginRuntime { switch activation { case .activateAuthenticatedAccount: @@ -1050,6 +1077,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil self.loginBackend = nil isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil activeAuthenticationSession = nil @@ -1287,6 +1315,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel ) async { + if let completingLoginID = nativeLoginCompletionInFlightID, + completion.loginID?.rawValue == nil || completion.loginID?.rawValue == completingLoginID { + if didDrainNativeLoginAccountUpdateDuringCompletion == false { + ignoresNextNativeLoginAccountUpdate = true + } + return + } + if let completedLoginID = nativeCompletedLoginIDAwaitingNotificationDrain, + completion.loginID?.rawValue == nil || completion.loginID?.rawValue == completedLoginID { + ignoresNextNativeLoginAccountUpdate = true + return + } guard completion.loginID?.rawValue == nil || completion.loginID?.rawValue == loginChallenge?.id else { return } @@ -1306,6 +1346,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) self.loginBackend = nil isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil loginNotificationTask?.cancel() @@ -1321,6 +1362,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel ) async { + if nativeLoginCompletionInFlightID != nil { + didDrainNativeLoginAccountUpdateDuringCompletion = true + ignoresNextNativeLoginAccountUpdate = false + return + } + if ignoresNextNativeLoginAccountUpdate { + nativeCompletedLoginIDAwaitingNotificationDrain = nil + ignoresNextNativeLoginAccountUpdate = false + return + } + nativeCompletedLoginIDAwaitingNotificationDrain = nil guard isWaitingForLoginAccountUpdate else { await refreshAuthAfterAccountNotification(backend: backend, auth: auth) return @@ -1371,6 +1423,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.loginAppServer = nil self.loginCodexHomeURL = nil isWaitingForLoginAccountUpdate = false + resetNativeLoginNotificationDrain() loginNotificationTask?.cancel() loginNotificationTask = nil await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) From c539c06a379ee5e45a9545e6bbbb9a09a8d193e0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:35:31 +0900 Subject: [PATCH 057/359] build(package): use local dependency checkout slot --- .gitignore | 4 ++++ Package.swift | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2da8eaa9..c6420fed 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ DerivedData/ .swiftpm/xcode/xcuserdata/ .netrc *.profraw + +# Local development dependency checkouts. These are intentionally untracked; +# Package.swift must replace them with pinned remote dependencies before release. +/dependencies/ diff --git a/Package.swift b/Package.swift index 743438de..33ee8bd7 100644 --- a/Package.swift +++ b/Package.swift @@ -29,8 +29,11 @@ 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")), - // Temporary local path for in-flight CodexKit integration; replace with a pinned remote CodexKit dependency before release. - .package(path: "/Users/kn/Dev/CodexKit"), + // Development-only CodexKit integration checkout. Keep the local CodexKit + // worktree at dependencies/CodexKit while this branch tracks in-flight + // APIs; replace this with a pinned remote CodexKit dependency before + // merging or releasing. + .package(path: "dependencies/CodexKit"), ], targets: [ .target( From 0a2e7c5150fd1fda4824e25efcf4a02b01345cab Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:51:04 +0900 Subject: [PATCH 058/359] build(package): fall back to pinned CodexKit dependency --- .gitignore | 4 ++-- Package.resolved | 2 +- Package.swift | 30 +++++++++++++++++++++++++----- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index c6420fed..a9c9352b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,6 @@ DerivedData/ .netrc *.profraw -# Local development dependency checkouts. These are intentionally untracked; -# Package.swift must replace them with pinned remote dependencies before release. +# Optional local development dependency checkouts. These are intentionally +# untracked; Package.swift falls back to pinned remote dependencies when absent. /dependencies/ diff --git a/Package.resolved b/Package.resolved index 008e61b9..27d3079f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "89e7e64237e35c358a14a47a205c0eef127e8091d722fa43cb04ca01f5e8f3e4", + "originHash" : "4c2e27873cd9a9d9fcfb1d2be9be58dbde7f7708d091947385b662c6f45859e3", "pins" : [ { "identity" : "eventsource", diff --git a/Package.swift b/Package.swift index 33ee8bd7..81638a66 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,31 @@ // swift-tools-version: 6.3 +import Foundation import PackageDescription +private let packageDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() +private let codexKitDevelopmentPath = "dependencies/CodexKit" +private let codexKitDevelopmentManifestPath = packageDirectory + .appendingPathComponent(codexKitDevelopmentPath) + .appendingPathComponent("Package.swift") + .path +private let codexKitDependency: Package.Dependency = { + if FileManager.default.fileExists(atPath: codexKitDevelopmentManifestPath) { + // Development-only CodexKit integration checkout. Keep the local CodexKit + // worktree at dependencies/CodexKit while this branch tracks in-flight APIs. + return .package(path: codexKitDevelopmentPath) + } + + // Fresh checkouts and CI must not depend on an ignored local checkout. Until + // CodexKit is released, fall back to the pinned integration revision; replace + // this with the final pinned remote CodexKit release dependency before release. + return .package( + url: "https://github.com/lynnswap/CodexKit.git", + revision: "09ad955e2d638a0287cbb4a2165214f8f8fa3dfb" + ) +}() + let package = Package( name: "CodexReviewKit", platforms: [ @@ -29,11 +53,7 @@ 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")), - // Development-only CodexKit integration checkout. Keep the local CodexKit - // worktree at dependencies/CodexKit while this branch tracks in-flight - // APIs; replace this with a pinned remote CodexKit dependency before - // merging or releasing. - .package(path: "dependencies/CodexKit"), + codexKitDependency, ], targets: [ .target( From 24d189b048cf3f9597f83b1ef6d906213463bd08 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:12:02 +0900 Subject: [PATCH 059/359] build(package): update CodexKit integration pin --- Package.resolved | 2 +- Package.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.resolved b/Package.resolved index 27d3079f..8c3fdc1b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4c2e27873cd9a9d9fcfb1d2be9be58dbde7f7708d091947385b662c6f45859e3", + "originHash" : "8f93f36a9faf75171d73a6369812556bf92776a6bce215d54a68de1d12379634", "pins" : [ { "identity" : "eventsource", diff --git a/Package.swift b/Package.swift index 81638a66..a001d91e 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ private let codexKitDependency: Package.Dependency = { // this with the final pinned remote CodexKit release dependency before release. return .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "09ad955e2d638a0287cbb4a2165214f8f8fa3dfb" + revision: "eac21a18ed21385142b3a142e006c593d2ba1f1a" ) }() From af9abe31ac729b081720cc322cf31c5fac3cd9ee Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:54:23 +0900 Subject: [PATCH 060/359] fix(app-server): cancel reconstructed review turns --- Package.resolved | 10 +++- Package.swift | 15 +++-- .../AppServerCodexReviewBackend.swift | 58 ++++++++++++------- .../AppServerReviewControl.swift | 50 ++++++++-------- .../AppServerClientTests.swift | 30 ++++++++++ 5 files changed, 108 insertions(+), 55 deletions(-) diff --git a/Package.resolved b/Package.resolved index 8c3fdc1b..92999acb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "8f93f36a9faf75171d73a6369812556bf92776a6bce215d54a68de1d12379634", + "originHash" : "76bf3ff9a09a59a3a199e66c3d36ad977e0bfa6d691e084f42688af3d4fd38ce", "pins" : [ + { + "identity" : "codexkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/CodexKit.git", + "state" : { + "revision" : "717dddc6cb778dfd72435da550b35b1690998ff8" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a001d91e..e20d5644 100644 --- a/Package.swift +++ b/Package.swift @@ -6,23 +6,26 @@ import PackageDescription private let packageDirectory = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() private let codexKitDevelopmentPath = "dependencies/CodexKit" +private let usesLocalCodexKit = ProcessInfo.processInfo.environment["CODEX_REVIEWKIT_USE_LOCAL_CODEXKIT"] == "1" private let codexKitDevelopmentManifestPath = packageDirectory .appendingPathComponent(codexKitDevelopmentPath) .appendingPathComponent("Package.swift") .path private let codexKitDependency: Package.Dependency = { - if FileManager.default.fileExists(atPath: codexKitDevelopmentManifestPath) { - // Development-only CodexKit integration checkout. Keep the local CodexKit - // worktree at dependencies/CodexKit while this branch tracks in-flight APIs. + if usesLocalCodexKit, + FileManager.default.fileExists(atPath: codexKitDevelopmentManifestPath) { + // Development-only CodexKit integration checkout. Set + // CODEX_REVIEWKIT_USE_LOCAL_CODEXKIT=1 to use dependencies/CodexKit while + // this branch tracks in-flight APIs. The default stays on the pinned + // remote dependency so fresh checkouts and locked resolution remain stable. return .package(path: codexKitDevelopmentPath) } - // Fresh checkouts and CI must not depend on an ignored local checkout. Until - // CodexKit is released, fall back to the pinned integration revision; replace + // Until CodexKit is released, use the pinned integration revision; replace // this with the final pinned remote CodexKit release dependency before release. return .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "eac21a18ed21385142b3a142e006c593d2ba1f1a" + revision: "717dddc6cb778dfd72435da550b35b1690998ff8" ) }() diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 31c3a829..3604cef5 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -173,7 +173,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { let session = await reviewEventSession(for: run) await session.requestCancellation(message: reason.message) do { - _ = try await sendTurnInterrupt(for: run) + _ = try await sendTurnCancellation(for: run) await finishReviewEventStream( threadID: run.threadID, cancellationMessage: reason.message, @@ -190,10 +190,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reason _: CodexReviewBackendModel.CancellationReason ) async throws -> CodexReviewBackendModel.Review.RecoveryToken { markTurnAbandoned(run.turnID) - let interruption = try await sendTurnInterrupt(for: run) { retryInterruption in - await self.markInterruptionTurnAbandoned(retryInterruption, canonicalThreadID: run.threadID) + let cancellation = try await sendTurnCancellation(for: run) { retryCancellation in + await self.markCancellationTurnAbandoned(retryCancellation, canonicalThreadID: run.threadID) } - markAttemptAbandoned(run, interruption: interruption) + markAttemptAbandoned(run, cancellation: cancellation) if let session = unregisterReviewEventSession(for: run) { await session.abandon() let metrics = await session.metricsSnapshot() @@ -203,7 +203,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } return CodexReviewBackendModel.Review.RecoveryToken( interruptedRun: run, - rollbackThreadID: interruption.threadID + rollbackThreadID: cancellation.threadID ) } @@ -434,20 +434,20 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private func markAttemptAbandoned( _ run: CodexReviewBackendModel.Review.Run, - interruption: AppServerReviewInterruption + cancellation: AppServerReviewCancellation ) { abandonedReviewAttemptIDs.insert(run.attemptID) markTurnAbandoned(run.turnID) - markTurnAbandoned(interruption.turnID) - noteReviewThreadIDForCleanup(interruption.threadID, canonicalThreadID: run.threadID) + markTurnAbandoned(cancellation.turnID) + noteReviewThreadIDForCleanup(cancellation.threadID, canonicalThreadID: run.threadID) } - private func markInterruptionTurnAbandoned( - _ interruption: AppServerReviewInterruption, + private func markCancellationTurnAbandoned( + _ cancellation: AppServerReviewCancellation, canonicalThreadID: String ) { - markTurnAbandoned(interruption.turnID) - noteReviewThreadIDForCleanup(interruption.threadID, canonicalThreadID: canonicalThreadID) + markTurnAbandoned(cancellation.turnID) + noteReviewThreadIDForCleanup(cancellation.threadID, canonicalThreadID: canonicalThreadID) } private func markTurnAbandoned(_ turnID: String?) { @@ -481,19 +481,33 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) } - private func sendTurnInterrupt( + private func sendTurnCancellation( 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 - } + willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil + ) async throws -> AppServerReviewCancellation { let control = reviewControl(for: run) - guard let interruption = try await control.interrupt(willInterruptActiveTurn: willInterruptActiveTurn) else { - throw CodexReviewAPI.Error.io("Review run has no interruptible app-server session.") + if let cancellation = try await control.cancel(willCancelActiveTurn: willCancelActiveTurn) { + return cancellation + } + + guard let expectedTurnID = run.turnID?.nilIfEmpty else { + throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.") + } + let thread = try await reviewThread(for: run) + let cancellation = try await thread.cancelActiveTurn( + expectedTurnID: .init(rawValue: expectedTurnID) + ) { retryCancellation in + let reviewCancellation = AppServerReviewCancellation(retryCancellation) + if reviewCancellation.turnID != expectedTurnID, + let willCancelActiveTurn { + await willCancelActiveTurn(reviewCancellation) + } + } + let reviewCancellation = AppServerReviewCancellation(cancellation) + if reviewCancellation.turnID != expectedTurnID { + control.recordReviewStarted(turnID: reviewCancellation.turnID) } - return interruption + return reviewCancellation } private func cleanupThreadIDs(for run: CodexReviewBackendModel.Review.Run) -> [String] { diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index 1ba9875f..ba2f1f38 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -2,7 +2,7 @@ import Foundation import CodexAppServerKit import CodexReviewKit -package struct AppServerReviewInterruption: Equatable, Sendable { +package struct AppServerReviewCancellation: Equatable, Sendable { package var threadID: String package var turnID: String @@ -10,6 +10,13 @@ package struct AppServerReviewInterruption: Equatable, Sendable { self.threadID = threadID self.turnID = turnID } + + package init(_ cancellation: CodexTurnCancellation) { + self.init( + threadID: cancellation.threadID.rawValue, + turnID: cancellation.turnID?.rawValue ?? "" + ) + } } package final class AppServerReviewControl: @unchecked Sendable { @@ -69,9 +76,9 @@ package final class AppServerReviewControl: @unchecked Sendable { } @discardableResult - package func interrupt( - willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? = nil - ) async throws -> AppServerReviewInterruption? { + package func cancel( + willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil + ) async throws -> AppServerReviewCancellation? { let snapshot = stateSnapshot() switch snapshot.phase { case .preparing, .finished: @@ -82,10 +89,10 @@ package final class AppServerReviewControl: @unchecked Sendable { guard let reviewSession = snapshot.reviewSession else { return nil } - return try await interrupt( + return try await cancel( reviewSession, expectedTurnID: turnID, - willInterruptActiveTurn: willInterruptActiveTurn + willCancelActiveTurn: willCancelActiveTurn ) } } @@ -103,21 +110,21 @@ package final class AppServerReviewControl: @unchecked Sendable { return Snapshot(phase: phase, reviewSession: reviewSession) } - private func interrupt( + private func cancel( _ reviewSession: CodexReviewSession, expectedTurnID: String, - willInterruptActiveTurn: (@Sendable (AppServerReviewInterruption) async -> Void)? - ) async throws -> AppServerReviewInterruption { - let interruption = try await reviewSession.interrupt() - let reviewInterruption = AppServerReviewInterruption(interruption) - if reviewInterruption.turnID != expectedTurnID, - let willInterruptActiveTurn { - await willInterruptActiveTurn(reviewInterruption) + willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? + ) async throws -> AppServerReviewCancellation { + let cancellation = try await reviewSession.cancel() + let reviewCancellation = AppServerReviewCancellation(cancellation) + if reviewCancellation.turnID != expectedTurnID, + let willCancelActiveTurn { + await willCancelActiveTurn(reviewCancellation) } - if reviewInterruption.turnID != expectedTurnID { - setPhase(.reviewStarted(turnID: reviewInterruption.turnID), reviewSession: reviewSession) + if reviewCancellation.turnID != expectedTurnID { + setPhase(.reviewStarted(turnID: reviewCancellation.turnID), reviewSession: reviewSession) } - return reviewInterruption + return reviewCancellation } private func setPhase(_ phase: Phase, reviewSession: CodexReviewSession? = nil) { @@ -127,12 +134,3 @@ package final class AppServerReviewControl: @unchecked Sendable { self.reviewSession = reviewSession } } - -private extension AppServerReviewInterruption { - init(_ interruption: CodexTurnInterruption) { - self.init( - threadID: interruption.threadID.rawValue, - turnID: interruption.turnID?.rawValue ?? "" - ) - } -} diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 20080f6b..f390276e 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -255,6 +255,36 @@ struct AppServerClientTests { let deletedIDs = try deleteRequests.map { try jsonObject(from: $0.params)["threadId"] as? String } #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"]) } + + @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 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" + ) + + 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") + } } private func eventSequence( From 241374ff62d96682587b4208439c7f176245dba1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:08:05 +0900 Subject: [PATCH 061/359] fix(app-server): cancel detached review turns on review thread --- .../AppServerCodexReviewBackend.swift | 10 ++++++- .../AppServerClientTests.swift | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3604cef5..f6c5f0de 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -493,7 +493,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { guard let expectedTurnID = run.turnID?.nilIfEmpty else { throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.") } - let thread = try await reviewThread(for: run) + let thread = try await activeReviewTurnThread(for: run) let cancellation = try await thread.cancelActiveTurn( expectedTurnID: .init(rawValue: expectedTurnID) ) { retryCancellation in @@ -530,6 +530,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return threadIDs } + private func activeReviewTurnThread(for run: CodexReviewBackendModel.Review.Run) async throws -> CodexThread { + try await threadHandle(id: activeReviewTurnThreadID(for: run), model: run.model) + } + + private func activeReviewTurnThreadID(for run: CodexReviewBackendModel.Review.Run) -> String { + run.reviewThreadID?.nilIfEmpty ?? run.threadID + } + private func mergedCleanupThreadIDs(_ lhs: [String], _ rhs: [String]) -> [String] { var seen: Set = [] var merged: [String] = [] diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index f390276e..57d14ba3 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -285,6 +285,36 @@ struct AppServerClientTests { #expect(interruptParams["threadId"] as? String == "thread-1") #expect(interruptParams["turnId"] as? String == "turn-1") } + + @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")) + + 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") + } } private func eventSequence( From 8df1434ea19d47971a507a5d26828a43aed9e2ce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:23:55 +0900 Subject: [PATCH 062/359] fix(app-server): preserve review cancellation recovery semantics --- .../AppServerCodexReviewBackend.swift | 23 +++- .../AppServerReviewControl.swift | 16 +-- .../AppServerClientTests.swift | 117 ++++++++++++++++++ .../CodexReviewHostTests.swift | 4 + 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index f6c5f0de..0082ab75 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1407,7 +1407,28 @@ private actor AppServerReviewEventSession { } metrics.decoded += 1 events = eventsWithTypedResultFallback(events) - for event in events { + for index in events.indices { + let event = events[index] + if case .domainEvents = event { + let followingEvents = events[events.index(after: index)...] + if shouldFlushPendingStreamedLogBeforeDomainEvent( + followingEvents: followingEvents, + suppressTimelineProjection: false + ), + await flushPendingStreamedLog(controlThreadID: converted.controlThreadID) { + return + } + if await emit(event, controlThreadID: converted.controlThreadID) { + return + } + continue + } + if bufferStreamedLog(event) { + continue + } + if await flushPendingStreamedLog(controlThreadID: converted.controlThreadID) { + return + } if event.shouldDeferCompletion { completionCoordinator.deferCompletion(event) continue diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift index ba2f1f38..687f4ed3 100644 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ b/Sources/CodexReviewAppServer/AppServerReviewControl.swift @@ -89,11 +89,10 @@ package final class AppServerReviewControl: @unchecked Sendable { guard let reviewSession = snapshot.reviewSession else { return nil } - return try await cancel( - reviewSession, - expectedTurnID: turnID, - willCancelActiveTurn: willCancelActiveTurn - ) + guard willCancelActiveTurn == nil else { + return nil + } + return try await cancel(reviewSession, expectedTurnID: turnID) } } @@ -112,15 +111,10 @@ package final class AppServerReviewControl: @unchecked Sendable { private func cancel( _ reviewSession: CodexReviewSession, - expectedTurnID: String, - willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? + expectedTurnID: String ) async throws -> AppServerReviewCancellation { let cancellation = try await reviewSession.cancel() let reviewCancellation = AppServerReviewCancellation(cancellation) - if reviewCancellation.turnID != expectedTurnID, - let willCancelActiveTurn { - await willCancelActiveTurn(reviewCancellation) - } if reviewCancellation.turnID != expectedTurnID { setPhase(.reviewStarted(turnID: reviewCancellation.turnID), reviewSession: reviewSession) } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 57d14ba3..fb679333 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -235,6 +235,59 @@ struct AppServerClientTests { #expect(secondCommand.output == "second") } + @Test func backendCoalescesTypedCommandOutputDeltas() 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()) + #expect(try await nextEvent(from: attempt.events) == .started( + turnID: "turn-1", + reviewThreadID: "review-thread", + model: "gpt-5" + )) + + 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" + ) + ) + + guard case .domainEvents = try await nextEvent(from: attempt.events), + case .domainEvents = try await nextEvent(from: attempt.events) + else { + Issue.record("expected direct timeline updates before coalesced legacy log") + return + } + guard case .logEntry(.commandOutput, let text, let groupID, let replacesGroup, let metadata) = + try await nextEvent(from: attempt.events) + else { + Issue.record("expected coalesced command output log") + return + } + #expect(text == "firstsecond") + #expect(groupID == "cmd-1") + #expect(replacesGroup == false) + #expect(metadata?.sourceType == "commandExecution") + #expect(metadata?.title == "Command output") + #expect(metadata?.itemID == "cmd-1") + } + @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") @@ -315,6 +368,70 @@ struct AppServerClientTests { #expect(interruptParams["threadId"] as? String == "review-thread") #expect(interruptParams["turnId"] as? String == "turn-1") } + + @Test func beginReviewRecoveryCancelsLiveSessionThroughReviewThreadWhenHookIsNeeded() 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 runtime.transport.enqueueEmpty(for: "turn/interrupt") + + let token = try await backend.beginReviewRecovery( + attempt.run, + reason: .init(message: "Recover after network outage") + ) + + #expect(token.rollbackThreadID == "review-thread") + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "review/start", + "thread/resume", + "turn/interrupt", + "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") + let interruptTurnIDs = try requests.filter { $0.method == "turn/interrupt" }.map { + try jsonObject(from: $0.params)["turnId"] as? String + } + #expect(interruptTurnIDs == ["turn-old", "turn-new"]) + } +} + +private enum AppServerClientTestTimeout: 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 AppServerClientTestTimeout.timedOut + } + let event = try await group.next() + group.cancelAll() + return event ?? nil + } } private func eventSequence( diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 9e467964..f3b5391d 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1428,6 +1428,10 @@ struct CodexReviewHostTests { AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), for: "review/start" ) + try await transport.enqueueJSON( + #"{"thread":{"id":"review-thread-1"},"model":"gpt-5"}"#, + for: "thread/resume" + ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], From f4d76c20de6e556874d1ec5f9767a4bc2f38ce40 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:04:08 +0900 Subject: [PATCH 063/359] refactor(review): extract backend event pipeline --- .../AppServerCodexReviewBackend.swift | 615 ++--------------- .../ReviewBackendEventSession.swift | 621 ++++++++++++++++++ .../ReviewBackendEventSessionTests.swift | 198 ++++++ 3 files changed, 860 insertions(+), 574 deletions(-) create mode 100644 Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift create mode 100644 Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 0082ab75..5b7a20f4 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -25,7 +25,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var threadsByThreadID: [String: CodexThread] = [:] private var abandonedReviewAttemptIDs: Set = [] private var abandonedTurnIDs: Set = [] - private var completedReviewEventSessionMetricsByThreadID: [String: AppServerReviewEventSessionMetrics] = [:] + private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] package init(appServer: CodexAppServer) { self.appServer = appServer @@ -308,7 +308,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func reviewEventSessionMetricsForTesting( threadID: String - ) async -> AppServerReviewEventSessionMetrics? { + ) async -> ReviewBackendEventSessionMetrics? { if let session = reviewEventSession(forThreadID: threadID) { return await session.metricsSnapshot() } @@ -549,91 +549,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } -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 PendingStreamedLogEntry: Sendable { - struct Key: Hashable, Sendable { - var kind: ReviewLogEntry.Kind - var groupID: String - var sourceType: String? - var itemID: String? - } - - 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 - ) - } - - 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] - } - - 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 - } - self.kind = kind - self.text = text - self.groupID = groupID - self.metadata = metadata - self.suppressesTimelineProjection = suppressesTimelineProjection - } - - mutating func append(_ suffix: String) { - text += suffix - } - - mutating func suppressTimelineProjection() { - suppressesTimelineProjection = true - } -} - private struct AppServerTypedReviewEvent: Sendable { var events: [CodexReviewBackendModel.Review.Event] var controlThreadID: String? @@ -1245,118 +1160,78 @@ private extension CodexThreadItem { } 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 reviewThreadIDsForCleanup: [String] = [] - private var pendingStreamedLogEntries: [PendingStreamedLogEntry] = [] - private var pendingStreamedLogIndexByKey: [PendingStreamedLogEntry.Key: Int] = [:] - private var streamedLogFlushTask: Task? - private var cancellationRequestedMessage: String? - private let completionCoordinator = ReviewCompletionCoordinator() - private let createdAt = Date() - private var finished = false - private var metrics = AppServerReviewEventSessionMetrics() + private let pipeline: ReviewBackendEventSession private var typedReviewStreamTask: Task? - private var typedMessageTextByItemID: [String: String] = [:] - private var typedReviewResultText: String? init( run: CodexReviewBackendModel.Review.Run, control: AppServerReviewControl, mailbox: BackendReviewEventMailbox = .init() ) { - self.run = run - self.control = control - self.mailbox = mailbox - if let reviewThreadID = run.reviewThreadID?.nilIfEmpty, - reviewThreadID != run.threadID { - self.reviewThreadIDsForCleanup.append(reviewThreadID) - } + self.pipeline = ReviewBackendEventSession( + run: run, + mailbox: mailbox, + callbacks: .init( + recordTurnStarted: { turnID in + control.recordTurnStarted(turnID: turnID) + }, + recordFinished: { run, metrics in + control.finish() + 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 updateRun(_ run: CodexReviewBackendModel.Review.Run) { - self.run = run - noteReviewThreadIDForCleanup(run.reviewThreadID) + func updateRun(_ run: CodexReviewBackendModel.Review.Run) async { + await pipeline.updateRun(run) } - func currentRun() -> CodexReviewBackendModel.Review.Run { - run + func currentRun() async -> CodexReviewBackendModel.Review.Run { + await pipeline.currentRun() } - func attempt() -> BackendReviewAttempt { - .init(run: run, events: mailbox) + func attempt() async -> BackendReviewAttempt { + await pipeline.attempt() } - func cleanupThreadIDs() -> [String] { - var threadIDs = reviewThreadIDsForCleanup.filter { $0 != run.threadID } - threadIDs.append(run.threadID) - return threadIDs + func cleanupThreadIDs() async -> [String] { + await pipeline.cleanupThreadIDs() } - func requestCancellation(message: String) { - cancellationRequestedMessage = message + func requestCancellation(message: String) async { + await pipeline.requestCancellation(message: message) } - func clearCancellationRequest() { - cancellationRequestedMessage = nil + func clearCancellationRequest() async { + await pipeline.clearCancellationRequest() } func finish( cancellationMessage: String?, - buffersMissingContinuation _: Bool = false + buffersMissingContinuation: Bool = false ) async { cancelTypedReviewStream() - let precedingEvents = drainPendingStreamedLogEvents() - if cancellationMessage == nil { - cancelPendingStreamedLogFlush() - } else { - cancellationRequestedMessage = cancellationMessage - } - await finish(precedingEvents: precedingEvents, cancellationMessage: cancellationMessage) + await pipeline.finish( + cancellationMessage: cancellationMessage, + buffersMissingContinuation: buffersMissingContinuation + ) } func finish(throwing error: (any Error)?) async { - guard finished == false else { - return - } cancelTypedReviewStream() - let precedingEvents = drainPendingStreamedLogEvents() - cancelPendingStreamedLogFlush() - await emitPrecedingEvents(precedingEvents) - if let error { - finished = true - completionCoordinator.cancelPendingCompletion() - await mailbox.fail(error) - } else { - if await flushPendingCompletion() { - finished = true - return - } - finished = true - await mailbox.finish() - } + await pipeline.finish(throwing: error) } func abandon() async { - guard finished == false else { - return - } cancelTypedReviewStream() - finished = true - completionCoordinator.cancelPendingCompletion() - cancelPendingStreamedLogFlush() - pendingStreamedLogEntries.removeAll(keepingCapacity: true) - pendingStreamedLogIndexByKey.removeAll(keepingCapacity: true) - await mailbox.abandon() + await pipeline.abandon() } - func metricsSnapshot() -> AppServerReviewEventSessionMetrics { - metrics + func metricsSnapshot() async -> ReviewBackendEventSessionMetrics { + await pipeline.metricsSnapshot() } func activeStreamSubscriptionIDForTesting() -> Int? { @@ -1378,6 +1253,7 @@ private actor AppServerReviewEventSession { defer { typedReviewStreamTask = nil } + let run = await pipeline.currentRun() await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run)) do { for try await event in review.events { @@ -1395,422 +1271,13 @@ private actor AppServerReviewEventSession { } private func receive(_ converted: AppServerTypedReviewEvent) async { - metrics.routed += 1 - guard finished == false else { - metrics.ignored += 1 - return - } - var events = converted.events - guard events.isEmpty == false else { - metrics.ignored += 1 - return - } - metrics.decoded += 1 - events = eventsWithTypedResultFallback(events) - for index in events.indices { - let event = events[index] - if case .domainEvents = event { - let followingEvents = events[events.index(after: index)...] - if shouldFlushPendingStreamedLogBeforeDomainEvent( - followingEvents: followingEvents, - suppressTimelineProjection: false - ), - await flushPendingStreamedLog(controlThreadID: converted.controlThreadID) { - return - } - if await emit(event, controlThreadID: converted.controlThreadID) { - return - } - continue - } - if bufferStreamedLog(event) { - continue - } - if await flushPendingStreamedLog(controlThreadID: converted.controlThreadID) { - return - } - if event.shouldDeferCompletion { - completionCoordinator.deferCompletion(event) - continue - } - if await emit(event, controlThreadID: converted.controlThreadID) { - return - } - } - } - - private func eventsWithTypedResultFallback( - _ events: [CodexReviewBackendModel.Review.Event] - ) -> [CodexReviewBackendModel.Review.Event] { - events.map { event in - switch event { - case .message(let text): - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - return event - case .messageDelta(let delta, let itemID): - let text = (typedMessageTextByItemID[itemID] ?? "") + delta - typedMessageTextByItemID[itemID] = text - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - return event - case .logEntry(.agentMessage, let text, _, _, _): - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - return event - case .completed(let summary, nil): - return .completed(summary: summary, result: typedReviewResultText) - case .domainEvents, - .suppressNextLegacyTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .log, - .logEntry, - .completed, - .failed, - .cancelled: - return event - } - } - } - - private func finish( - precedingEvents: [CodexReviewBackendModel.Review.Event], - cancellationMessage: String? - ) async { - guard finished == false else { - return - } - cancelTypedReviewStream() - completionCoordinator.cancelPendingCompletion() - cancelPendingStreamedLogFlush() - await emitPrecedingEvents(precedingEvents) - 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) - let didFinish = completionCoordinator.emit(event) - await mailbox.append(event) - recordReviewEvent(event, controlThreadID: controlThreadID) - return didFinish - } - - 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 + await pipeline.receive(converted.events, controlThreadID: converted.controlThreadID) } private func cancelTypedReviewStream() { typedReviewStreamTask?.cancel() typedReviewStreamTask = nil } - - private func flushPendingCompletion( - controlThreadID: String? = nil - ) async -> Bool { - guard let event = completionCoordinator.flushPendingCompletion() else { - return false - } - let events = eventsWithTypedResultFallback([event]) - guard let event = events.first 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(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 shouldDeferCompletion: Bool { - guard case .completed(_, let result) = self else { - return false - } - return result?.nilIfEmpty == 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] - } - 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 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 { - return nil - } - pendingCompletion = nil - finished = true - return event - } - - func cancelPendingCompletion() { - pendingCompletion = nil - } - - func finishIfNeeded() { - guard finished == false else { - return - } - finished = true - pendingCompletion = nil - } } private extension CodexAppServerKit.CodexAccount { diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift new file mode 100644 index 00000000..0c748911 --- /dev/null +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -0,0 +1,621 @@ +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 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 mailbox: BackendReviewEventMailbox + private let callbacks: ReviewBackendEventSessionCallbacks + private var reviewThreadIDsForCleanup: [String] = [] + private var pendingStreamedLogEntries: [PendingStreamedLogEntry] = [] + private var pendingStreamedLogIndexByKey: [PendingStreamedLogEntry.Key: Int] = [:] + private var streamedLogFlushTask: Task? + private var cancellationRequestedMessage: String? + private let completionCoordinator = ReviewCompletionCoordinator() + private let createdAt = Date() + private var finished = false + private var metrics = ReviewBackendEventSessionMetrics() + private var typedMessageTextByItemID: [String: String] = [:] + private var typedReviewResultText: String? + + 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?, + buffersMissingContinuation _: Bool = false + ) async { + let precedingEvents = drainPendingStreamedLogEvents() + if cancellationMessage == nil { + cancelPendingStreamedLogFlush() + } else { + cancellationRequestedMessage = cancellationMessage + } + await finish(precedingEvents: precedingEvents, cancellationMessage: cancellationMessage) + } + + package func finish(throwing error: (any Error)?) async { + guard finished == false else { + return + } + let precedingEvents = drainPendingStreamedLogEvents() + cancelPendingStreamedLogFlush() + await emitPrecedingEvents(precedingEvents) + if let error { + finished = true + completionCoordinator.cancelPendingCompletion() + await mailbox.fail(error) + } else { + if await flushPendingCompletion() { + finished = true + return + } + finished = true + await mailbox.finish() + } + } + + package func abandon() async { + guard finished == false else { + return + } + finished = true + completionCoordinator.cancelPendingCompletion() + cancelPendingStreamedLogFlush() + pendingStreamedLogEntries.removeAll(keepingCapacity: true) + pendingStreamedLogIndexByKey.removeAll(keepingCapacity: 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 + let events = eventsWithTypedResultFallback(events) + for index in events.indices { + let event = events[index] + if case .domainEvents = event { + let followingEvents = events[events.index(after: index)...] + if shouldFlushPendingStreamedLogBeforeDomainEvent( + followingEvents: followingEvents, + suppressTimelineProjection: false + ), + await flushPendingStreamedLog(controlThreadID: controlThreadID) { + return + } + if await emit(event, controlThreadID: controlThreadID) { + return + } + continue + } + if bufferStreamedLog(event) { + continue + } + if await flushPendingStreamedLog(controlThreadID: controlThreadID) { + return + } + if event.shouldDeferReviewBackendCompletion { + completionCoordinator.deferCompletion(event) + continue + } + if await emit(event, controlThreadID: controlThreadID) { + return + } + } + } + + private func eventsWithTypedResultFallback( + _ events: [CodexReviewBackendModel.Review.Event] + ) -> [CodexReviewBackendModel.Review.Event] { + events.map { event in + switch event { + case .message(let text): + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .messageDelta(let delta, let itemID): + let text = (typedMessageTextByItemID[itemID] ?? "") + delta + typedMessageTextByItemID[itemID] = text + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .logEntry(.agentMessage, let text, _, _, _): + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + return event + case .completed(let summary, nil): + return .completed(summary: summary, result: typedReviewResultText) + case .domainEvents, + .suppressNextLegacyTimelineProjection, + .suppressNextTerminalFailureLogTimelineProjection, + .started, + .log, + .logEntry, + .completed, + .failed, + .cancelled: + return event + } + } + } + + private func finish( + precedingEvents: [CodexReviewBackendModel.Review.Event], + cancellationMessage: String? + ) async { + guard finished == false else { + return + } + completionCoordinator.cancelPendingCompletion() + cancelPendingStreamedLogFlush() + await emitPrecedingEvents(precedingEvents) + 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) + let didFinish = completionCoordinator.emit(event) + await mailbox.append(event) + await recordReviewEvent(event, controlThreadID: controlThreadID) + return didFinish + } + + 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 + } + let events = eventsWithTypedResultFallback([event]) + guard let event = events.first else { + return false + } + noteEmission(event) + await mailbox.append(event) + await recordReviewEvent(event, controlThreadID: controlThreadID) + return true + } + + 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) + 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) + await 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.isReviewBackendTerminal { + 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 struct PendingStreamedLogEntry: Sendable { + struct Key: Hashable, Sendable { + var kind: ReviewLogEntry.Kind + var groupID: String + var sourceType: String? + var itemID: String? + } + + 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 + ) + } + + 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] + } + + 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 + } + self.kind = kind + self.text = text + self.groupID = groupID + self.metadata = metadata + self.suppressesTimelineProjection = suppressesTimelineProjection + } + + mutating func append(_ suffix: String) { + text += suffix + } + + mutating func suppressTimelineProjection() { + suppressesTimelineProjection = true + } +} + +package 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] + } + return [event] + } + } +} + +private extension CodexReviewBackendModel.Review.Event { + var isReviewBackendTerminal: Bool { + return switch self { + case .completed, .failed, .cancelled: + true + case .domainEvents, + .suppressNextLegacyTimelineProjection, + .suppressNextTerminalFailureLogTimelineProjection, + .started, + .message, + .messageDelta, + .log, + .logEntry: + false + } + } + + var shouldDeferReviewBackendCompletion: Bool { + guard case .completed(_, let result) = self else { + return false + } + return result?.nilIfEmpty == nil + } + + 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 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.isReviewBackendTerminal 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 { + return nil + } + pendingCompletion = nil + finished = true + return event + } + + func cancelPendingCompletion() { + pendingCompletion = nil + } +} diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift new file mode 100644 index 00000000..6f4ca06c --- /dev/null +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -0,0 +1,198 @@ +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(summary: "Succeeded.", result: "Looks good."), + ], 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( + summary: "Succeeded.", + result: "Looks good." + )) + #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) + } + + @Test func coalescesStreamedLogEntriesWithoutDelayingDomainEvents() async throws { + let session = ReviewBackendEventSession(run: makeRun()) + let attempt = await session.attempt() + + await session.receive([ + commandDomainEvent(output: "first"), + commandOutputDeltaLogEntry(text: "first"), + ], controlThreadID: "review-thread") + await session.receive([ + commandDomainEvent(output: "firstsecond"), + commandOutputDeltaLogEntry(text: "second"), + ], controlThreadID: "review-thread") + await session.finish(throwing: nil) + + guard case .domainEvents(let firstDomainEvents, _) = try await nextEvent(from: attempt.events), + case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), + case .command(let firstCommand) = firstSeed.content + else { + Issue.record("expected first domain event") + return + } + #expect(firstCommand.output == "first") + + guard case .domainEvents(let secondDomainEvents, _) = try await nextEvent(from: attempt.events), + case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), + case .command(let secondCommand) = secondSeed.content + else { + Issue.record("expected second domain event") + return + } + #expect(secondCommand.output == "firstsecond") + + #expect(try await nextEvent(from: attempt.events) == .logEntry( + kind: .commandOutput, + text: "firstsecond", + groupID: "cmd-1", + replacesGroup: false, + metadata: commandOutputMetadata() + )) + #expect(try await nextEvent(from: attempt.events) == nil) + } + + @Test func fillsCompletionResultFromStreamedMessageText() async throws { + let session = ReviewBackendEventSession(run: makeRun()) + let attempt = await session.attempt() + + await session.receive([.messageDelta("Looks ", itemID: "msg-1")]) + await session.receive([.messageDelta("good.", itemID: "msg-1")]) + await session.receive([.completed(summary: "Succeeded.", result: nil)]) + + #expect(try await nextEvent(from: attempt.events) == .messageDelta("Looks ", itemID: "msg-1")) + #expect(try await nextEvent(from: attempt.events) == .messageDelta("good.", itemID: "msg-1")) + #expect(try await nextEvent(from: attempt.events) == .completed( + summary: "Succeeded.", + result: "Looks good." + )) + } +} + +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" + ) +} + +private func commandDomainEvent(output: String) -> CodexReviewBackendModel.Review.Event { + .domainEvents([ + .itemUpdated(.init( + id: "cmd-1", + kind: .commandExecution, + family: .command, + phase: .running, + content: .command(.init(command: "swift test", output: output)) + )), + ], legacyProjectionSuppressionCount: 1) +} + +private func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { + .logEntry( + kind: .commandOutput, + text: text, + groupID: "cmd-1", + replacesGroup: false, + metadata: commandOutputMetadata() + ) +} + +private func commandOutputMetadata() -> ReviewLogEntry.Metadata { + .init( + sourceType: "commandExecution", + title: "Command output", + itemID: "cmd-1" + ) +} From d906dc954929df90a4072a4374e25b2ede835d8c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:43:16 +0900 Subject: [PATCH 064/359] refactor(backend): expose review restart primitives --- .../AppServerCodexReviewBackend.swift | 39 ++++++-- Sources/CodexReviewHost/CodexReviewHost.swift | 13 +-- .../LiveCodexReviewStoreBackend.swift | 13 +-- .../CodexReviewKit/CodexReviewBackend.swift | 9 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 10 +- .../Store/CodexReviewStoreBackend.swift | 9 +- .../Store/CodexReviewStoreReviews.swift | 29 +++--- .../PreviewCodexReviewStoreBackend.swift | 9 +- Sources/CodexReviewTesting/TestSupport.swift | 98 +++++++++---------- .../AppServerClientTests.swift | 25 +++-- .../CodexReviewStoreCommandTests.swift | 82 ++++++++-------- 11 files changed, 174 insertions(+), 162 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 5b7a20f4..33e48d81 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -12,6 +12,11 @@ private func makeAppServerReviewAttemptID() -> String { UUID().uuidString } +private struct AppServerReviewRestartContext: Sendable { + var interruptedRun: CodexReviewBackendModel.Review.Run + var rollbackThreadID: String +} + package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" @@ -23,6 +28,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:] private var reviewThreadIDsForCleanupByThreadID: [String: Set] = [:] private var threadsByThreadID: [String: CodexThread] = [:] + private var restartContextsByTokenID: [String: AppServerReviewRestartContext] = [:] private var abandonedReviewAttemptIDs: Set = [] private var abandonedTurnIDs: Set = [] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] @@ -185,10 +191,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } } - package func beginReviewRecovery( - _ run: CodexReviewBackendModel.Review.Run, - reason _: CodexReviewBackendModel.CancellationReason - ) async throws -> CodexReviewBackendModel.Review.RecoveryToken { + package func prepareReviewRestart( + _ run: CodexReviewBackendModel.Review.Run + ) async throws -> CodexReviewBackendModel.Review.RestartToken { markTurnAbandoned(run.turnID) let cancellation = try await sendTurnCancellation(for: run) { retryCancellation in await self.markCancellationTurnAbandoned(retryCancellation, canonicalThreadID: run.threadID) @@ -201,18 +206,27 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { completedReviewEventSessionMetricsByThreadID[threadID] = metrics } } - return CodexReviewBackendModel.Review.RecoveryToken( + discardRestartContexts(for: run) + let tokenID = UUID().uuidString + restartContextsByTokenID[tokenID] = AppServerReviewRestartContext( interruptedRun: run, rollbackThreadID: cancellation.threadID ) + return CodexReviewBackendModel.Review.RestartToken( + id: tokenID, + interruptedRun: run + ) } - package func resumeReviewRecovery( - _ token: CodexReviewBackendModel.Review.RecoveryToken, + package func restartPreparedReview( + _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { - let interruptedRun = token.interruptedRun - let rollbackThread = try await threadHandle(id: token.rollbackThreadID, model: interruptedRun.model) + guard let restartContext = restartContextsByTokenID.removeValue(forKey: token.id) else { + throw CodexReviewAPI.Error.io("Prepared review restart is no longer available.") + } + let interruptedRun = restartContext.interruptedRun + let rollbackThread = try await threadHandle(id: restartContext.rollbackThreadID, model: interruptedRun.model) try await rollbackThread.rollback(turnCount: 1) let control = reviewControl(for: interruptedRun) @@ -252,6 +266,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { + discardRestartContexts(for: run) controlsByThreadID.removeValue(forKey: run.threadID) var cleanupThreadIDs = cleanupThreadIDs(for: run) if let session = unregisterReviewEventSession(for: run) { @@ -450,6 +465,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { noteReviewThreadIDForCleanup(cancellation.threadID, canonicalThreadID: canonicalThreadID) } + private func discardRestartContexts(for run: CodexReviewBackendModel.Review.Run) { + restartContextsByTokenID = restartContextsByTokenID.filter { _, context in + context.interruptedRun.attemptID != run.attemptID + } + } + private func markTurnAbandoned(_ turnID: String?) { guard let turnID = turnID?.nilIfEmpty else { return diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift index 3752d7ea..93b2fc7a 100644 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ b/Sources/CodexReviewHost/CodexReviewHost.swift @@ -230,18 +230,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 { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index d14666a6..da6b26c6 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1102,24 +1102,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 { diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index bd1c7eaf..8c74b938 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -12,12 +12,9 @@ package protocol CodexReviewBackend: Sendable { 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 diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 7b26e325..f85b5149 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -287,16 +287,16 @@ struct Run: Codable, Equatable, Sendable { package extension CodexReviewBackendModel.Review { -struct RecoveryToken: Equatable, Sendable { +struct RestartToken: Equatable, Sendable { + package var id: String package var interruptedRun: CodexReviewBackendModel.Review.Run - package var rollbackThreadID: String package init( - interruptedRun: CodexReviewBackendModel.Review.Run, - rollbackThreadID: String + id: String, + interruptedRun: CodexReviewBackendModel.Review.Run ) { + self.id = id self.interruptedRun = interruptedRun - self.rollbackThreadID = rollbackThreadID } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index ad4d13d4..a26307f6 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -46,12 +46,9 @@ 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 diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index b0f4d568..5abe0a71 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -660,7 +660,7 @@ extension CodexReviewStore { job: job, startRequest: startRequest, inputs: inputs, - recoveryToken: recoveryState.recoveryToken + preparedRestartToken: recoveryState.preparedRestartToken ) { case .continueWaiting: recoveryState.markWaitingForNetworkRecovery() @@ -688,11 +688,8 @@ extension CodexReviewStore { reviewRecoveryWaitingJobIDs.insert(job.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) } } @@ -750,7 +747,7 @@ extension CodexReviewStore { job: CodexReviewJob, startRequest: CodexReviewBackendModel.Review.Start, inputs: ReviewWorkerInputs, - recoveryToken: CodexReviewBackendModel.Review.RecoveryToken? + preparedRestartToken: CodexReviewBackendModel.Review.RestartToken? ) async throws -> NetworkRestoreRestartResult { if job.isTerminal || completePendingCancellationIfNeeded(for: job) { return .finished @@ -764,11 +761,11 @@ extension CodexReviewStore { 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 @@ -1184,12 +1181,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 } @@ -1201,14 +1196,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 @@ -1240,7 +1235,7 @@ private struct ReviewNetworkRecoveryLoopState { isWaitingForNetworkRecovery && isSettlingForNetworkRecovery && recoverySettleGeneration == recoveryGeneration - && recoveryToken != nil + && preparedRestartToken != nil } func shouldConsumeEvent(from run: CodexReviewBackendModel.Review.Run) -> Bool { diff --git a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift index d75426d9..b5f89460 100644 --- a/Sources/CodexReviewKit/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/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 5570d335..44fb0283 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -121,8 +121,8 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { 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) } @@ -135,11 +135,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 { @@ -188,8 +188,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) { @@ -272,9 +272,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 @@ -286,7 +286,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 @@ -294,25 +294,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 @@ -324,7 +324,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 @@ -332,19 +332,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() } } @@ -420,13 +420,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() } @@ -436,21 +435,21 @@ 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) @@ -512,12 +511,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() } } @@ -770,18 +769,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 { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index fb679333..d9ff7724 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -369,7 +369,7 @@ struct AppServerClientTests { #expect(interruptParams["turnId"] as? String == "turn-1") } - @Test func beginReviewRecoveryCancelsLiveSessionThroughReviewThreadWhenHookIsNeeded() async throws { + @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") @@ -386,13 +386,16 @@ struct AppServerClientTests { for: "turn/interrupt" ) try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "review-thread") - let token = try await backend.beginReviewRecovery( - attempt.run, - reason: .init(message: "Recover after network outage") - ) + let token = try await backend.prepareReviewRestart(attempt.run) + let restartedAttempt = try await backend.restartPreparedReview(token, request: makeReviewStart()) - #expect(token.rollbackThreadID == "review-thread") + #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", @@ -401,6 +404,8 @@ struct AppServerClientTests { "thread/resume", "turn/interrupt", "turn/interrupt", + "thread/rollback", + "review/start", ]) let resume = try #require(requests.first { $0.method == "thread/resume" }) let resumeParams = try jsonObject(from: resume.params) @@ -409,6 +414,14 @@ struct AppServerClientTests { 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") } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 37e0c44c..d7021dca 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -2500,7 +2500,7 @@ struct CodexReviewStoreCommandTests { let attemptedRecovery = await waitUntil(timeout: .milliseconds(100)) { let commands = await backend.recordedCommands() return commands.contains { command in - if case .beginReviewRecovery = command { + if case .prepareReviewRestart = command { true } else { false @@ -2510,7 +2510,7 @@ struct CodexReviewStoreCommandTests { #expect(attemptedRecovery == false) let commands = await backend.recordedCommands() #expect(commands.contains { command in - if case .beginReviewRecovery = command { + if case .prepareReviewRestart = command { true } else { false @@ -2539,7 +2539,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) let running = try store.readReview(jobID: "job-1") #expect(running.core.lifecycle.status == .running) @@ -2581,13 +2581,13 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(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 await backend.waitForRestartPreparedReview(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)) @@ -2597,7 +2597,7 @@ struct CodexReviewStoreCommandTests { #expect(final.core.output.lastAgentMessage == "recovered review") let commands = await backend.recordedCommands() #expect(commands.contains { command in - if case .resumeReviewRecovery = command { + if case .restartPreparedReview = command { true } else { false @@ -2644,7 +2644,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) await sleeper.blockFutureSleeps() networkMonitor.yield(.satisfied()) #expect(await waitUntil { @@ -2654,7 +2654,7 @@ struct CodexReviewStoreCommandTests { 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 await backend.waitForRestartPreparedReview(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 @@ -2664,7 +2664,7 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "recovered review") let commands = await backend.recordedCommands() #expect(commands.contains { command in - if case .resumeReviewRecovery = command { + if case .restartPreparedReview = command { true } else { false @@ -2711,7 +2711,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) await sleeper.blockFutureSleeps() networkMonitor.yield(.satisfied()) #expect(await waitUntil { @@ -2719,7 +2719,7 @@ struct CodexReviewStoreCommandTests { }) networkMonitor.yield(.satisfied()) await settleGate.open() - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) @@ -2776,10 +2776,10 @@ struct CodexReviewStoreCommandTests { ), for: initialRun) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) _ = try await running networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) @@ -2835,10 +2835,10 @@ struct CodexReviewStoreCommandTests { }) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) let commandsAfterInterrupt = await backend.recordedCommands() let interruptedRuns = commandsAfterInterrupt.compactMap { command -> CodexReviewBackendModel.Review.Run? in - if case .beginReviewRecovery(let run, _) = command { + if case .prepareReviewRestart(let run) = command { return run } return nil @@ -2846,10 +2846,10 @@ struct CodexReviewStoreCommandTests { #expect(interruptedRuns.last?.turnID == "turn-actual") networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) let commandsAfterRecovery = await backend.recordedCommands() let recoveredFromRuns = commandsAfterRecovery.compactMap { command -> CodexReviewBackendModel.Review.Run? in - if case .resumeReviewRecovery(let token, _) = command { + if case .restartPreparedReview(let token, _) = command { return token.interruptedRun } return nil @@ -2895,11 +2895,11 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(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)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) #expect(await waitUntil { guard let read = try? store.readReview(jobID: "job-1") else { return false @@ -2959,9 +2959,9 @@ struct CodexReviewStoreCommandTests { } != nil) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.messageDelta("fresh review", itemID: "message-1"), for: recoveredRun) @@ -3008,9 +3008,9 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(summary: "Succeeded.", result: "stale review"), for: initialRun) @@ -3040,7 +3040,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend(nextRun: initialRun) await backend.setNextRecoveredRun(recoveredRun) let recoverGate = AsyncGate() - await backend.holdResumeReviewRecovery(with: recoverGate) + await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -3055,9 +3055,9 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + 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)) @@ -3101,10 +3101,10 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) await backend.finishEvents(for: initialRun) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) @@ -3133,7 +3133,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend(nextRun: initialRun) await backend.setNextRecoveredRun(recoveredRun) let recoverGate = AsyncGate() - await backend.holdResumeReviewRecovery(with: recoverGate) + await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -3148,9 +3148,9 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) #expect(cancel.cancelled) @@ -3196,7 +3196,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) _ = try await running await backend.finishEvents(for: initialRun) @@ -3316,7 +3316,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) _ = try await running let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( @@ -3418,7 +3418,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) await backend.finishEvents(for: initialRun) @@ -3458,12 +3458,12 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(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 await backend.waitForRestartPreparedReview(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 @@ -3502,14 +3502,14 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .cancelled) let commands = await backend.recordedCommands() #expect(commands.contains { command in - if case .beginReviewRecovery = command { + if case .prepareReviewRestart = command { true } else { false } } == false) #expect(commands.contains { command in - if case .resumeReviewRecovery = command { + if case .restartPreparedReview = command { true } else { false @@ -3535,7 +3535,7 @@ struct CodexReviewStoreCommandTests { ) networkMonitor.yield(.init(status: .requiresConnection)) - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) let read = try await result @@ -3738,9 +3738,9 @@ struct CodexReviewStoreCommandTests { #expect(failedBeforeOutageConfirmed == false) await debounceGate.open() - try await backend.waitForBeginReviewRecovery(timeout: .seconds(2)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) networkMonitor.yield(.satisfied()) - try await backend.waitForResumeReviewRecovery(timeout: .seconds(2)) + try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) From cbf27a6de91547cba2b7b32abc0699c63c442f5f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:15:09 +0900 Subject: [PATCH 065/359] refactor(appserver): use CodexKit review lifecycle identity --- Package.resolved | 2 +- Package.swift | 2 +- .../AppServerCodexReviewBackend.swift | 344 +++++++++++------- .../AppServerReviewControl.swift | 130 ------- .../AppServerClientTests.swift | 18 +- .../CodexReviewHostTests.swift | 11 + .../xcshareddata/swiftpm/Package.resolved | 10 +- 7 files changed, 258 insertions(+), 259 deletions(-) delete mode 100644 Sources/CodexReviewAppServer/AppServerReviewControl.swift diff --git a/Package.resolved b/Package.resolved index 92999acb..3e990e4c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "717dddc6cb778dfd72435da550b35b1690998ff8" + "revision" : "a9c18e92bc3be26093525f6396d82e7050d1acb5" } }, { diff --git a/Package.swift b/Package.swift index e20d5644..37e25c38 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ private let codexKitDependency: Package.Dependency = { // this with the final pinned remote CodexKit release dependency before release. return .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "717dddc6cb778dfd72435da550b35b1690998ff8" + revision: "a9c18e92bc3be26093525f6396d82e7050d1acb5" ) }() diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 33e48d81..52e04938 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -12,22 +12,55 @@ private func makeAppServerReviewAttemptID() -> String { UUID().uuidString } +private struct AppServerReviewCancellation: Equatable, Sendable { + var threadID: String + var turnID: String + + init(threadID: String, turnID: String) { + self.threadID = threadID + self.turnID = turnID + } + + init(_ cancellation: CodexTurnCancellation) { + self.init( + threadID: cancellation.threadID.rawValue, + turnID: cancellation.turnID?.rawValue ?? "" + ) + } + + func cleanupIdentity( + sourceRun run: CodexReviewBackendModel.Review.Run + ) -> CodexReviewIdentity? { + let turnID = turnID.nilIfEmpty ?? run.turnID?.nilIfEmpty + guard let turnID else { + return nil + } + let sourceThreadID = CodexThreadID(rawValue: run.threadID) + let cancelledThreadID = CodexThreadID(rawValue: threadID) + return CodexReviewIdentity( + threadID: sourceThreadID, + turnID: .init(rawValue: turnID), + reviewThreadID: cancelledThreadID == sourceThreadID ? nil : cancelledThreadID, + model: run.model + ) + } +} + private struct AppServerReviewRestartContext: Sendable { var interruptedRun: CodexReviewBackendModel.Review.Run - var rollbackThreadID: String + var rollbackThreadID: CodexThreadID + var rollbackModel: String? } package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" private let appServer: CodexAppServer - private var controlsByThreadID: [String: AppServerReviewControl] = [:] 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 threadsByThreadID: [String: CodexThread] = [:] + private var retainedCleanupIdentitiesBySourceThreadID: [String: [CodexReviewIdentity]] = [:] private var restartContextsByTokenID: [String: AppServerReviewRestartContext] = [:] private var abandonedReviewAttemptIDs: Set = [] private var abandonedTurnIDs: Set = [] @@ -109,10 +142,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - let control = AppServerReviewControl() let thread = try await startReviewThread(appServer: appServer, request: request) - threadsByThreadID[thread.id.rawValue] = thread - controlsByThreadID[thread.id.rawValue] = control let attemptID = makeAppServerReviewAttemptID() let provisionalRun = CodexReviewBackendModel.Review.Run( @@ -121,9 +151,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewThreadID: thread.id.rawValue, model: thread.model ?? request.model ) - let session = AppServerReviewEventSession(run: provisionalRun, control: control) + let session = AppServerReviewEventSession(run: provisionalRun) registerReviewEventSession(session, for: provisionalRun) - control.recordThreadStarted() let review: CodexReviewSession do { @@ -142,7 +171,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) await session.updateRun(run) registerReviewEventSession(session, for: run) - control.recordReviewStarted(review) await session.startConsuming(review) return await session.attempt() @@ -179,7 +207,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { let session = await reviewEventSession(for: run) await session.requestCancellation(message: reason.message) do { - _ = try await sendTurnCancellation(for: run) + _ = try await cancelReviewTurn(for: run) await finishReviewEventStream( threadID: run.threadID, cancellationMessage: reason.message, @@ -195,14 +223,18 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { _ run: CodexReviewBackendModel.Review.Run ) async throws -> CodexReviewBackendModel.Review.RestartToken { markTurnAbandoned(run.turnID) - let cancellation = try await sendTurnCancellation(for: run) { retryCancellation in + let cancellation = try await cancelReviewTurn(for: run) { retryCancellation in await self.markCancellationTurnAbandoned(retryCancellation, canonicalThreadID: run.threadID) } markAttemptAbandoned(run, cancellation: cancellation) if let session = unregisterReviewEventSession(for: run) { await session.abandon() let metrics = await session.metricsSnapshot() - for threadID in await session.cleanupThreadIDs() { + let cleanupThreadIDs = orderedCleanupThreadIDs( + sourceThreadID: run.threadID, + cleanupThreadIDSequences(for: run) + [await session.cleanupThreadIDs()] + ) + for threadID in cleanupThreadIDs { completedReviewEventSessionMetricsByThreadID[threadID] = metrics } } @@ -210,7 +242,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { let tokenID = UUID().uuidString restartContextsByTokenID[tokenID] = AppServerReviewRestartContext( interruptedRun: run, - rollbackThreadID: cancellation.threadID + rollbackThreadID: .init(rawValue: cancellation.threadID), + rollbackModel: run.model ) return CodexReviewBackendModel.Review.RestartToken( id: tokenID, @@ -226,11 +259,13 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { throw CodexReviewAPI.Error.io("Prepared review restart is no longer available.") } let interruptedRun = restartContext.interruptedRun - let rollbackThread = try await threadHandle(id: restartContext.rollbackThreadID, model: interruptedRun.model) + let rollbackThread = try await appServer.resumeThread( + restartContext.rollbackThreadID, + options: .init(model: restartContext.rollbackModel) + ) try await rollbackThread.rollback(turnCount: 1) - let control = reviewControl(for: interruptedRun) - let thread = try await reviewThread(for: interruptedRun) + let thread = try await sourceReviewThread(for: interruptedRun, fallbackModel: request.model) let attemptID = makeAppServerReviewAttemptID() let provisionalRun = CodexReviewBackendModel.Review.Run( attemptID: attemptID, @@ -238,7 +273,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewThreadID: interruptedRun.threadID, model: thread.model ?? interruptedRun.model ?? request.model ) - let session = AppServerReviewEventSession(run: provisionalRun, control: control) + let session = AppServerReviewEventSession(run: provisionalRun) registerReviewEventSession(session, for: provisionalRun) let review: CodexReviewSession @@ -259,7 +294,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) await session.updateRun(recoveredRun) registerReviewEventSession(session, for: recoveredRun) - controlsByThreadID[interruptedRun.threadID]?.recordReviewStarted(review) await session.startConsuming(review) return await session.attempt() @@ -267,18 +301,28 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { discardRestartContexts(for: run) - controlsByThreadID.removeValue(forKey: run.threadID) - var cleanupThreadIDs = cleanupThreadIDs(for: run) + var cleanupSequences = cleanupThreadIDSequences(for: run) + var completedMetrics: ReviewBackendEventSessionMetrics? if let session = unregisterReviewEventSession(for: run) { await session.finish(cancellationMessage: nil) let metrics = await session.metricsSnapshot() - cleanupThreadIDs = mergedCleanupThreadIDs(cleanupThreadIDs, await session.cleanupThreadIDs()) + cleanupSequences.append(await session.cleanupThreadIDs()) + completedMetrics = metrics + } + let cleanupThreadIDs = orderedCleanupThreadIDs( + sourceThreadID: run.threadID, + cleanupSequences + ) + if let completedMetrics { for threadID in cleanupThreadIDs { - completedReviewEventSessionMetricsByThreadID[threadID] = metrics + completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics } } for threadID in cleanupThreadIDs { - guard let thread = try? await threadHandle(id: threadID, model: run.model) else { + guard let thread = try? await appServer.resumeThread( + .init(rawValue: threadID), + options: .init(model: run.model) + ) else { continue } try? await thread.delete() @@ -286,9 +330,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) - threadsByThreadID.removeValue(forKey: threadID) } - reviewThreadIDsForCleanupByThreadID.removeValue(forKey: run.threadID) + retainedCleanupIdentitiesBySourceThreadID.removeValue(forKey: run.threadID) } package func cleanupActiveReviewsForShutdown(reason: CodexReviewBackendModel.CancellationReason) async { @@ -355,40 +398,19 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { registerReviewEventSession(session, for: run) return session } - let control = reviewControl(for: run) - let session = AppServerReviewEventSession(run: run, control: control) + let session = AppServerReviewEventSession(run: run) registerReviewEventSession(session, for: run) return session } - private func reviewControl(for run: CodexReviewBackendModel.Review.Run) -> AppServerReviewControl { - if let control = controlsByThreadID[run.threadID] { - return control - } - let control = AppServerReviewControl() - if let turnID = run.turnID { - control.recordReviewStarted(turnID: turnID) - } else { - control.recordThreadStarted() - } - controlsByThreadID[run.threadID] = control - return control - } - - private func reviewThread(for run: CodexReviewBackendModel.Review.Run) async throws -> CodexThread { - try await threadHandle(id: run.threadID, model: run.model) - } - - private func threadHandle(id threadID: String, model: String?) async throws -> CodexThread { - if let thread = threadsByThreadID[threadID] { - return thread - } - let thread = try await appServer.resumeThread( - .init(rawValue: threadID), - options: .init(model: model) + private func sourceReviewThread( + for run: CodexReviewBackendModel.Review.Run, + fallbackModel: String? + ) async throws -> CodexThread { + try await appServer.resumeThread( + .init(rawValue: run.threadID), + options: .init(model: run.model ?? fallbackModel) ) - threadsByThreadID[threadID] = thread - return thread } private func registerReviewEventSession( @@ -396,7 +418,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { for run: CodexReviewBackendModel.Review.Run ) { reviewEventSessionsByAttemptID[run.attemptID] = session - let activeThreadIDs = Set([run.threadID, run.reviewThreadID].compactMap { $0?.nilIfEmpty }) + 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) @@ -407,11 +429,9 @@ 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 { reviewEventSessionCanonicalThreadIDByThreadID[reviewThreadID] = run.threadID - noteReviewThreadIDForCleanup(reviewThreadID, canonicalThreadID: run.threadID) } } @@ -430,23 +450,16 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } 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) + 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, cancellation: AppServerReviewCancellation @@ -454,7 +467,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { abandonedReviewAttemptIDs.insert(run.attemptID) markTurnAbandoned(run.turnID) markTurnAbandoned(cancellation.turnID) - noteReviewThreadIDForCleanup(cancellation.threadID, canonicalThreadID: run.threadID) + rememberCleanupIdentity(for: run) + rememberCleanupIdentity(cancellation.cleanupIdentity(sourceRun: run)) } private func markCancellationTurnAbandoned( @@ -462,7 +476,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { canonicalThreadID: String ) { markTurnAbandoned(cancellation.turnID) - noteReviewThreadIDForCleanup(cancellation.threadID, canonicalThreadID: canonicalThreadID) + let sourceRun = CodexReviewBackendModel.Review.Run( + threadID: canonicalThreadID, + turnID: cancellation.turnID, + reviewThreadID: cancellation.threadID + ) + rememberCleanupIdentity(cancellation.cleanupIdentity(sourceRun: sourceRun)) } private func discardRestartContexts(for run: CodexReviewBackendModel.Review.Run) { @@ -502,70 +521,77 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) } - private func sendTurnCancellation( + private func cancelReviewTurn( for run: CodexReviewBackendModel.Review.Run, willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil ) async throws -> AppServerReviewCancellation { - let control = reviewControl(for: run) - if let cancellation = try await control.cancel(willCancelActiveTurn: willCancelActiveTurn) { - return cancellation - } - - guard let expectedTurnID = run.turnID?.nilIfEmpty else { + guard let identity = run.appServerReviewIdentity else { throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.") } - let thread = try await activeReviewTurnThread(for: run) - let cancellation = try await thread.cancelActiveTurn( - expectedTurnID: .init(rawValue: expectedTurnID) - ) { retryCancellation in - let reviewCancellation = AppServerReviewCancellation(retryCancellation) - if reviewCancellation.turnID != expectedTurnID, - let willCancelActiveTurn { - await willCancelActiveTurn(reviewCancellation) - } + if let session = reviewEventSessionsByAttemptID[run.attemptID], + let cancellation = try await session.cancelReview( + expectedTurnID: identity.turnID.rawValue, + willCancelActiveTurn: willCancelActiveTurn + ) { + return cancellation } - let reviewCancellation = AppServerReviewCancellation(cancellation) - if reviewCancellation.turnID != expectedTurnID { - control.recordReviewStarted(turnID: reviewCancellation.turnID) + let review = try await appServer.resumeReview( + identity, + threadOptions: .init(model: run.model) + ) + let cancellation: CodexTurnCancellation + if let willCancelActiveTurn { + cancellation = try await review.cancel { retryCancellation in + let reviewCancellation = AppServerReviewCancellation(retryCancellation) + if reviewCancellation.turnID != identity.turnID.rawValue { + await willCancelActiveTurn(reviewCancellation) + } + } + } else { + cancellation = try await review.cancel() } - return reviewCancellation + return AppServerReviewCancellation(cancellation) + } + + private func cleanupThreadIDSequences( + for run: CodexReviewBackendModel.Review.Run + ) -> [[String]] { + let retainedSequences = retainedCleanupIdentitiesBySourceThreadID[run.threadID, default: []] + .map { $0.cleanupThreadIDs.map(\.rawValue) } + return retainedSequences + [run.appServerCleanupThreadIDs] } - private func cleanupThreadIDs(for run: CodexReviewBackendModel.Review.Run) -> [String] { + private func orderedCleanupThreadIDs( + sourceThreadID: String, + _ sequences: [[String]] + ) -> [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) + for sequence in sequences { + for threadID in sequence where threadID != sourceThreadID && seen.insert(threadID).inserted { + threadIDs.append(threadID) + } } - if seen.insert(run.threadID).inserted { - threadIDs.append(run.threadID) + if seen.insert(sourceThreadID).inserted { + threadIDs.append(sourceThreadID) } return threadIDs } - private func activeReviewTurnThread(for run: CodexReviewBackendModel.Review.Run) async throws -> CodexThread { - try await threadHandle(id: activeReviewTurnThreadID(for: run), model: run.model) - } - - private func activeReviewTurnThreadID(for run: CodexReviewBackendModel.Review.Run) -> String { - run.reviewThreadID?.nilIfEmpty ?? run.threadID + private func rememberCleanupIdentity( + for run: CodexReviewBackendModel.Review.Run + ) { + rememberCleanupIdentity(run.appServerReviewIdentity) } - private func mergedCleanupThreadIDs(_ lhs: [String], _ rhs: [String]) -> [String] { - var seen: Set = [] - var merged: [String] = [] - for threadID in lhs + rhs where seen.insert(threadID).inserted { - merged.append(threadID) + private func rememberCleanupIdentity(_ identity: CodexReviewIdentity?) { + guard let identity else { + return + } + let sourceThreadID = identity.sourceThreadID.rawValue + if retainedCleanupIdentitiesBySourceThreadID[sourceThreadID, default: []].contains(identity) == false { + retainedCleanupIdentitiesBySourceThreadID[sourceThreadID, default: []].append(identity) } - return merged } } @@ -1183,21 +1209,17 @@ private extension CodexThreadItem { private actor AppServerReviewEventSession { private let pipeline: ReviewBackendEventSession private var typedReviewStreamTask: Task? + private var reviewSession: CodexReviewSession? init( run: CodexReviewBackendModel.Review.Run, - control: AppServerReviewControl, mailbox: BackendReviewEventMailbox = .init() ) { self.pipeline = ReviewBackendEventSession( run: run, mailbox: mailbox, callbacks: .init( - recordTurnStarted: { turnID in - control.recordTurnStarted(turnID: turnID) - }, recordFinished: { run, metrics in - control.finish() 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)" ) @@ -1265,14 +1287,39 @@ private actor AppServerReviewEventSession { guard typedReviewStreamTask == nil else { return } + reviewSession = review typedReviewStreamTask = Task { [weak self] in await self?.consume(review) } } + func cancelReview( + expectedTurnID: String, + willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil + ) async throws -> AppServerReviewCancellation? { + guard let reviewSession else { + return nil + } + let cancellation: CodexTurnCancellation + if let willCancelActiveTurn { + cancellation = try await reviewSession.cancel { retryCancellation in + let reviewCancellation = AppServerReviewCancellation(retryCancellation) + if reviewCancellation.turnID != expectedTurnID { + await willCancelActiveTurn(reviewCancellation) + } + } + } else { + cancellation = try await reviewSession.cancel() + } + return AppServerReviewCancellation(cancellation) + } + private func consume(_ review: CodexReviewSession) async { defer { typedReviewStreamTask = nil + if reviewSession?.id == review.id { + reviewSession = nil + } } let run = await pipeline.currentRun() await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run)) @@ -1298,6 +1345,59 @@ private actor AppServerReviewEventSession { private func cancelTypedReviewStream() { typedReviewStreamTask?.cancel() typedReviewStreamTask = nil + reviewSession = nil + } +} + +private extension CodexReviewBackendModel.Review.Run { + var appServerReviewIdentity: CodexReviewIdentity? { + guard let turnID = turnID?.nilIfEmpty else { + return 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 + ) + } + + var appServerAssociatedThreadIDs: [String] { + if let identity = appServerReviewIdentity { + return identity.associatedThreadIDs.map(\.rawValue) + } + return fallbackThreadIDs(sourceLast: false) + } + + var appServerCleanupThreadIDs: [String] { + if let identity = appServerReviewIdentity { + return identity.cleanupThreadIDs.map(\.rawValue) + } + return fallbackThreadIDs(sourceLast: true) + } + + private func fallbackThreadIDs(sourceLast: Bool) -> [String] { + var seen: Set = [] + var threadIDs: [String] = [] + let reviewThreadID = reviewThreadID?.nilIfEmpty + if sourceLast, + let reviewThreadID, + reviewThreadID != threadID, + seen.insert(reviewThreadID).inserted { + threadIDs.append(reviewThreadID) + } + if seen.insert(threadID).inserted { + threadIDs.append(threadID) + } + if sourceLast == false, + let reviewThreadID, + reviewThreadID != threadID, + seen.insert(reviewThreadID).inserted { + threadIDs.append(reviewThreadID) + } + return threadIDs } } diff --git a/Sources/CodexReviewAppServer/AppServerReviewControl.swift b/Sources/CodexReviewAppServer/AppServerReviewControl.swift deleted file mode 100644 index 687f4ed3..00000000 --- a/Sources/CodexReviewAppServer/AppServerReviewControl.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import CodexAppServerKit -import CodexReviewKit - -package struct AppServerReviewCancellation: Equatable, Sendable { - package var threadID: String - package var turnID: String - - package init(threadID: String, turnID: String) { - self.threadID = threadID - self.turnID = turnID - } - - package init(_ cancellation: CodexTurnCancellation) { - self.init( - threadID: cancellation.threadID.rawValue, - turnID: cancellation.turnID?.rawValue ?? "" - ) - } -} - -package final class AppServerReviewControl: @unchecked Sendable { - private enum Phase { - case preparing - case threadStarted - case reviewStarted(turnID: String) - case finished - } - - private struct Snapshot { - var phase: Phase - var reviewSession: CodexReviewSession? - } - - private let phaseLock = NSLock() - private var phase: Phase = .preparing - private var reviewSession: CodexReviewSession? - - package init() {} - - package func recordThreadStarted() { - phaseLock.lock() - defer { phaseLock.unlock() } - guard case .preparing = phase else { - return - } - phase = .threadStarted - reviewSession = nil - } - - package func recordReviewStarted(turnID: String) { - phaseLock.lock() - defer { phaseLock.unlock() } - phase = .reviewStarted(turnID: turnID) - reviewSession = nil - } - - package func recordReviewStarted(_ reviewSession: CodexReviewSession) { - phaseLock.lock() - defer { phaseLock.unlock() } - phase = .reviewStarted(turnID: reviewSession.turnID.rawValue) - self.reviewSession = reviewSession - } - - package func recordTurnStarted(turnID: String) { - phaseLock.lock() - defer { phaseLock.unlock() } - switch phase { - case .preparing, .finished: - return - case .threadStarted: - phase = .reviewStarted(turnID: turnID) - case .reviewStarted: - phase = .reviewStarted(turnID: turnID) - } - } - - @discardableResult - package func cancel( - willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil - ) async throws -> AppServerReviewCancellation? { - let snapshot = stateSnapshot() - switch snapshot.phase { - case .preparing, .finished: - return nil - case .threadStarted: - return nil - case .reviewStarted(let turnID): - guard let reviewSession = snapshot.reviewSession else { - return nil - } - guard willCancelActiveTurn == nil else { - return nil - } - return try await cancel(reviewSession, expectedTurnID: turnID) - } - } - - package func finish() { - phaseLock.lock() - defer { phaseLock.unlock() } - phase = .finished - reviewSession = nil - } - - private func stateSnapshot() -> Snapshot { - phaseLock.lock() - defer { phaseLock.unlock() } - return Snapshot(phase: phase, reviewSession: reviewSession) - } - - private func cancel( - _ reviewSession: CodexReviewSession, - expectedTurnID: String - ) async throws -> AppServerReviewCancellation { - let cancellation = try await reviewSession.cancel() - let reviewCancellation = AppServerReviewCancellation(cancellation) - if reviewCancellation.turnID != expectedTurnID { - setPhase(.reviewStarted(turnID: reviewCancellation.turnID), reviewSession: reviewSession) - } - return reviewCancellation - } - - private func setPhase(_ phase: Phase, reviewSession: CodexReviewSession? = nil) { - phaseLock.lock() - defer { phaseLock.unlock() } - self.phase = phase - self.reviewSession = reviewSession - } -} diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index d9ff7724..87bd104d 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -297,6 +297,10 @@ struct AppServerClientTests { for: "thread/resume" ) try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueJSON( + #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, + for: "thread/resume" + ) try await runtime.transport.enqueueEmpty(for: "thread/delete") let backend = AppServerCodexReviewBackend(appServer: runtime.server) @@ -387,6 +391,10 @@ struct AppServerClientTests { ) try await runtime.transport.enqueueEmpty(for: "turn/interrupt") 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") let token = try await backend.prepareReviewRestart(attempt.run) @@ -401,15 +409,17 @@ struct AppServerClientTests { "initialize", "thread/start", "review/start", - "thread/resume", "turn/interrupt", "turn/interrupt", + "thread/resume", "thread/rollback", + "thread/resume", "review/start", ]) - let resume = try #require(requests.first { $0.method == "thread/resume" }) - let resumeParams = try jsonObject(from: resume.params) - #expect(resumeParams["threadId"] as? String == "review-thread") + let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { + try jsonObject(from: $0.params)["threadId"] as? String + } + #expect(resumeThreadIDs == ["review-thread", "thread-1"]) let interruptTurnIDs = try requests.filter { $0.method == "turn/interrupt" }.map { try jsonObject(from: $0.params)["turnId"] as? String } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index f3b5391d..d664e7b1 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1321,6 +1321,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.enqueueJSON( + #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, + for: "thread/resume" + ) + try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, @@ -1433,6 +1438,12 @@ struct CodexReviewHostTests { for: "thread/resume" ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + try await transport.enqueue(EmptyResponse(), for: "thread/delete") + try await transport.enqueueJSON( + #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, + for: "thread/resume" + ) + try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, 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..b5434692 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", + "originHash" : "e2ffbde1e6ac921858b2932a576a2fec0c5df351dd837e1df2cd5205bbd8358a", "pins" : [ + { + "identity" : "codexkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/CodexKit.git", + "state" : { + "revision" : "a9c18e92bc3be26093525f6396d82e7050d1acb5" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", From 758579453bd64e9db99f9e164962725f22931649 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:42:28 +0900 Subject: [PATCH 066/359] fix(appserver): delete cleanup threads by identity --- .../AppServerCodexReviewBackend.swift | 8 +------- .../AppServerClientTests.swift | 8 -------- .../CodexReviewHostTests/CodexReviewHostTests.swift | 12 ------------ 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 52e04938..5595d2ed 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -319,13 +319,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } } for threadID in cleanupThreadIDs { - guard let thread = try? await appServer.resumeThread( - .init(rawValue: threadID), - options: .init(model: run.model) - ) else { - continue - } - try? await thread.delete() + try? await appServer.deleteThread(.init(rawValue: threadID)) } for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 87bd104d..2adf142a 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -292,15 +292,7 @@ struct AppServerClientTests { 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.enqueueJSON( - #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, - for: "thread/resume" - ) try await runtime.transport.enqueueEmpty(for: "thread/delete") - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" - ) try await runtime.transport.enqueueEmpty(for: "thread/delete") let backend = AppServerCodexReviewBackend(appServer: runtime.server) diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index d664e7b1..bdb3e26f 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1321,10 +1321,6 @@ 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.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" - ) try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1433,16 +1429,8 @@ struct CodexReviewHostTests { AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), for: "review/start" ) - try await transport.enqueueJSON( - #"{"thread":{"id":"review-thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" - ) try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") try await transport.enqueue(EmptyResponse(), for: "thread/delete") - try await transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" - ) try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], From f17d92de35e1b3cb1990bef8dd41ff5e5ab669aa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:14:07 +0900 Subject: [PATCH 067/359] chore(deps): require remote CodexKit pin --- .gitignore | 4 ---- Package.swift | 34 ++++++---------------------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index a9c9352b..2da8eaa9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,3 @@ DerivedData/ .swiftpm/xcode/xcuserdata/ .netrc *.profraw - -# Optional local development dependency checkouts. These are intentionally -# untracked; Package.swift falls back to pinned remote dependencies when absent. -/dependencies/ diff --git a/Package.swift b/Package.swift index 37e25c38..77e6b671 100644 --- a/Package.swift +++ b/Package.swift @@ -1,34 +1,7 @@ // swift-tools-version: 6.3 -import Foundation import PackageDescription -private let packageDirectory = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() -private let codexKitDevelopmentPath = "dependencies/CodexKit" -private let usesLocalCodexKit = ProcessInfo.processInfo.environment["CODEX_REVIEWKIT_USE_LOCAL_CODEXKIT"] == "1" -private let codexKitDevelopmentManifestPath = packageDirectory - .appendingPathComponent(codexKitDevelopmentPath) - .appendingPathComponent("Package.swift") - .path -private let codexKitDependency: Package.Dependency = { - if usesLocalCodexKit, - FileManager.default.fileExists(atPath: codexKitDevelopmentManifestPath) { - // Development-only CodexKit integration checkout. Set - // CODEX_REVIEWKIT_USE_LOCAL_CODEXKIT=1 to use dependencies/CodexKit while - // this branch tracks in-flight APIs. The default stays on the pinned - // remote dependency so fresh checkouts and locked resolution remain stable. - return .package(path: codexKitDevelopmentPath) - } - - // Until CodexKit is released, use the pinned integration revision; replace - // this with the final pinned remote CodexKit release dependency before release. - return .package( - url: "https://github.com/lynnswap/CodexKit.git", - revision: "a9c18e92bc3be26093525f6396d82e7050d1acb5" - ) -}() - let package = Package( name: "CodexReviewKit", platforms: [ @@ -56,7 +29,12 @@ 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")), - codexKitDependency, + // Until CodexKit is released, use the pinned integration revision; replace + // this with the final pinned remote CodexKit release dependency before release. + .package( + url: "https://github.com/lynnswap/CodexKit.git", + revision: "a9c18e92bc3be26093525f6396d82e7050d1acb5" + ), ], targets: [ .target( From 2ed0d02e81275eb3dfc348f561e56567b970debb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:16:12 +0900 Subject: [PATCH 068/359] refactor(appserver): drop abandoned turn bookkeeping --- .../AppServerCodexReviewBackend.swift | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 5595d2ed..3e51820c 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -63,7 +63,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var retainedCleanupIdentitiesBySourceThreadID: [String: [CodexReviewIdentity]] = [:] private var restartContextsByTokenID: [String: AppServerReviewRestartContext] = [:] private var abandonedReviewAttemptIDs: Set = [] - private var abandonedTurnIDs: Set = [] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] package init(appServer: CodexAppServer) { @@ -222,9 +221,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func prepareReviewRestart( _ run: CodexReviewBackendModel.Review.Run ) async throws -> CodexReviewBackendModel.Review.RestartToken { - markTurnAbandoned(run.turnID) let cancellation = try await cancelReviewTurn(for: run) { retryCancellation in - await self.markCancellationTurnAbandoned(retryCancellation, canonicalThreadID: run.threadID) + await self.rememberCancellationCleanupIdentity(retryCancellation, canonicalThreadID: run.threadID) } markAttemptAbandoned(run, cancellation: cancellation) if let session = unregisterReviewEventSession(for: run) { @@ -459,17 +457,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { cancellation: AppServerReviewCancellation ) { abandonedReviewAttemptIDs.insert(run.attemptID) - markTurnAbandoned(run.turnID) - markTurnAbandoned(cancellation.turnID) rememberCleanupIdentity(for: run) rememberCleanupIdentity(cancellation.cleanupIdentity(sourceRun: run)) } - private func markCancellationTurnAbandoned( + private func rememberCancellationCleanupIdentity( _ cancellation: AppServerReviewCancellation, canonicalThreadID: String ) { - markTurnAbandoned(cancellation.turnID) let sourceRun = CodexReviewBackendModel.Review.Run( threadID: canonicalThreadID, turnID: cancellation.turnID, @@ -484,13 +479,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } } - private func markTurnAbandoned(_ turnID: String?) { - guard let turnID = turnID?.nilIfEmpty else { - return - } - abandonedTurnIDs.insert(turnID) - } - private func activeReviewRunsForShutdown() async -> [CodexReviewBackendModel.Review.Run] { let sessions = Array(reviewEventSessionsByAttemptID.values) var runsByAttemptID: [String: CodexReviewBackendModel.Review.Run] = [:] From f004f15a5e786156363f990b14ebd2939818d93b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:36:20 +0900 Subject: [PATCH 069/359] refactor(runtime): move teardown review cleanup into store --- .../AppServerCodexReviewBackend.swift | 20 ++----- .../LiveCodexReviewStoreBackend.swift | 32 ++++------ .../Store/CodexReviewStore.swift | 2 +- .../Store/CodexReviewStoreBackend.swift | 34 ++++++++++- .../Store/CodexReviewStoreCancellation.swift | 35 +++++++++++ .../CodexReviewStoreCommandTests.swift | 59 +++++++++++++++++++ 6 files changed, 145 insertions(+), 37 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3e51820c..3cf91769 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -326,33 +326,25 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { retainedCleanupIdentitiesBySourceThreadID.removeValue(forKey: run.threadID) } - package func cleanupActiveReviewsForShutdown(reason: CodexReviewBackendModel.CancellationReason) async { + package func cleanupActiveReviewsForShutdown(_ request: CodexReviewRuntimeStopReviewCleanupRequest) async { let runs = await activeReviewRunsForShutdown() - guard runs.isEmpty == false else { - return - } + 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 Task.isCancelled { return } - try? await interruptReview(run, reason: reason) + await cleanupReview(run) } } diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index da6b26c6..299a8ecd 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -280,7 +280,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer != nil } - var handlesActiveReviewStopCleanup: Bool { + var invokesRuntimeStopReviewCleanupDuringStop: Bool { true } @@ -491,30 +491,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return message } - private func cancelActiveReviewsForRuntimeTeardown( + private func cleanupActiveReviewsForRuntimeTeardown( store: CodexReviewStore, appServerBackend: AppServerCodexReviewBackend, reason: ReviewCancellation, timeoutWarning: String ) async { - let recoveryRunsForCleanup = store.reviewRecoveryWaitingJobIDs - .sorted() - .compactMap { store.activeRuns[$0] } - let didCleanup = await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) { - await appServerBackend.cleanupActiveReviewsForShutdown(reason: .init(message: reason.message)) - for run in recoveryRunsForCleanup { - await appServerBackend.cleanupReview(run) + let shutdownCleanupTimeout = shutdownCleanupTimeout + let cleanupResult = await store.cleanupActiveReviewsForRuntimeStop( + reason: reason, + workerDrainTimeout: shutdownCleanupTimeout + ) { request in + await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) { + await appServerBackend.cleanupActiveReviewsForShutdown(request) } } - let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( - reason: reason, - cancelWorkers: false - ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) - let didDrainReviewWorkers = await store.drainReviewWorkersForRuntimeStop( - timeout: shutdownCleanupTimeout - ) - if didCleanup == false || didDrainReviewWorkers == false { + if cleanupResult.didComplete == false { logger.warning("\(timeoutWarning, privacy: .public)") } } @@ -531,7 +523,7 @@ 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, @@ -1227,7 +1219,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index f60abe0c..b311512d 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -133,7 +133,7 @@ public final class CodexReviewStore { public func stop() async { let locallyCancelledJobIDs: [String] - if backend.handlesActiveReviewStopCleanup { + if backend.invokesRuntimeStopReviewCleanupDuringStop { locallyCancelledJobIDs = [] } else { locallyCancelledJobIDs = await requestActiveReviewCancellationsForRuntimeStop() diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index a26307f6..c2ae5765 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -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 @@ -54,8 +54,38 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { 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 index 16fcb3de..68f16516 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -133,6 +133,41 @@ extension CodexReviewStore { return activeJobIDs } + 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 locallyCancelledJobIDs = cancelActiveReviewsLocallyForRuntimeStop( + reason: reason, + cancelWorkers: false + ) + cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop( + timeout: workerDrainTimeout + ) + return .init( + didCompleteBackendCleanup: didCompleteBackendCleanup, + didDrainReviewWorkers: didDrainReviewWorkers + ) + } + + private func runtimeStopReviewCleanupRequest( + reason: ReviewCancellation + ) -> CodexReviewRuntimeStopReviewCleanupRequest { + let recoveryWaitingRuns = reviewRecoveryWaitingJobIDs + .sorted() + .compactMap { activeRuns[$0] } + return .init( + reason: .init(message: reason.message), + recoveryWaitingRuns: recoveryWaitingRuns + ) + } + @discardableResult package func cancelActiveReviewsLocallyForRuntimeStop( reason: ReviewCancellation = .system(message: "Review runtime stopped."), diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index d7021dca..659e4927 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -3331,6 +3331,53 @@ struct CodexReviewStoreCommandTests { } } + @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: { "job-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(jobID: "job-1") + + #expect(result.didComplete) + #expect(request.reason.message == "Review runtime stopped.") + #expect(request.recoveryWaitingRuns == [run]) + #expect(read.core.lifecycle.status == .cancelled) + #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", @@ -4201,6 +4248,18 @@ private func cleanupStoreCommandTest( 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 From 786e90ec754356d4031d463fc7c7195caeb391c9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:50:34 +0900 Subject: [PATCH 070/359] test(app-server): cover shutdown recovery cleanup --- .../AppServerClientTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 2adf142a..f5451c78 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -305,6 +305,33 @@ struct AppServerClientTests { #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"]) } + @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] + ) + + await backend.cleanupActiveReviewsForShutdown(request) + + 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"]) + } + @Test func interruptReviewCancelsReconstructedRunThroughResumedThread() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueJSON( From 29490fc51a3cd5d8819823dddd7b928657dcace3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:23:21 +0900 Subject: [PATCH 071/359] refactor(store): hide review runtime state dictionaries --- .../Store/CodexReviewStore.swift | 14 +- .../Store/CodexReviewStoreCancellation.swift | 21 +-- .../Store/CodexReviewStoreReviews.swift | 72 ++++---- .../Store/CodexReviewStoreRuntimeState.swift | 162 ++++++++++++++++++ .../Store/CodexReviewStoreTesting.swift | 16 +- Sources/CodexReviewTesting/TestSupport.swift | 5 +- .../CodexReviewStoreCommandTests.swift | 39 +++-- 7 files changed, 232 insertions(+), 97 deletions(-) create mode 100644 Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index b311512d..44d35e1a 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -22,12 +22,7 @@ public final class CodexReviewStore { @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 +66,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 { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 68f16516..5a3c0180 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -159,12 +159,9 @@ extension CodexReviewStore { private func runtimeStopReviewCleanupRequest( reason: ReviewCancellation ) -> CodexReviewRuntimeStopReviewCleanupRequest { - let recoveryWaitingRuns = reviewRecoveryWaitingJobIDs - .sorted() - .compactMap { activeRuns[$0] } return .init( reason: .init(message: reason.message), - recoveryWaitingRuns: recoveryWaitingRuns + recoveryWaitingRuns: runtimeState.recoveryWaitingRuns() ) } @@ -189,7 +186,7 @@ extension CodexReviewStore { ) } if cancelWorkers { - reviewWorkerTasks[jobID]?.cancel() + runtimeState.cancelActiveWorker(for: jobID) } } return activeJobIDs @@ -197,24 +194,18 @@ extension CodexReviewStore { 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) + runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(jobID: jobID) + runtimeState.clearRuntimeStopState(for: jobID) } } package func drainRuntimeStopDetachedReviewWorkers(timeout: Duration) async -> Bool { - let tasks = Array(runtimeStopDetachedReviewWorkerTasks.values) + let tasks = runtimeState.detachedWorkerTasks() return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) } package func drainReviewWorkersForRuntimeStop(timeout: Duration) async -> Bool { - let tasks = Array(reviewWorkerTasks.values) + Array(runtimeStopDetachedReviewWorkerTasks.values) + let tasks = runtimeState.allWorkerTasks() return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 5abe0a71..9634bd09 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -18,11 +18,11 @@ extension CodexReviewStore { let jobID = try beginReview(sessionID: sessionID, request: request) return try await withTaskCancellationHandler { _ = try await awaitReview(sessionID: sessionID, jobID: jobID) - await reviewWorkerTasks[jobID]?.value + await runtimeState.awaitActiveWorker(for: jobID) return try readReview(sessionID: sessionID, jobID: jobID) } onCancel: { Task { @MainActor [weak self] in - self?.reviewWorkerTasks[jobID]?.cancel() + self?.runtimeState.cancelActiveWorker(for: jobID) } } } @@ -78,7 +78,7 @@ extension CodexReviewStore { ) insertReviewJob(job) markReviewRunning(job, startedAt: createdAt) - startingJobIDs.insert(jobID) + runtimeState.markStarting(jobID) launchReviewWorker(jobID: jobID, sessionID: sessionID, request: validatedRequest) return jobID } @@ -88,10 +88,10 @@ extension CodexReviewStore { sessionID: String, request: CodexReviewAPI.Start.Request ) { - reviewWorkerTasks[jobID]?.cancel() - reviewWorkerTasks[jobID] = Task { [weak self] in + runtimeState.cancelActiveWorker(for: jobID) + runtimeState.setActiveWorker(Task { [weak self] in await self?.runReviewWorker(jobID: jobID, sessionID: sessionID, request: request) - } + }, for: jobID) } private func runReviewWorker( @@ -100,8 +100,8 @@ extension CodexReviewStore { request validatedRequest: CodexReviewAPI.Start.Request ) async { guard let job = job(id: jobID) else { - startingJobIDs.remove(jobID) - reviewWorkerTasks.removeValue(forKey: jobID) + runtimeState.clearStarting(jobID) + runtimeState.removeActiveWorker(for: jobID) return } let startRequest = CodexReviewBackendModel.Review.Start( @@ -114,13 +114,13 @@ extension CodexReviewStore { do { let backendAttempt = try await backend.startReview(startRequest) let backendRun = backendAttempt.run - startingJobIDs.remove(jobID) + runtimeState.clearStarting(jobID) run = backendRun if Task.isCancelled { throw CancellationError() } applyBackendRun(backendRun, to: job) - if let startupCancellation = startupCancellations.removeValue(forKey: jobID) { + if let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) { try? await backend.interruptReview( backendRun, reason: .init(message: startupCancellation.message) @@ -146,8 +146,7 @@ extension CodexReviewStore { if job.isTerminal { await backend.cleanupReview(backendRun) - activeRuns.removeValue(forKey: jobID) - reviewRecoveryWaitingJobIDs.remove(jobID) + runtimeState.clearReviewRunState(for: jobID) } else { let currentRun = try await consumeReviewEvents( for: backendAttempt, @@ -156,13 +155,12 @@ extension CodexReviewStore { ) run = currentRun await backend.cleanupReview(currentRun) - activeRuns.removeValue(forKey: jobID) - reviewRecoveryWaitingJobIDs.remove(jobID) + runtimeState.clearReviewRunState(for: jobID) } } catch let error where error is CancellationError || Task.isCancelled { - startingJobIDs.remove(jobID) - let startupCancellation = startupCancellations.removeValue(forKey: jobID) - if let cleanupRun = activeRuns[jobID] ?? run { + runtimeState.clearStarting(jobID) + let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) + if let cleanupRun = runtimeState.activeRun(for: jobID) ?? run { await interruptReviewAfterTaskCancellation(cleanupRun, job: job) await backend.cleanupReview(cleanupRun) } else if job.isTerminal == false || startupCancellation != nil { @@ -172,16 +170,14 @@ extension CodexReviewStore { cancellation: startupCancellation ?? job.core.lifecycle.cancellation ?? .system() ) } - activeRuns.removeValue(forKey: jobID) - reviewRecoveryWaitingJobIDs.remove(jobID) + runtimeState.clearReviewRunState(for: jobID) } catch { - startingJobIDs.remove(jobID) - let startupCancellation = startupCancellations.removeValue(forKey: jobID) - if let cleanupRun = activeRuns[jobID] ?? run { + runtimeState.clearStarting(jobID) + let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) + if let cleanupRun = runtimeState.activeRun(for: jobID) ?? run { await backend.cleanupReview(cleanupRun) } - activeRuns.removeValue(forKey: jobID) - reviewRecoveryWaitingJobIDs.remove(jobID) + runtimeState.clearReviewRunState(for: jobID) if job.isTerminal == false, let startupCancellation { try? completeCancellationLocally( jobID: job.id, @@ -192,8 +188,8 @@ extension CodexReviewStore { markReviewFailed(job, message: error.localizedDescription) } } - reviewWorkerTasks.removeValue(forKey: jobID) - runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: jobID) + runtimeState.removeActiveWorker(for: jobID) + runtimeState.removeDetachedWorker(for: jobID) } private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: CodexReviewJob) async { @@ -225,7 +221,7 @@ extension CodexReviewStore { } private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: CodexReviewJob) { - activeRuns[job.id] = backendRun + runtimeState.setActiveRun(backendRun, for: job.id) job.core.run = .init( reviewThreadID: backendRun.reviewThreadID, threadID: backendRun.threadID, @@ -409,17 +405,17 @@ extension CodexReviewStore { return .init(jobID: job.id, cancelled: true, core: job.core) } - if reviewRecoveryWaitingJobIDs.contains(jobID) { + if runtimeState.isWaitingForNetworkRecovery(jobID) { try completeCancellationLocally( jobID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - reviewWorkerTasks[jobID]?.cancel() + runtimeState.cancelActiveWorker(for: jobID) return .init(jobID: job.id, cancelled: true, core: job.core) } - if let run = activeRuns[jobID] { + if let run = runtimeState.activeRun(for: jobID) { do { try await backend.interruptReview( run, @@ -430,7 +426,7 @@ extension CodexReviewStore { sessionID: job.sessionID, cancellation: cancellation ) - reviewWorkerTasks[jobID]?.cancel() + runtimeState.cancelActiveWorker(for: jobID) } catch { try recordCancellationFailure( jobID: job.id, @@ -450,7 +446,7 @@ extension CodexReviewStore { sessionID: job.sessionID, cancellation: cancellation ) - reviewWorkerTasks[jobID]?.cancel() + runtimeState.cancelActiveWorker(for: jobID) } catch { try recordCancellationFailure( jobID: job.id, @@ -459,8 +455,8 @@ extension CodexReviewStore { ) throw error } - } else if startingJobIDs.contains(jobID) { - startupCancellations[jobID] = cancellation + } else if runtimeState.isStarting(jobID) { + runtimeState.setStartupCancellation(cancellation, for: jobID) try completeCancellationLocally( jobID: job.id, sessionID: job.sessionID, @@ -666,13 +662,13 @@ extension CodexReviewStore { recoveryState.markWaitingForNetworkRecovery() continue case .finished: - reviewRecoveryWaitingJobIDs.remove(job.id) + runtimeState.clearWaitingForNetworkRecovery(job.id) return recoveryState.currentRun case .recovered(let recoveredAttempt): let recoveredRun = recoveredAttempt.run applyBackendRun(recoveredRun, to: job) recoveryState.markRecovered(with: recoveredRun) - reviewRecoveryWaitingJobIDs.remove(job.id) + runtimeState.clearWaitingForNetworkRecovery(job.id) activeEventSubscriptionID = await inputs.subscribe(to: recoveredAttempt) } case .networkOutageConfirmed: @@ -685,7 +681,7 @@ extension CodexReviewStore { } recoveryState.markWaitingForNetworkRecovery() markReviewWaitingForNetworkRecovery(job) - reviewRecoveryWaitingJobIDs.insert(job.id) + runtimeState.markWaitingForNetworkRecovery(job.id) activeEventSubscriptionID = nil await inputs.cancelActiveEventSubscription() let restartToken = try await backend.prepareReviewRestart(recoveryState.currentRun) @@ -855,7 +851,7 @@ extension CodexReviewStore { updatedRun.turnID = turnID updatedRun.reviewThreadID = reviewThreadID ?? updatedRun.reviewThreadID updatedRun.model = model ?? updatedRun.model - activeRuns[job.id] = updatedRun + runtimeState.setActiveRun(updatedRun, for: job.id) job.core.output.summary = "Review started." job.timeline.apply(.runStarted( turnID: .init(rawValue: turnID), diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift new file mode 100644 index 00000000..197f12de --- /dev/null +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -0,0 +1,162 @@ +import Foundation + +package struct CodexReviewRuntimeJobState: Sendable { + package let activeRun: CodexReviewBackendModel.Review.Run? + package let hasActiveWorker: Bool + package let hasDetachedWorker: Bool + package let isWaitingForNetworkRecovery: Bool +} + +@MainActor +final class CodexReviewStoreRuntimeState { + typealias ReviewRun = CodexReviewBackendModel.Review.Run + + private var activeRuns: [String: ReviewRun] = [:] + private var reviewRecoveryWaitingJobIDs: Set = [] + private var startingJobIDs: Set = [] + private var startupCancellations: [String: ReviewCancellation] = [:] + private var reviewWorkerTasks: [String: Task] = [:] + private var runtimeStopDetachedReviewWorkerTasks: [String: Task] = [:] + + func jobState(for jobID: String) -> CodexReviewRuntimeJobState { + CodexReviewRuntimeJobState( + activeRun: activeRuns[jobID], + hasActiveWorker: reviewWorkerTasks[jobID] != nil, + hasDetachedWorker: runtimeStopDetachedReviewWorkerTasks[jobID] != nil, + isWaitingForNetworkRecovery: reviewRecoveryWaitingJobIDs.contains(jobID) + ) + } + + func activeRun(for jobID: String) -> ReviewRun? { + activeRuns[jobID] + } + + func setActiveRun(_ run: ReviewRun, for jobID: String) { + activeRuns[jobID] = run + } + + func removeActiveRun(for jobID: String) { + activeRuns.removeValue(forKey: jobID) + } + + func recoveryWaitingRuns() -> [ReviewRun] { + reviewRecoveryWaitingJobIDs + .sorted() + .compactMap { activeRuns[$0] } + } + + func isWaitingForNetworkRecovery(_ jobID: String) -> Bool { + reviewRecoveryWaitingJobIDs.contains(jobID) + } + + func markWaitingForNetworkRecovery(_ jobID: String) { + reviewRecoveryWaitingJobIDs.insert(jobID) + } + + func clearWaitingForNetworkRecovery(_ jobID: String) { + reviewRecoveryWaitingJobIDs.remove(jobID) + } + + func markStarting(_ jobID: String) { + startingJobIDs.insert(jobID) + } + + func clearStarting(_ jobID: String) { + startingJobIDs.remove(jobID) + } + + func isStarting(_ jobID: String) -> Bool { + startingJobIDs.contains(jobID) + } + + func setStartupCancellation(_ cancellation: ReviewCancellation, for jobID: String) { + startupCancellations[jobID] = cancellation + } + + func takeStartupCancellation(for jobID: String) -> ReviewCancellation? { + startupCancellations.removeValue(forKey: jobID) + } + + func setActiveWorker(_ task: Task, for jobID: String) { + reviewWorkerTasks[jobID] = task + } + + func cancelActiveWorker(for jobID: String) { + reviewWorkerTasks[jobID]?.cancel() + } + + func removeActiveWorker(for jobID: String) { + reviewWorkerTasks.removeValue(forKey: jobID) + } + + func awaitActiveWorker(for jobID: String) async { + let task = reviewWorkerTasks[jobID] + await task?.value + } + + func cancelAndDetachActiveWorkerForRuntimeStop(jobID: String) { + if let task = reviewWorkerTasks.removeValue(forKey: jobID) { + task.cancel() + runtimeStopDetachedReviewWorkerTasks[jobID] = task + } + } + + func removeDetachedWorker(for jobID: String) { + runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: jobID) + } + + func activeWorkerTasks() -> [Task] { + Array(reviewWorkerTasks.values) + } + + func detachedWorkerTasks() -> [Task] { + Array(runtimeStopDetachedReviewWorkerTasks.values) + } + + func allWorkerTasks() -> [Task] { + activeWorkerTasks() + detachedWorkerTasks() + } + + func clearRuntimeStopState(for jobID: String) { + removeActiveRun(for: jobID) + clearWaitingForNetworkRecovery(jobID) + clearStarting(jobID) + _ = takeStartupCancellation(for: jobID) + } + + func clearReviewRunState(for jobID: String) { + removeActiveRun(for: jobID) + clearWaitingForNetworkRecovery(jobID) + } + + 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) + startingJobIDs.removeAll(keepingCapacity: false) + startupCancellations.removeAll(keepingCapacity: false) + activeRuns.removeAll(keepingCapacity: false) + reviewRecoveryWaitingJobIDs.removeAll(keepingCapacity: false) + } +} + +extension CodexReviewStore { + package func runtimeJobState(jobID: String) -> CodexReviewRuntimeJobState { + runtimeState.jobState(for: jobID) + } +} diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index c6f20a44..5cc239f0 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -84,19 +84,7 @@ extension CodexReviewStore { } 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) + await runtimeState.cancelAndDrainAllWorkersForTesting() + runtimeState.clearForTesting() } } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 44fb0283..ba681707 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -538,14 +538,15 @@ package final class StoreSnapshotProbe { return lhs.sortOrder > rhs.sortOrder } .map { job in - StoreJobSnapshot( + let runtimeState = store.runtimeJobState(jobID: job.id) + return 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], + activeRun: runtimeState.activeRun, cancellationRequested: job.cancellationRequested ) } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 659e4927..25f09f68 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -3202,7 +3202,8 @@ struct CodexReviewStoreCommandTests { 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 runtimeState = store.runtimeJobState(jobID: "job-1") + return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil } let read = try store.readReview(jobID: "job-1") @@ -3241,13 +3242,15 @@ struct CodexReviewStoreCommandTests { #expect(locallyCancelledJobIDs == ["job-1"]) #expect(cancelled.core.lifecycle.status == .cancelled) - #expect(store.reviewWorkerTasks["job-1"] != nil) - #expect(store.activeRuns["job-1"] == run) + let runtimeStateBeforeDetach = store.runtimeJobState(jobID: "job-1") + #expect(runtimeStateBeforeDetach.hasActiveWorker) + #expect(runtimeStateBeforeDetach.activeRun == run) store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) - #expect(store.reviewWorkerTasks["job-1"] == nil) - #expect(store.activeRuns["job-1"] == nil) + let runtimeStateAfterDetach = store.runtimeJobState(jobID: "job-1") + #expect(runtimeStateAfterDetach.hasActiveWorker == false) + #expect(runtimeStateAfterDetach.activeRun == nil) } } @@ -3288,8 +3291,9 @@ struct CodexReviewStoreCommandTests { 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) + let runtimeState = store.runtimeJobState(jobID: "job-1") + #expect(runtimeState.activeRun == nil) + #expect(runtimeState.hasActiveWorker == false) } } @@ -3325,9 +3329,10 @@ struct CodexReviewStoreCommandTests { ) store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) - #expect(store.reviewWorkerTasks["job-1"] == nil) - #expect(store.activeRuns["job-1"] == nil) - #expect(store.reviewRecoveryWaitingJobIDs.contains("job-1") == false) + let runtimeState = store.runtimeJobState(jobID: "job-1") + #expect(runtimeState.hasActiveWorker == false) + #expect(runtimeState.activeRun == nil) + #expect(runtimeState.isWaitingForNetworkRecovery == false) } } @@ -3372,9 +3377,10 @@ struct CodexReviewStoreCommandTests { #expect(request.reason.message == "Review runtime stopped.") #expect(request.recoveryWaitingRuns == [run]) #expect(read.core.lifecycle.status == .cancelled) - #expect(store.reviewWorkerTasks["job-1"] == nil) - #expect(store.activeRuns["job-1"] == nil) - #expect(store.reviewRecoveryWaitingJobIDs.contains("job-1") == false) + let runtimeState = store.runtimeJobState(jobID: "job-1") + #expect(runtimeState.hasActiveWorker == false) + #expect(runtimeState.activeRun == nil) + #expect(runtimeState.isWaitingForNetworkRecovery == false) } } @@ -3405,7 +3411,7 @@ struct CodexReviewStoreCommandTests { store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) - #expect(store.runtimeStopDetachedReviewWorkerTasks["job-1"] == nil) + #expect(store.runtimeJobState(jobID: "job-1").hasDetachedWorker == false) #expect(await backend.recordedCommands().contains(.cleanupReview(run))) } } @@ -3438,8 +3444,9 @@ struct CodexReviewStoreCommandTests { #expect(locallyCancelledJobIDs == ["job-1"]) #expect(result.core.lifecycle.status == .cancelled) - #expect(store.reviewWorkerTasks["job-1"] == nil) - #expect(store.activeRuns["job-1"] == nil) + let runtimeState = store.runtimeJobState(jobID: "job-1") + #expect(runtimeState.hasActiveWorker == false) + #expect(runtimeState.activeRun == nil) } } From 92f203f4a0fa1a80eb0b26915f63b3a1e0c80849 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:19:34 +0900 Subject: [PATCH 072/359] refactor(app-server): delegate review lifecycle to CodexKit --- Package.resolved | 4 +- Package.swift | 2 +- .../AppServerCodexReviewBackend.swift | 219 ++++-------------- .../AppServerClientTests.swift | 47 +++- .../CodexReviewHostTests.swift | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 6 files changed, 101 insertions(+), 179 deletions(-) diff --git a/Package.resolved b/Package.resolved index 3e990e4c..1d24c633 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "76bf3ff9a09a59a3a199e66c3d36ad977e0bfa6d691e084f42688af3d4fd38ce", + "originHash" : "b7bbb6335aefa7d96d28aa2e8d969e432023f8834c442754d697ad4fff1630fd", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "a9c18e92bc3be26093525f6396d82e7050d1acb5" + "revision" : "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" } }, { diff --git a/Package.swift b/Package.swift index 77e6b671..17d9bc6e 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( // this with the final pinned remote CodexKit release dependency before release. .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "a9c18e92bc3be26093525f6396d82e7050d1acb5" + revision: "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" ), ], targets: [ diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3cf91769..8a3efb3a 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -12,46 +12,6 @@ private func makeAppServerReviewAttemptID() -> String { UUID().uuidString } -private struct AppServerReviewCancellation: Equatable, Sendable { - var threadID: String - var turnID: String - - init(threadID: String, turnID: String) { - self.threadID = threadID - self.turnID = turnID - } - - init(_ cancellation: CodexTurnCancellation) { - self.init( - threadID: cancellation.threadID.rawValue, - turnID: cancellation.turnID?.rawValue ?? "" - ) - } - - func cleanupIdentity( - sourceRun run: CodexReviewBackendModel.Review.Run - ) -> CodexReviewIdentity? { - let turnID = turnID.nilIfEmpty ?? run.turnID?.nilIfEmpty - guard let turnID else { - return nil - } - let sourceThreadID = CodexThreadID(rawValue: run.threadID) - let cancelledThreadID = CodexThreadID(rawValue: threadID) - return CodexReviewIdentity( - threadID: sourceThreadID, - turnID: .init(rawValue: turnID), - reviewThreadID: cancelledThreadID == sourceThreadID ? nil : cancelledThreadID, - model: run.model - ) - } -} - -private struct AppServerReviewRestartContext: Sendable { - var interruptedRun: CodexReviewBackendModel.Review.Run - var rollbackThreadID: CodexThreadID - var rollbackModel: String? -} - package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" @@ -60,8 +20,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var activeReviewAttemptIDByThreadID: [String: String] = [:] private var activeThreadIDsByAttemptID: [String: Set] = [:] private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:] - private var retainedCleanupIdentitiesBySourceThreadID: [String: [CodexReviewIdentity]] = [:] - private var restartContextsByTokenID: [String: AppServerReviewRestartContext] = [:] private var abandonedReviewAttemptIDs: Set = [] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] @@ -221,30 +179,20 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func prepareReviewRestart( _ run: CodexReviewBackendModel.Review.Run ) async throws -> CodexReviewBackendModel.Review.RestartToken { - let cancellation = try await cancelReviewTurn(for: run) { retryCancellation in - await self.rememberCancellationCleanupIdentity(retryCancellation, canonicalThreadID: run.threadID) + guard let identity = run.appServerReviewIdentity else { + throw CodexReviewAPI.Error.io("Review run has no restartable app-server turn.") } - markAttemptAbandoned(run, cancellation: cancellation) + let appServerToken = try await appServer.prepareReviewRestart(identity) + markAttemptAbandoned(run) if let session = unregisterReviewEventSession(for: run) { await session.abandon() let metrics = await session.metricsSnapshot() - let cleanupThreadIDs = orderedCleanupThreadIDs( - sourceThreadID: run.threadID, - cleanupThreadIDSequences(for: run) + [await session.cleanupThreadIDs()] - ) - for threadID in cleanupThreadIDs { + for threadID in localCleanupThreadIDs(for: run, additional: await session.cleanupThreadIDs()) { completedReviewEventSessionMetricsByThreadID[threadID] = metrics } } - discardRestartContexts(for: run) - let tokenID = UUID().uuidString - restartContextsByTokenID[tokenID] = AppServerReviewRestartContext( - interruptedRun: run, - rollbackThreadID: .init(rawValue: cancellation.threadID), - rollbackModel: run.model - ) return CodexReviewBackendModel.Review.RestartToken( - id: tokenID, + id: appServerToken.id, interruptedRun: run ) } @@ -253,30 +201,31 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { - guard let restartContext = restartContextsByTokenID.removeValue(forKey: token.id) else { - throw CodexReviewAPI.Error.io("Prepared review restart is no longer available.") + let interruptedRun = token.interruptedRun + guard let interruptedIdentity = interruptedRun.appServerReviewIdentity else { + throw CodexReviewAPI.Error.io("Prepared review restart has no app-server identity.") } - let interruptedRun = restartContext.interruptedRun - let rollbackThread = try await appServer.resumeThread( - restartContext.rollbackThreadID, - options: .init(model: restartContext.rollbackModel) + let appServerToken = CodexReviewRestartToken( + id: token.id, + interruptedIdentity: interruptedIdentity ) - try await rollbackThread.rollback(turnCount: 1) - - let thread = try await sourceReviewThread(for: interruptedRun, fallbackModel: request.model) let attemptID = makeAppServerReviewAttemptID() let provisionalRun = CodexReviewBackendModel.Review.Run( attemptID: attemptID, threadID: interruptedRun.threadID, reviewThreadID: interruptedRun.threadID, - model: thread.model ?? interruptedRun.model ?? request.model + model: interruptedRun.model ?? request.model ) let session = AppServerReviewEventSession(run: provisionalRun) registerReviewEventSession(session, for: provisionalRun) let review: CodexReviewSession do { - review = try await thread.startReview(target: request.request.target.appServerReviewTarget) + review = try await appServer.restartPreparedReview( + appServerToken, + target: request.request.target.appServerReviewTarget, + threadOptions: .init(model: interruptedRun.model ?? request.model) + ) } catch { _ = unregisterReviewEventSession(for: provisionalRun) await session.abandon() @@ -288,7 +237,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { threadID: interruptedRun.threadID, turnID: review.turnID.rawValue, reviewThreadID: review.reviewThreadID.rawValue, - model: thread.model ?? interruptedRun.model ?? request.model + model: review.model ?? interruptedRun.model ?? request.model ) await session.updateRun(recoveredRun) registerReviewEventSession(session, for: recoveredRun) @@ -298,32 +247,25 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - discardRestartContexts(for: run) - var cleanupSequences = cleanupThreadIDSequences(for: run) var completedMetrics: ReviewBackendEventSessionMetrics? + var additionalCleanupThreadIDs: [String] = [] if let session = unregisterReviewEventSession(for: run) { await session.finish(cancellationMessage: nil) let metrics = await session.metricsSnapshot() - cleanupSequences.append(await session.cleanupThreadIDs()) + additionalCleanupThreadIDs = await session.cleanupThreadIDs() completedMetrics = metrics } - let cleanupThreadIDs = orderedCleanupThreadIDs( - sourceThreadID: run.threadID, - cleanupSequences - ) + let cleanupThreadIDs = localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs) if let completedMetrics { for threadID in cleanupThreadIDs { completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics } } - for threadID in cleanupThreadIDs { - try? await appServer.deleteThread(.init(rawValue: threadID)) - } + await cleanupAppServerReview(run, additionalCleanupThreadIDs: additionalCleanupThreadIDs) for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) } - retainedCleanupIdentitiesBySourceThreadID.removeValue(forKey: run.threadID) } package func cleanupActiveReviewsForShutdown(_ request: CodexReviewRuntimeStopReviewCleanupRequest) async { @@ -387,16 +329,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return session } - private func sourceReviewThread( - for run: CodexReviewBackendModel.Review.Run, - fallbackModel: String? - ) async throws -> CodexThread { - try await appServer.resumeThread( - .init(rawValue: run.threadID), - options: .init(model: run.model ?? fallbackModel) - ) - } - private func registerReviewEventSession( _ session: AppServerReviewEventSession, for run: CodexReviewBackendModel.Review.Run @@ -444,31 +376,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return reviewEventSessionsByAttemptID.removeValue(forKey: run.attemptID) } - private func markAttemptAbandoned( - _ run: CodexReviewBackendModel.Review.Run, - cancellation: AppServerReviewCancellation - ) { + private func markAttemptAbandoned(_ run: CodexReviewBackendModel.Review.Run) { abandonedReviewAttemptIDs.insert(run.attemptID) - rememberCleanupIdentity(for: run) - rememberCleanupIdentity(cancellation.cleanupIdentity(sourceRun: run)) - } - - private func rememberCancellationCleanupIdentity( - _ cancellation: AppServerReviewCancellation, - canonicalThreadID: String - ) { - let sourceRun = CodexReviewBackendModel.Review.Run( - threadID: canonicalThreadID, - turnID: cancellation.turnID, - reviewThreadID: cancellation.threadID - ) - rememberCleanupIdentity(cancellation.cleanupIdentity(sourceRun: sourceRun)) - } - - private func discardRestartContexts(for run: CodexReviewBackendModel.Review.Run) { - restartContextsByTokenID = restartContextsByTokenID.filter { _, context in - context.interruptedRun.attemptID != run.attemptID - } } private func activeReviewRunsForShutdown() async -> [CodexReviewBackendModel.Review.Run] { @@ -496,16 +405,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } private func cancelReviewTurn( - for run: CodexReviewBackendModel.Review.Run, - willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil - ) async throws -> AppServerReviewCancellation { + 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 let session = reviewEventSessionsByAttemptID[run.attemptID], let cancellation = try await session.cancelReview( - expectedTurnID: identity.turnID.rawValue, - willCancelActiveTurn: willCancelActiveTurn + expectedTurnID: identity.turnID.rawValue ) { return cancellation } @@ -513,35 +420,17 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { identity, threadOptions: .init(model: run.model) ) - let cancellation: CodexTurnCancellation - if let willCancelActiveTurn { - cancellation = try await review.cancel { retryCancellation in - let reviewCancellation = AppServerReviewCancellation(retryCancellation) - if reviewCancellation.turnID != identity.turnID.rawValue { - await willCancelActiveTurn(reviewCancellation) - } - } - } else { - cancellation = try await review.cancel() - } - return AppServerReviewCancellation(cancellation) - } - - private func cleanupThreadIDSequences( - for run: CodexReviewBackendModel.Review.Run - ) -> [[String]] { - let retainedSequences = retainedCleanupIdentitiesBySourceThreadID[run.threadID, default: []] - .map { $0.cleanupThreadIDs.map(\.rawValue) } - return retainedSequences + [run.appServerCleanupThreadIDs] + return try await review.cancel() } - private func orderedCleanupThreadIDs( - sourceThreadID: String, - _ sequences: [[String]] + private func localCleanupThreadIDs( + for run: CodexReviewBackendModel.Review.Run, + additional: [String] ) -> [String] { + let sourceThreadID = run.threadID var seen: Set = [] var threadIDs: [String] = [] - for sequence in sequences { + for sequence in [run.appServerCleanupThreadIDs, additional] { for threadID in sequence where threadID != sourceThreadID && seen.insert(threadID).inserted { threadIDs.append(threadID) } @@ -552,20 +441,20 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return threadIDs } - private func rememberCleanupIdentity( - for run: CodexReviewBackendModel.Review.Run - ) { - rememberCleanupIdentity(run.appServerReviewIdentity) - } - - private func rememberCleanupIdentity(_ identity: CodexReviewIdentity?) { - guard let identity else { + private func cleanupAppServerReview( + _ run: CodexReviewBackendModel.Review.Run, + additionalCleanupThreadIDs: [String] + ) async { + guard let identity = run.appServerReviewIdentity else { + for threadID in localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs) { + try? await appServer.deleteThread(.init(rawValue: threadID)) + } return } - let sourceThreadID = identity.sourceThreadID.rawValue - if retainedCleanupIdentitiesBySourceThreadID[sourceThreadID, default: []].contains(identity) == false { - retainedCleanupIdentitiesBySourceThreadID[sourceThreadID, default: []].append(identity) - } + await appServer.cleanupReview( + identity, + additionalCleanupThreadIDs: [additionalCleanupThreadIDs.map(CodexThreadID.init(rawValue:))] + ) } } @@ -1268,24 +1157,12 @@ private actor AppServerReviewEventSession { } func cancelReview( - expectedTurnID: String, - willCancelActiveTurn: (@Sendable (AppServerReviewCancellation) async -> Void)? = nil - ) async throws -> AppServerReviewCancellation? { + expectedTurnID _: String + ) async throws -> CodexTurnCancellation? { guard let reviewSession else { return nil } - let cancellation: CodexTurnCancellation - if let willCancelActiveTurn { - cancellation = try await reviewSession.cancel { retryCancellation in - let reviewCancellation = AppServerReviewCancellation(retryCancellation) - if reviewCancellation.turnID != expectedTurnID { - await willCancelActiveTurn(reviewCancellation) - } - } - } else { - cancellation = try await reviewSession.cancel() - } - return AppServerReviewCancellation(cancellation) + return try await reviewSession.cancel() } private func consume(_ review: CodexReviewSession) async { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index f5451c78..c0bc7709 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -409,6 +409,10 @@ struct AppServerClientTests { for: "turn/interrupt" ) 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"}"#, @@ -416,7 +420,23 @@ struct AppServerClientTests { ) try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "review-thread") - let token = try await backend.prepareReviewRestart(attempt.run) + 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) @@ -428,6 +448,7 @@ struct AppServerClientTests { "initialize", "thread/start", "review/start", + "thread/resume", "turn/interrupt", "turn/interrupt", "thread/resume", @@ -438,7 +459,11 @@ struct AppServerClientTests { let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { try jsonObject(from: $0.params)["threadId"] as? String } - #expect(resumeThreadIDs == ["review-thread", "thread-1"]) + #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"]) let interruptTurnIDs = try requests.filter { $0.method == "turn/interrupt" }.map { try jsonObject(from: $0.params)["turnId"] as? String } @@ -458,6 +483,24 @@ 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() + } + group.addTask { + try await Task.sleep(for: timeout) + throw AppServerClientTestTimeout.timedOut + } + let result = try #require(await group.next()) + group.cancelAll() + return result + } +} + private func nextEvent( from mailbox: BackendReviewEventMailbox, timeout: Duration = .seconds(1) diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index bdb3e26f..f8f93862 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1429,6 +1429,7 @@ 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") @@ -1442,6 +1443,7 @@ struct CodexReviewHostTests { ) await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) let reviewRead = Task { @MainActor in try await store.startReview( sessionID: "session-1", @@ -1467,7 +1469,7 @@ struct CodexReviewHostTests { #expect(await stopFinished.isCompleted()) #expect(result.core.lifecycle.status == .cancelled) #expect(methods.contains("turn/interrupt")) - #expect(methods.contains("thread/delete")) + #expect(methods.filter { $0 == "thread/delete" }.count == 2) } @Test func liveStoreMarksRuntimeFailedWhenAppServerNotificationStreamCloses() async throws { 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 b5434692..a69b2df7 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "e2ffbde1e6ac921858b2932a576a2fec0c5df351dd837e1df2cd5205bbd8358a", + "originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "a9c18e92bc3be26093525f6396d82e7050d1acb5" + "revision" : "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" } }, { From 3ae35c434e06dc15908d6cc59ddd940644207a24 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:48:47 +0900 Subject: [PATCH 073/359] refactor(app-server): start reviews through CodexKit --- Package.resolved | 2 +- Package.swift | 2 +- .../AppServerCodexReviewBackend.swift | 42 ++++--------------- .../xcshareddata/swiftpm/Package.resolved | 2 +- 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/Package.resolved b/Package.resolved index 1d24c633..2b68a408 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" + "revision" : "3bed980f5012eef7d4da4b955441bc3f749ba0ae" } }, { diff --git a/Package.swift b/Package.swift index 17d9bc6e..73b7c13c 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( // this with the final pinned remote CodexKit release dependency before release. .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" + revision: "3bed980f5012eef7d4da4b955441bc3f749ba0ae" ), ], targets: [ diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 8a3efb3a..5719076f 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -99,51 +99,27 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - let thread = try await startReviewThread(appServer: appServer, request: request) - - let attemptID = makeAppServerReviewAttemptID() - let provisionalRun = CodexReviewBackendModel.Review.Run( - attemptID: attemptID, - threadID: thread.id.rawValue, - reviewThreadID: thread.id.rawValue, - model: thread.model ?? request.model + let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) + let review = try await appServer.startReview( + in: workspace, + target: request.request.target.appServerReviewTarget, + options: reviewThreadOptions(request) ) - let session = AppServerReviewEventSession(run: provisionalRun) - registerReviewEventSession(session, for: provisionalRun) - - let review: CodexReviewSession - do { - review = try await thread.startReview(target: request.request.target.appServerReviewTarget) - } catch { - await cleanupReview(provisionalRun) - throw error - } - + let attemptID = makeAppServerReviewAttemptID() let run = CodexReviewBackendModel.Review.Run( attemptID: attemptID, - threadID: thread.id.rawValue, + threadID: review.threadID.rawValue, turnID: review.turnID.rawValue, reviewThreadID: review.reviewThreadID.rawValue, - model: thread.model ?? request.model + model: review.model ?? request.model ) - await session.updateRun(run) + let session = AppServerReviewEventSession(run: run) registerReviewEventSession(session, for: run) await session.startConsuming(review) return await session.attempt() } - private func startReviewThread( - appServer: CodexAppServer, - request: CodexReviewBackendModel.Review.Start - ) async throws -> CodexThread { - let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) - return try await appServer.startThread( - in: workspace, - options: reviewThreadOptions(request) - ) - } - private func reviewThreadOptions( _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { 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 a69b2df7..6c4a057a 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "58f41bb0f0fc1d367838375fae4b2da0638c6e4f" + "revision" : "3bed980f5012eef7d4da4b955441bc3f749ba0ae" } }, { From 4029db9762ce4c689e012917b6ebeeb3c1cebfa3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:24:02 +0900 Subject: [PATCH 074/359] chore(format): fix review types indentation --- .swift-format | 52 ++ Sources/CodexReviewKit/CodexReviewTypes.swift | 476 +++++++++--------- 2 files changed, 282 insertions(+), 246 deletions(-) create mode 100644 .swift-format 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/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index f85b5149..9b3724a3 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -9,96 +9,91 @@ package enum CodexReviewBackendModel { } 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 + 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) + 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 + struct ID: Codable, Hashable, Sendable { + package var rawValue: String - package init(_ rawValue: String) { - self.rawValue = rawValue + package init(_ rawValue: String) { + self.rawValue = rawValue + } } } -} - package extension CodexReviewBackendModel.Account { -enum Kind: String, Codable, Equatable, Sendable { - case chatGPT = "chatgpt" - case apiKey - case amazonBedrock -} + 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 + struct Capabilities: Codable, Equatable, Sendable { + package var supportsRateLimitRefresh: Bool - package init(supportsRateLimitRefresh: Bool = true) { - self.supportsRateLimitRefresh = supportsRateLimitRefresh - } + package init(supportsRateLimitRefresh: Bool = true) { + self.supportsRateLimitRefresh = supportsRateLimitRefresh + } - package static var supportsCodexRateLimits: Self { - .init(supportsRateLimitRefresh: true) - } + package static var supportsCodexRateLimits: Self { + .init(supportsRateLimitRefresh: true) + } - package static var noCodexRateLimits: Self { - .init(supportsRateLimitRefresh: false) + package static var noCodexRateLimits: Self { + .init(supportsRateLimitRefresh: false) + } } } -} - package extension CodexReviewBackendModel.Account.Kind { var capabilities: CodexReviewBackendModel.Account.Capabilities { @@ -111,226 +106,215 @@ package extension CodexReviewBackendModel.Account.Kind { } } - 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 + 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 + 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) -} + 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 + 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 + 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? + 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 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) - } + 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 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 - } + 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( + 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 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 + 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 { -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) -} + 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 + struct CancellationReason: Codable, Equatable, Sendable { + package var message: String - package init(message: String = "Cancellation requested.") { - self.message = message + package init(message: String = "Cancellation requested.") { + self.message = message + } } } -} From 05f76be133a88fb4ba4ce9905be540fd12c95aa1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:21:41 +0900 Subject: [PATCH 075/359] build(app-server): pin CodexKit restart cleanup --- Package.resolved | 2 +- Package.swift | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2b68a408..8ed2ca74 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "3bed980f5012eef7d4da4b955441bc3f749ba0ae" + "revision" : "cfaa2f5763014eacc01b728b79791527a17fe07c" } }, { diff --git a/Package.swift b/Package.swift index 73b7c13c..5e44fd70 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,7 @@ let package = Package( // this with the final pinned remote CodexKit release dependency before release. .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "3bed980f5012eef7d4da4b955441bc3f749ba0ae" + revision: "cfaa2f5763014eacc01b728b79791527a17fe07c" ), ], targets: [ 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 6c4a057a..53877f51 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "3bed980f5012eef7d4da4b955441bc3f749ba0ae" + "revision" : "cfaa2f5763014eacc01b728b79791527a17fe07c" } }, { From d95b929b0a452d90fc7f9de8ea4815657006d5c8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:32:51 +0900 Subject: [PATCH 076/359] fix(app-server): remove provisional restart session --- .../AppServerCodexReviewBackend.swift | 29 ++------ .../AppServerClientTests.swift | 68 ++++++++++++++++++ .../CodexReviewStoreCommandTests.swift | 69 +++++++++++++++++++ 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 5719076f..cf45c4bf 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -185,29 +185,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { id: token.id, interruptedIdentity: interruptedIdentity ) - let attemptID = makeAppServerReviewAttemptID() - let provisionalRun = CodexReviewBackendModel.Review.Run( - attemptID: attemptID, - threadID: interruptedRun.threadID, - reviewThreadID: interruptedRun.threadID, - model: interruptedRun.model ?? request.model + let review = try await appServer.restartPreparedReview( + appServerToken, + target: request.request.target.appServerReviewTarget, + threadOptions: .init(model: interruptedRun.model ?? request.model) ) - let session = AppServerReviewEventSession(run: provisionalRun) - registerReviewEventSession(session, for: provisionalRun) - - let review: CodexReviewSession - do { - review = try await appServer.restartPreparedReview( - appServerToken, - target: request.request.target.appServerReviewTarget, - threadOptions: .init(model: interruptedRun.model ?? request.model) - ) - } catch { - _ = unregisterReviewEventSession(for: provisionalRun) - await session.abandon() - throw error - } - + let attemptID = makeAppServerReviewAttemptID() let recoveredRun = CodexReviewBackendModel.Review.Run( attemptID: attemptID, threadID: interruptedRun.threadID, @@ -215,7 +198,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { reviewThreadID: review.reviewThreadID.rawValue, model: review.model ?? interruptedRun.model ?? request.model ) - await session.updateRun(recoveredRun) + let session = AppServerReviewEventSession(run: recoveredRun) registerReviewEventSession(session, for: recoveredRun) await session.startConsuming(review) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index c0bc7709..c5a4aaff 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -477,6 +477,74 @@ struct AppServerClientTests { 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 + } + + 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 backend.cleanupActiveReviewsForShutdown(.init( + reason: .init(message: "Review runtime stopped."), + recoveryWaitingRuns: [] + )) + + #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 enum AppServerClientTestTimeout: Error { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 25f09f68..b0eb1ad4 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -3173,6 +3173,75 @@ struct CodexReviewStoreCommandTests { } } + @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: { "job-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.runtimeJobState(jobID: "job-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", From 50b73744fdd18feaafbe41465bd257bcd7bae8b1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:50:04 +0900 Subject: [PATCH 077/359] fix(app-server): defer restart cleanup during shutdown --- .../AppServerCodexReviewBackend.swift | 25 +++++++++++++++++++ .../AppServerClientTests.swift | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index cf45c4bf..33f32207 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -21,6 +21,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var activeThreadIDsByAttemptID: [String: Set] = [:] private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:] private var abandonedReviewAttemptIDs: Set = [] + private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] package init(appServer: CodexAppServer) { @@ -185,6 +186,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { id: token.id, interruptedIdentity: interruptedIdentity ) + markRestartInFlight(forInterrupted: interruptedRun) + defer { + clearRestartInFlight(forInterrupted: interruptedRun) + } let review = try await appServer.restartPreparedReview( appServerToken, target: request.request.target.appServerReviewTarget, @@ -242,6 +247,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { cleanedAttemptIDs.insert(run.attemptID) } for run in request.recoveryWaitingRuns where cleanedAttemptIDs.insert(run.attemptID).inserted { + if isRestartInFlight(forInterrupted: run) { + continue + } if Task.isCancelled { return } @@ -339,6 +347,23 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { abandonedReviewAttemptIDs.insert(run.attemptID) } + private func markRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) { + inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] += 1 + } + + 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 + } + } + + private func isRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) -> Bool { + inFlightRestartCountByInterruptedAttemptID[run.attemptID] != nil + } + private func activeReviewRunsForShutdown() async -> [CodexReviewBackendModel.Review.Run] { let sessions = Array(reviewEventSessionsByAttemptID.values) var runsByAttemptID: [String: CodexReviewBackendModel.Review.Run] = [:] diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index c5a4aaff..b4c1e589 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -534,7 +534,7 @@ struct AppServerClientTests { await backend.cleanupActiveReviewsForShutdown(.init( reason: .init(message: "Review runtime stopped."), - recoveryWaitingRuns: [] + recoveryWaitingRuns: [attempt.run] )) #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) From 361fd2f62c58088cc90a6cd3011381413bae7996 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:45:01 +0900 Subject: [PATCH 078/359] refactor(app-server): adopt CodexDataKit boundaries --- Docs/architecture.md | 52 ++++++++ Package.resolved | 4 +- Package.swift | 8 +- README.md | 4 +- .../AppServerCodexReviewBackend.swift | 21 +-- .../LiveCodexReviewStoreBackend.swift | 100 +------------- .../CodexReviewKit/CodexReviewBackend.swift | 1 - Sources/CodexReviewKit/CodexReviewTypes.swift | 12 -- Sources/CodexReviewTesting/TestSupport.swift | 8 -- .../ReviewMonitorCodexSidebarLibrary.swift | 126 ++++++++++++++++++ .../CodexReviewHostTests.swift | 99 ++++++-------- ...eviewMonitorCodexSidebarLibraryTests.swift | 68 ++++++++++ .../xcshareddata/swiftpm/Package.resolved | 2 +- 13 files changed, 305 insertions(+), 200 deletions(-) create mode 100644 Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift create mode 100644 Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index 919f3bcf..9d1ead92 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -37,6 +37,16 @@ This table describes intended ownership. | `CodexReviewTesting` | Deterministic fake backend, fake JSON-RPC transport, gates, manual clock | | `TextTransitions` | UI text transition view support | +`CodexDataKit` lives in the separate CodexKit repository and owns reusable +Codex-facing observable model state for generic app-server concepts. +ReviewMonitor should depend on those owners for basic Codex data concerns such +as workspace groups, workspaces, chats, account/login state, rate-limit +presentation, and model configuration once the corresponding `CodexDataKit` +public models exist. Review-specific jobs, review timelines, findings, +cancellation/restart state, and MCP result projections remain +`CodexReviewKit` responsibilities because they are product semantics rather +than generic Codex data state. + ReviewMonitor is the product entry point. The host target wires the concrete runtime together; lower targets do not import the host. @@ -104,6 +114,10 @@ flowchart TB Process["codex app-server"] end + subgraph GenericData["CodexDataKit"] + GenericOwners["Generic Codex model owners"] + end + subgraph AppServer["CodexReviewAppServer"] Runtime["Review runtime"] end @@ -131,7 +145,9 @@ flowchart TB Timeline --> Renderer PublicStore --> UI Renderer --> UI + GenericOwners --> UI DomainAPI --> Client + DomainAPI --> GenericOwners ReviewSession --> Client Client --> Process Runtime --> PublicStore @@ -245,12 +261,48 @@ The public tool surface is: - Views and view controllers render observable state. - User actions call store methods. - UI rendering may use `ReviewMonitorRendering` helpers over `ReviewTimeline`. +- Generic Codex model state should come from `CodexDataKit` owners instead of + ReviewMonitor-local models when a reusable CodexDataKit model exists. Today + CodexDataKit provides `CodexModelContainer`, `CodexModelContext`, + `CodexFetchRequest`, `CodexFetchedResults`, `CodexWorkspaceGroup`, + `CodexWorkspace`, and `CodexChat`; account/login/rate-limit and + model-configuration owners still need to be added before ReviewMonitor can + migrate those basic surfaces. - 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. +The current ReviewMonitor UI model boundary is therefore: + +- `CodexDataKit`: generic observable app-server models and fetch/query owners. +- `CodexReviewKit`: review-job, review-timeline, review-auth compatibility, + settings compatibility, and product command state until the matching generic + CodexDataKit owners are available. +- `ReviewUI`: native AppKit/SwiftUI rendering state such as sidebar selection, + filters, installed controllers, row views, and transient presentation. + +CodexDataKit migration candidates should be split by owner: + +- `CodexModelContext` and `CodexFetchedResults` should own generic + chat/workspace sidebar inputs. ReviewMonitor adapts those models through + `ReviewMonitorCodexSidebarLibrary` for generic Codex sidebar surfaces, while + keeping review jobs and review timelines in `CodexReviewKit`. +- `CodexChat` should be used for generic chat presentation and thread-level + send/refresh/archive/delete actions, not as a replacement for + `CodexReviewJob`. +- A future CodexDataKit account owner should own active-account loading, login + progress, login cancellation/completion, logout, account events, and + rate-limit windows over `CodexAppServer`. +- A future CodexDataKit model-configuration owner should own 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. + `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. diff --git a/Package.resolved b/Package.resolved index 8ed2ca74..4390b6ad 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "b7bbb6335aefa7d96d28aa2e8d969e432023f8834c442754d697ad4fff1630fd", + "originHash" : "b5e8218fa86cb5fd17a3326e03de7ffb2ff0a218a4ffdbcb140f9020554e39bd", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "cfaa2f5763014eacc01b728b79791527a17fe07c" + "revision" : "77340166f494e16077a156b4a2a832d2cd88527d" } }, { diff --git a/Package.swift b/Package.swift index 5e44fd70..83323eb8 100644 --- a/Package.swift +++ b/Package.swift @@ -29,11 +29,9 @@ 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")), - // Until CodexKit is released, use the pinned integration revision; replace - // this with the final pinned remote CodexKit release dependency before release. .package( url: "https://github.com/lynnswap/CodexKit.git", - revision: "cfaa2f5763014eacc01b728b79791527a17fe07c" + revision: "77340166f494e16077a156b4a2a832d2cd88527d" ), ], targets: [ @@ -98,6 +96,7 @@ let package = Package( "CodexReviewKit", "ReviewMonitorRendering", "TextTransitions", + .product(name: "CodexDataKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ @@ -166,6 +165,9 @@ let package = Package( .testTarget( name: "ReviewUITests", dependencies: [ + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", "ReviewUI", diff --git a/README.md b/README.md index c416b974..bb94db8e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ await appServer.close() only ReviewMonitor-specific `review/start` orchestration and review event conversion. -See [CodexAppServerKit README](Sources/CodexAppServerKit/README.md) for the +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. @@ -111,3 +111,5 @@ Create the `notarytool` profile in the local Keychain before publishing: ```bash xcrun notarytool store-credentials codex-reviewkit ``` + +[codex-app-server-kit-readme]: https://github.com/lynnswap/CodexKit/blob/main/Sources/CodexAppServerKit/README.md diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 33f32207..677c7b03 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -68,12 +68,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { try await appServer.rateLimits() } - package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { - let handle = try await appServer.loginChatGPT( - callbackURLScheme: request.nativeWebAuthenticationCallbackScheme - ) + package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { + let handle = try await appServer.loginChatGPT() return try handle.backendChallenge( - nativeWebAuthenticationCallbackScheme: request.nativeWebAuthenticationCallbackScheme + nativeWebAuthenticationCallbackScheme: nil ) } @@ -81,19 +79,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { try await appServer.cancelLogin(id: .init(rawValue: challenge.id)) } - package func completeLogin(_ response: CodexReviewBackendModel.Login.Response) async throws -> CodexReviewBackendModel.Auth.Snapshot { - if let callbackURL = response.callbackURL { - guard let url = URL(string: callbackURL) else { - throw CodexReviewAPI.Error.io("Invalid ChatGPT authentication callback URL.") - } - try await appServer.completeLogin( - id: .init(rawValue: response.challengeID), - callbackURL: url - ) - } - return try await readAuth() - } - package func logout(_: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot { try await appServer.logout() return try await readAuth() diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 299a8ecd..9dd1bc6c 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -211,11 +211,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private var authenticationTask: Task? private var authNotificationTask: Task? private var loginNotificationTask: Task? - // Native completeLogin returns the account before app-server notification echoes settle. - private var nativeLoginCompletionInFlightID: String? - private var nativeCompletedLoginIDAwaitingNotificationDrain: String? - private var didDrainNativeLoginAccountUpdateDuringCompletion = false - private var ignoresNextNativeLoginAccountUpdate = false private var settingsSnapshot = CodexReviewSettings.Snapshot() private let codexHomeURL: URL private let mcpHTTPServerConfiguration: CodexReviewMCPHTTPServer.Configuration @@ -796,7 +791,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func startLogin(auth: CodexReviewAuthModel, activation: LoginActivation) async { var isolatedLoginAppServer: CodexAppServer? var isolatedLoginCodexHomeURL: URL? - resetNativeLoginNotificationDrain() do { let runtime = try await loginRuntime(for: activation) let appServerBackend = runtime.backend @@ -824,7 +818,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.loginCodexHomeURL = loginCodexHomeURL loginActivation = activation isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() if let loginAppServer { observeLoginNotifications(appServer: loginAppServer, backend: appServerBackend, auth: auth) } @@ -854,7 +847,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { try? await appServerBackend.cancelLogin(challenge) loginChallenge = nil loginBackend = nil - resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil updateAuthenticationFailure( @@ -879,7 +871,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await self.monitorAuthenticationSession( challenge: challenge, session: session, - completesLoginThroughCallback: nativeCallbackScheme != nil, auth: auth ) } @@ -890,7 +881,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil loginBackend = nil isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() let loginAppServer = loginAppServer ?? isolatedLoginAppServer self.loginAppServer = nil let loginCodexHomeURL = loginCodexHomeURL ?? isolatedLoginCodexHomeURL @@ -915,70 +905,20 @@ 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 - } - resetNativeLoginNotificationDrain() - nativeLoginCompletionInFlightID = challenge.id - let snapshot = try await loginBackend.completeLogin(.init( - challengeID: challenge.id, - callbackURL: callbackURL.absoluteString - )) - let activation = loginActivation - let loginAppServer = loginAppServer - let loginCodexHomeURL = loginCodexHomeURL - loginChallenge = nil - self.loginBackend = nil - isWaitingForLoginAccountUpdate = false - nativeLoginCompletionInFlightID = nil - nativeCompletedLoginIDAwaitingNotificationDrain = didDrainNativeLoginAccountUpdateDuringCompletion - ? nil - : challenge.id - if didDrainNativeLoginAccountUpdateDuringCompletion { - ignoresNextNativeLoginAccountUpdate = false - } - didDrainNativeLoginAccountUpdateDuringCompletion = false - self.loginAppServer = 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(appServer: loginAppServer, 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 { await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth) } catch { - guard loginChallenge?.id == challenge.id || nativeLoginCompletionInFlightID == challenge.id else { + guard loginChallenge?.id == challenge.id else { return } logger.error("ChatGPT login failed to complete: \(error.localizedDescription, privacy: .public)") @@ -987,7 +927,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil self.loginBackend = nil isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil activeAuthenticationSession = nil @@ -1016,13 +955,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func resetNativeLoginNotificationDrain() { - nativeLoginCompletionInFlightID = nil - nativeCompletedLoginIDAwaitingNotificationDrain = nil - didDrainNativeLoginAccountUpdateDuringCompletion = false - ignoresNextNativeLoginAccountUpdate = false - } - private func loginRuntime(for activation: LoginActivation) async throws -> LoginRuntime { switch activation { case .activateAuthenticatedAccount: @@ -1069,7 +1001,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginChallenge = nil self.loginBackend = nil isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil activeAuthenticationSession = nil @@ -1304,18 +1235,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel ) async { - if let completingLoginID = nativeLoginCompletionInFlightID, - completion.loginID?.rawValue == nil || completion.loginID?.rawValue == completingLoginID { - if didDrainNativeLoginAccountUpdateDuringCompletion == false { - ignoresNextNativeLoginAccountUpdate = true - } - return - } - if let completedLoginID = nativeCompletedLoginIDAwaitingNotificationDrain, - completion.loginID?.rawValue == nil || completion.loginID?.rawValue == completedLoginID { - ignoresNextNativeLoginAccountUpdate = true - return - } guard completion.loginID?.rawValue == nil || completion.loginID?.rawValue == loginChallenge?.id else { return } @@ -1335,7 +1254,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) self.loginBackend = nil isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() self.loginAppServer = nil self.loginCodexHomeURL = nil loginNotificationTask?.cancel() @@ -1351,17 +1269,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel ) async { - if nativeLoginCompletionInFlightID != nil { - didDrainNativeLoginAccountUpdateDuringCompletion = true - ignoresNextNativeLoginAccountUpdate = false - return - } - if ignoresNextNativeLoginAccountUpdate { - nativeCompletedLoginIDAwaitingNotificationDrain = nil - ignoresNextNativeLoginAccountUpdate = false - return - } - nativeCompletedLoginIDAwaitingNotificationDrain = nil guard isWaitingForLoginAccountUpdate else { await refreshAuthAfterAccountNotification(backend: backend, auth: auth) return @@ -1412,7 +1319,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.loginAppServer = nil self.loginCodexHomeURL = nil isWaitingForLoginAccountUpdate = false - resetNativeLoginNotificationDrain() loginNotificationTask?.cancel() loginNotificationTask = nil await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 8c74b938..07957978 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -7,7 +7,6 @@ package protocol CodexReviewBackend: Sendable { func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot 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 diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 9b3724a3..30a5fce5 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -194,18 +194,6 @@ package extension CodexReviewBackendModel.Login { } } -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 diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index ba681707..f1fd47d1 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -117,7 +117,6 @@ 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) @@ -379,13 +378,6 @@ 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 { commands.append(.logout(account)) auth = .init() diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift new file mode 100644 index 00000000..aec30bdd --- /dev/null +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -0,0 +1,126 @@ +import CodexAppServerKit +import CodexDataKit +import Foundation + +@MainActor +package struct ReviewMonitorCodexSidebarWorkspace { + package var workspace: CodexWorkspace + package var chats: [CodexChat] + + package var id: CodexWorkspaceID { + workspace.id + } + + package var cwd: String { + workspace.url.path + } + + package var title: String { + workspace.name + } +} + +@MainActor +package struct ReviewMonitorCodexSidebarSection { + package var id: String + package var title: String + package var workspaces: [ReviewMonitorCodexSidebarWorkspace] + package var uncategorizedChats: [CodexChat] + + package var chats: [CodexChat] { + workspaces.flatMap(\.chats) + uncategorizedChats + } + + var selection: ReviewMonitorWorkspaceSectionSelection { + ReviewMonitorWorkspaceSectionSelection( + id: id, + title: title, + workspaceCWDs: workspaces.map(\.cwd) + ) + } +} + +@MainActor +package final class ReviewMonitorCodexSidebarLibrary { + package static var defaultRequest: CodexFetchRequest { + CodexFetchRequest( + sortDescriptors: [.updatedAt(.reverse)], + sectionDescriptor: .workspaceGroup + ) + } + + private let fetchedResults: CodexFetchedResults + + package init( + modelContext: CodexModelContext, + request: CodexFetchRequest = ReviewMonitorCodexSidebarLibrary.defaultRequest + ) { + self.fetchedResults = modelContext.fetchedResults(for: request) + } + + package var phase: CodexDataPhase { + fetchedResults.phase + } + + package var lastErrorDescription: String? { + fetchedResults.lastErrorDescription + } + + package var nextCursor: String? { + fetchedResults.nextCursor + } + + package var sections: [ReviewMonitorCodexSidebarSection] { + Self.sidebarSections(from: fetchedResults.sections) + } + + package func performFetch() async throws { + try await fetchedResults.performFetch() + } + + package func refresh() async throws { + try await fetchedResults.refresh() + } + + package func loadNextPage() async throws { + try await fetchedResults.loadNextPage() + } + + package func chat(id: CodexThreadID) -> CodexChat? { + fetchedResults.items.first { $0.id == id } + } + + private static func sidebarSections( + from fetchSections: [CodexFetchSection] + ) -> [ReviewMonitorCodexSidebarSection] { + fetchSections.map { fetchSection in + var workspaces: [ReviewMonitorCodexSidebarWorkspace] = [] + var workspaceIndexesByID: [CodexWorkspaceID: Int] = [:] + var uncategorizedChats: [CodexChat] = [] + + for chat in fetchSection.items { + guard let workspace = chat.workspace else { + uncategorizedChats.append(chat) + continue + } + + if let index = workspaceIndexesByID[workspace.id] { + workspaces[index].chats.append(chat) + } else { + workspaceIndexesByID[workspace.id] = workspaces.count + workspaces.append(ReviewMonitorCodexSidebarWorkspace( + workspace: workspace, + chats: [chat] + )) + } + } + + return ReviewMonitorCodexSidebarSection( + id: fetchSection.id, + title: fetchSection.title ?? "Unknown", + workspaces: workspaces, + uncategorizedChats: uncategorizedChats + ) + } + } +} diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index f8f93862..06b9f125 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -512,7 +512,7 @@ struct CodexReviewHostTests { ]) } - @Test func liveStoreUsesNativeLoginCompletionWhenCallbackIsAvailable() async throws { + @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") @@ -529,10 +529,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" @@ -559,26 +555,26 @@ struct CodexReviewHostTests { ) 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) - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) - await transport.waitForRequestCount(7) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) + #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" } 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?.callbackURLScheme == "lynnpd.CodexReviewMonitor.auth") - let completeRequest = try #require(await transport.recordedRequests().first { - $0.method == "account/login/complete" - }) - let completeParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Complete.Params.self, from: completeRequest.params) - #expect(completeParams.loginID == "login-1") - #expect(completeParams.callbackURL == "lynnpd.CodexReviewMonitor.auth://callback?code=abc") + #expect(loginParams.nativeWebAuthentication == nil) let methods = await transport.recordedRequests().map(\.method) #expect(methods == [ "initialize", @@ -586,14 +582,13 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", - "account/login/complete", "account/read", "account/rateLimits/read", ]) await store.stop() } - @Test func liveStoreReportsNativeAuthenticationSessionCreationFailure() async throws { + @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") @@ -633,19 +628,17 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await store.addAccount() - await transport.waitForRequestCount(6) + await transport.waitForRequestCount(5) - #expect(didCreateNativeSession) - #expect(store.auth.isAuthenticating == false) - #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") - #expect(externalURLOpener.openedURLs == []) - #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() } @@ -684,10 +677,6 @@ struct CodexReviewHostTests { ), for: "account/login/start" ) - try await authTransport.enqueue( - AppServerAPI.Account.Login.Complete.Response(), - for: "account/login/complete" - ) try await authTransport.enqueue( AppServerAPI.Account.Read.Response( account: .init(email: "new@example.com", planType: "plus") @@ -754,17 +743,22 @@ struct CodexReviewHostTests { await store.addAccount() await authTransport.waitForNotificationStreamCount(1) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) - #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") - await authTransport.waitForRequestCount(5) + #expect(loginParams.nativeWebAuthentication == nil) + try await authTransport.emitServerNotification( + method: "account/login/completed", + params: TestLoginCompletedNotification(loginID: "login-2", success: true) + ) + try await authTransport.emitServerNotification( + method: "account/updated", + params: EmptyResponse() + ) await waitUntil { store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" } && store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25 @@ -779,7 +773,6 @@ struct CodexReviewHostTests { #expect(await authTransport.recordedRequests().map(\.method) == [ "initialize", "account/login/start", - "account/login/complete", "account/read", "account/rateLimits/read", ]) @@ -896,10 +889,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" @@ -933,9 +922,9 @@ 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=abc")!) + 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) @@ -961,14 +950,12 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", - "account/login/complete", "account/read", "account/rateLimits/read", ]) - #expect(externalURLOpener.openedURLs == []) } - @Test func liveStoreAddAccountReportsNativeSessionSetupFailure() async throws { + @Test func liveStoreAddAccountUsesExternalBrowserWhenNativeSessionFactoryFails() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( @@ -1037,17 +1024,15 @@ struct CodexReviewHostTests { await loginTransport.waitForRequestCount(2) let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - #expect(store.auth.authenticationFailureCount == previousFailureCount + 1) - #expect(store.auth.isAuthenticating == false) - #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") - #expect(externalURLOpener.openedURLs == []) + #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", ]) } @@ -1561,7 +1546,7 @@ struct CodexReviewHostTests { await store.addAccount() let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path)) - #expect(externalURLOpener.openedURLs == []) + #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!]) await mainTransport.finishNotificationStreams(throwing: TestTransportClosedError()) await waitUntil { @@ -1773,7 +1758,7 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await store.addAccount() await loginTransport.waitForNotificationStreamCount(1) - #expect(externalURLOpener.openedURLs == []) + #expect(externalURLOpener.openedURLs == [URL(string: "https://example.com/auth")!]) try await loginTransport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification( diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift new file mode 100644 index 00000000..9ae488e2 --- /dev/null +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -0,0 +1,68 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Foundation +import Testing +@_spi(PreviewSupport) @testable import ReviewUI + +@Suite("ReviewMonitor Codex sidebar library") +@MainActor +struct ReviewMonitorCodexSidebarLibraryTests { + @Test func buildsSidebarSectionsFromCodexDataKitFetchResults() 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 library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + + let section = try #require(library.sections.first) + let appWorkspace = try #require(section.workspaces.first) + let appChat = try #require(appWorkspace.chats.first) + let resolvedAppPath = app.standardizedFileURL.resolvingSymlinksInPath().path + let resolvedToolsPath = tools.standardizedFileURL.resolvingSymlinksInPath().path + + #expect(library.sections.count == 1) + #expect(section.title == repo.lastPathComponent) + #expect(section.selection.workspaceCWDs == [resolvedAppPath, resolvedToolsPath]) + #expect(section.workspaces.map(\.title) == ["App", "Tools"]) + #expect(section.chats.map(\.title) == ["App chat", "Tools chat"]) + #expect(appChat === library.chat(id: CodexThreadID(rawValue: "thread-app"))) + } +} + +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/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 53877f51..798083ef 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "cfaa2f5763014eacc01b728b79791527a17fe07c" + "revision" : "77340166f494e16077a156b4a2a832d2cd88527d" } }, { From d6a83bbaefba0254b16e097d6d1d9e8e096172db Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:18:51 +0900 Subject: [PATCH 079/359] refactor(review-ui): render selected reviews from CodexDataKit chats --- Package.resolved | 10 +- Package.swift | 7 +- .../LiveCodexReviewStoreBackend.swift | 19 +- .../CodexReviewKit/Model/ReviewChatLink.swift | 118 ++++++ .../CodexReviewKit/Model/ReviewJobCore.swift | 3 + .../Store/CodexReviewStoreReviews.swift | 2 + ...ewMonitorCodexChatTimelineProjection.swift | 390 ++++++++++++++++++ ...ReviewMonitorTransportViewController.swift | 69 +++- .../ReviewMonitorCodexModelSource.swift | 32 ++ .../ReviewUI/ReviewMonitorContentView.swift | 21 + .../ReviewMonitorSelectedReviewChat.swift | 99 +++++ .../ReviewMonitorSplitViewController.swift | 21 +- .../ReviewMonitorWindowController.swift | 59 ++- .../CodexReviewHostTests.swift | 38 ++ .../ReviewChatLinkTests.swift | 136 ++++++ ...ReviewMonitorSelectedReviewChatTests.swift | 320 ++++++++++++++ .../CodexReviewMonitorApp.swift | 22 +- .../CodexReviewMonitorCITests.swift | 27 +- 18 files changed, 1345 insertions(+), 48 deletions(-) create mode 100644 Sources/CodexReviewKit/Model/ReviewChatLink.swift create mode 100644 Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift create mode 100644 Sources/ReviewUI/ReviewMonitorCodexModelSource.swift create mode 100644 Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift create mode 100644 Tests/CodexReviewKitTests/ReviewChatLinkTests.swift create mode 100644 Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift diff --git a/Package.resolved b/Package.resolved index 4390b6ad..a2370001 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,6 @@ { - "originHash" : "b5e8218fa86cb5fd17a3326e03de7ffb2ff0a218a4ffdbcb140f9020554e39bd", + "originHash" : "def0fe429586f0032af3e78b1884133aa9747c47dc4c22080e5d9232c010398d", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "77340166f494e16077a156b4a2a832d2cd88527d" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 83323eb8..79e3b4d7 100644 --- a/Package.swift +++ b/Package.swift @@ -29,10 +29,9 @@ 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")), - .package( - url: "https://github.com/lynnswap/CodexKit.git", - revision: "77340166f494e16077a156b4a2a832d2cd88527d" - ), + // Temporary local reference while CodexKit integration work is in flight. + // Replace this with a pinned remote CodexKit revision before merging. + .package(path: "../CodexKit"), ], targets: [ .target( diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 9dd1bc6c..2b287d5b 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -10,6 +10,7 @@ private typealias ReviewCodexAccount = CodexReviewKit.CodexAccount private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void +public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexAppServer?) -> Void private let defaultExternalURLOpener: ExternalURLOpener = { url in _ = NSWorkspace.shared.open(url) @@ -110,12 +111,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 )) } @@ -130,7 +133,8 @@ public extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, - appServer: CodexAppServer + appServer: CodexAppServer, + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil ) -> CodexReviewStore { makeLiveStoreForTesting( environment: environment, @@ -143,6 +147,7 @@ public extension CodexReviewStore { shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, + appServerLifecycleHandler: appServerLifecycleHandler, appServerFactory: { _ in appServer } ) } @@ -162,6 +167,7 @@ public extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, appServerFactory: @escaping @MainActor @Sendable (URL) async throws -> CodexAppServer ) -> CodexReviewStore { CodexReviewStore( @@ -175,6 +181,7 @@ public extension CodexReviewStore { mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, shutdownCleanupTimeout: shutdownCleanupTimeout, + appServerLifecycleHandler: appServerLifecycleHandler, appServerRuntimeFactory: { codexHomeURL in let appServer = try await appServerFactory(codexHomeURL) return .init( @@ -222,6 +229,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( @@ -239,6 +247,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, shutdownCleanupTimeout: Duration = .seconds(2), + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, appServerRuntimeFactory: AppServerRuntimeFactory? = nil ) { let runtimePreferences = runtimePreferences.normalized @@ -258,6 +267,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 ) @@ -435,6 +445,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { startedAppServer = appServer self.appServer = appServer self.appServerBackend = backend + appServerLifecycleHandler?(appServer) observeAuthNotifications(appServer: appServer, backend: backend, store: store) if let mcpHTTPServerFactory { let mcpHTTPServer = mcpHTTPServerFactory(store, mcpHTTPServerConfiguration) @@ -455,6 +466,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.appServer = nil self.appServerBackend = nil self.mcpHTTPServer = nil + appServerLifecycleHandler?(nil) authNotificationTask?.cancel() authNotificationTask = nil store.transitionToFailed(failureMessage) @@ -527,6 +539,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } self.appServer = nil self.mcpHTTPServer = nil + appServerLifecycleHandler?(nil) authNotificationTask?.cancel() authNotificationTask = nil await mcpHTTPServer?.stop() diff --git a/Sources/CodexReviewKit/Model/ReviewChatLink.swift b/Sources/CodexReviewKit/Model/ReviewChatLink.swift new file mode 100644 index 00000000..d8d95eee --- /dev/null +++ b/Sources/CodexReviewKit/Model/ReviewChatLink.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct ReviewChatLink: Codable, Sendable, Hashable { + public let jobID: String + public let sessionID: String + public let cwd: String + public let attemptID: String? + public let sourceThreadID: String + public let activeChatThreadID: String + public let reviewThreadID: String? + public let turnID: String + public let model: String? + + private enum CodingKeys: String, CodingKey { + case jobID + case sessionID + case cwd + case attemptID + case sourceThreadID + case activeChatThreadID + case reviewThreadID + case turnID + case model + } + + public init?( + jobID: String, + sessionID: String, + cwd: String, + attemptID: String? = nil, + sourceThreadID: String?, + reviewThreadID: String? = nil, + turnID: String?, + model: String? = nil + ) { + guard let sourceThreadID = Self.normalize(sourceThreadID), + let turnID = Self.normalize(turnID) + else { + return nil + } + let reviewThreadID = Self.normalize(reviewThreadID) + + self.jobID = jobID + self.sessionID = sessionID + self.cwd = cwd + self.attemptID = Self.normalize(attemptID) + self.sourceThreadID = sourceThreadID + self.activeChatThreadID = reviewThreadID ?? sourceThreadID + self.reviewThreadID = reviewThreadID + self.turnID = turnID + self.model = Self.normalize(model) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let jobID = try container.decode(String.self, forKey: .jobID) + let sessionID = try container.decode(String.self, forKey: .sessionID) + let cwd = try container.decode(String.self, forKey: .cwd) + let attemptID = try container.decodeIfPresent(String.self, forKey: .attemptID) + let sourceThreadID = try container.decode(String.self, forKey: .sourceThreadID) + let reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID) + let turnID = try container.decode(String.self, forKey: .turnID) + let model = try container.decodeIfPresent(String.self, forKey: .model) + + guard + let link = Self( + jobID: jobID, + sessionID: sessionID, + cwd: cwd, + attemptID: attemptID, + sourceThreadID: sourceThreadID, + reviewThreadID: reviewThreadID, + turnID: turnID, + model: model + ) + else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "ReviewChatLink requires non-empty sourceThreadID and turnID." + )) + } + + self = link + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jobID, forKey: .jobID) + try container.encode(sessionID, forKey: .sessionID) + try container.encode(cwd, forKey: .cwd) + try container.encodeIfPresent(attemptID, forKey: .attemptID) + try container.encode(sourceThreadID, forKey: .sourceThreadID) + try container.encode(activeChatThreadID, forKey: .activeChatThreadID) + try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID) + try container.encode(turnID, forKey: .turnID) + try container.encodeIfPresent(model, forKey: .model) + } + + private static func normalize(_ value: String?) -> String? { + value.flatMap(\.nilIfEmpty) + } +} + +public extension CodexReviewJob { + var reviewChatLink: ReviewChatLink? { + ReviewChatLink( + jobID: id, + sessionID: sessionID, + cwd: cwd, + attemptID: core.run.attemptID, + sourceThreadID: core.run.threadID, + reviewThreadID: core.run.reviewThreadID, + turnID: core.run.turnID, + model: core.run.model + ) + } +} diff --git a/Sources/CodexReviewKit/Model/ReviewJobCore.swift b/Sources/CodexReviewKit/Model/ReviewJobCore.swift index 0cf87646..b67b7c1a 100644 --- a/Sources/CodexReviewKit/Model/ReviewJobCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewJobCore.swift @@ -2,17 +2,20 @@ import Foundation public struct ReviewJobCore: Codable, Sendable, Hashable { public struct Run: Codable, Sendable, Hashable { + public internal(set) var attemptID: String? 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( + 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 diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 9634bd09..50112a78 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -223,6 +223,7 @@ extension CodexReviewStore { private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: CodexReviewJob) { runtimeState.setActiveRun(backendRun, for: job.id) job.core.run = .init( + attemptID: backendRun.attemptID, reviewThreadID: backendRun.reviewThreadID, threadID: backendRun.threadID, turnID: backendRun.turnID, @@ -1088,6 +1089,7 @@ private extension CodexReviewJob { return nil } return .init( + attemptID: core.run.attemptID ?? "attempt-1", threadID: threadID, turnID: core.run.turnID, reviewThreadID: core.run.reviewThreadID, diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift new file mode 100644 index 00000000..244ee670 --- /dev/null +++ b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift @@ -0,0 +1,390 @@ +import CodexAppServerKit +import CodexDataKit +import CodexReviewKit +import Foundation +import ReviewMonitorRendering + +@MainActor +struct ReviewMonitorCodexChatTimelineProjection { + func document( + from chat: CodexChat, + link: ReviewChatLink, + revision: UInt64 + ) -> ReviewTimelineDocument? { + let turnID = CodexTurnID(rawValue: link.turnID) + let items = chat.items.filter { $0.turnID == turnID } + guard items.isEmpty == false else { + return nil + } + + let turn = chat.turns.first { $0.id == turnID } + let blocks = items.map { block(from: $0, turn: turn, chat: chat) } + let activeBlockIDs = blocks.filter(\.isActive).map(\.id) + return ReviewTimelineDocument( + timelineRevision: .init(rawValue: revision), + orderedBlockIDs: blocks.map(\.id), + activeBlockIDs: activeBlockIDs, + activeBlockCount: activeBlockIDs.count, + latestActivityBlockID: blocks.last?.id, + terminalStatus: terminalStatus(for: turn), + terminalSummary: turn?.errorDescription, + terminalResult: nil, + blocks: blocks + ) + } + + private func block( + from item: CodexChat.Item, + turn: CodexChat.Turn?, + chat: CodexChat + ) -> ReviewTimelineDocument.Block { + let content = content(from: item, turn: turn) + let phase = phase(for: item, turn: turn) + let blockID = blockID(for: item) + let sourceItemID = ReviewTimelineItem.ID(rawValue: blockID.rawValue) + let timestamp = chat.updatedAt ?? chat.createdAt ?? Date(timeIntervalSince1970: 0) + return ReviewTimelineDocument.Block( + id: blockID, + sourceItemID: sourceItemID, + kind: kind(for: item.kind), + family: family(for: item), + phase: phase, + isActive: phase.isTerminal == false, + primaryText: primaryText(for: content), + rawTranscriptText: rawTranscriptText(for: content), + content: content, + createdAt: chat.createdAt ?? timestamp, + updatedAt: timestamp + ) + } + + private func blockID(for item: CodexChat.Item) -> ReviewTimelineDocument.Block.ID { + let rawTurnID = item.turnID?.rawValue ?? "unknown-turn" + return .init(rawValue: "\(rawTurnID):\(item.id)") + } + + private func kind(for kind: CodexThreadItem.Kind) -> ReviewItemKind { + switch kind { + case .agentMessage: + return .agentMessage + case .userMessage: + return ReviewItemKind(rawValue: kind.rawValue) + case .plan: + return .plan + case .reasoning: + return .reasoning + case .commandExecution: + return .commandExecution + case .fileChange: + return .fileChange + case .mcpToolCall: + return .mcpToolCall + case .dynamicToolCall: + return .dynamicToolCall + case .webSearch: + return .webSearch + case .imageGeneration: + return .imageGeneration + case .imageView: + return .imageView + case .contextCompaction: + return .contextCompaction + case .collabAgentToolCall, .subAgentActivity, .sleep, .diagnostic, .error, .unknown: + return ReviewItemKind(rawValue: kind.rawValue) + } + } + + private func family(for item: CodexChat.Item) -> ReviewItemFamily { + switch item.content { + case .message: + return .message + case .plan: + return .plan + case .reasoning: + return .reasoning + case .command: + return .command + case .fileChange: + return .fileChange + case .toolCall: + return item.kind == .webSearch ? .search : .tool + case .contextCompaction: + return .contextCompaction + case .diagnostic, .log: + return .diagnostic + case .unknown: + return .unknown + } + } + + private func content(from item: CodexChat.Item, turn: CodexChat.Turn?) -> ReviewTimelineDocument.Content { + switch item.content { + case .message(let message): + return .message(.init(text: message.text)) + case .plan(let markdown): + return .plan(.init(markdown: markdown)) + case .reasoning(let reasoning): + return .reasoning( + .init( + text: reasoning.text, + style: reasoning.summary.isEmpty ? .raw : .summary + )) + case .command(let command): + return .command( + .init( + title: command.command, + command: command.command, + cwd: command.cwd, + output: command.output ?? "", + exitCode: command.exitCode, + status: commandStatus(for: command.status) + )) + case .fileChange(let fileChange): + let title = fileChange.path ?? "File change" + return .fileChange( + .init( + title: title, + output: fileChange.output ?? "", + paths: fileChange.path.map { [$0] } ?? [], + status: fileChangeStatus(for: fileChange.status) + )) + case .toolCall(let toolCall): + if item.kind == .webSearch { + return .search( + .init( + query: toolCall.arguments ?? toolCall.name ?? "Web search", + result: toolCall.result ?? toolCall.error, + status: searchStatus(for: toolCall.status) + )) + } + return .toolCall( + .init( + namespace: toolCall.namespace, + server: toolCall.server, + name: toolCall.name, + arguments: toolCall.arguments, + result: toolCall.result, + error: toolCall.error, + status: toolCallStatus(for: toolCall.status) + )) + case .contextCompaction(let text): + return .contextCompaction( + .init( + title: text ?? "Context compaction", + status: contextCompactionStatus(for: turn?.status) + )) + case .diagnostic(let message): + return .diagnostic( + .init( + message: message, + severity: item.kind == .error ? .error : nil + )) + case .log(let message): + return .diagnostic(.init(message: message)) + case .unknown(let raw): + return .unknown( + .init( + title: raw.text ?? raw.rawType, + detail: raw.text, + rawKind: ReviewItemKind(rawValue: item.kind.rawValue), + rawStatus: item.itemStatus?.rawValue + )) + } + } + + private func phase(for item: CodexChat.Item, turn: CodexChat.Turn?) -> ReviewItemPhase { + statusPhase(item.itemStatus ?? turn?.status) + } + + private func terminalStatus(for turn: CodexChat.Turn?) -> ReviewLifecycleStatus? { + switch turn?.status { + case .completed: + return .succeeded + case .failed, .interrupted: + return .failed + case .cancelled: + return .cancelled + case .running, .unknown, nil: + return nil + } + } + + private func statusPhase(_ status: CodexTurnStatus?) -> ReviewItemPhase { + switch status { + case .running: + return .running + case .completed: + return .completed + case .failed: + return .failed + case .interrupted: + return .incomplete + case .cancelled: + return .cancelled + case .unknown(let rawValue): + return .normalized(rawValue) + case nil: + return .running + } + } + + private func commandStatus(for status: CodexTurnStatus?) -> ReviewCommandStatus? { + switch status { + case .running: + return .inProgress + case .completed: + return .completed + case .failed: + return .failed + case .interrupted, .cancelled: + return .cancelled + case .unknown(let rawValue): + return .init(rawValue: rawValue) + case nil: + return nil + } + } + + private func toolCallStatus(for status: CodexTurnStatus?) -> ReviewToolCallStatus? { + switch status { + case .running: + return .inProgress + case .completed: + return .completed + case .failed: + return .failed + case .interrupted, .cancelled: + return .cancelled + case .unknown(let rawValue): + return .init(rawValue: rawValue) + case nil: + return nil + } + } + + private func fileChangeStatus(for status: CodexTurnStatus?) -> ReviewFileChangeStatus? { + switch status { + case .running: + return .updated + case .completed: + return .completed + case .failed, .interrupted, .cancelled: + return .failed + case .unknown(let rawValue): + return .init(rawValue: rawValue) + case nil: + return nil + } + } + + private func searchStatus(for status: CodexTurnStatus?) -> ReviewSearchStatus? { + switch status { + case .running: + return .started + case .completed: + return .completed + case .failed, .interrupted, .cancelled: + return .failed + case .unknown(let rawValue): + return .init(rawValue: rawValue) + case nil: + return nil + } + } + + private func contextCompactionStatus(for status: CodexTurnStatus?) -> ReviewContextCompactionStatus? { + switch status { + case .running: + return .inProgress + case .completed: + return .completed + case .failed, .interrupted, .cancelled: + return .failed + case .unknown(let rawValue): + return .init(rawValue: rawValue) + case nil: + return nil + } + } + + private 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 [toolCall.namespace, toolCall.server, toolCall.name] + .compactMap { $0?.nilIfEmpty } + .joined(separator: ".") + case .unknown(let unknown): + return unknown.title + } + } + + private 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 [ + primaryText(for: content).nilIfEmpty, + toolCall.progress?.nilIfEmpty, + toolCall.result?.nilIfEmpty, + toolCall.error?.nilIfEmpty, + ] + .compactMap { $0 } + .joined(separator: "\n") + case .unknown(let unknown): + return [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n") + } + } +} + +private extension CodexChat.Item { + var itemStatus: CodexTurnStatus? { + switch content { + 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 + } + } +} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index b870a313..e4ef772a 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,7 +1,7 @@ import AppKit +import CodexDataKit import ObservationBridge import CodexReviewKit -import CodexReviewKit import ReviewMonitorRendering @MainActor @@ -13,6 +13,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private let uiState: ReviewMonitorUIState private let store: CodexReviewStore + private let selectedReviewChat: ReviewMonitorSelectedReviewChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() @@ -30,9 +31,26 @@ final class ReviewMonitorTransportViewController: NSViewController { private var appliedLogRenderGeneration: UInt64 = 0 private var hasAppliedBoundJobLog = false - init(store: CodexReviewStore, uiState: ReviewMonitorUIState) { + convenience init( + store: CodexReviewStore, + uiState: ReviewMonitorUIState, + modelContext: CodexModelContext + ) { + self.init( + store: store, + uiState: uiState, + codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext) + ) + } + + init( + store: CodexReviewStore, + uiState: ReviewMonitorUIState, + codexModelSource: ReviewMonitorCodexModelSource? = nil + ) { self.store = store self.uiState = uiState + self.selectedReviewChat = ReviewMonitorSelectedReviewChat(modelSource: codexModelSource) super.init(nibName: nil, bundle: nil) } @@ -159,22 +177,21 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation = nil resetLogRenderer() boundJob = selectedJob + selectedReviewChat.bind(to: 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.selectedReviewChat.bind(to: selectedJob) + let timeline = selectedJob.timeline + _ = timeline.revision + let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) self.renderBoundJobLog( - timeline: timeline, + timelineDocument: timelineDocument, restorationTarget: eventKind == .initial ? self.restorationTarget(selectedJob) : self.logScrollView.currentScrollRestorationTarget, @@ -188,6 +205,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation?.cancel() selectedJobObservation = nil boundJob = nil + selectedReviewChat.bind(to: nil) resetLogRenderer() logScrollView.resetFindStateForContentReuse() logScrollView.clear() @@ -362,7 +380,7 @@ final class ReviewMonitorTransportViewController: NSViewController { @discardableResult private func renderBoundJobLog( - timeline: ReviewTimeline, + timelineDocument: ReviewTimelineDocument, restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, allowIncrementalUpdate: Bool ) -> Bool { @@ -376,7 +394,6 @@ final class ReviewMonitorTransportViewController: NSViewController { 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, @@ -397,6 +414,20 @@ final class ReviewMonitorTransportViewController: NSViewController { return true } + private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { + if let chat = selectedReviewChat.chat, + let link = selectedReviewChat.link, + let document = ReviewMonitorCodexChatTimelineProjection().document( + from: chat, + link: link, + revision: logRenderGeneration &+ 1 + ) { + return document + } + + return ReviewTimelineDocumentRenderer().document(from: timeline) + } + private func cacheBoundJobScrollTarget() { guard let boundJob else { return @@ -482,6 +513,22 @@ extension ReviewMonitorTransportViewController { selectedWorkspaceFindingsObservation } + var selectedReviewChatLinkForTesting: ReviewChatLink? { + selectedReviewChat.link + } + + var selectedReviewChatIDForTesting: String? { + selectedReviewChat.chat?.id.rawValue + } + + var selectedReviewChatPhaseForTesting: CodexDataPhase { + selectedReviewChat.phase + } + + var selectedReviewChatItemTextsForTesting: [String] { + selectedReviewChat.chat?.items.compactMap(\.text) ?? [] + } + var observationForExpectedRenderedStateForTesting: PortableObservationTracking.Token? { let expectedSelection = expectedRenderedStateForTesting.selection if displayedSelectionForTesting != expectedSelection { diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift new file mode 100644 index 00000000..cba5f4f4 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -0,0 +1,32 @@ +import CodexDataKit +import Observation + +@MainActor +@Observable +public final class ReviewMonitorCodexModelSource { + public private(set) var modelContext: CodexModelContext? + public private(set) var generation: UInt64 = 0 + + @ObservationIgnored + private var container: CodexModelContainer? + + public init(modelContext: CodexModelContext? = nil) { + self.modelContext = modelContext + } + + public func install(container: CodexModelContainer) { + self.container = container + modelContext = container.mainContext + bumpGeneration() + } + + public func clear() { + container = nil + modelContext = nil + bumpGeneration() + } + + private func bumpGeneration() { + generation &+= 1 + } +} diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 002adf02..969a4442 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import CodexDataKit import ObservationBridge import CodexReviewKit @@ -13,6 +14,7 @@ typealias ReviewMonitorContentTransitionAnimator = @MainActor ( 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 var observation: PortableObservationTracking.Token? @@ -22,19 +24,38 @@ final class ReviewMonitorRootViewController: NSViewController { private lazy var splitViewController = ReviewMonitorSplitViewController( store: store, uiState: uiState, + codexModelSource: codexModelSource, showSettings: showSettings ) private lazy var signInViewController = ReviewMonitorSignInViewController(store: store) + 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 + ) + } + init( store: CodexReviewStore, uiState: ReviewMonitorUIState, + codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil ) { self.store = store self.uiState = uiState + self.codexModelSource = codexModelSource self.contentTransitionAnimator = contentTransitionAnimator self.showSettings = showSettings super.init(nibName: nil, bundle: nil) diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift new file mode 100644 index 00000000..196debaf --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -0,0 +1,99 @@ +import CodexAppServerKit +import CodexDataKit +import CodexReviewKit +import Observation +import ObservationBridge + +@MainActor +@Observable +final class ReviewMonitorSelectedReviewChat { + private(set) var link: ReviewChatLink? + private(set) var chat: CodexChat? + var phase: CodexDataPhase { + chat?.phase ?? .idle + } + var lastErrorDescription: String? { + chat?.lastErrorDescription + } + + @ObservationIgnored + private let modelSource: ReviewMonitorCodexModelSource? + @ObservationIgnored + private weak var boundModelContext: CodexModelContext? + @ObservationIgnored + private weak var boundJob: CodexReviewJob? + @ObservationIgnored + private var observation: CodexChatObservation? + @ObservationIgnored + private var observationTask: Task? + @ObservationIgnored + private var modelSourceObservation: PortableObservationTracking.Token? + + init(modelSource: ReviewMonitorCodexModelSource?) { + self.modelSource = modelSource + bindModelSource() + } + + isolated deinit { + modelSourceObservation?.cancel() + cancelObservation() + } + + func bind(to job: CodexReviewJob?) { + boundJob = job + refreshBinding() + } + + private func bindModelSource() { + guard let modelSource else { + return + } + modelSourceObservation = withPortableContinuousObservation { [weak self, modelSource] _ in + _ = modelSource.generation + self?.refreshBinding() + } + } + + private func refreshBinding() { + let nextLink = boundJob?.reviewChatLink + let nextModelContext = modelSource?.modelContext + guard nextLink != link || nextModelContext !== boundModelContext else { + return + } + cancelObservation() + link = nextLink + chat = nil + boundModelContext = nextModelContext + + guard let nextLink, let modelContext = nextModelContext else { + return + } + + let nextChat = modelContext.model(for: CodexThreadID(rawValue: nextLink.activeChatThreadID)) + chat = nextChat + + observationTask = Task { @MainActor [weak self, nextChat, nextLink] in + do { + let observation = try await nextChat.observe() + guard Task.isCancelled == false, + let self, + self.link == nextLink, + self.chat === nextChat + else { + observation.cancel() + return + } + self.observation = observation + } catch is CancellationError { + } catch { + } + } + } + + private func cancelObservation() { + observationTask?.cancel() + observationTask = nil + observation?.cancel() + observation = nil + } +} diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index d364fa6b..0dff780e 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import CodexDataKit import Foundation import ObservationBridge import CodexReviewKit @@ -19,6 +20,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe 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? @@ -35,13 +37,29 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe 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) } @@ -64,7 +82,8 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe ) let transportViewController = ReviewMonitorTransportViewController( store: store, - uiState: uiState + uiState: uiState, + codexModelSource: codexModelSource ) let statusAccessoryViewController = ReviewMonitorServerStatusAccessoryViewController( store: store, diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 49d42dc4..602fc123 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -1,4 +1,5 @@ import AppKit +import CodexDataKit import CodexReviewKit @MainActor @@ -21,6 +22,19 @@ public final class ReviewMonitorWindowController: NSWindowController { public convenience init(store: CodexReviewStore) { self.init( store: store, + codexModelSource: nil, + contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + showSettings: nil + ) + } + + public convenience init( + store: CodexReviewStore, + codexModelContext: CodexModelContext + ) { + self.init( + store: store, + codexModelSource: ReviewMonitorCodexModelSource(modelContext: codexModelContext), contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: nil ) @@ -33,6 +47,21 @@ public final class ReviewMonitorWindowController: NSWindowController { ) { self.init( store: store, + codexModelSource: nil, + contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + showSettings: showSettings + ) + } + + @_spi(PreviewSupport) + public convenience init( + store: CodexReviewStore, + codexModelSource: ReviewMonitorCodexModelSource, + showSettings: @escaping @MainActor () -> Void + ) { + self.init( + store: store, + codexModelSource: codexModelSource, contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: showSettings ) @@ -40,12 +69,14 @@ public final class ReviewMonitorWindowController: NSWindowController { convenience init( store: CodexReviewStore, + codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, sidebarJobFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil ) { self.init( store: store, + codexModelSource: codexModelSource, contentTransitionAnimator: contentTransitionAnimator, frameAutosaveName: Self.frameAutosaveName, sidebarJobFilterDefaults: sidebarJobFilterDefaults, @@ -55,6 +86,7 @@ public final class ReviewMonitorWindowController: NSWindowController { init( store: CodexReviewStore, + codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, frameAutosaveName: NSWindow.FrameAutosaveName, sidebarJobFilterDefaults: UserDefaults? = .standard, @@ -67,6 +99,7 @@ public final class ReviewMonitorWindowController: NSWindowController { let rootViewController = ReviewMonitorRootViewController( store: store, uiState: uiState, + codexModelSource: codexModelSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings ) @@ -112,20 +145,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 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 + } + return filter } - return filter - } - static func save(_ filter: SidebarJobFilter, to defaults: UserDefaults) { - defaults.set(filter.persistedValue, forKey: defaultsKey) + static func save(_ filter: SidebarJobFilter, to defaults: UserDefaults) { + defaults.set(filter.persistedValue, forKey: defaultsKey) + } } } -} diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 06b9f125..0374b160 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -25,6 +25,7 @@ private extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, transport: FakeCodexAppServerTransport ) -> CodexReviewStore { makeLiveStoreForTesting( @@ -38,6 +39,7 @@ private extension CodexReviewStore { shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, + appServerLifecycleHandler: appServerLifecycleHandler, appServerFactory: { codexHomeURL in try await CodexAppServerTestRuntime.start( transport: transport, @@ -63,6 +65,7 @@ private extension CodexReviewStore { shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, transportFactory: @escaping @MainActor @Sendable (URL) async throws -> FakeCodexAppServerTransport ) -> CodexReviewStore { makeLiveStoreForTesting( @@ -77,6 +80,7 @@ private extension CodexReviewStore { shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, + appServerLifecycleHandler: appServerLifecycleHandler, appServerFactory: { codexHomeURL in let transport = try await transportFactory(codexHomeURL) return try await CodexAppServerTestRuntime.start( @@ -273,6 +277,40 @@ 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 = FakeCodexAppServerTransport() diff --git a/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift b/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift new file mode 100644 index 00000000..39e99ff8 --- /dev/null +++ b/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift @@ -0,0 +1,136 @@ +import Testing +@_spi(Testing) @testable import CodexReviewKit +import CodexReviewTesting + +@Suite("Review chat link", .serialized) +@MainActor +struct ReviewChatLinkTests { + @Test func startedReviewExposesDetachedReviewThreadAsActiveChatThread() async throws { + let backend = FakeCodexReviewBackend( + nextRun: .init( + attemptID: "attempt-1", + threadID: "source-thread", + turnID: "turn-1", + reviewThreadID: "review-thread", + model: "gpt-5.5" + )) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "job-1" }) + ) + + try await withReviewChatLinkStoreCleanup(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 link = try #require(store.job(id: "job-1")?.reviewChatLink) + #expect(link.jobID == "job-1") + #expect(link.sessionID == "session-1") + #expect(link.cwd == "/tmp/project") + #expect(link.attemptID == "attempt-1") + #expect(link.sourceThreadID == "source-thread") + #expect(link.reviewThreadID == "review-thread") + #expect(link.activeChatThreadID == "review-thread") + #expect(link.turnID == "turn-1") + #expect(link.model == "gpt-5.5") + } + } + + @Test func missingOrInlineReviewThreadFallsBackToSourceThread() throws { + let missingReviewThreadJob = makeRunningJob( + attemptID: "attempt-1", + sourceThreadID: "source-thread", + reviewThreadID: nil, + turnID: "turn-1" + ) + let missingReviewThreadLink = try #require(missingReviewThreadJob.reviewChatLink) + #expect(missingReviewThreadLink.attemptID == "attempt-1") + #expect(missingReviewThreadLink.reviewThreadID == nil) + #expect(missingReviewThreadLink.activeChatThreadID == "source-thread") + + let inlineReviewThreadJob = makeRunningJob( + attemptID: "attempt-2", + sourceThreadID: "source-thread", + reviewThreadID: "source-thread", + turnID: "turn-1" + ) + let inlineReviewThreadLink = try #require(inlineReviewThreadJob.reviewChatLink) + #expect(inlineReviewThreadLink.attemptID == "attempt-2") + #expect(inlineReviewThreadLink.reviewThreadID == "source-thread") + #expect(inlineReviewThreadLink.activeChatThreadID == "source-thread") + } + + @Test func emptyStringsAreNormalizedAway() throws { + let job = makeRunningJob( + sourceThreadID: "source-thread", + reviewThreadID: " ", + turnID: "turn-1", + model: "\n" + ) + + let link = try #require(job.reviewChatLink) + #expect(link.reviewThreadID == nil) + #expect(link.activeChatThreadID == "source-thread") + #expect(link.attemptID == nil) + #expect(link.model == nil) + + job.core.run.threadID = "" + #expect(job.reviewChatLink == nil) + + job.core.run.threadID = "source-thread" + job.core.run.turnID = " " + #expect(job.reviewChatLink == nil) + } + + private func makeRunningJob( + attemptID: String? = nil, + sourceThreadID: String?, + reviewThreadID: String?, + turnID: String?, + model: String? = "gpt-5" + ) -> CodexReviewJob { + let job = CodexReviewJob.makeForTesting( + id: "job-1", + sessionID: "session-1", + cwd: "/tmp/project", + targetSummary: "Uncommitted changes", + model: model, + threadID: sourceThreadID, + turnID: turnID, + status: .running, + summary: "Running" + ) + job.core.run.attemptID = attemptID + job.core.run.reviewThreadID = reviewThreadID + return job + } +} + +@MainActor +private func withReviewChatLinkStoreCleanup( + backend: FakeCodexReviewBackend, + store: CodexReviewStore, + operation: () async throws -> Void +) async rethrows { + do { + try await operation() + } catch { + await cleanupReviewChatLinkStore(backend: backend, store: store) + throw error + } + await cleanupReviewChatLinkStore(backend: backend, store: store) +} + +@MainActor +private func cleanupReviewChatLinkStore( + backend: FakeCodexReviewBackend, + store: CodexReviewStore +) async { + await backend.finishEventMailboxes() + await store.cancelAndDrainReviewWorkersForTesting() + await backend.finishEventMailboxes() +} diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift new file mode 100644 index 00000000..359baa14 --- /dev/null +++ b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift @@ -0,0 +1,320 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Testing +@_spi(Testing) @testable import CodexReviewKit +@testable import ReviewUI + +@Suite("ReviewMonitor selected review chat", .serialized) +@MainActor +struct ReviewMonitorSelectedReviewChatTests { + @Test func selectedReviewJobObservesActiveCodexChat() 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( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + + let job = makeRunningReviewJob( + sourceThreadID: "source-thread", + reviewThreadID: "review-thread", + turnID: "turn-1" + ) + uiState.selection = .job(job) + + try await waitForCondition { + transport.selectedReviewChatIDForTesting == "review-thread" + && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatItemTextsForTesting == ["Review snapshot"] + } + #expect(transport.selectedReviewChatLinkForTesting?.activeChatThreadID == "review-thread") + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + } + + @Test func clearingSelectionDetachesSelectedReviewChat() 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( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + + uiState.selection = .job( + makeRunningReviewJob( + sourceThreadID: "source-thread", + reviewThreadID: "review-thread", + turnID: "turn-1" + )) + try await waitForCondition { + transport.selectedReviewChatIDForTesting == "review-thread" + && transport.selectedReviewChatPhaseForTesting == .loaded + } + + uiState.selection = nil + try await waitForCondition { + transport.selectedReviewChatIDForTesting == nil && transport.selectedReviewChatLinkForTesting == nil + && transport.selectedReviewChatPhaseForTesting == .idle + } + } + + @Test func selectedReviewJobConnectsWhenModelSourceInstallsLater() 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( + store: store, + uiState: uiState, + codexModelSource: modelSource + ) + transport.loadViewIfNeeded() + uiState.selection = .job( + makeRunningReviewJob( + sourceThreadID: "source-thread", + reviewThreadID: "review-thread", + turnID: "turn-1" + )) + + try await waitForCondition { + transport.selectedReviewChatLinkForTesting?.activeChatThreadID == "review-thread" + } + #expect(transport.selectedReviewChatIDForTesting == nil) + + modelSource.install(container: CodexModelContainer(appServer: runtime.server)) + + try await waitForCondition { + transport.selectedReviewChatIDForTesting == "review-thread" + && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatItemTextsForTesting == ["Late source"] + } + } + + @Test func selectedReviewJobConnectsWhenRunIdentifiersArriveAfterSelection() 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: "Arrived after selection" + )) + ) + ] + ) + ] + )) + + let store = CodexReviewStore.makePreviewStore() + let uiState = ReviewMonitorUIState(auth: store.auth) + let transport = ReviewMonitorTransportViewController( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + let job = makeRunningReviewJob( + sourceThreadID: nil, + reviewThreadID: nil, + turnID: nil + ) + uiState.selection = .job(job) + + #expect(transport.selectedReviewChatLinkForTesting == nil) + + job.core.run.threadID = "source-thread" + job.core.run.reviewThreadID = "review-thread" + job.core.run.turnID = "turn-1" + job.appendLogEntry(.init(kind: .agentMessage, text: "Legacy trigger")) + + try await waitForCondition { + transport.selectedReviewChatIDForTesting == "review-thread" + && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatItemTextsForTesting == ["Arrived after selection"] + } + } + + @Test func selectedReviewJobRendersCodexChatTurnAndLiveUpdates() 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( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + + let job = makeRunningReviewJob( + sourceThreadID: "source-thread", + reviewThreadID: "review-thread", + turnID: "turn-1" + ) + job.appendLogEntry(.init(kind: .agentMessage, text: "Legacy fallback")) + uiState.selection = .job(job) + + let initialSnapshot = try await awaitTransportRender(transport) { 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) { snapshot in + snapshot.log.contains("Chat stream update") + } + #expect(updatedSnapshot.log.contains("Chat snapshot") == false) + #expect(updatedSnapshot.log.contains("Legacy fallback") == false) + } + + private func makeRunningReviewJob( + sourceThreadID: String?, + reviewThreadID: String?, + turnID: String? + ) -> CodexReviewJob { + let job = CodexReviewJob.makeForTesting( + id: "job-1", + sessionID: "session-1", + cwd: "/tmp/project", + targetSummary: "Uncommitted changes", + threadID: sourceThreadID, + turnID: turnID, + status: .running, + summary: "Running" + ) + job.core.run.attemptID = "attempt-1" + job.core.run.reviewThreadID = reviewThreadID + return job + } +} + +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/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index ac61ca86..04d7d5b5 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -6,6 +6,7 @@ // import AppKit +import CodexDataKit import CodexReviewKit import CodexReviewHost @_spi(PreviewSupport) import ReviewUI @@ -219,7 +220,8 @@ struct ReviewMonitorAppComposition { typealias PresentationAnchorProvider = @MainActor () -> NSWindow? typealias LiveStoreFactory = ( CodexReviewRuntime.Preferences, - CodexReviewNativeAuthentication.Configuration? + CodexReviewNativeAuthentication.Configuration?, + CodexReviewAppServerLifecycleHandler? ) -> CodexReviewStore var makeStore: (ReviewMonitorLaunchContext, @escaping PresentationAnchorProvider) -> CodexReviewStore @@ -262,14 +264,16 @@ 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( + let codexModelSource = ReviewMonitorCodexModelSource() + return ReviewMonitorAppComposition( makeStore: { context, presentationAnchorProvider in if context.requestsPreviewContent { return ReviewMonitorPreviewContent.makeStore() @@ -280,12 +284,20 @@ struct ReviewMonitorAppComposition { callbackScheme: ReviewMonitorNativeAuthentication.callbackScheme, browserSessionPolicy: .ephemeral, presentationAnchorProvider: presentationAnchorProvider - ) + ), + { appServer in + if let appServer { + codexModelSource.install(container: CodexModelContainer(appServer: appServer)) + } else { + codexModelSource.clear() + } + } ) }, makeWindowController: { store, showSettings in ReviewMonitorWindowController( store: store, + codexModelSource: codexModelSource, showSettings: showSettings ) }, diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index 85cf6407..4932b86f 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -222,7 +222,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() @@ -266,7 +266,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 @@ -296,6 +296,29 @@ 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 store = composition.makeStore(context) { nil } + + #expect(store === expectedStore) + #expect(capturedLifecycleHandler != nil) + } + @Test func liveCompositionBuildsLifecycleFromLaunchContext() async { let store = FakeLifecycleStore() let lifecycle = ReviewMonitorAppComposition.live().makeLifecycleController( From 2edb460887a667a1e974a363da0368a301686e67 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:46:09 +0900 Subject: [PATCH 080/359] refactor(review-ui): consume CodexDataKit turn snapshots --- ...ewMonitorCodexChatTimelineProjection.swift | 38 +++++++++++-------- .../ReviewMonitorTimelineLogProjection.swift | 1 - 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift index 244ee670..1499412d 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift @@ -12,13 +12,15 @@ struct ReviewMonitorCodexChatTimelineProjection { revision: UInt64 ) -> ReviewTimelineDocument? { let turnID = CodexTurnID(rawValue: link.turnID) - let items = chat.items.filter { $0.turnID == turnID } - guard items.isEmpty == false else { + guard let turnSnapshot = chat.turnSnapshot(for: turnID), + turnSnapshot.items.isEmpty == false + else { return nil } - let turn = chat.turns.first { $0.id == turnID } - let blocks = items.map { block(from: $0, turn: turn, chat: chat) } + let blocks = turnSnapshot.items.map { + block(from: $0, turnSnapshot: turnSnapshot, chat: chat) + } let activeBlockIDs = blocks.filter(\.isActive).map(\.id) return ReviewTimelineDocument( timelineRevision: .init(rawValue: revision), @@ -26,8 +28,8 @@ struct ReviewMonitorCodexChatTimelineProjection { activeBlockIDs: activeBlockIDs, activeBlockCount: activeBlockIDs.count, latestActivityBlockID: blocks.last?.id, - terminalStatus: terminalStatus(for: turn), - terminalSummary: turn?.errorDescription, + terminalStatus: terminalStatus(for: turnSnapshot), + terminalSummary: turnSnapshot.errorDescription, terminalResult: nil, blocks: blocks ) @@ -35,11 +37,11 @@ struct ReviewMonitorCodexChatTimelineProjection { private func block( from item: CodexChat.Item, - turn: CodexChat.Turn?, + turnSnapshot: CodexChatTurnSnapshot, chat: CodexChat ) -> ReviewTimelineDocument.Block { - let content = content(from: item, turn: turn) - let phase = phase(for: item, turn: turn) + let content = content(from: item, turnSnapshot: turnSnapshot) + let phase = phase(for: item, turnSnapshot: turnSnapshot) let blockID = blockID(for: item) let sourceItemID = ReviewTimelineItem.ID(rawValue: blockID.rawValue) let timestamp = chat.updatedAt ?? chat.createdAt ?? Date(timeIntervalSince1970: 0) @@ -117,7 +119,10 @@ struct ReviewMonitorCodexChatTimelineProjection { } } - private func content(from item: CodexChat.Item, turn: CodexChat.Turn?) -> ReviewTimelineDocument.Content { + private func content( + from item: CodexChat.Item, + turnSnapshot: CodexChatTurnSnapshot + ) -> ReviewTimelineDocument.Content { switch item.content { case .message(let message): return .message(.init(text: message.text)) @@ -171,7 +176,7 @@ struct ReviewMonitorCodexChatTimelineProjection { return .contextCompaction( .init( title: text ?? "Context compaction", - status: contextCompactionStatus(for: turn?.status) + status: contextCompactionStatus(for: turnSnapshot.status) )) case .diagnostic(let message): return .diagnostic( @@ -192,12 +197,15 @@ struct ReviewMonitorCodexChatTimelineProjection { } } - private func phase(for item: CodexChat.Item, turn: CodexChat.Turn?) -> ReviewItemPhase { - statusPhase(item.itemStatus ?? turn?.status) + private func phase( + for item: CodexChat.Item, + turnSnapshot: CodexChatTurnSnapshot + ) -> ReviewItemPhase { + statusPhase(item.itemStatus ?? turnSnapshot.status) } - private func terminalStatus(for turn: CodexChat.Turn?) -> ReviewLifecycleStatus? { - switch turn?.status { + private func terminalStatus(for turnSnapshot: CodexChatTurnSnapshot) -> ReviewLifecycleStatus? { + switch turnSnapshot.status { case .completed: return .succeeded case .failed, .interrupted: diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift index b69f5097..dbe8bae5 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift @@ -1,6 +1,5 @@ import Foundation import CodexReviewKit -import CodexReviewKit import ReviewMonitorRendering struct ReviewMonitorTimelineLogProjection: Sendable { From 09bf894f1513b3e97da37a8c739be890ade205b8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:29:06 +0900 Subject: [PATCH 081/359] refactor(review-ui): project selected Codex chat turns --- ...ewMonitorCodexChatTimelineProjection.swift | 24 +++++++++++-------- ...ReviewMonitorTransportViewController.swift | 8 +++---- .../ReviewMonitorSelectedReviewChat.swift | 13 ++++++++++ ...ReviewMonitorSelectedReviewChatTests.swift | 6 ++--- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift index 1499412d..a5dea140 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift @@ -7,19 +7,22 @@ import ReviewMonitorRendering @MainActor struct ReviewMonitorCodexChatTimelineProjection { func document( - from chat: CodexChat, - link: ReviewChatLink, + from turnSnapshot: CodexChatTurnSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date?, revision: UInt64 ) -> ReviewTimelineDocument? { - let turnID = CodexTurnID(rawValue: link.turnID) - guard let turnSnapshot = chat.turnSnapshot(for: turnID), - turnSnapshot.items.isEmpty == false - else { + guard turnSnapshot.items.isEmpty == false else { return nil } let blocks = turnSnapshot.items.map { - block(from: $0, turnSnapshot: turnSnapshot, chat: chat) + block( + from: $0, + turnSnapshot: turnSnapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) } let activeBlockIDs = blocks.filter(\.isActive).map(\.id) return ReviewTimelineDocument( @@ -38,13 +41,14 @@ struct ReviewMonitorCodexChatTimelineProjection { private func block( from item: CodexChat.Item, turnSnapshot: CodexChatTurnSnapshot, - chat: CodexChat + chatCreatedAt: Date?, + chatUpdatedAt: Date? ) -> ReviewTimelineDocument.Block { let content = content(from: item, turnSnapshot: turnSnapshot) let phase = phase(for: item, turnSnapshot: turnSnapshot) let blockID = blockID(for: item) let sourceItemID = ReviewTimelineItem.ID(rawValue: blockID.rawValue) - let timestamp = chat.updatedAt ?? chat.createdAt ?? Date(timeIntervalSince1970: 0) + let timestamp = chatUpdatedAt ?? chatCreatedAt ?? Date(timeIntervalSince1970: 0) return ReviewTimelineDocument.Block( id: blockID, sourceItemID: sourceItemID, @@ -55,7 +59,7 @@ struct ReviewMonitorCodexChatTimelineProjection { primaryText: primaryText(for: content), rawTranscriptText: rawTranscriptText(for: content), content: content, - createdAt: chat.createdAt ?? timestamp, + createdAt: chatCreatedAt ?? timestamp, updatedAt: timestamp ) } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index e4ef772a..e2bf7416 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -415,11 +415,11 @@ final class ReviewMonitorTransportViewController: NSViewController { } private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { - if let chat = selectedReviewChat.chat, - let link = selectedReviewChat.link, + if let turnSnapshot = selectedReviewChat.turnSnapshot, let document = ReviewMonitorCodexChatTimelineProjection().document( - from: chat, - link: link, + from: turnSnapshot, + chatCreatedAt: selectedReviewChat.chatCreatedAt, + chatUpdatedAt: selectedReviewChat.chatUpdatedAt, revision: logRenderGeneration &+ 1 ) { return document diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 196debaf..9c09c0c1 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -1,6 +1,7 @@ import CodexAppServerKit import CodexDataKit import CodexReviewKit +import Foundation import Observation import ObservationBridge @@ -15,6 +16,18 @@ final class ReviewMonitorSelectedReviewChat { var lastErrorDescription: String? { chat?.lastErrorDescription } + var turnSnapshot: CodexChatTurnSnapshot? { + guard let chat, let link else { + return nil + } + return chat.turnSnapshot(for: CodexTurnID(rawValue: link.turnID)) + } + var chatCreatedAt: Date? { + chat?.createdAt + } + var chatUpdatedAt: Date? { + chat?.updatedAt + } @ObservationIgnored private let modelSource: ReviewMonitorCodexModelSource? diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift index 359baa14..5c6bff08 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift @@ -54,7 +54,7 @@ struct ReviewMonitorSelectedReviewChatTests { try await waitForCondition { transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatPhaseForTesting == .loading && transport.selectedReviewChatItemTextsForTesting == ["Review snapshot"] } #expect(transport.selectedReviewChatLinkForTesting?.activeChatThreadID == "review-thread") @@ -146,7 +146,7 @@ struct ReviewMonitorSelectedReviewChatTests { try await waitForCondition { transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatPhaseForTesting == .loading && transport.selectedReviewChatItemTextsForTesting == ["Late source"] } } @@ -203,7 +203,7 @@ struct ReviewMonitorSelectedReviewChatTests { try await waitForCondition { transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loaded + && transport.selectedReviewChatPhaseForTesting == .loading && transport.selectedReviewChatItemTextsForTesting == ["Arrived after selection"] } } From 5d32ec44ae08e0fcf9a40a8d53031862f5d05cf0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:44:25 +0900 Subject: [PATCH 082/359] refactor(review-ui): observe selected reviews by identity --- Package.swift | 1 + .../CodexReviewKit/Model/ReviewChatLink.swift | 118 --------------- ...ReviewMonitorTransportViewController.swift | 7 +- .../ReviewMonitorSelectedReviewChat.swift | 38 +++-- .../ReviewChatLinkTests.swift | 136 ------------------ ...ReviewMonitorSelectedReviewChatTests.swift | 10 +- 6 files changed, 38 insertions(+), 272 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/ReviewChatLink.swift delete mode 100644 Tests/CodexReviewKitTests/ReviewChatLinkTests.swift diff --git a/Package.swift b/Package.swift index 79e3b4d7..400a3faa 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,7 @@ let package = Package( "CodexReviewKit", "ReviewMonitorRendering", "TextTransitions", + .product(name: "CodexAppServerKit", package: "CodexKit"), .product(name: "CodexDataKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], diff --git a/Sources/CodexReviewKit/Model/ReviewChatLink.swift b/Sources/CodexReviewKit/Model/ReviewChatLink.swift deleted file mode 100644 index d8d95eee..00000000 --- a/Sources/CodexReviewKit/Model/ReviewChatLink.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation - -public struct ReviewChatLink: Codable, Sendable, Hashable { - public let jobID: String - public let sessionID: String - public let cwd: String - public let attemptID: String? - public let sourceThreadID: String - public let activeChatThreadID: String - public let reviewThreadID: String? - public let turnID: String - public let model: String? - - private enum CodingKeys: String, CodingKey { - case jobID - case sessionID - case cwd - case attemptID - case sourceThreadID - case activeChatThreadID - case reviewThreadID - case turnID - case model - } - - public init?( - jobID: String, - sessionID: String, - cwd: String, - attemptID: String? = nil, - sourceThreadID: String?, - reviewThreadID: String? = nil, - turnID: String?, - model: String? = nil - ) { - guard let sourceThreadID = Self.normalize(sourceThreadID), - let turnID = Self.normalize(turnID) - else { - return nil - } - let reviewThreadID = Self.normalize(reviewThreadID) - - self.jobID = jobID - self.sessionID = sessionID - self.cwd = cwd - self.attemptID = Self.normalize(attemptID) - self.sourceThreadID = sourceThreadID - self.activeChatThreadID = reviewThreadID ?? sourceThreadID - self.reviewThreadID = reviewThreadID - self.turnID = turnID - self.model = Self.normalize(model) - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let jobID = try container.decode(String.self, forKey: .jobID) - let sessionID = try container.decode(String.self, forKey: .sessionID) - let cwd = try container.decode(String.self, forKey: .cwd) - let attemptID = try container.decodeIfPresent(String.self, forKey: .attemptID) - let sourceThreadID = try container.decode(String.self, forKey: .sourceThreadID) - let reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID) - let turnID = try container.decode(String.self, forKey: .turnID) - let model = try container.decodeIfPresent(String.self, forKey: .model) - - guard - let link = Self( - jobID: jobID, - sessionID: sessionID, - cwd: cwd, - attemptID: attemptID, - sourceThreadID: sourceThreadID, - reviewThreadID: reviewThreadID, - turnID: turnID, - model: model - ) - else { - throw DecodingError.dataCorrupted( - .init( - codingPath: decoder.codingPath, - debugDescription: "ReviewChatLink requires non-empty sourceThreadID and turnID." - )) - } - - self = link - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(jobID, forKey: .jobID) - try container.encode(sessionID, forKey: .sessionID) - try container.encode(cwd, forKey: .cwd) - try container.encodeIfPresent(attemptID, forKey: .attemptID) - try container.encode(sourceThreadID, forKey: .sourceThreadID) - try container.encode(activeChatThreadID, forKey: .activeChatThreadID) - try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID) - try container.encode(turnID, forKey: .turnID) - try container.encodeIfPresent(model, forKey: .model) - } - - private static func normalize(_ value: String?) -> String? { - value.flatMap(\.nilIfEmpty) - } -} - -public extension CodexReviewJob { - var reviewChatLink: ReviewChatLink? { - ReviewChatLink( - jobID: id, - sessionID: sessionID, - cwd: cwd, - attemptID: core.run.attemptID, - sourceThreadID: core.run.threadID, - reviewThreadID: core.run.reviewThreadID, - turnID: core.run.turnID, - model: core.run.model - ) - } -} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index e2bf7416..940b4bf5 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,7 +1,8 @@ import AppKit +import CodexAppServerKit import CodexDataKit -import ObservationBridge import CodexReviewKit +import ObservationBridge import ReviewMonitorRendering @MainActor @@ -513,8 +514,8 @@ extension ReviewMonitorTransportViewController { selectedWorkspaceFindingsObservation } - var selectedReviewChatLinkForTesting: ReviewChatLink? { - selectedReviewChat.link + var selectedReviewChatIdentityForTesting: CodexReviewIdentity? { + selectedReviewChat.identity } var selectedReviewChatIDForTesting: String? { diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 9c09c0c1..23e94aa7 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -8,7 +8,7 @@ import ObservationBridge @MainActor @Observable final class ReviewMonitorSelectedReviewChat { - private(set) var link: ReviewChatLink? + private(set) var identity: CodexReviewIdentity? private(set) var chat: CodexChat? var phase: CodexDataPhase { chat?.phase ?? .idle @@ -17,10 +17,10 @@ final class ReviewMonitorSelectedReviewChat { chat?.lastErrorDescription } var turnSnapshot: CodexChatTurnSnapshot? { - guard let chat, let link else { + guard let chat, let identity else { return nil } - return chat.turnSnapshot(for: CodexTurnID(rawValue: link.turnID)) + return chat.turnSnapshot(for: identity.turnID) } var chatCreatedAt: Date? { chat?.createdAt @@ -68,29 +68,29 @@ final class ReviewMonitorSelectedReviewChat { } private func refreshBinding() { - let nextLink = boundJob?.reviewChatLink + let nextIdentity = boundJob?.reviewIdentity let nextModelContext = modelSource?.modelContext - guard nextLink != link || nextModelContext !== boundModelContext else { + guard nextIdentity != identity || nextModelContext !== boundModelContext else { return } cancelObservation() - link = nextLink + identity = nextIdentity chat = nil boundModelContext = nextModelContext - guard let nextLink, let modelContext = nextModelContext else { + guard let nextIdentity, let modelContext = nextModelContext else { return } - let nextChat = modelContext.model(for: CodexThreadID(rawValue: nextLink.activeChatThreadID)) + let nextChat = modelContext.model(for: nextIdentity) chat = nextChat - observationTask = Task { @MainActor [weak self, nextChat, nextLink] in + observationTask = Task { @MainActor [weak self, nextChat, nextIdentity, modelContext] in do { - let observation = try await nextChat.observe() + let observation = try await modelContext.observe(nextIdentity) guard Task.isCancelled == false, let self, - self.link == nextLink, + self.identity == nextIdentity, self.chat === nextChat else { observation.cancel() @@ -110,3 +110,19 @@ final class ReviewMonitorSelectedReviewChat { observation = nil } } + +private extension CodexReviewJob { + var reviewIdentity: CodexReviewIdentity? { + guard let sourceThreadID = core.run.threadID?.nilIfEmpty, + let turnID = core.run.turnID?.nilIfEmpty + else { + return nil + } + return CodexReviewIdentity( + threadID: CodexThreadID(rawValue: sourceThreadID), + turnID: CodexTurnID(rawValue: turnID), + reviewThreadID: core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), + model: core.run.model?.nilIfEmpty + ) + } +} diff --git a/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift b/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift deleted file mode 100644 index 39e99ff8..00000000 --- a/Tests/CodexReviewKitTests/ReviewChatLinkTests.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Testing -@_spi(Testing) @testable import CodexReviewKit -import CodexReviewTesting - -@Suite("Review chat link", .serialized) -@MainActor -struct ReviewChatLinkTests { - @Test func startedReviewExposesDetachedReviewThreadAsActiveChatThread() async throws { - let backend = FakeCodexReviewBackend( - nextRun: .init( - attemptID: "attempt-1", - threadID: "source-thread", - turnID: "turn-1", - reviewThreadID: "review-thread", - model: "gpt-5.5" - )) - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) - ) - - try await withReviewChatLinkStoreCleanup(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 link = try #require(store.job(id: "job-1")?.reviewChatLink) - #expect(link.jobID == "job-1") - #expect(link.sessionID == "session-1") - #expect(link.cwd == "/tmp/project") - #expect(link.attemptID == "attempt-1") - #expect(link.sourceThreadID == "source-thread") - #expect(link.reviewThreadID == "review-thread") - #expect(link.activeChatThreadID == "review-thread") - #expect(link.turnID == "turn-1") - #expect(link.model == "gpt-5.5") - } - } - - @Test func missingOrInlineReviewThreadFallsBackToSourceThread() throws { - let missingReviewThreadJob = makeRunningJob( - attemptID: "attempt-1", - sourceThreadID: "source-thread", - reviewThreadID: nil, - turnID: "turn-1" - ) - let missingReviewThreadLink = try #require(missingReviewThreadJob.reviewChatLink) - #expect(missingReviewThreadLink.attemptID == "attempt-1") - #expect(missingReviewThreadLink.reviewThreadID == nil) - #expect(missingReviewThreadLink.activeChatThreadID == "source-thread") - - let inlineReviewThreadJob = makeRunningJob( - attemptID: "attempt-2", - sourceThreadID: "source-thread", - reviewThreadID: "source-thread", - turnID: "turn-1" - ) - let inlineReviewThreadLink = try #require(inlineReviewThreadJob.reviewChatLink) - #expect(inlineReviewThreadLink.attemptID == "attempt-2") - #expect(inlineReviewThreadLink.reviewThreadID == "source-thread") - #expect(inlineReviewThreadLink.activeChatThreadID == "source-thread") - } - - @Test func emptyStringsAreNormalizedAway() throws { - let job = makeRunningJob( - sourceThreadID: "source-thread", - reviewThreadID: " ", - turnID: "turn-1", - model: "\n" - ) - - let link = try #require(job.reviewChatLink) - #expect(link.reviewThreadID == nil) - #expect(link.activeChatThreadID == "source-thread") - #expect(link.attemptID == nil) - #expect(link.model == nil) - - job.core.run.threadID = "" - #expect(job.reviewChatLink == nil) - - job.core.run.threadID = "source-thread" - job.core.run.turnID = " " - #expect(job.reviewChatLink == nil) - } - - private func makeRunningJob( - attemptID: String? = nil, - sourceThreadID: String?, - reviewThreadID: String?, - turnID: String?, - model: String? = "gpt-5" - ) -> CodexReviewJob { - let job = CodexReviewJob.makeForTesting( - id: "job-1", - sessionID: "session-1", - cwd: "/tmp/project", - targetSummary: "Uncommitted changes", - model: model, - threadID: sourceThreadID, - turnID: turnID, - status: .running, - summary: "Running" - ) - job.core.run.attemptID = attemptID - job.core.run.reviewThreadID = reviewThreadID - return job - } -} - -@MainActor -private func withReviewChatLinkStoreCleanup( - backend: FakeCodexReviewBackend, - store: CodexReviewStore, - operation: () async throws -> Void -) async rethrows { - do { - try await operation() - } catch { - await cleanupReviewChatLinkStore(backend: backend, store: store) - throw error - } - await cleanupReviewChatLinkStore(backend: backend, store: store) -} - -@MainActor -private func cleanupReviewChatLinkStore( - backend: FakeCodexReviewBackend, - store: CodexReviewStore -) async { - await backend.finishEventMailboxes() - await store.cancelAndDrainReviewWorkersForTesting() - await backend.finishEventMailboxes() -} diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift index 5c6bff08..4c96aea9 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift @@ -57,7 +57,9 @@ struct ReviewMonitorSelectedReviewChatTests { && transport.selectedReviewChatPhaseForTesting == .loading && transport.selectedReviewChatItemTextsForTesting == ["Review snapshot"] } - #expect(transport.selectedReviewChatLinkForTesting?.activeChatThreadID == "review-thread") + #expect( + transport.selectedReviewChatIdentityForTesting?.activeTurnThreadID == "review-thread" + ) #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) } @@ -89,7 +91,7 @@ struct ReviewMonitorSelectedReviewChatTests { uiState.selection = nil try await waitForCondition { - transport.selectedReviewChatIDForTesting == nil && transport.selectedReviewChatLinkForTesting == nil + transport.selectedReviewChatIDForTesting == nil && transport.selectedReviewChatIdentityForTesting == nil && transport.selectedReviewChatPhaseForTesting == .idle } } @@ -138,7 +140,7 @@ struct ReviewMonitorSelectedReviewChatTests { )) try await waitForCondition { - transport.selectedReviewChatLinkForTesting?.activeChatThreadID == "review-thread" + transport.selectedReviewChatIdentityForTesting?.activeTurnThreadID == "review-thread" } #expect(transport.selectedReviewChatIDForTesting == nil) @@ -194,7 +196,7 @@ struct ReviewMonitorSelectedReviewChatTests { ) uiState.selection = .job(job) - #expect(transport.selectedReviewChatLinkForTesting == nil) + #expect(transport.selectedReviewChatIdentityForTesting == nil) job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" From eef036f8de74efd196343a98f7419dc24af2666f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:49:17 +0900 Subject: [PATCH 083/359] refactor(review): remove unused review job wrappers --- .../Application/ReviewStore.swift | 35 ------- .../Domain/ReviewIdentifiers.swift | 24 ----- Sources/CodexReviewKit/Domain/ReviewJob.swift | 34 ------- .../Domain/ReviewLifecycleStatus.swift | 19 ++++ Sources/CodexReviewKit/Domain/ReviewRun.swift | 95 ------------------- .../CodexReviewKit/Model/CodexReviewJob.swift | 7 +- .../Store/CodexReviewStoreRuntimeState.swift | 10 +- 7 files changed, 26 insertions(+), 198 deletions(-) delete mode 100644 Sources/CodexReviewKit/Application/ReviewStore.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewJob.swift create mode 100644 Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewRun.swift diff --git a/Sources/CodexReviewKit/Application/ReviewStore.swift b/Sources/CodexReviewKit/Application/ReviewStore.swift deleted file mode 100644 index 31733a4e..00000000 --- a/Sources/CodexReviewKit/Application/ReviewStore.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import Observation - -@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/CodexReviewKit/Domain/ReviewIdentifiers.swift b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift index 4fd3d867..2ca106a2 100644 --- a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift +++ b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift @@ -10,30 +10,6 @@ public extension ReviewStringIdentifier { } } -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 diff --git a/Sources/CodexReviewKit/Domain/ReviewJob.swift b/Sources/CodexReviewKit/Domain/ReviewJob.swift deleted file mode 100644 index bed4e229..00000000 --- a/Sources/CodexReviewKit/Domain/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/CodexReviewKit/Domain/ReviewLifecycleStatus.swift b/Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift new file mode 100644 index 00000000..a867d232 --- /dev/null +++ b/Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift @@ -0,0 +1,19 @@ +import Foundation + +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 + } + } +} diff --git a/Sources/CodexReviewKit/Domain/ReviewRun.swift b/Sources/CodexReviewKit/Domain/ReviewRun.swift deleted file mode 100644 index 0ccfa809..00000000 --- a/Sources/CodexReviewKit/Domain/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/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 9b39013d..afd5b0a6 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -18,10 +18,7 @@ public final class CodexReviewJob: Identifiable, Hashable { private var legacyLogBuffer: ReviewLegacyLogBuffer public nonisolated let id: String - public let domainJob: ReviewJob - public var timeline: ReviewTimeline { - domainJob.timeline - } + public let timeline: ReviewTimeline public let sessionID: String public let cwd: String public internal(set) var sortOrder: Double @@ -86,7 +83,7 @@ public final class CodexReviewJob: Identifiable, Hashable { let initialLogBuffer = ReviewLegacyLogBuffer(entries: logEntries) let initialLogSnapshot = initialLogBuffer.snapshot self.id = id - self.domainJob = ReviewJob(id: .init(rawValue: id)) + self.timeline = ReviewTimeline() self.sessionID = sessionID self.cwd = cwd self.sortOrder = sortOrder diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift index 197f12de..655e7945 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -9,9 +9,9 @@ package struct CodexReviewRuntimeJobState: Sendable { @MainActor final class CodexReviewStoreRuntimeState { - typealias ReviewRun = CodexReviewBackendModel.Review.Run + typealias BackendReviewRun = CodexReviewBackendModel.Review.Run - private var activeRuns: [String: ReviewRun] = [:] + private var activeRuns: [String: BackendReviewRun] = [:] private var reviewRecoveryWaitingJobIDs: Set = [] private var startingJobIDs: Set = [] private var startupCancellations: [String: ReviewCancellation] = [:] @@ -27,11 +27,11 @@ final class CodexReviewStoreRuntimeState { ) } - func activeRun(for jobID: String) -> ReviewRun? { + func activeRun(for jobID: String) -> BackendReviewRun? { activeRuns[jobID] } - func setActiveRun(_ run: ReviewRun, for jobID: String) { + func setActiveRun(_ run: BackendReviewRun, for jobID: String) { activeRuns[jobID] = run } @@ -39,7 +39,7 @@ final class CodexReviewStoreRuntimeState { activeRuns.removeValue(forKey: jobID) } - func recoveryWaitingRuns() -> [ReviewRun] { + func recoveryWaitingRuns() -> [BackendReviewRun] { reviewRecoveryWaitingJobIDs .sorted() .compactMap { activeRuns[$0] } From a24c8e8be876e53a7a81503fbe4b51dd50577c22 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:35:20 +0900 Subject: [PATCH 084/359] refactor(review): rename account model --- Sources/CodexReviewHost/CodexReviewHost.swift | 10 +-- .../LiveCodexReviewStoreBackend.swift | 70 +++++++++--------- ...Account.swift => CodexReviewAccount.swift} | 30 ++++---- .../Model/CodexReviewAuthModel.swift | 22 +++--- .../Store/CodexReviewStore.swift | 14 ++-- .../Store/CodexReviewStoreBackend.swift | 8 +- ...CodexReviewStoreRateLimitAutoRefresh.swift | 16 ++-- .../Store/CodexReviewStoreTesting.swift | 4 +- Sources/CodexReviewTesting/TestSupport.swift | 4 +- .../ReviewMonitorContentPreview.swift | 2 +- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 6 +- .../Accounts/AccountContextMenuView.swift | 6 +- .../ReviewMonitorAccountRowView.swift | 2 +- .../ReviewMonitorAccountsViewController.swift | 48 ++++++------ .../Status/AccountRateLimitGaugesView.swift | 20 ++--- ...rServerStatusAccessoryViewController.swift | 12 +-- .../CodexReviewHostTests.swift | 10 +-- .../CodexReviewAuthModelTests.swift | 10 +-- .../CodexReviewStoreCommandTests.swift | 8 +- ...ReviewStoreRateLimitAutoRefreshTests.swift | 14 ++-- Tests/ReviewUITests/ReviewUIShellTests.swift | 6 +- Tests/ReviewUITests/ReviewUITests.swift | 74 +++++++++---------- 23 files changed, 197 insertions(+), 201 deletions(-) rename Sources/CodexReviewKit/Model/{CodexAccount.swift => CodexReviewAccount.swift} (91%) diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift index 93b2fc7a..8f9a7480 100644 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ b/Sources/CodexReviewHost/CodexReviewHost.swift @@ -2,8 +2,6 @@ import Foundation import CodexReviewKit import CodexReviewMCPServer -private typealias ReviewCodexAccount = CodexReviewKit.CodexAccount - @MainActor package final class CodexReviewHost { package let store: CodexReviewStore @@ -261,13 +259,13 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend { _ snapshot: CodexReviewBackendModel.Auth.Snapshot, to auth: CodexReviewAuthModel ) { - let accounts = snapshot.accounts.compactMap { account -> ReviewCodexAccount? in + let accounts = snapshot.accounts.compactMap { account -> CodexReviewAccount? in let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) - let accountKey = ReviewCodexAccount.normalizedEmail(account.id.rawValue) + let accountKey = CodexReviewAccount.normalizedEmail(account.id.rawValue) guard label.isEmpty == false, accountKey.isEmpty == false else { return nil } - return ReviewCodexAccount( + return CodexReviewAccount( accountKey: accountKey, email: label, planType: account.planType, @@ -276,7 +274,7 @@ private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend { ) } let activeAccountKey = snapshot.activeAccountID - .map { ReviewCodexAccount.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 2b287d5b..9ef5ed7e 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -6,8 +6,6 @@ import CodexReviewKit import CodexReviewAppServer import CodexReviewMCPServer -private typealias ReviewCodexAccount = CodexReviewKit.CodexAccount - private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexAppServer?) -> Void @@ -1068,7 +1066,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, authSourceCodexHomeURL: URL? = nil - ) -> ReviewCodexAccount? { + ) -> CodexReviewAccount? { guard let activeAccountID = snapshot.activeAccountID?.rawValue, let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), let account = Self.monitorAccount(from: backendAccount) @@ -1082,7 +1080,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return nil } var persistedAccounts = auth.persistedAccounts - let persistedAccount: ReviewCodexAccount + 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) @@ -1373,7 +1371,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await refreshRateLimits(for: selectedAccount, auth: auth) } - private func refreshRateLimits(for account: ReviewCodexAccount, auth: CodexReviewAuthModel) async { + private func refreshRateLimits(for account: CodexReviewAccount, auth: CodexReviewAuthModel) async { guard account.capabilities.supportsRateLimitRefresh else { return } @@ -1390,7 +1388,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func refreshSavedAccountRateLimits(for account: ReviewCodexAccount) async { + private func refreshSavedAccountRateLimits(for account: CodexReviewAccount) async { let temporaryCodexHomeURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-rate-limits-\(UUID().uuidString)", isDirectory: true) do { @@ -1435,7 +1433,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func refreshRateLimits( - for account: ReviewCodexAccount, + for account: CodexReviewAccount, using backend: AppServerCodexReviewBackend?, source: String ) async -> Bool { @@ -1467,14 +1465,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func validateRateLimitBackendAccount( - _ account: ReviewCodexAccount, + _ 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 = ReviewCodexAccount.normalizedEmail(activeAccountID) + let actualAccountKey = CodexReviewAccount.normalizedEmail(activeAccountID) guard actualAccountKey == account.accountKey else { let actualEmail = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID })?.label ?? activeAccountID @@ -1485,10 +1483,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func recordRateLimitRefreshFailure( _ error: any Error, - account: ReviewCodexAccount + account: CodexReviewAccount ) { let message = error.localizedDescription - if ReviewCodexAccount.requiresReauthentication(errorMessage: message) { + if CodexReviewAccount.requiresReauthentication(errorMessage: message) { account.markRateLimitReauthenticationRequired( fetchedAt: Date(), error: message @@ -1538,7 +1536,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func applyRateLimits( _ rateLimits: CodexRateLimits, - to account: ReviewCodexAccount + to account: CodexReviewAccount ) { account.updateRateLimits(rateLimits.windows.map { ( @@ -1555,7 +1553,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func persistRefreshedSharedAuth( from sourceCodexHomeURL: URL?, - for account: ReviewCodexAccount + for account: CodexReviewAccount ) { guard let sourceCodexHomeURL else { return @@ -1604,13 +1602,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } - private static func monitorAccount(from snapshot: CodexReviewBackendModel.Account.Snapshot) -> ReviewCodexAccount? { + private static func monitorAccount(from snapshot: CodexReviewBackendModel.Account.Snapshot) -> CodexReviewAccount? { let label = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines) - let accountKey = ReviewCodexAccount.normalizedEmail(snapshot.id.rawValue) + let accountKey = CodexReviewAccount.normalizedEmail(snapshot.id.rawValue) guard label.isEmpty == false, accountKey.isEmpty == false else { return nil } - return ReviewCodexAccount( + return CodexReviewAccount( accountKey: accountKey, email: label, planType: snapshot.planType, @@ -1647,7 +1645,7 @@ private enum LoginActivation: Equatable, Sendable { func resolvedActiveAccountKey( authenticatedAccountKey: String, - persistedAccounts: [ReviewCodexAccount] + persistedAccounts: [CodexReviewAccount] ) -> String? { switch self { case .activateAuthenticatedAccount: @@ -1758,9 +1756,9 @@ private enum CodexReviewAccountRegistry { static func legacyDefault(accountKey: String?, email: String) -> Self { let normalizedAccountKey = accountKey - .map(ReviewCodexAccount.normalizedEmail) + .map(CodexReviewAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } - switch normalizedAccountKey ?? ReviewCodexAccount.normalizedEmail(email) { + switch normalizedAccountKey ?? CodexReviewAccount.normalizedEmail(email) { case "api-key": return .apiKey case "amazon-bedrock": @@ -1781,11 +1779,11 @@ private enum CodexReviewAccountRegistry { } } - static func load(codexHomeURL: URL) -> (accounts: [ReviewCodexAccount], 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(ReviewCodexAccount.normalizedEmail) + .map(CodexReviewAccount.normalizedEmail) .flatMap { activeAccountKey in accounts.contains(where: { $0.accountKey == activeAccountKey }) ? activeAccountKey : nil } @@ -1794,7 +1792,7 @@ private enum CodexReviewAccountRegistry { } static func saveAccounts( - _ accounts: [ReviewCodexAccount], + _ accounts: [CodexReviewAccount], activeAccountKey: String?, codexHomeURL: URL ) throws { @@ -1803,7 +1801,7 @@ private enum CodexReviewAccountRegistry { normalizedAccountKey(from: entry).map { ($0, entry) } }) let normalizedActiveAccountKey = activeAccountKey - .map(ReviewCodexAccount.normalizedEmail) + .map(CodexReviewAccount.normalizedEmail) .flatMap { accountKey in accounts.contains(where: { $0.accountKey == accountKey }) ? accountKey : nil } @@ -1844,10 +1842,10 @@ private enum CodexReviewAccountRegistry { static func activateAccount( _ accountKey: String, - accounts: [ReviewCodexAccount], + accounts: [CodexReviewAccount], codexHomeURL: URL ) throws { - let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) let savedAuthURL = savedAccountAuthURL( accountKey: normalizedAccountKey, codexHomeURL: codexHomeURL @@ -1864,7 +1862,7 @@ private enum CodexReviewAccountRegistry { } static func updateCachedRateLimits( - from account: ReviewCodexAccount, + from account: CodexReviewAccount, codexHomeURL: URL ) throws { var registry = loadRegistry(codexHomeURL: codexHomeURL) @@ -1887,7 +1885,7 @@ private enum CodexReviewAccountRegistry { } static func saveSharedAuth( - for account: ReviewCodexAccount, + for account: CodexReviewAccount, codexHomeURL: URL ) throws { try saveSharedAuth( @@ -1899,7 +1897,7 @@ private enum CodexReviewAccountRegistry { static func saveSharedAuth( from sourceCodexHomeURL: URL, - for account: ReviewCodexAccount, + for account: CodexReviewAccount, codexHomeURL: URL ) throws { let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL) @@ -1936,7 +1934,7 @@ private enum CodexReviewAccountRegistry { from sourceCodexHomeURL: URL, to destinationCodexHomeURL: URL ) throws -> Bool { - let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) let sourceURL = savedAccountAuthURL( accountKey: normalizedAccountKey, codexHomeURL: sourceCodexHomeURL @@ -1951,17 +1949,17 @@ private enum CodexReviewAccountRegistry { return true } - private static func makeAccount(from entry: Entry) -> ReviewCodexAccount? { + private static func makeAccount(from entry: Entry) -> CodexReviewAccount? { let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) guard email.isEmpty == false else { return nil } - let normalizedEmail = ReviewCodexAccount.normalizedEmail(email) + let normalizedEmail = CodexReviewAccount.normalizedEmail(email) let accountKey = entry.accountKey - .map(ReviewCodexAccount.normalizedEmail) + .map(CodexReviewAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } ?? normalizedEmail - let account = ReviewCodexAccount( + let account = CodexReviewAccount( accountKey: accountKey, email: email, planType: entry.planType, @@ -2027,9 +2025,9 @@ private enum CodexReviewAccountRegistry { private static func normalizedAccountKey(from entry: Entry) -> String? { let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedEmail = ReviewCodexAccount.normalizedEmail(email) + let normalizedEmail = CodexReviewAccount.normalizedEmail(email) return entry.accountKey - .map(ReviewCodexAccount.normalizedEmail) + .map(CodexReviewAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } ?? (normalizedEmail.isEmpty ? nil : normalizedEmail) } @@ -2058,7 +2056,7 @@ private enum CodexReviewAccountRegistry { } private static func pathComponent(forAccountKey accountKey: String) -> String { - let normalizedAccountKey = ReviewCodexAccount.normalizedEmail(accountKey) + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) switch normalizedAccountKey { case ".": return "%2E" diff --git a/Sources/CodexReviewKit/Model/CodexAccount.swift b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift similarity index 91% rename from Sources/CodexReviewKit/Model/CodexAccount.swift rename to Sources/CodexReviewKit/Model/CodexReviewAccount.swift index 899249fc..54e17c46 100644 --- a/Sources/CodexReviewKit/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/CodexReviewKit/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift index 4274b4ec..4e6a6266 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift @@ -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/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 44d35e1a..0c63c452 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -198,7 +198,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 } @@ -213,7 +213,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 @@ -221,7 +221,7 @@ public final class CodexReviewStore { confirmPendingAccountAction() } - package func requestSwitchAccountFromUserAction(_ account: CodexAccount) { + package func requestSwitchAccountFromUserAction(_ account: CodexReviewAccount) { requestSwitchAccount( account, requiresConfirmation: hasRunningJobs @@ -237,7 +237,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 @@ -300,11 +300,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 switchActionRequiresRunningJobsConfirmation(for account: CodexReviewAccount) -> Bool { guard canSwitchAccount(account) else { return false } @@ -436,7 +436,7 @@ public final class CodexReviewStore { } } - private func canSwitchAccount(_ account: CodexAccount) -> Bool { + private func canSwitchAccount(_ account: CodexReviewAccount) -> Bool { auth.canRequestSwitchAccount(account) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index c2ae5765..3358a336 100644 --- a/Sources/CodexReviewKit/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() ) { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift index 560b289c..5cee08e5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift @@ -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,7 +85,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy { } private func intervalTarget( - for account: CodexAccount, + for account: CodexReviewAccount, context: CodexReviewStoreRateLimitAutoRefreshContext ) -> CodexReviewStoreRateLimitAutoRefreshTarget { let kind: CodexReviewStoreRateLimitAutoRefreshTarget.Kind @@ -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 { @@ -273,7 +273,7 @@ package final class CodexReviewStoreRateLimitAutoRefreshDriver { } package static func targets( - accounts: [CodexAccount], + accounts: [CodexReviewAccount], selectedAccountKey: String?, hasRunningJobs: Bool, serverState: CodexReviewServerState, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index 5cc239f0..dcc3ff55 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -16,8 +16,8 @@ extension CodexReviewStore { package func loadForTesting( serverState: CodexReviewServerState, authPhase: CodexReviewAuthModel.Phase = .signedOut, - account: CodexAccount? = nil, - persistedAccounts: [CodexAccount]? = nil, + account: CodexReviewAccount? = nil, + persistedAccounts: [CodexReviewAccount]? = nil, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob] = [], diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index f1fd47d1..ac490f26 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -657,12 +657,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 -> CodexReviewKit.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 CodexReviewKit.CodexAccount(email: label) + return CodexReviewKit.CodexReviewAccount(email: label) } auth.applyPersistedAccountStates( accounts.map(savedAccountPayload(from:)), diff --git a/Sources/ReviewUI/ReviewMonitorContentPreview.swift b/Sources/ReviewUI/ReviewMonitorContentPreview.swift index de763758..737806cf 100644 --- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift +++ b/Sources/ReviewUI/ReviewMonitorContentPreview.swift @@ -26,7 +26,7 @@ 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 { diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 969a4442..a064d903 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -283,7 +283,7 @@ func makeReviewMonitorPreviewContentViewController() -> NSViewController { @MainActor func makeReviewMonitorPreviewContentViewControllerForPreview( authPhase: CodexReviewAuthModel.Phase = .signedOut, - account: CodexAccount? = nil, + account: CodexReviewAccount? = nil, serverState: CodexReviewServerState = .running, previewStore: CodexReviewStore? = nil ) -> ReviewMonitorRootViewController { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index f42ab864..1b4e5283 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -519,7 +519,7 @@ public enum ReviewMonitorPreviewContent { } @_spi(PreviewSupport) - public static func makePreviewAccounts() -> [CodexAccount] { + public static func makePreviewAccounts() -> [CodexReviewAccount] { [ makePreviewAccount( email: "workspace@example.com", @@ -540,8 +540,8 @@ public enum ReviewMonitorPreviewContent { 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") + ) -> CodexReviewAccount { + let account = CodexReviewAccount(email: email, planType: "pro") account.updateRateLimits( [ ( diff --git a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift index 1bdfe251..6917e0b1 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift @@ -3,7 +3,7 @@ import SwiftUI struct AccountContextMenuView: View { let store: CodexReviewStore - let account: CodexAccount + let account: CodexReviewAccount private var auth: CodexReviewAuthModel { store.auth @@ -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 78a5cb47..b4fb53b9 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift @@ -3,7 +3,7 @@ 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 ee3a96e1..33cb4045 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountsViewController.swift @@ -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/Status/AccountRateLimitGaugesView.swift b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift index 7cafc7bb..d051fe97 100644 --- a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift +++ b/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift @@ -2,24 +2,24 @@ import SwiftUI 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 ce49fea4..3cf5f613 100644 --- a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift +++ b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift @@ -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 @@ -288,7 +288,7 @@ struct StatusView: View { @MainActor func makeStatusPreviewStore( authPhase: CodexReviewAuthModel.Phase = .signedOut, - account: CodexAccount? = nil, + account: CodexReviewAccount? = nil, serverState: CodexReviewServerState = .running ) -> CodexReviewStore { let store = ReviewMonitorPreviewContent.makeStore() @@ -303,7 +303,7 @@ func makeStatusPreviewStore( return store } @MainActor -func makeStatusPreviewAccount() -> CodexAccount { +func makeStatusPreviewAccount() -> CodexReviewAccount { ReviewMonitorPreviewContent.makePreviewAccount() } #endif diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 0374b160..486c6d2a 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1196,7 +1196,7 @@ struct CodexReviewHostTests { ) await waitUntil { store.jobs.first?.core.run.turnID == "turn-first" } - try await store.switchAccount(CodexReviewKit.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) @@ -1320,7 +1320,7 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await #expect(throws: (any Error).self) { - try await store.switchAccount(CodexReviewKit.CodexAccount(email: "second@example.com")) + try await store.switchAccount(CodexReviewKit.CodexReviewAccount(email: "second@example.com")) } #expect(store.auth.selectedAccount?.accountKey == "first@example.com") @@ -1821,7 +1821,7 @@ struct CodexReviewHostTests { @Test func liveStoreRemovesOnlyEncodedSavedAccountDirectory() async throws { let homeURL = try temporaryHome() let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) - let account = CodexReviewKit.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( @@ -1844,8 +1844,8 @@ struct CodexReviewHostTests { let sentinelURL = codexHomeURL.appendingPathComponent("sentinel.txt") try Data("keep".utf8).write(to: sentinelURL) - let dotAccount = CodexReviewKit.CodexAccount(email: ".") - let dotDotAccount = CodexReviewKit.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) diff --git a/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift index 2728c706..5badb836 100644 --- a/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewAuthModelTests.swift @@ -6,10 +6,10 @@ import Testing 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 index b0eb1ad4..4c1eb92f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -4106,8 +4106,8 @@ struct CodexReviewStoreCommandTests { } @Test func initialActiveAccountKeySelectsPersistedAccount() { - let active = CodexAccount(email: "active@example.com") - let inactive = CodexAccount(email: "inactive@example.com") + let active = CodexReviewAccount(email: "active@example.com") + let inactive = CodexReviewAccount(email: "inactive@example.com") let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend( @@ -4128,8 +4128,8 @@ struct CodexReviewStoreCommandTests { } @Test func switchActionsAreUnavailableForSelectedAccount() async throws { - let selectedAccount = CodexAccount(email: "selected@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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( diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 990d01b8..8f614102 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -274,8 +274,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,8 +287,8 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { } private func targets( - accounts: [CodexAccount], - selectedAccount: CodexAccount?, + accounts: [CodexReviewAccount], + selectedAccount: CodexReviewAccount?, hasRunningJobs: Bool, serverState: CodexReviewServerState = .running ) -> [CodexReviewStoreRateLimitAutoRefreshTarget] { @@ -303,7 +303,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { private func loadStore( _ store: CodexReviewStore, - account: CodexAccount, + account: CodexReviewAccount, jobs: [CodexReviewJob] ) { store.loadForTesting( @@ -359,7 +359,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 +380,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/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 3e8c915d..c6c67e20 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -755,7 +755,7 @@ 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, @@ -784,7 +784,7 @@ 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, @@ -829,7 +829,7 @@ extension ReviewUITests { serverState: .running, authPhase: .signedOut, account: nil, - persistedAccounts: [CodexAccount(email: "saved@example.com", planType: "pro")], + persistedAccounts: [CodexReviewAccount(email: "saved@example.com", planType: "pro")], workspaces: [] ) let windowController = ReviewMonitorWindowController( diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index e4aa6761..63f2e84c 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -11,11 +11,11 @@ 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) } } @@ -1034,7 +1034,7 @@ struct ReviewUITests { @Test func addAccountToolbarItemShowsProgressPresentation() async throws { let store = CodexReviewStore.makePreviewStore() - let activeAccount = CodexAccount(email: "first@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, authPhase: .signingIn( @@ -1066,7 +1066,7 @@ struct ReviewUITests { @Test func addAccountToolbarItemDoesNotStickInProgressModeWhenAuthenticationEndsImmediately() async throws { let store = CodexReviewStore.makePreviewStore() - let activeAccount = CodexAccount(email: "first@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, authPhase: .signedOut, @@ -1106,7 +1106,7 @@ struct ReviewUITests { @Test func addAccountToolbarItemProvidesOverflowMenuFallback() async throws { let store = CodexReviewStore.makePreviewStore() - let activeAccount = CodexAccount(email: "first@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, authPhase: .signedOut, @@ -1146,7 +1146,7 @@ struct ReviewUITests { @Test func addAccountToolbarItemStaysVisibleDuringAuthenticationOutsideAccountSidebar() async throws { let store = CodexReviewStore.makePreviewStore() - let activeAccount = CodexAccount(email: "first@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, authPhase: .signingIn( @@ -1178,7 +1178,7 @@ struct ReviewUITests { @Test func addAccountToolbarItemRehidesAfterAuthenticationEndsOutsideAccountSidebar() async throws { let store = CodexReviewStore.makePreviewStore() - let activeAccount = CodexAccount(email: "first@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, authPhase: .signingIn( @@ -1215,8 +1215,8 @@ struct ReviewUITests { } @Test func accountSidebarUsesOutlineViewRows() throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -1246,9 +1246,9 @@ struct ReviewUITests { } @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 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, @@ -1300,9 +1300,9 @@ struct ReviewUITests { } @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 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, @@ -1870,8 +1870,8 @@ struct ReviewUITests { } @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -1924,8 +1924,8 @@ struct ReviewUITests { } @Test func accountOutlineRowsRejectUserSelection() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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( @@ -1968,8 +1968,8 @@ struct ReviewUITests { } @Test func accountDragUsesClickedRowWithoutChangingAuthSelection() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -2001,8 +2001,8 @@ struct ReviewUITests { } @Test func accountBlankClickKeepsAuthenticatedSelection() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -2039,8 +2039,8 @@ struct ReviewUITests { } @Test func accountSelectionChangeKeepsDisplayedAccounts() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -2093,7 +2093,7 @@ struct ReviewUITests { } @Test func accountContentUpdateDoesNotReloadOutlineTopology() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") + let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro") let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, @@ -2115,7 +2115,7 @@ struct ReviewUITests { .accountIncrementalMembershipChangeCountForTesting let incrementalMoveCountBeforeUpdate = accountsViewController.accountIncrementalMoveCountForTesting - let updatedAccount = CodexAccount(email: "active@example.com", planType: "team") + let updatedAccount = CodexReviewAccount(email: "active@example.com", planType: "team") store.auth.applyPersistedAccountStates([savedAccountPayload(from: updatedAccount)]) for _ in 0..<10 { await Task.yield() @@ -2133,7 +2133,7 @@ struct ReviewUITests { } @Test func accountListTracksDetachedCurrentSessionMembership() async throws { - let savedAccount = CodexAccount(email: "saved@example.com", planType: "pro") + let savedAccount = CodexReviewAccount(email: "saved@example.com", planType: "pro") let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, @@ -2158,7 +2158,7 @@ struct ReviewUITests { .accountIncrementalMembershipChangeCountForTesting let incrementalMoveCountBeforeMembershipChanges = accountsViewController.accountIncrementalMoveCountForTesting - store.auth.updateCurrentAccount(CodexAccount(email: "detached@example.com", planType: "pro")) + store.auth.updateCurrentAccount(CodexReviewAccount(email: "detached@example.com", planType: "pro")) try await waitForObservedValue( from: accountsViewController.accountListObservationForTesting, [ @@ -2200,8 +2200,8 @@ struct ReviewUITests { } @Test func accountActionAlertRestoresSelectionToAuthenticatedAccount() async throws { - let activeAccount = CodexAccount(email: "active@example.com", planType: "pro") - let otherAccount = CodexAccount(email: "other@example.com", planType: "plus") + 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, @@ -7274,15 +7274,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?) } } @@ -7310,14 +7310,14 @@ extension CodexReviewStore { 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" ) @@ -7404,7 +7404,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( From 1db542a88e44f9b9f010d9eef75d1eeb1e34cc98 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:40:06 +0900 Subject: [PATCH 085/359] build(review-ui): use CodexKit umbrella import --- Package.swift | 6 ++---- .../Detail/ReviewMonitorCodexChatTimelineProjection.swift | 3 +-- .../Detail/ReviewMonitorTransportViewController.swift | 3 +-- Sources/ReviewUI/ReviewMonitorCodexModelSource.swift | 2 +- Sources/ReviewUI/ReviewMonitorContentView.swift | 2 +- Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift | 3 +-- Sources/ReviewUI/ReviewMonitorSplitViewController.swift | 2 +- Sources/ReviewUI/ReviewMonitorWindowController.swift | 2 +- .../Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift | 3 +-- .../ReviewMonitorCodexSidebarLibraryTests.swift | 3 +-- .../ReviewMonitorSelectedReviewChatTests.swift | 3 +-- 11 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index 400a3faa..63077587 100644 --- a/Package.swift +++ b/Package.swift @@ -95,8 +95,7 @@ let package = Package( "CodexReviewKit", "ReviewMonitorRendering", "TextTransitions", - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + .product(name: "CodexKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ @@ -165,9 +164,8 @@ let package = Package( .testTarget( name: "ReviewUITests", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexKit", package: "CodexKit"), .product(name: "CodexAppServerKitTesting", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", "ReviewUI", diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift index a5dea140..d25e1934 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift @@ -1,5 +1,4 @@ -import CodexAppServerKit -import CodexDataKit +import CodexKit import CodexReviewKit import Foundation import ReviewMonitorRendering diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 940b4bf5..3281c6f3 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,6 +1,5 @@ import AppKit -import CodexAppServerKit -import CodexDataKit +import CodexKit import CodexReviewKit import ObservationBridge import ReviewMonitorRendering diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift index cba5f4f4..9982bff6 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -1,4 +1,4 @@ -import CodexDataKit +import CodexKit import Observation @MainActor diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index a064d903..4d5c7352 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -1,6 +1,6 @@ import AppKit import Combine -import CodexDataKit +import CodexKit import ObservationBridge import CodexReviewKit diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 23e94aa7..b53a2f50 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -1,5 +1,4 @@ -import CodexAppServerKit -import CodexDataKit +import CodexKit import CodexReviewKit import Foundation import Observation diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 0dff780e..4fe735d2 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -1,6 +1,6 @@ import AppKit import Combine -import CodexDataKit +import CodexKit import Foundation import ObservationBridge import CodexReviewKit diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 602fc123..cd36a39d 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -1,5 +1,5 @@ import AppKit -import CodexDataKit +import CodexKit import CodexReviewKit @MainActor diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index aec30bdd..d16ade65 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -1,5 +1,4 @@ -import CodexAppServerKit -import CodexDataKit +import CodexKit import Foundation @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 9ae488e2..2095385d 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -1,6 +1,5 @@ -import CodexAppServerKit +import CodexKit import CodexAppServerKitTesting -import CodexDataKit import Foundation import Testing @_spi(PreviewSupport) @testable import ReviewUI diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift index 4c96aea9..edd8d774 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift @@ -1,6 +1,5 @@ -import CodexAppServerKit +import CodexKit import CodexAppServerKitTesting -import CodexDataKit import Testing @_spi(Testing) @testable import CodexReviewKit @testable import ReviewUI From ec09d8bc443fbcc9344fe3a4aaf4e47fe7a604ce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:44:00 +0900 Subject: [PATCH 086/359] refactor(review-ui): consume chat change streams --- ...ewMonitorCodexChatTimelineProjection.swift | 48 +++- ...ReviewMonitorTransportViewController.swift | 8 +- .../ReviewMonitorSelectedReviewChat.swift | 32 ++- ...SelectedReviewChatDocumentProjection.swift | 259 ++++++++++++++++++ 4 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift index d25e1934..8f259a15 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift @@ -6,16 +6,36 @@ import ReviewMonitorRendering @MainActor struct ReviewMonitorCodexChatTimelineProjection { func document( - from turnSnapshot: CodexChatTurnSnapshot, + from snapshot: CodexChatSnapshot, + activeTurnID: CodexTurnID, chatCreatedAt: Date?, chatUpdatedAt: Date?, revision: UInt64 ) -> ReviewTimelineDocument? { - guard turnSnapshot.items.isEmpty == false else { + guard let turn = snapshot.turns.first(where: { $0.id == activeTurnID }) else { + return nil + } + return document( + from: turn, + items: snapshot.items.filter { $0.turnID == activeTurnID }, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + revision: revision + ) + } + + func document( + from turnSnapshot: CodexChatTurnStateSnapshot, + items: [CodexChatItemSnapshot], + chatCreatedAt: Date?, + chatUpdatedAt: Date?, + revision: UInt64 + ) -> ReviewTimelineDocument? { + guard items.isEmpty == false else { return nil } - let blocks = turnSnapshot.items.map { + let blocks = items.map { block( from: $0, turnSnapshot: turnSnapshot, @@ -37,9 +57,9 @@ struct ReviewMonitorCodexChatTimelineProjection { ) } - private func block( - from item: CodexChat.Item, - turnSnapshot: CodexChatTurnSnapshot, + func block( + from item: CodexChatItemSnapshot, + turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewTimelineDocument.Block { @@ -63,7 +83,7 @@ struct ReviewMonitorCodexChatTimelineProjection { ) } - private func blockID(for item: CodexChat.Item) -> ReviewTimelineDocument.Block.ID { + func blockID(for item: CodexChatItemSnapshot) -> ReviewTimelineDocument.Block.ID { let rawTurnID = item.turnID?.rawValue ?? "unknown-turn" return .init(rawValue: "\(rawTurnID):\(item.id)") } @@ -99,7 +119,7 @@ struct ReviewMonitorCodexChatTimelineProjection { } } - private func family(for item: CodexChat.Item) -> ReviewItemFamily { + private func family(for item: CodexChatItemSnapshot) -> ReviewItemFamily { switch item.content { case .message: return .message @@ -123,8 +143,8 @@ struct ReviewMonitorCodexChatTimelineProjection { } private func content( - from item: CodexChat.Item, - turnSnapshot: CodexChatTurnSnapshot + from item: CodexChatItemSnapshot, + turnSnapshot: CodexChatTurnStateSnapshot ) -> ReviewTimelineDocument.Content { switch item.content { case .message(let message): @@ -201,13 +221,13 @@ struct ReviewMonitorCodexChatTimelineProjection { } private func phase( - for item: CodexChat.Item, - turnSnapshot: CodexChatTurnSnapshot + for item: CodexChatItemSnapshot, + turnSnapshot: CodexChatTurnStateSnapshot ) -> ReviewItemPhase { statusPhase(item.itemStatus ?? turnSnapshot.status) } - private func terminalStatus(for turnSnapshot: CodexChatTurnSnapshot) -> ReviewLifecycleStatus? { + func terminalStatus(for turnSnapshot: CodexChatTurnStateSnapshot) -> ReviewLifecycleStatus? { switch turnSnapshot.status { case .completed: return .succeeded @@ -385,7 +405,7 @@ struct ReviewMonitorCodexChatTimelineProjection { } } -private extension CodexChat.Item { +private extension CodexChatItemSnapshot { var itemStatus: CodexTurnStatus? { switch content { case .command(let command): diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 3281c6f3..6c9764fb 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -415,13 +415,7 @@ final class ReviewMonitorTransportViewController: NSViewController { } private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { - if let turnSnapshot = selectedReviewChat.turnSnapshot, - let document = ReviewMonitorCodexChatTimelineProjection().document( - from: turnSnapshot, - chatCreatedAt: selectedReviewChat.chatCreatedAt, - chatUpdatedAt: selectedReviewChat.chatUpdatedAt, - revision: logRenderGeneration &+ 1 - ) { + if let document = selectedReviewChat.timelineDocument { return document } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index b53a2f50..7edd39a3 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -3,30 +3,20 @@ import CodexReviewKit import Foundation import Observation import ObservationBridge +import ReviewMonitorRendering @MainActor @Observable final class ReviewMonitorSelectedReviewChat { private(set) var identity: CodexReviewIdentity? private(set) var chat: CodexChat? + private(set) var timelineDocument: ReviewTimelineDocument? var phase: CodexDataPhase { chat?.phase ?? .idle } var lastErrorDescription: String? { chat?.lastErrorDescription } - var turnSnapshot: CodexChatTurnSnapshot? { - guard let chat, let identity else { - return nil - } - return chat.turnSnapshot(for: identity.turnID) - } - var chatCreatedAt: Date? { - chat?.createdAt - } - var chatUpdatedAt: Date? { - chat?.updatedAt - } @ObservationIgnored private let modelSource: ReviewMonitorCodexModelSource? @@ -39,6 +29,8 @@ final class ReviewMonitorSelectedReviewChat { @ObservationIgnored private var observationTask: Task? @ObservationIgnored + private var documentProjection = ReviewMonitorSelectedReviewChatDocumentProjection() + @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? init(modelSource: ReviewMonitorCodexModelSource?) { @@ -75,6 +67,8 @@ final class ReviewMonitorSelectedReviewChat { cancelObservation() identity = nextIdentity chat = nil + timelineDocument = nil + documentProjection.reset() boundModelContext = nextModelContext guard let nextIdentity, let modelContext = nextModelContext else { @@ -96,6 +90,20 @@ final class ReviewMonitorSelectedReviewChat { return } self.observation = observation + for await change in observation.changes { + guard Task.isCancelled == false, + self.identity == nextIdentity, + self.chat === nextChat + else { + break + } + self.timelineDocument = self.documentProjection.apply( + change, + activeTurnID: nextIdentity.turnID, + chatCreatedAt: nextChat.createdAt, + chatUpdatedAt: nextChat.updatedAt + ) + } } catch is CancellationError { } catch { } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift new file mode 100644 index 00000000..ba6774f7 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift @@ -0,0 +1,259 @@ +import CodexKit +import Foundation +import ReviewMonitorRendering + +@MainActor +struct ReviewMonitorSelectedReviewChatDocumentProjection { + private struct ItemKey: Hashable { + var id: String + var turnID: CodexTurnID? + + init(id: String, turnID: CodexTurnID?) { + self.id = id + self.turnID = turnID + } + + init(_ item: CodexChatItemSnapshot) { + self.id = item.id + self.turnID = item.turnID + } + } + + private var turnsByID: [CodexTurnID: CodexChatTurnStateSnapshot] = [:] + private var itemsByKey: [ItemKey: CodexChatItemSnapshot] = [:] + private var orderedItemKeys: [ItemKey] = [] + private var document: ReviewTimelineDocument? + private var revision: UInt64 = 0 + + mutating func reset() { + turnsByID.removeAll(keepingCapacity: true) + itemsByKey.removeAll(keepingCapacity: true) + orderedItemKeys.removeAll(keepingCapacity: true) + document = nil + revision = 0 + } + + mutating func apply( + _ change: CodexChatChange, + activeTurnID: CodexTurnID, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewTimelineDocument? { + switch change { + case .snapshot(let snapshot): + apply(snapshot) + return rebuildDocument( + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + + case .turnInserted(let turn), .turnUpdated(let turn): + turnsByID[turn.id] = turn + guard turn.id == activeTurnID else { + return document + } + return rebuildDocument( + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + + case .itemInserted(let item), .itemUpdated(let item): + upsert(item) + guard item.turnID == activeTurnID else { + return document + } + return rebuildOrReplaceBlock( + for: item, + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + + case .itemTextAppended(_, _, _, let item): + upsert(item) + guard item.turnID == activeTurnID else { + return document + } + return replaceBlock( + for: item, + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + + case .itemRemoved(let id, let turnID): + removeItem(id: id, turnID: turnID) + guard turnID == activeTurnID else { + return document + } + return removeBlock( + id: .init(rawValue: "\(activeTurnID.rawValue):\(id)"), + activeTurnID: activeTurnID + ) + + case .phaseChanged: + return document + } + } + + private mutating func apply(_ snapshot: CodexChatSnapshot) { + turnsByID = Dictionary(uniqueKeysWithValues: snapshot.turns.map { ($0.id, $0) }) + itemsByKey.removeAll(keepingCapacity: true) + orderedItemKeys.removeAll(keepingCapacity: true) + for item in snapshot.items { + upsert(item) + } + } + + private mutating func upsert(_ item: CodexChatItemSnapshot) { + let key = ItemKey(item) + if itemsByKey[key] == nil { + orderedItemKeys.append(key) + } + itemsByKey[key] = item + } + + private mutating func removeItem(id: String, turnID: CodexTurnID?) { + let key = ItemKey(id: id, turnID: turnID) + itemsByKey.removeValue(forKey: key) + orderedItemKeys.removeAll { $0 == key } + } + + private mutating func rebuildOrReplaceBlock( + for item: CodexChatItemSnapshot, + activeTurnID: CodexTurnID, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewTimelineDocument? { + guard document != nil else { + return rebuildDocument( + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + return replaceBlock( + for: item, + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + + private mutating func rebuildDocument( + activeTurnID: CodexTurnID, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewTimelineDocument? { + guard let turn = turnsByID[activeTurnID] else { + document = nil + return nil + } + let items = orderedItemKeys.compactMap { key -> CodexChatItemSnapshot? in + guard key.turnID == activeTurnID else { + return nil + } + return itemsByKey[key] + } + document = ReviewMonitorCodexChatTimelineProjection().document( + from: turn, + items: items, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + revision: nextRevision() + ) + return document + } + + private mutating func replaceBlock( + for item: CodexChatItemSnapshot, + activeTurnID: CodexTurnID, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewTimelineDocument? { + guard let turn = turnsByID[activeTurnID] else { + return rebuildDocument( + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + guard var document else { + return rebuildDocument( + activeTurnID: activeTurnID, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + + let projection = ReviewMonitorCodexChatTimelineProjection() + let block = projection.block( + from: item, + turnSnapshot: turn, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + if let index = document.blocks.firstIndex(where: { $0.id == block.id }) { + document.blocks[index] = block + } else { + document.blocks.append(block) + document.orderedBlockIDs.append(block.id) + } + refreshMetadata( + in: &document, + turn: turn, + latestActivityBlockID: block.id, + revision: nextRevision() + ) + self.document = document + return document + } + + private mutating func removeBlock( + id: ReviewTimelineDocument.Block.ID, + activeTurnID: CodexTurnID + ) -> ReviewTimelineDocument? { + guard var document else { + return nil + } + document.blocks.removeAll { $0.id == id } + document.orderedBlockIDs.removeAll { $0 == id } + let latestActivityBlockID = document.blocks.last?.id + if let turn = turnsByID[activeTurnID] { + refreshMetadata( + in: &document, + turn: turn, + latestActivityBlockID: latestActivityBlockID, + revision: nextRevision() + ) + } else { + document.timelineRevision = .init(rawValue: nextRevision()) + document.activeBlockIDs = document.blocks.filter(\.isActive).map(\.id) + document.activeBlockCount = document.activeBlockIDs.count + document.latestActivityBlockID = latestActivityBlockID + } + self.document = document.blocks.isEmpty ? nil : document + return self.document + } + + private mutating func nextRevision() -> UInt64 { + revision &+= 1 + return revision + } + + private func refreshMetadata( + in document: inout ReviewTimelineDocument, + turn: CodexChatTurnStateSnapshot, + latestActivityBlockID: ReviewTimelineDocument.Block.ID?, + revision: UInt64 + ) { + document.timelineRevision = .init(rawValue: revision) + document.activeBlockIDs = document.blocks.filter(\.isActive).map(\.id) + document.activeBlockCount = document.activeBlockIDs.count + document.latestActivityBlockID = latestActivityBlockID + document.terminalStatus = ReviewMonitorCodexChatTimelineProjection().terminalStatus(for: turn) + document.terminalSummary = turn.errorDescription + } +} From 11d69120c8de836f2a5bf0ad519e9b8a96bf52de Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:00:33 +0900 Subject: [PATCH 087/359] refactor(review-ui): snapshot Codex sidebar rows --- .../ReviewMonitorCodexSidebarLibrary.swift | 127 ++++++++++++++++++ ...eviewMonitorCodexSidebarLibraryTests.swift | 67 +++++++++ 2 files changed, 194 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index d16ade65..e21b1ae6 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -1,6 +1,93 @@ import CodexKit import Foundation +package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringConvertible { + package var rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package static func section(_ id: String) -> Self { + .init(rawValue: "section:\(id)") + } + + package static func workspace(_ id: CodexWorkspaceID) -> Self { + .init(rawValue: "workspace:\(id.rawValue)") + } + + package static func chat(_ id: CodexThreadID) -> Self { + .init(rawValue: "chat:\(id.rawValue)") + } + + package var description: String { + rawValue + } +} + +package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { + package struct Section: Equatable, Sendable { + package var rowID: ReviewMonitorCodexSidebarRowID + package var id: String + package var title: String + package var workspaces: [Workspace] + package var uncategorizedChats: [Chat] + + package var rowIDs: [ReviewMonitorCodexSidebarRowID] { + [rowID] + workspaces.flatMap(\.rowIDs) + uncategorizedChats.map(\.rowID) + } + + var selection: ReviewMonitorWorkspaceSectionSelection { + ReviewMonitorWorkspaceSectionSelection( + id: id, + title: title, + workspaceCWDs: workspaces.map(\.cwd) + ) + } + } + + package struct Workspace: Equatable, Sendable { + package var rowID: ReviewMonitorCodexSidebarRowID + package var id: CodexWorkspaceID + package var cwd: String + package var title: String + package var chats: [Chat] + + package var rowIDs: [ReviewMonitorCodexSidebarRowID] { + [rowID] + chats.map(\.rowID) + } + } + + package struct Chat: Equatable, Sendable { + package var rowID: ReviewMonitorCodexSidebarRowID + package var id: CodexThreadID + package var title: String + package var preview: String? + package var workspaceCWD: String? + package var updatedAt: Date? + } + + package var sections: [Section] + + package var rowIDs: [ReviewMonitorCodexSidebarRowID] { + sections.flatMap(\.rowIDs) + } + + package func chat(id: CodexThreadID) -> Chat? { + for section in sections { + for workspace in section.workspaces { + if let chat = workspace.chats.first(where: { $0.id == id }) { + return chat + } + } + if let chat = section.uncategorizedChats.first(where: { $0.id == id }) { + return chat + } + } + return nil + } +} + @MainActor package struct ReviewMonitorCodexSidebarWorkspace { package var workspace: CodexWorkspace @@ -43,6 +130,7 @@ package struct ReviewMonitorCodexSidebarSection { package final class ReviewMonitorCodexSidebarLibrary { package static var defaultRequest: CodexFetchRequest { CodexFetchRequest( + filter: .init(sourceKinds: [.subAgentReview]), sortDescriptors: [.updatedAt(.reverse)], sectionDescriptor: .workspaceGroup ) @@ -73,6 +161,10 @@ package final class ReviewMonitorCodexSidebarLibrary { Self.sidebarSections(from: fetchedResults.sections) } + package var snapshot: ReviewMonitorCodexSidebarSnapshot { + Self.sidebarSnapshot(from: sections) + } + package func performFetch() async throws { try await fetchedResults.performFetch() } @@ -122,4 +214,39 @@ package final class ReviewMonitorCodexSidebarLibrary { ) } } + + private static func sidebarSnapshot( + from sections: [ReviewMonitorCodexSidebarSection] + ) -> ReviewMonitorCodexSidebarSnapshot { + ReviewMonitorCodexSidebarSnapshot( + sections: sections.map { section in + ReviewMonitorCodexSidebarSnapshot.Section( + rowID: .section(section.id), + id: section.id, + title: section.title, + workspaces: section.workspaces.map { workspace in + ReviewMonitorCodexSidebarSnapshot.Workspace( + rowID: .workspace(workspace.id), + id: workspace.id, + cwd: workspace.cwd, + title: workspace.title, + chats: workspace.chats.map(Self.snapshotChat(_:)) + ) + }, + uncategorizedChats: section.uncategorizedChats.map(Self.snapshotChat(_:)) + ) + } + ) + } + + private static func snapshotChat(_ chat: CodexChat) -> ReviewMonitorCodexSidebarSnapshot.Chat { + ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chat.id), + id: chat.id, + title: chat.title, + preview: chat.preview, + workspaceCWD: chat.workspace?.url.path, + updatedAt: chat.updatedAt + ) + } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 2095385d..927a2242 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -35,8 +35,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await library.performFetch() let section = try #require(library.sections.first) + let snapshotSection = try #require(library.snapshot.sections.first) let appWorkspace = try #require(section.workspaces.first) let appChat = try #require(appWorkspace.chats.first) + let snapshotAppWorkspace = try #require(snapshotSection.workspaces.first) + let snapshotAppChat = try #require(snapshotAppWorkspace.chats.first) let resolvedAppPath = app.standardizedFileURL.resolvingSymlinksInPath().path let resolvedToolsPath = tools.standardizedFileURL.resolvingSymlinksInPath().path @@ -46,7 +49,71 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(section.workspaces.map(\.title) == ["App", "Tools"]) #expect(section.chats.map(\.title) == ["App chat", "Tools chat"]) #expect(appChat === library.chat(id: CodexThreadID(rawValue: "thread-app"))) + #expect(snapshotSection.selection.workspaceCWDs == [resolvedAppPath, resolvedToolsPath]) + #expect(snapshotAppWorkspace.cwd == resolvedAppPath) + #expect(snapshotAppChat.id == CodexThreadID(rawValue: "thread-app")) + #expect(snapshotAppChat.title == "App chat") + #expect(snapshotAppChat.workspaceCWD == resolvedAppPath) + #expect(library.snapshot.chat(id: CodexThreadID(rawValue: "thread-app")) == snapshotAppChat) + #expect(library.snapshot.rowIDs.map(\.rawValue) == [ + "section:\(section.id)", + "workspace:\(resolvedAppPath)", + "chat:thread-app", + "workspace:\(resolvedToolsPath)", + "chat:thread-tools", + ]) } + + @Test func defaultRequestFetchesReviewThreadsOnly() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(threads: [])) + + let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + + let request = try #require(await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try request.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["subAgentReview"]) + } + + @Test func sidebarSnapshotIncludesUncategorizedChatsWithStableRowIDs() 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 library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + + let section = try #require(library.snapshot.sections.first) + let chat = try #require(section.uncategorizedChats.first) + + #expect(section.workspaces.isEmpty) + #expect(chat.id == CodexThreadID(rawValue: "thread-uncategorized")) + #expect(chat.rowID.rawValue == "chat:thread-uncategorized") + #expect(chat.title == "Floating review") + #expect(chat.preview == "Uncategorized preview") + #expect(chat.workspaceCWD == nil) + #expect(section.rowIDs.map(\.rawValue) == [ + section.rowID.rawValue, + "chat:thread-uncategorized", + ]) + } +} + +private struct ThreadListParams: Decodable { + var sourceKinds: [String]? } private func makeDirectory(_ name: String, in parent: URL) throws -> URL { From 8a2fa829d2e98d542854f99cd6b9b025cacad376 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:07:03 +0900 Subject: [PATCH 088/359] refactor(review-ui): isolate review chat binding --- ...ReviewMonitorTransportViewController.swift | 4 +-- .../ReviewMonitorReviewChatBinding.swift | 21 ++++++++++++++++ .../ReviewMonitorSelectedReviewChat.swift | 25 +++---------------- 3 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 6c9764fb..9a99db64 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -177,7 +177,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation = nil resetLogRenderer() boundJob = selectedJob - selectedReviewChat.bind(to: selectedJob) + selectedReviewChat.bind(to: ReviewMonitorReviewChatBinding(job: selectedJob)) selectedJobObservation = withPortableContinuousObservation { [weak self] event in let eventKind = event.kind @@ -186,7 +186,7 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - self.selectedReviewChat.bind(to: selectedJob) + self.selectedReviewChat.bind(to: ReviewMonitorReviewChatBinding(job: selectedJob)) let timeline = selectedJob.timeline _ = timeline.revision let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) diff --git a/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift b/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift new file mode 100644 index 00000000..83038ad9 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift @@ -0,0 +1,21 @@ +import CodexKit +import CodexReviewKit + +@MainActor +struct ReviewMonitorReviewChatBinding: Equatable { + var identity: CodexReviewIdentity + + init?(job: CodexReviewJob) { + guard let sourceThreadID = job.core.run.threadID?.nilIfEmpty, + let turnID = job.core.run.turnID?.nilIfEmpty + else { + return nil + } + self.identity = CodexReviewIdentity( + threadID: CodexThreadID(rawValue: sourceThreadID), + turnID: CodexTurnID(rawValue: turnID), + reviewThreadID: job.core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), + model: job.core.run.model?.nilIfEmpty + ) + } +} diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 7edd39a3..8fc76c04 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -1,5 +1,4 @@ import CodexKit -import CodexReviewKit import Foundation import Observation import ObservationBridge @@ -23,7 +22,7 @@ final class ReviewMonitorSelectedReviewChat { @ObservationIgnored private weak var boundModelContext: CodexModelContext? @ObservationIgnored - private weak var boundJob: CodexReviewJob? + private var boundChatBinding: ReviewMonitorReviewChatBinding? @ObservationIgnored private var observation: CodexChatObservation? @ObservationIgnored @@ -43,8 +42,8 @@ final class ReviewMonitorSelectedReviewChat { cancelObservation() } - func bind(to job: CodexReviewJob?) { - boundJob = job + func bind(to chatBinding: ReviewMonitorReviewChatBinding?) { + boundChatBinding = chatBinding refreshBinding() } @@ -59,7 +58,7 @@ final class ReviewMonitorSelectedReviewChat { } private func refreshBinding() { - let nextIdentity = boundJob?.reviewIdentity + let nextIdentity = boundChatBinding?.identity let nextModelContext = modelSource?.modelContext guard nextIdentity != identity || nextModelContext !== boundModelContext else { return @@ -117,19 +116,3 @@ final class ReviewMonitorSelectedReviewChat { observation = nil } } - -private extension CodexReviewJob { - var reviewIdentity: CodexReviewIdentity? { - guard let sourceThreadID = core.run.threadID?.nilIfEmpty, - let turnID = core.run.turnID?.nilIfEmpty - else { - return nil - } - return CodexReviewIdentity( - threadID: CodexThreadID(rawValue: sourceThreadID), - turnID: CodexTurnID(rawValue: turnID), - reviewThreadID: core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), - model: core.run.model?.nilIfEmpty - ) - } -} From 9b21fa1fd31352ec53d317f60fd42ac4feefa1e3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:14:42 +0900 Subject: [PATCH 089/359] refactor(review-ui): identify selections by stable ids --- .../ReviewMonitorTransportViewController.swift | 15 +++++---------- Sources/ReviewUI/ReviewMonitorSelection.swift | 14 ++++++++++++++ Sources/ReviewUI/ReviewMonitorUIState.swift | 4 ++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 9a99db64..f16c966c 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -6,11 +6,6 @@ import ReviewMonitorRendering @MainActor final class ReviewMonitorTransportViewController: NSViewController { - private enum DisplayedSelection: Equatable { - case job(String) - case workspaceSection(String) - } - private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let selectedReviewChat: ReviewMonitorSelectedReviewChat @@ -24,7 +19,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? private var boundJob: CodexReviewJob? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? - private var displayedSelection: DisplayedSelection? + private var displayedSelection: ReviewMonitorSelectionID? private var logScrollTargetsByJobID: [String: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] private var logRenderTask: Task? private var logRenderGeneration: UInt64 = 0 @@ -133,9 +128,9 @@ final class ReviewMonitorTransportViewController: NSViewController { private func selectionRequiresPresentationUpdate(_ selection: ReviewMonitorSelection?) -> Bool { switch selection { case .job(let selectedJob): - return boundJob !== selectedJob || displayedSelection != .job(selectedJob.id) + return boundJob !== selectedJob || displayedSelection != selection?.id case .workspaceSection(let selectedSection): - return boundWorkspaceSection != selectedSection || displayedSelection != .workspaceSection(selectedSection.id) + return boundWorkspaceSection != selectedSection || displayedSelection != selection?.id case nil: return displayedSelection != nil } @@ -149,13 +144,13 @@ final class ReviewMonitorTransportViewController: NSViewController { hidePlaceholder() logScrollView.isHidden = false workspaceFindingsView.isHidden = true - displayedSelection = .job(selectedJob.id) + displayedSelection = selection?.id case .workspaceSection(let selectedSection): clearDisplayedJob() displayWorkspaceSection(selectedSection) logScrollView.isHidden = true - displayedSelection = .workspaceSection(selectedSection.id) + displayedSelection = selection?.id case nil: clearDisplayedJob() diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 1280511a..8d211744 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -1,7 +1,21 @@ import CodexReviewKit +enum ReviewMonitorSelectionID: Hashable, Sendable { + case workspaceSection(String) + case job(String) +} + @MainActor enum ReviewMonitorSelection { case workspaceSection(ReviewMonitorWorkspaceSectionSelection) case job(CodexReviewJob) + + var id: ReviewMonitorSelectionID { + switch self { + case .workspaceSection(let section): + return .workspaceSection(section.id) + case .job(let job): + return .job(job.id) + } + } } diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index ccfe6455..ccf70562 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -51,6 +51,10 @@ final class ReviewMonitorUIState { } } + var selectionID: ReviewMonitorSelectionID? { + selection?.id + } + var contentKind: ReviewMonitorContentKind { if auth.selectedAccount != nil || auth.hasAccounts { return .contentView From 1d481354fd7e88d4244b5e1aad50d3284128a14b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:16:38 +0900 Subject: [PATCH 090/359] refactor(review-ui): reconcile sidebar jobs by selection id --- Sources/ReviewUI/ReviewMonitorUIState.swift | 7 +++++++ .../Jobs/ReviewMonitorSidebarViewController.swift | 13 ++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index ccf70562..02444cba 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -39,6 +39,13 @@ final class ReviewMonitorUIState { } } + var selectedJobID: String? { + guard case .job(let id) = selectionID else { + return nil + } + return id + } + var selectedWorkspaceSectionEntry: ReviewMonitorWorkspaceSectionSelection? { get { guard case .workspaceSection(let section) = selection else { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 86daed9d..2f971fee 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -748,17 +748,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedJobRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard let selectedJob = uiState.selectedJobEntry, + guard let selectedJobID = uiState.selectedJobID, + let selectedJob = job(withID: selectedJobID), 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, + self.uiState.selectedJobID == selectedJobID, let row = self.row(forJobID: selectedJobID), self.outlineView.selectedRow != row else { @@ -1170,8 +1170,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD || section.workspaceCWDs.contains { cwd in workspaces.contains(where: { $0.cwd == cwd }) } - case .job(let job): - return containsJob(id: job.id, in: workspaces) + case .job: + guard case .job(let id) = selection.id else { + return false + } + return containsJob(id: id, in: workspaces) } } From 123d5be5b95cc3cc2ee2d90d45a1473c6505ef0c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:18:24 +0900 Subject: [PATCH 091/359] refactor(review-ui): bind selected chats by identity --- .../CodexReviewJob+ReviewChatIdentity.swift | 19 +++++++++++++++++ ...ReviewMonitorTransportViewController.swift | 4 ++-- .../ReviewMonitorReviewChatBinding.swift | 21 ------------------- .../ReviewMonitorSelectedReviewChat.swift | 8 +++---- 4 files changed, 25 insertions(+), 27 deletions(-) create mode 100644 Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift delete mode 100644 Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift diff --git a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift new file mode 100644 index 00000000..fd79f8ee --- /dev/null +++ b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift @@ -0,0 +1,19 @@ +import CodexKit +import CodexReviewKit + +extension CodexReviewJob { + @MainActor + var reviewChatIdentity: CodexReviewIdentity? { + guard let sourceThreadID = core.run.threadID?.nilIfEmpty, + let turnID = core.run.turnID?.nilIfEmpty + else { + return nil + } + return CodexReviewIdentity( + threadID: CodexThreadID(rawValue: sourceThreadID), + turnID: CodexTurnID(rawValue: turnID), + reviewThreadID: core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), + model: core.run.model?.nilIfEmpty + ) + } +} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index f16c966c..f36643e8 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -172,7 +172,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation = nil resetLogRenderer() boundJob = selectedJob - selectedReviewChat.bind(to: ReviewMonitorReviewChatBinding(job: selectedJob)) + selectedReviewChat.bind(to: selectedJob.reviewChatIdentity) selectedJobObservation = withPortableContinuousObservation { [weak self] event in let eventKind = event.kind @@ -181,7 +181,7 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - self.selectedReviewChat.bind(to: ReviewMonitorReviewChatBinding(job: selectedJob)) + self.selectedReviewChat.bind(to: selectedJob.reviewChatIdentity) let timeline = selectedJob.timeline _ = timeline.revision let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) diff --git a/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift b/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift deleted file mode 100644 index 83038ad9..00000000 --- a/Sources/ReviewUI/ReviewMonitorReviewChatBinding.swift +++ /dev/null @@ -1,21 +0,0 @@ -import CodexKit -import CodexReviewKit - -@MainActor -struct ReviewMonitorReviewChatBinding: Equatable { - var identity: CodexReviewIdentity - - init?(job: CodexReviewJob) { - guard let sourceThreadID = job.core.run.threadID?.nilIfEmpty, - let turnID = job.core.run.turnID?.nilIfEmpty - else { - return nil - } - self.identity = CodexReviewIdentity( - threadID: CodexThreadID(rawValue: sourceThreadID), - turnID: CodexTurnID(rawValue: turnID), - reviewThreadID: job.core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), - model: job.core.run.model?.nilIfEmpty - ) - } -} diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 8fc76c04..86fb5e70 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -22,7 +22,7 @@ final class ReviewMonitorSelectedReviewChat { @ObservationIgnored private weak var boundModelContext: CodexModelContext? @ObservationIgnored - private var boundChatBinding: ReviewMonitorReviewChatBinding? + private var boundIdentity: CodexReviewIdentity? @ObservationIgnored private var observation: CodexChatObservation? @ObservationIgnored @@ -42,8 +42,8 @@ final class ReviewMonitorSelectedReviewChat { cancelObservation() } - func bind(to chatBinding: ReviewMonitorReviewChatBinding?) { - boundChatBinding = chatBinding + func bind(to identity: CodexReviewIdentity?) { + boundIdentity = identity refreshBinding() } @@ -58,7 +58,7 @@ final class ReviewMonitorSelectedReviewChat { } private func refreshBinding() { - let nextIdentity = boundChatBinding?.identity + let nextIdentity = boundIdentity let nextModelContext = modelSource?.modelContext guard nextIdentity != identity || nextModelContext !== boundModelContext else { return From 1664eff8b8d859653850d350d20e2c0f9a99f779 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:24:59 +0900 Subject: [PATCH 092/359] refactor(review-ui): attach codex sidebar library --- .../ReviewMonitorSplitViewController.swift | 3 +- .../ReviewMonitorSidebarViewController.swift | 53 ++++++++++++++++++- ...eviewMonitorCodexSidebarLibraryTests.swift | 33 ++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 4fe735d2..5e555235 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -78,7 +78,8 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe let sidebarViewController = ReviewMonitorSidebarViewController( store: store, - uiState: uiState + uiState: uiState, + codexModelSource: codexModelSource ) let transportViewController = ReviewMonitorTransportViewController( store: store, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 2f971fee..93f37e65 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1,4 +1,5 @@ import AppKit +import CodexKit import ObservationBridge import CodexReviewKit import SwiftUI @@ -190,6 +191,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let store: CodexReviewStore private let uiState: ReviewMonitorUIState + private let codexModelSource: ReviewMonitorCodexModelSource? private let scrollView = NSScrollView() private let outlineView = ReviewMonitorSidebarOutlineView() private let accountsViewController: ReviewMonitorAccountsViewController @@ -200,6 +202,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarKindObservation: PortableObservationTracking.Token? private var sidebarTopologyObservation: PortableObservationTracking.Token? private var sidebarFilterObservation: PortableObservationTracking.Token? + private var codexSidebarObservation: PortableObservationTracking.Token? + private var codexSidebarFetchTask: Task? + private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] @@ -211,9 +216,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var incrementalMembershipChangeCountForTesting = 0 #endif - init(store: CodexReviewStore, uiState: ReviewMonitorUIState) { + 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() @@ -229,6 +239,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarKindObservation?.cancel() sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() + codexSidebarObservation?.cancel() + codexSidebarFetchTask?.cancel() } override func loadView() { @@ -240,6 +252,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD configureHierarchy() configureOutlineView() bindObservation() + bindCodexSidebarLibrary() } override func viewDidLayout() { @@ -368,6 +381,40 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } + private func bindCodexSidebarLibrary() { + guard let codexModelSource else { + return + } + codexSidebarObservation?.cancel() + codexSidebarObservation = withPortableContinuousObservation { [weak self, codexModelSource] _ in + _ = codexModelSource.generation + self?.installCodexSidebarLibrary(modelContext: codexModelSource.modelContext) + } + } + + private func installCodexSidebarLibrary(modelContext: CodexModelContext?) { + codexSidebarFetchTask?.cancel() + codexSidebarFetchTask = nil + guard let modelContext else { + codexSidebarLibrary = nil + return + } + + let library = ReviewMonitorCodexSidebarLibrary(modelContext: modelContext) + codexSidebarLibrary = library + codexSidebarFetchTask = Task { @MainActor [weak self, library] in + do { + try await library.performFetch() + } catch is CancellationError { + } catch { + } + guard self?.codexSidebarLibrary === library else { + return + } + self?.codexSidebarFetchTask = nil + } + } + private func bindSidebarStoreTopologyObservation( filter: SidebarJobFilter, animatedInitialDelivery: Bool @@ -1945,6 +1992,10 @@ extension ReviewMonitorSidebarViewController { uiState.selectedWorkspaceSectionEntry } + var codexSidebarSnapshotForTesting: ReviewMonitorCodexSidebarSnapshot? { + codexSidebarLibrary?.snapshot + } + var sidebarFullReloadCountForTesting: Int { fullReloadCountForTesting } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 927a2242..7d1c5920 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -2,6 +2,7 @@ import CodexKit import CodexAppServerKitTesting import Foundation import Testing +@_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI @Suite("ReviewMonitor Codex sidebar library") @@ -110,6 +111,38 @@ struct ReviewMonitorCodexSidebarLibraryTests { "chat:thread-uncategorized", ]) } + + @Test func sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() 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-app", + workspace: try makeGitRepository(), + name: "App review", + updatedAt: Date(timeIntervalSince1970: 5_000) + ), + ] + )) + + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting(serverState: .running, workspaces: []) + let viewController = ReviewMonitorSplitViewController( + store: store, + uiState: ReviewMonitorUIState(auth: store.auth), + modelContext: context + ) + viewController.loadViewIfNeeded() + + let sidebar = viewController.sidebarViewControllerForTesting + try await waitForCondition { + sidebar.codexSidebarSnapshotForTesting? + .chat(id: CodexThreadID(rawValue: "thread-app"))? + .title == "App review" + } + } } private struct ThreadListParams: Decodable { From 6c565e611955e828bbb3b61fbfd1db499999678c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:31:18 +0900 Subject: [PATCH 093/359] refactor(review-ui): model Codex sidebar outline rows --- .../ReviewMonitorCodexSidebarLibrary.swift | 68 +++++++++++++++++++ ...eviewMonitorCodexSidebarLibraryTests.swift | 20 ++++++ 2 files changed, 88 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index e21b1ae6..a8acd336 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -73,6 +73,10 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { sections.flatMap(\.rowIDs) } + package var outlineItems: [ReviewMonitorCodexSidebarOutlineItem] { + sections.map(ReviewMonitorCodexSidebarOutlineItem.section) + } + package func chat(id: CodexThreadID) -> Chat? { for section in sections { for workspace in section.workspaces { @@ -86,6 +90,70 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { } return nil } + + package func outlineItem(rowID: ReviewMonitorCodexSidebarRowID) -> ReviewMonitorCodexSidebarOutlineItem? { + for section in sections { + if section.rowID == rowID { + return .section(section) + } + for workspace in section.workspaces { + if workspace.rowID == rowID { + return .workspace(workspace) + } + if let chat = workspace.chats.first(where: { $0.rowID == rowID }) { + return .chat(chat) + } + } + if let chat = section.uncategorizedChats.first(where: { $0.rowID == rowID }) { + return .chat(chat) + } + } + return nil + } +} + +package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { + case section(ReviewMonitorCodexSidebarSnapshot.Section) + case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) + case chat(ReviewMonitorCodexSidebarSnapshot.Chat) + + package var rowID: ReviewMonitorCodexSidebarRowID { + switch self { + case .section(let section): + section.rowID + case .workspace(let workspace): + workspace.rowID + case .chat(let chat): + chat.rowID + } + } + + package var title: String { + switch self { + case .section(let section): + section.title + case .workspace(let workspace): + workspace.title + case .chat(let chat): + chat.title + } + } + + package var children: [ReviewMonitorCodexSidebarOutlineItem] { + switch self { + case .section(let section): + section.workspaces.map(ReviewMonitorCodexSidebarOutlineItem.workspace) + + section.uncategorizedChats.map(ReviewMonitorCodexSidebarOutlineItem.chat) + case .workspace(let workspace): + workspace.chats.map(ReviewMonitorCodexSidebarOutlineItem.chat) + case .chat: + [] + } + } + + package var isExpandable: Bool { + children.isEmpty == false + } } @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 7d1c5920..f812b204 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -56,6 +56,20 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(snapshotAppChat.title == "App chat") #expect(snapshotAppChat.workspaceCWD == resolvedAppPath) #expect(library.snapshot.chat(id: CodexThreadID(rawValue: "thread-app")) == snapshotAppChat) + let outlineSection = try #require(library.snapshot.outlineItems.first) + let outlineAppWorkspace = try #require(outlineSection.children.first) + #expect(outlineSection.rowID == snapshotSection.rowID) + #expect(outlineSection.title == section.title) + #expect(outlineSection.isExpandable) + #expect(outlineSection.children.map(\.rowID.rawValue) == [ + "workspace:\(resolvedAppPath)", + "workspace:\(resolvedToolsPath)", + ]) + #expect(outlineAppWorkspace.rowID == snapshotAppWorkspace.rowID) + #expect(outlineAppWorkspace.title == "App") + #expect(outlineAppWorkspace.isExpandable) + #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) + #expect(library.snapshot.outlineItem(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == .chat(snapshotAppChat)) #expect(library.snapshot.rowIDs.map(\.rawValue) == [ "section:\(section.id)", "workspace:\(resolvedAppPath)", @@ -106,6 +120,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(chat.title == "Floating review") #expect(chat.preview == "Uncategorized preview") #expect(chat.workspaceCWD == nil) + let outlineSection = try #require(library.snapshot.outlineItems.first) + let outlineChat = try #require(outlineSection.children.first) + #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) + #expect(outlineChat == .chat(chat)) + #expect(outlineChat.isExpandable == false) + #expect(library.snapshot.outlineItem(rowID: chat.rowID) == .chat(chat)) #expect(section.rowIDs.map(\.rawValue) == [ section.rowID.rawValue, "chat:thread-uncategorized", From 0ca37d400ec1674fee0a801e6d6606ba98c27b97 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:37:41 +0900 Subject: [PATCH 094/359] refactor(review-ui): identify Codex sidebar rows --- .../Detail/ReviewMonitorTransportViewController.swift | 6 +++--- Sources/ReviewUI/ReviewMonitorSelection.swift | 3 +++ .../Jobs/ReviewMonitorCodexSidebarLibrary.swift | 11 +++++++++++ .../ReviewMonitorCodexSidebarLibraryTests.swift | 5 +++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index f36643e8..3e18ea39 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -446,7 +446,7 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollView.performDisplayedTextFinderAction(sender) case .workspaceSection: return workspaceFindingsView.performDisplayedTextFinderAction(sender) - case nil: + case .workspace, .chat, nil: return false } } @@ -457,7 +457,7 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollView.validateDisplayedTextFinderAction(item) case .workspaceSection: return workspaceFindingsView.validateDisplayedTextFinderAction(item) - case nil: + case .workspace, .chat, nil: return false } } @@ -930,7 +930,7 @@ extension ReviewMonitorTransportViewController { .job(id) case .workspaceSection(let id): .workspaceSection(id) - case nil: + case .workspace, .chat, nil: nil } } diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 8d211744..7e554780 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -1,7 +1,10 @@ +import CodexKit import CodexReviewKit enum ReviewMonitorSelectionID: Hashable, Sendable { case workspaceSection(String) + case workspace(CodexWorkspaceID) + case chat(CodexThreadID) case job(String) } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index a8acd336..86091f81 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -139,6 +139,17 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { } } + var selectionID: ReviewMonitorSelectionID { + switch self { + case .section(let section): + .workspaceSection(section.id) + case .workspace(let workspace): + .workspace(workspace.id) + case .chat(let chat): + .chat(chat.id) + } + } + package var children: [ReviewMonitorCodexSidebarOutlineItem] { switch self { case .section(let section): diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index f812b204..e0992d13 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -60,6 +60,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let outlineAppWorkspace = try #require(outlineSection.children.first) #expect(outlineSection.rowID == snapshotSection.rowID) #expect(outlineSection.title == section.title) + #expect(outlineSection.selectionID == .workspaceSection(section.id)) #expect(outlineSection.isExpandable) #expect(outlineSection.children.map(\.rowID.rawValue) == [ "workspace:\(resolvedAppPath)", @@ -67,7 +68,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) #expect(outlineAppWorkspace.rowID == snapshotAppWorkspace.rowID) #expect(outlineAppWorkspace.title == "App") + #expect(outlineAppWorkspace.selectionID == .workspace(snapshotAppWorkspace.id)) #expect(outlineAppWorkspace.isExpandable) + let outlineAppChat = try #require(outlineAppWorkspace.children.first) + #expect(outlineAppChat.selectionID == .chat(snapshotAppChat.id)) #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) #expect(library.snapshot.outlineItem(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == .chat(snapshotAppChat)) #expect(library.snapshot.rowIDs.map(\.rawValue) == [ @@ -124,6 +128,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let outlineChat = try #require(outlineSection.children.first) #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) #expect(outlineChat == .chat(chat)) + #expect(outlineChat.selectionID == .chat(chat.id)) #expect(outlineChat.isExpandable == false) #expect(library.snapshot.outlineItem(rowID: chat.rowID) == .chat(chat)) #expect(section.rowIDs.map(\.rawValue) == [ From a175f52a08351f324e6f2d8a1c6b3baee9e8fffa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:42:28 +0900 Subject: [PATCH 095/359] refactor(review-ui): preserve Codex sidebar node identity --- .../ReviewMonitorCodexSidebarLibrary.swift | 56 +++++++++++++ ...eviewMonitorCodexSidebarLibraryTests.swift | 79 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 86091f81..f10b9213 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -167,6 +167,62 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { } } +@MainActor +final class ReviewMonitorCodexSidebarOutlineTree { + private var nodesByRowID: [ReviewMonitorCodexSidebarRowID: ReviewMonitorCodexSidebarOutlineNode] = [:] + private(set) var roots: [ReviewMonitorCodexSidebarOutlineNode] = [] + + func apply(snapshot: ReviewMonitorCodexSidebarSnapshot) { + var activeRowIDs: Set = [] + roots = snapshot.outlineItems.map { node(for: $0, activeRowIDs: &activeRowIDs) } + nodesByRowID = nodesByRowID.filter { activeRowIDs.contains($0.key) } + } + + func node(rowID: ReviewMonitorCodexSidebarRowID) -> ReviewMonitorCodexSidebarOutlineNode? { + nodesByRowID[rowID] + } + + 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 + node.item = item + node.children = item.children.map { child in + self.node(for: child, activeRowIDs: &activeRowIDs) + } + return node + } +} + +@MainActor +final class ReviewMonitorCodexSidebarOutlineNode { + fileprivate(set) var item: ReviewMonitorCodexSidebarOutlineItem + fileprivate(set) var children: [ReviewMonitorCodexSidebarOutlineNode] = [] + + fileprivate init(item: ReviewMonitorCodexSidebarOutlineItem) { + self.item = item + } + + var rowID: ReviewMonitorCodexSidebarRowID { + item.rowID + } + + var title: String { + item.title + } + + var selectionID: ReviewMonitorSelectionID { + item.selectionID + } + + var isExpandable: Bool { + children.isEmpty == false + } +} + @MainActor package struct ReviewMonitorCodexSidebarWorkspace { package var workspace: CodexWorkspace diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index e0992d13..9ef9ca04 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -137,6 +137,50 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) } + @Test func sidebarOutlineTreePreservesNodeIdentityAcrossSnapshots() throws { + let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + let threadID = CodexThreadID(rawValue: "thread-app") + let tree = ReviewMonitorCodexSidebarOutlineTree() + + tree.apply(snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Initial review", + includesChat: true + )) + + let root = try #require(tree.roots.first) + let workspace = try #require(tree.node(rowID: .workspace(workspaceID))) + let chat = try #require(tree.node(rowID: .chat(threadID))) + + tree.apply(snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Updated review", + includesChat: true + )) + + let updatedRoot = try #require(tree.roots.first) + let updatedWorkspace = try #require(tree.node(rowID: .workspace(workspaceID))) + let updatedChat = try #require(tree.node(rowID: .chat(threadID))) + #expect(updatedRoot === root) + #expect(updatedWorkspace === workspace) + #expect(updatedChat === chat) + #expect(chat.title == "Updated review") + #expect(root.children.first === workspace) + #expect(workspace.children.first === chat) + + tree.apply(snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Removed review", + includesChat: false + )) + + #expect(tree.node(rowID: .chat(threadID)) == nil) + #expect(workspace.children.isEmpty) + } + @Test func sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -174,6 +218,41 @@ private struct ThreadListParams: Decodable { var sourceKinds: [String]? } +private func sidebarSnapshot( + workspaceID: CodexWorkspaceID, + threadID: CodexThreadID, + chatTitle: String, + includesChat: Bool +) -> ReviewMonitorCodexSidebarSnapshot { + let chat = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(threadID), + id: threadID, + title: chatTitle, + preview: nil, + workspaceCWD: workspaceID.rawValue, + updatedAt: nil + ) + return ReviewMonitorCodexSidebarSnapshot( + sections: [ + ReviewMonitorCodexSidebarSnapshot.Section( + rowID: .section("repo"), + id: "repo", + title: "Repo", + workspaces: [ + ReviewMonitorCodexSidebarSnapshot.Workspace( + rowID: .workspace(workspaceID), + id: workspaceID, + cwd: workspaceID.rawValue, + title: "App", + chats: includesChat ? [chat] : [] + ), + ], + uncategorizedChats: [] + ), + ] + ) +} + 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) From a4ea24cfa240122cd01cc1a69570d8ac87e257e6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:44:47 +0900 Subject: [PATCH 096/359] refactor(review-ui): apply Codex sidebar snapshots --- .../Jobs/ReviewMonitorSidebarViewController.swift | 15 +++++++++++++++ .../ReviewMonitorCodexSidebarLibraryTests.swift | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 93f37e65..6b72d91c 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -205,6 +205,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? + private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] @@ -397,6 +398,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarFetchTask = nil guard let modelContext else { codexSidebarLibrary = nil + applyCodexSidebarSnapshot(ReviewMonitorCodexSidebarSnapshot(sections: [])) return } @@ -411,10 +413,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard self?.codexSidebarLibrary === library else { return } + self?.applyCodexSidebarSnapshot(library.snapshot) self?.codexSidebarFetchTask = nil } } + private func applyCodexSidebarSnapshot(_ snapshot: ReviewMonitorCodexSidebarSnapshot) { + codexSidebarOutlineTree.apply(snapshot: snapshot) + } + private func bindSidebarStoreTopologyObservation( filter: SidebarJobFilter, animatedInitialDelivery: Bool @@ -1996,6 +2003,14 @@ extension ReviewMonitorSidebarViewController { codexSidebarLibrary?.snapshot } + var codexSidebarRootTitlesForTesting: [String] { + codexSidebarOutlineTree.roots.map(\.title) + } + + func codexSidebarNodeTitleForTesting(rowID: ReviewMonitorCodexSidebarRowID) -> String? { + codexSidebarOutlineTree.node(rowID: rowID)?.title + } + var sidebarFullReloadCountForTesting: Int { fullReloadCountForTesting } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 9ef9ca04..0b0aed0f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -184,12 +184,13 @@ struct ReviewMonitorCodexSidebarLibraryTests { @Test func sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() 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-app", - workspace: try makeGitRepository(), + workspace: repo, name: "App review", updatedAt: Date(timeIntervalSince1970: 5_000) ), @@ -211,6 +212,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { .chat(id: CodexThreadID(rawValue: "thread-app"))? .title == "App review" } + try await waitForCondition { + sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent] + && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == "App review" + } } } From 3b846a5254e01832bea575175d58af97df26e221 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:57:47 +0900 Subject: [PATCH 097/359] refactor(review-ui): render Codex sidebar rows --- .../ReviewMonitorSidebarViewController.swift | 118 ++++++++++++++++-- ...eviewMonitorCodexSidebarLibraryTests.swift | 10 +- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 6b72d91c..6be1316b 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -358,7 +358,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarSelection: sidebarSelection, serverState: serverState, hasReviewJobs: hasReviewJobs, - hasWorkspaces: hasWorkspaces + hasWorkspaces: hasWorkspaces, + hasCodexSidebarContent: self.hasCodexSidebarContent )) } @@ -419,7 +420,31 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarSnapshot(_ snapshot: ReviewMonitorCodexSidebarSnapshot) { + let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline codexSidebarOutlineTree.apply(snapshot: snapshot) + applySidebarKind(sidebarKind) + if wasUsingCodexSidebarOutline || isUsingCodexSidebarOutline { + reloadCodexSidebarOutline() + } + } + + private func reloadCodexSidebarOutline() { + isReconcilingSelection = true + outlineView.reloadData() + expandCodexSidebarNodes(codexSidebarOutlineTree.roots) + if isUsingCodexSidebarOutline { + reconcileOutlineSelection() + } else if outlineView.selectedRow != -1 { + outlineView.deselectAll(nil) + } + isReconcilingSelection = false + } + + private func expandCodexSidebarNodes(_ nodes: [ReviewMonitorCodexSidebarOutlineNode]) { + for node in nodes where node.isExpandable { + outlineView.expandItem(node) + expandCodexSidebarNodes(node.children) + } } private func bindSidebarStoreTopologyObservation( @@ -449,7 +474,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarSelection: uiState.sidebarSelection, serverState: store.serverState, hasReviewJobs: store.hasReviewJobs, - hasWorkspaces: store.workspaces.isEmpty == false + hasWorkspaces: store.workspaces.isEmpty == false, + hasCodexSidebarContent: hasCodexSidebarContent ) } @@ -457,7 +483,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarSelection: SidebarPickerSelection?, serverState: CodexReviewServerState, hasReviewJobs: Bool, - hasWorkspaces: Bool + hasWorkspaces: Bool, + hasCodexSidebarContent: Bool ) -> SidebarKind { if sidebarSelection == .account { return .accountList @@ -468,10 +495,18 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .running: break } - let hasSidebarContent = hasReviewJobs || hasWorkspaces + let hasSidebarContent = hasReviewJobs || hasWorkspaces || hasCodexSidebarContent return hasSidebarContent ? .jobList : .empty } + private var hasCodexSidebarContent: Bool { + codexSidebarOutlineTree.roots.isEmpty == false + } + + private var isUsingCodexSidebarOutline: Bool { + hasCodexSidebarContent + } + private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] { store.orderedWorkspaces.map { workspace in SidebarWorkspaceTopology( @@ -1132,8 +1167,33 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD item as? CodexReviewJob } + private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { + item as? ReviewMonitorCodexSidebarOutlineNode + } + private func shouldAllowSelection(of item: Any?) -> Bool { - workspaceSection(from: item) != nil || job(from: item) != nil + guard codexSidebarNode(from: item) == nil else { + return false + } + return workspaceSection(from: item) != nil || job(from: item) != nil + } + + private func configureCodexSidebarCell( + _ view: ReviewMonitorWorkspaceCellView, + node: ReviewMonitorCodexSidebarOutlineNode + ) { + switch node.item { + case .section(let section): + view.configure(title: section.title, toolTip: section.title, systemSymbolName: "folder") + case .workspace(let workspace): + view.configure(title: workspace.title, toolTip: workspace.cwd, systemSymbolName: "folder") + case .chat(let chat): + view.configure( + title: chat.title, + toolTip: chat.preview ?? chat.title, + systemSymbolName: "bubble.left" + ) + } } private func workspaces() -> [CodexReviewWorkspace] { @@ -1790,6 +1850,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if isUsingCodexSidebarOutline { + guard let item else { + return codexSidebarOutlineTree.roots.count + } + if let node = codexSidebarNode(from: item) { + return node.children.count + } + return 0 + } guard let item else { return currentRootTopologies.count } @@ -1800,6 +1869,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if isUsingCodexSidebarOutline { + guard let item else { + return codexSidebarOutlineTree.roots[index] + } + if let node = codexSidebarNode(from: item) { + return node.children[index] + } + fatalError("Unsupported Codex sidebar item.") + } guard let item else { return currentRootTopologies[index].item } @@ -1810,7 +1888,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { - workspaceSection(from: item) != nil + if let node = codexSidebarNode(from: item) { + return node.isExpandable + } + return workspaceSection(from: item) != nil } func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { @@ -1818,6 +1899,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + if codexSidebarNode(from: item) != nil { + return rowHeights.workspace + } if workspaceSection(from: item) != nil { return rowHeights.workspace } @@ -1920,6 +2004,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + if codexSidebarNode(from: item) != nil { + return ReviewMonitorWorkspaceRowView() + } if workspaceSection(from: item) != nil { return ReviewMonitorWorkspaceRowView() } @@ -1934,6 +2021,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD viewFor tableColumn: NSTableColumn?, item: Any ) -> NSView? { + if let node = codexSidebarNode(from: item) { + let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView) + ?? ReviewMonitorWorkspaceCellView() + view.identifier = Identifier.workspaceCell + view.objectValue = node + configureCodexSidebarCell(view, node: node) + return view + } + if let section = workspaceSection(from: item) { let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView) ?? ReviewMonitorWorkspaceCellView() @@ -2007,6 +2103,12 @@ extension ReviewMonitorSidebarViewController { codexSidebarOutlineTree.roots.map(\.title) } + var displayedCodexSidebarTitlesForTesting: [String] { + (0.. String? { codexSidebarOutlineTree.node(rowID: rowID)?.title } @@ -2886,8 +2988,8 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { configure(title: workspace.displayTitle, toolTip: workspace.cwd) } - func configure(title: String, toolTip: String) { - iconView.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil) + func configure(title: String, toolTip: String, systemSymbolName: String = "folder") { + iconView.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) titleLabel.stringValue = title self.toolTip = toolTip } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 0b0aed0f..d36e63c0 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -198,7 +198,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting( + serverState: .running, + workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-job-store")] + ) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -216,6 +219,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent] && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == "App review" } + #expect(sidebar.displayedCodexSidebarTitlesForTesting == [ + repo.lastPathComponent, + repo.lastPathComponent, + "App review", + ]) } } From 32477e44fb4577e7855efc97202877316517f4f2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:05:01 +0900 Subject: [PATCH 098/359] refactor(review-ui): select Codex sidebar models --- ...ReviewMonitorTransportViewController.swift | 55 ++++++++- Sources/ReviewUI/ReviewMonitorSelection.swift | 6 + .../ReviewMonitorSplitViewController.swift | 10 ++ .../ReviewMonitorSidebarViewController.swift | 105 +++++++++++++++++- ...eviewMonitorCodexSidebarLibraryTests.swift | 10 +- 5 files changed, 179 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 3e18ea39..aecad784 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -131,6 +131,8 @@ final class ReviewMonitorTransportViewController: NSViewController { return boundJob !== selectedJob || displayedSelection != selection?.id case .workspaceSection(let selectedSection): return boundWorkspaceSection != selectedSection || displayedSelection != selection?.id + case .workspace, .chat: + return displayedSelection != selection?.id case nil: return displayedSelection != nil } @@ -152,6 +154,23 @@ final class ReviewMonitorTransportViewController: NSViewController { logScrollView.isHidden = true displayedSelection = selection?.id + case .workspace: + clearDisplayedJob() + clearDisplayedWorkspace() + displayPlaceholder(.noFindings) + logScrollView.isHidden = true + workspaceFindingsView.isHidden = true + displayedSelection = selection?.id + + case .chat: + clearDisplayedJob() + clearDisplayedWorkspace() + hidePlaceholder() + logScrollView.clear() + logScrollView.isHidden = false + workspaceFindingsView.isHidden = true + displayedSelection = selection?.id + case nil: clearDisplayedJob() clearDisplayedWorkspace() @@ -446,7 +465,9 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollView.performDisplayedTextFinderAction(sender) case .workspaceSection: return workspaceFindingsView.performDisplayedTextFinderAction(sender) - case .workspace, .chat, nil: + case .chat: + return logScrollView.performDisplayedTextFinderAction(sender) + case .workspace, nil: return false } } @@ -457,7 +478,9 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollView.validateDisplayedTextFinderAction(item) case .workspaceSection: return workspaceFindingsView.validateDisplayedTextFinderAction(item) - case .workspace, .chat, nil: + case .chat: + return logScrollView.validateDisplayedTextFinderAction(item) + case .workspace, nil: return false } } @@ -483,6 +506,8 @@ extension ReviewMonitorTransportViewController { enum DisplayedSelectionForTesting: Sendable, Equatable { case job(String) case workspaceSection(String) + case workspace(String) + case chat(String) } struct RenderedStateForTesting: Sendable, Equatable { @@ -528,6 +553,8 @@ extension ReviewMonitorTransportViewController { return selectedJobObservation ?? selectionObservation case .workspaceSection: return selectedWorkspaceFindingsObservation ?? selectionObservation + case .workspace, .chat: + return selectionObservation case nil: return selectionObservation } @@ -907,6 +934,20 @@ extension ReviewMonitorTransportViewController { log: "", isShowingEmptyState: false ) + case .workspace: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: false + ) + case .chat: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: false + ) case nil: .init( title: nil, @@ -930,7 +971,11 @@ extension ReviewMonitorTransportViewController { .job(id) case .workspaceSection(let id): .workspaceSection(id) - case .workspace, .chat, nil: + case .workspace(let id): + .workspace(id.rawValue) + case .chat(let id): + .chat(id.rawValue) + case nil: nil } } @@ -941,6 +986,10 @@ extension ReviewMonitorTransportViewController { .job(job.id) case .workspaceSection(let section): .workspaceSection(section.id) + case .workspace(let workspace): + .workspace(workspace.id.rawValue) + case .chat(let chat): + .chat(chat.id.rawValue) case nil: nil } diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 7e554780..8e866d10 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -11,12 +11,18 @@ enum ReviewMonitorSelectionID: Hashable, Sendable { @MainActor enum ReviewMonitorSelection { case workspaceSection(ReviewMonitorWorkspaceSectionSelection) + case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) + case chat(ReviewMonitorCodexSidebarSnapshot.Chat) case job(CodexReviewJob) var id: ReviewMonitorSelectionID { switch self { case .workspaceSection(let section): return .workspaceSection(section.id) + case .workspace(let workspace): + return .workspace(workspace.id) + case .chat(let chat): + return .chat(chat.id) case .job(let job): return .job(job.id) } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 5e555235..cf26d61d 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -241,6 +241,16 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe title: section.title, subtitle: section.subtitle ) + case .workspace(let workspace): + WindowTitlePresentation( + title: workspace.title, + subtitle: workspace.cwd + ) + case .chat(let chat): + WindowTitlePresentation( + title: chat.title, + subtitle: chat.workspaceCWD ?? "" + ) case .job(let job): WindowTitlePresentation( title: job.targetSummary, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 6be1316b..96fc0393 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -777,6 +777,42 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + case .workspace(let selectedWorkspace): + guard let currentWorkspace = codexWorkspaceSelection(id: selectedWorkspace.id), + let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspace.id)) + else { + uiState.selection = nil + outlineView.deselectAll(nil) + return + } + + if currentWorkspace != selectedWorkspace { + uiState.selection = .workspace(currentWorkspace) + } + + guard outlineView.selectedRow != row else { + return + } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + + case .chat(let selectedChat): + guard let currentChat = codexChatSelection(id: selectedChat.id), + let row = row(forCodexSidebarSelectionID: .chat(selectedChat.id)) + else { + uiState.selection = nil + outlineView.deselectAll(nil) + return + } + + if currentChat != selectedChat { + uiState.selection = .chat(currentChat) + } + + 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 @@ -811,7 +847,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return } let item = outlineView.item(atRow: outlineView.selectedRow) - if let section = workspaceSection(from: item) { + if let node = codexSidebarNode(from: item) { + switch node.item { + case .section(let section): + uiState.selection = .workspaceSection(section.selection) + case .workspace(let workspace): + uiState.selection = .workspace(workspace) + case .chat(let chat): + uiState.selection = .chat(chat) + } + } else if let section = workspaceSection(from: item) { uiState.selection = .workspaceSection(section.selection) } else if let job = job(from: item) { uiState.selection = .job(job) @@ -1172,8 +1217,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func shouldAllowSelection(of item: Any?) -> Bool { - guard codexSidebarNode(from: item) == nil else { - return false + if codexSidebarNode(from: item) != nil { + return true } return workspaceSection(from: item) != nil || job(from: item) != nil } @@ -1259,6 +1304,25 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return row == -1 ? nil : row } + private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { + let rowID: ReviewMonitorCodexSidebarRowID + switch selectionID { + case .workspace(let id): + rowID = .workspace(id) + case .chat(let id): + rowID = .chat(id) + case .workspaceSection(let id): + rowID = .section(id) + case .job: + return nil + } + guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { + return nil + } + let row = outlineView.row(forItem: node) + return row == -1 ? nil : row + } + private func containsJob(id: String) -> Bool { job(withID: id) != nil } @@ -1284,6 +1348,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD || section.workspaceCWDs.contains { cwd in workspaces.contains(where: { $0.cwd == cwd }) } + case .workspace(let workspace): + return codexWorkspaceSelection(id: workspace.id) != nil + case .chat(let chat): + return codexChatSelection(id: chat.id) != nil case .job: guard case .job(let id) = selection.id else { return false @@ -1305,6 +1373,26 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } + private func codexWorkspaceSelection( + id: CodexWorkspaceID + ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { + guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), + case .workspace(let workspace) = node.item + else { + return nil + } + return workspace + } + + private func codexChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), + case .chat(let chat) = node.item + else { + return nil + } + return chat + } + private func workspace(containing job: CodexReviewJob) -> CodexReviewWorkspace? { store.workspace(containing: job) } @@ -2113,6 +2201,17 @@ extension ReviewMonitorSidebarViewController { codexSidebarOutlineTree.node(rowID: rowID)?.title } + 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.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + var sidebarFullReloadCountForTesting: Int { fullReloadCountForTesting } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index d36e63c0..d535f8d0 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -202,9 +202,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { serverState: .running, workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-job-store")] ) + let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth), + uiState: uiState, modelContext: context ) viewController.loadViewIfNeeded() @@ -224,6 +225,13 @@ struct ReviewMonitorCodexSidebarLibraryTests { repo.lastPathComponent, "App review", ]) + sidebar.selectCodexSidebarRowForTesting(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) + guard case .chat(let selectedChat) = uiState.selection else { + Issue.record("Expected selecting a Codex sidebar chat row to select the chat.") + return + } + #expect(selectedChat.id == CodexThreadID(rawValue: "thread-app")) + #expect(selectedChat.title == "App review") } } From cfcb640df71a2d15b5aeb9c4fde65f0fe49bd310 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:17:13 +0900 Subject: [PATCH 099/359] refactor(review-ui): bind selected Codex chats --- ...ReviewMonitorTransportViewController.swift | 83 ++++++++++++++++++- .../ReviewMonitorSelectedReviewChat.swift | 80 +++++++++++++++--- ...SelectedReviewChatDocumentProjection.swift | 62 +++++++++++--- ...ReviewMonitorSelectedReviewChatTests.swift | 74 +++++++++++++++++ 4 files changed, 273 insertions(+), 26 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index aecad784..e96929d9 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -16,8 +16,10 @@ final class ReviewMonitorTransportViewController: NSViewController { private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? private var selectedJobObservation: PortableObservationTracking.Token? + private var selectedChatObservation: PortableObservationTracking.Token? private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? private var boundJob: CodexReviewJob? + private var boundChatID: CodexThreadID? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? private var displayedSelection: ReviewMonitorSelectionID? private var logScrollTargetsByJobID: [String: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] @@ -57,6 +59,7 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() selectedJobObservation?.cancel() + selectedChatObservation?.cancel() selectedWorkspaceFindingsObservation?.cancel() logRenderTask?.cancel() } @@ -165,8 +168,10 @@ final class ReviewMonitorTransportViewController: NSViewController { case .chat: clearDisplayedJob() clearDisplayedWorkspace() + if case .chat(let selectedChat) = selection { + displayChat(selectedChat) + } hidePlaceholder() - logScrollView.clear() logScrollView.isHidden = false workspaceFindingsView.isHidden = true displayedSelection = selection?.id @@ -189,6 +194,9 @@ final class ReviewMonitorTransportViewController: NSViewController { } selectedJobObservation?.cancel() selectedJobObservation = nil + selectedChatObservation?.cancel() + selectedChatObservation = nil + boundChatID = nil resetLogRenderer() boundJob = selectedJob selectedReviewChat.bind(to: selectedJob.reviewChatIdentity) @@ -214,12 +222,47 @@ final class ReviewMonitorTransportViewController: NSViewController { } } + private func displayChat(_ selectedChat: ReviewMonitorCodexSidebarSnapshot.Chat) { + if boundChatID != selectedChat.id { + logScrollView.resetFindStateForContentReuse() + } + selectedChatObservation?.cancel() + selectedChatObservation = nil + resetLogRenderer() + boundChatID = selectedChat.id + selectedReviewChat.bind(toChatID: selectedChat.id) + + selectedChatObservation = withPortableContinuousObservation { [weak self] event in + let eventKind = event.kind + guard let self, + self.boundChatID == selectedChat.id + else { + return + } + self.selectedReviewChat.bind(toChatID: selectedChat.id) + guard let timelineDocument = self.selectedReviewChat.timelineDocument else { + return + } + self.renderSelectedChatLog( + timelineDocument: timelineDocument, + chatID: selectedChat.id, + restorationTarget: eventKind == .initial + ? .bottom + : self.logScrollView.currentScrollRestorationTarget, + allowIncrementalUpdate: eventKind != .initial + ) + } + } + private func clearDisplayedJob() { cacheBoundJobScrollTarget() selectedJobObservation?.cancel() selectedJobObservation = nil + selectedChatObservation?.cancel() + selectedChatObservation = nil boundJob = nil - selectedReviewChat.bind(to: nil) + boundChatID = nil + selectedReviewChat.unbind() resetLogRenderer() logScrollView.resetFindStateForContentReuse() logScrollView.clear() @@ -428,6 +471,38 @@ final class ReviewMonitorTransportViewController: NSViewController { return true } + @discardableResult + private func renderSelectedChatLog( + timelineDocument: ReviewTimelineDocument, + chatID: CodexThreadID, + restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, + allowIncrementalUpdate: Bool + ) -> Bool { + logRenderGeneration &+= 1 + let generation = logRenderGeneration + let renderer = logRenderer + logRenderTask?.cancel() + logRenderTask = Task { @MainActor [weak self] in + let renderedDocument = await renderer.render(timelineDocument: timelineDocument) + guard Task.isCancelled == false, + let self, + self.logRenderGeneration == generation, + self.boundChatID == chatID + 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 timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { if let document = selectedReviewChat.timelineDocument { return document @@ -523,6 +598,10 @@ extension ReviewMonitorTransportViewController { selectedJobObservation } + var selectedChatObservationForTesting: PortableObservationTracking.Token? { + selectedChatObservation + } + var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { selectedWorkspaceFindingsObservation } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift index 86fb5e70..65b0a3da 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift @@ -7,7 +7,36 @@ import ReviewMonitorRendering @MainActor @Observable final class ReviewMonitorSelectedReviewChat { + private enum BindingTarget: Equatable { + case review(CodexReviewIdentity) + case chat(CodexThreadID) + + var reviewIdentity: CodexReviewIdentity? { + guard case .review(let identity) = self else { + return nil + } + return identity + } + + var chatID: CodexThreadID { + switch self { + case .review(let identity): + identity.activeTurnThreadID + case .chat(let id): + id + } + } + + var activeTurnID: CodexTurnID? { + guard case .review(let identity) = self else { + return nil + } + return identity.turnID + } + } + private(set) var identity: CodexReviewIdentity? + private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? private(set) var timelineDocument: ReviewTimelineDocument? var phase: CodexDataPhase { @@ -22,7 +51,9 @@ final class ReviewMonitorSelectedReviewChat { @ObservationIgnored private weak var boundModelContext: CodexModelContext? @ObservationIgnored - private var boundIdentity: CodexReviewIdentity? + private var boundTarget: BindingTarget? + @ObservationIgnored + private var target: BindingTarget? @ObservationIgnored private var observation: CodexChatObservation? @ObservationIgnored @@ -43,7 +74,17 @@ final class ReviewMonitorSelectedReviewChat { } func bind(to identity: CodexReviewIdentity?) { - boundIdentity = identity + boundTarget = identity.map(BindingTarget.review) + refreshBinding() + } + + func bind(toChatID chatID: CodexThreadID?) { + boundTarget = chatID.map(BindingTarget.chat) + refreshBinding() + } + + func unbind() { + boundTarget = nil refreshBinding() } @@ -58,31 +99,33 @@ final class ReviewMonitorSelectedReviewChat { } private func refreshBinding() { - let nextIdentity = boundIdentity + let nextTarget = boundTarget let nextModelContext = modelSource?.modelContext - guard nextIdentity != identity || nextModelContext !== boundModelContext else { + guard nextTarget != target || nextModelContext !== boundModelContext else { return } cancelObservation() - identity = nextIdentity + target = nextTarget + identity = nextTarget?.reviewIdentity + chatID = nextTarget?.chatID chat = nil timelineDocument = nil documentProjection.reset() boundModelContext = nextModelContext - guard let nextIdentity, let modelContext = nextModelContext else { + guard let nextTarget, let modelContext = nextModelContext else { return } - let nextChat = modelContext.model(for: nextIdentity) + let nextChat = modelContext.model(for: nextTarget.chatID) chat = nextChat - observationTask = Task { @MainActor [weak self, nextChat, nextIdentity, modelContext] in + observationTask = Task { @MainActor [weak self, nextChat, nextTarget, modelContext] in do { - let observation = try await modelContext.observe(nextIdentity) + let observation = try await Self.observe(nextTarget, modelContext: modelContext, chat: nextChat) guard Task.isCancelled == false, let self, - self.identity == nextIdentity, + self.target == nextTarget, self.chat === nextChat else { observation.cancel() @@ -91,14 +134,14 @@ final class ReviewMonitorSelectedReviewChat { self.observation = observation for await change in observation.changes { guard Task.isCancelled == false, - self.identity == nextIdentity, + self.target == nextTarget, self.chat === nextChat else { break } self.timelineDocument = self.documentProjection.apply( change, - activeTurnID: nextIdentity.turnID, + activeTurnID: nextTarget.activeTurnID, chatCreatedAt: nextChat.createdAt, chatUpdatedAt: nextChat.updatedAt ) @@ -115,4 +158,17 @@ final class ReviewMonitorSelectedReviewChat { observation?.cancel() observation = nil } + + private static func observe( + _ target: BindingTarget, + modelContext: CodexModelContext, + chat: CodexChat + ) async throws -> CodexChatObservation { + switch target { + case .review(let identity): + try await modelContext.observe(identity) + case .chat: + try await chat.observe() + } + } } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift index ba6774f7..48933c0d 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift @@ -20,6 +20,7 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { } private var turnsByID: [CodexTurnID: CodexChatTurnStateSnapshot] = [:] + private var orderedTurnIDs: [CodexTurnID] = [] private var itemsByKey: [ItemKey: CodexChatItemSnapshot] = [:] private var orderedItemKeys: [ItemKey] = [] private var document: ReviewTimelineDocument? @@ -27,6 +28,7 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { mutating func reset() { turnsByID.removeAll(keepingCapacity: true) + orderedTurnIDs.removeAll(keepingCapacity: true) itemsByKey.removeAll(keepingCapacity: true) orderedItemKeys.removeAll(keepingCapacity: true) document = nil @@ -35,62 +37,83 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { mutating func apply( _ change: CodexChatChange, - activeTurnID: CodexTurnID, + activeTurnID: CodexTurnID?, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewTimelineDocument? { switch change { case .snapshot(let snapshot): apply(snapshot) + guard let effectiveTurnID = effectiveTurnID(activeTurnID: activeTurnID) else { + document = nil + return nil + } return rebuildDocument( - activeTurnID: activeTurnID, + activeTurnID: effectiveTurnID, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) case .turnInserted(let turn), .turnUpdated(let turn): - turnsByID[turn.id] = turn - guard turn.id == activeTurnID else { + upsert(turn) + guard let effectiveTurnID = effectiveTurnID(activeTurnID: activeTurnID), + turn.id == effectiveTurnID + else { return document } return rebuildDocument( - activeTurnID: activeTurnID, + activeTurnID: effectiveTurnID, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) case .itemInserted(let item), .itemUpdated(let item): upsert(item) - guard item.turnID == activeTurnID else { + guard let effectiveTurnID = effectiveTurnID( + activeTurnID: activeTurnID, + preferredTurnID: item.turnID + ), + item.turnID == effectiveTurnID + else { return document } return rebuildOrReplaceBlock( for: item, - activeTurnID: activeTurnID, + activeTurnID: effectiveTurnID, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) case .itemTextAppended(_, _, _, let item): upsert(item) - guard item.turnID == activeTurnID else { + guard let effectiveTurnID = effectiveTurnID( + activeTurnID: activeTurnID, + preferredTurnID: item.turnID + ), + item.turnID == effectiveTurnID + else { return document } return replaceBlock( for: item, - activeTurnID: activeTurnID, + activeTurnID: effectiveTurnID, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) case .itemRemoved(let id, let turnID): removeItem(id: id, turnID: turnID) - guard turnID == activeTurnID else { + guard let effectiveTurnID = effectiveTurnID( + activeTurnID: activeTurnID, + preferredTurnID: turnID + ), + turnID == effectiveTurnID + else { return document } return removeBlock( - id: .init(rawValue: "\(activeTurnID.rawValue):\(id)"), - activeTurnID: activeTurnID + id: .init(rawValue: "\(effectiveTurnID.rawValue):\(id)"), + activeTurnID: effectiveTurnID ) case .phaseChanged: @@ -100,6 +123,7 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { private mutating func apply(_ snapshot: CodexChatSnapshot) { turnsByID = Dictionary(uniqueKeysWithValues: snapshot.turns.map { ($0.id, $0) }) + orderedTurnIDs = snapshot.turns.map(\.id) itemsByKey.removeAll(keepingCapacity: true) orderedItemKeys.removeAll(keepingCapacity: true) for item in snapshot.items { @@ -107,6 +131,13 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { } } + private mutating func upsert(_ turn: CodexChatTurnStateSnapshot) { + if turnsByID[turn.id] == nil { + orderedTurnIDs.append(turn.id) + } + turnsByID[turn.id] = turn + } + private mutating func upsert(_ item: CodexChatItemSnapshot) { let key = ItemKey(item) if itemsByKey[key] == nil { @@ -121,6 +152,13 @@ struct ReviewMonitorSelectedReviewChatDocumentProjection { orderedItemKeys.removeAll { $0 == key } } + private func effectiveTurnID( + activeTurnID: CodexTurnID?, + preferredTurnID: CodexTurnID? = nil + ) -> CodexTurnID? { + activeTurnID ?? orderedTurnIDs.last ?? preferredTurnID + } + private mutating func rebuildOrReplaceBlock( for item: CodexChatItemSnapshot, activeTurnID: CodexTurnID, diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift index edd8d774..738f49f5 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift @@ -280,6 +280,80 @@ struct ReviewMonitorSelectedReviewChatTests { #expect(updatedSnapshot.log.contains("Legacy fallback") == false) } + @Test func selectedCodexChatRendersThreadAndLiveUpdates() 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( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + + uiState.selection = .chat(.init( + rowID: .chat(CodexThreadID(rawValue: "chat-thread")), + id: CodexThreadID(rawValue: "chat-thread"), + title: "Generic chat", + preview: nil, + workspaceCWD: "/tmp/project", + updatedAt: nil + )) + + _ = try await awaitTransportRender(transport) { snapshot in + snapshot.log.contains("Generic chat snapshot") + } + #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) + #expect(transport.selectedReviewChatIdentityForTesting == nil) + #expect(transport.selectedReviewChatIDForTesting == "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) { 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 makeRunningReviewJob( sourceThreadID: String?, reviewThreadID: String?, From 7ee232b782b1f962b146f5334fb9ad2726d2d40f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:21:41 +0900 Subject: [PATCH 100/359] refactor(review-ui): rename selected Codex chat owner --- ...ReviewMonitorTransportViewController.swift | 56 +++++++++---------- ...t => ReviewMonitorSelectedCodexChat.swift} | 4 +- ...SelectedCodexChatDocumentProjection.swift} | 2 +- ...ReviewMonitorSelectedCodexChatTests.swift} | 44 +++++++-------- 4 files changed, 53 insertions(+), 53 deletions(-) rename Sources/ReviewUI/{ReviewMonitorSelectedReviewChat.swift => ReviewMonitorSelectedCodexChat.swift} (97%) rename Sources/ReviewUI/{ReviewMonitorSelectedReviewChatDocumentProjection.swift => ReviewMonitorSelectedCodexChatDocumentProjection.swift} (99%) rename Tests/ReviewUITests/{ReviewMonitorSelectedReviewChatTests.swift => ReviewMonitorSelectedCodexChatTests.swift} (88%) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index e96929d9..db2f0a05 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -8,7 +8,7 @@ import ReviewMonitorRendering final class ReviewMonitorTransportViewController: NSViewController { private let uiState: ReviewMonitorUIState private let store: CodexReviewStore - private let selectedReviewChat: ReviewMonitorSelectedReviewChat + private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() @@ -26,7 +26,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private var logRenderTask: Task? private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 - private var hasAppliedBoundJobLog = false + private var hasAppliedBoundLog = false convenience init( store: CodexReviewStore, @@ -47,7 +47,7 @@ final class ReviewMonitorTransportViewController: NSViewController { ) { self.store = store self.uiState = uiState - self.selectedReviewChat = ReviewMonitorSelectedReviewChat(modelSource: codexModelSource) + self.selectedCodexChat = ReviewMonitorSelectedCodexChat(modelSource: codexModelSource) super.init(nibName: nil, bundle: nil) } @@ -152,13 +152,13 @@ final class ReviewMonitorTransportViewController: NSViewController { displayedSelection = selection?.id case .workspaceSection(let selectedSection): - clearDisplayedJob() + clearDisplayedLogSelection() displayWorkspaceSection(selectedSection) logScrollView.isHidden = true displayedSelection = selection?.id case .workspace: - clearDisplayedJob() + clearDisplayedLogSelection() clearDisplayedWorkspace() displayPlaceholder(.noFindings) logScrollView.isHidden = true @@ -166,7 +166,7 @@ final class ReviewMonitorTransportViewController: NSViewController { displayedSelection = selection?.id case .chat: - clearDisplayedJob() + clearDisplayedLogSelection() clearDisplayedWorkspace() if case .chat(let selectedChat) = selection { displayChat(selectedChat) @@ -177,7 +177,7 @@ final class ReviewMonitorTransportViewController: NSViewController { displayedSelection = selection?.id case nil: - clearDisplayedJob() + clearDisplayedLogSelection() clearDisplayedWorkspace() displayPlaceholder(.noSelection) logScrollView.isHidden = true @@ -199,7 +199,7 @@ final class ReviewMonitorTransportViewController: NSViewController { boundChatID = nil resetLogRenderer() boundJob = selectedJob - selectedReviewChat.bind(to: selectedJob.reviewChatIdentity) + selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) selectedJobObservation = withPortableContinuousObservation { [weak self] event in let eventKind = event.kind @@ -208,7 +208,7 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - self.selectedReviewChat.bind(to: selectedJob.reviewChatIdentity) + self.selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) let timeline = selectedJob.timeline _ = timeline.revision let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) @@ -230,7 +230,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedChatObservation = nil resetLogRenderer() boundChatID = selectedChat.id - selectedReviewChat.bind(toChatID: selectedChat.id) + selectedCodexChat.bind(toChatID: selectedChat.id) selectedChatObservation = withPortableContinuousObservation { [weak self] event in let eventKind = event.kind @@ -239,8 +239,8 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - self.selectedReviewChat.bind(toChatID: selectedChat.id) - guard let timelineDocument = self.selectedReviewChat.timelineDocument else { + self.selectedCodexChat.bind(toChatID: selectedChat.id) + guard let timelineDocument = self.selectedCodexChat.timelineDocument else { return } self.renderSelectedChatLog( @@ -254,7 +254,7 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func clearDisplayedJob() { + private func clearDisplayedLogSelection() { cacheBoundJobScrollTarget() selectedJobObservation?.cancel() selectedJobObservation = nil @@ -262,7 +262,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedChatObservation = nil boundJob = nil boundChatID = nil - selectedReviewChat.unbind() + selectedCodexChat.unbind() resetLogRenderer() logScrollView.resetFindStateForContentReuse() logScrollView.clear() @@ -463,10 +463,10 @@ final class ReviewMonitorTransportViewController: NSViewController { sourceDocument: renderedDocument.source, displayDocument: renderedDocument.display, restoring: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundJobLog + allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundLog ) self.appliedLogRenderGeneration = generation - self.hasAppliedBoundJobLog = true + self.hasAppliedBoundLog = true } return true } @@ -495,16 +495,16 @@ final class ReviewMonitorTransportViewController: NSViewController { sourceDocument: renderedDocument.source, displayDocument: renderedDocument.display, restoring: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundJobLog + allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundLog ) self.appliedLogRenderGeneration = generation - self.hasAppliedBoundJobLog = true + self.hasAppliedBoundLog = true } return true } private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { - if let document = selectedReviewChat.timelineDocument { + if let document = selectedCodexChat.timelineDocument { return document } @@ -523,7 +523,7 @@ final class ReviewMonitorTransportViewController: NSViewController { logRenderTask = nil logRenderGeneration &+= 1 appliedLogRenderGeneration = logRenderGeneration - hasAppliedBoundJobLog = false + hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() } @@ -606,20 +606,20 @@ extension ReviewMonitorTransportViewController { selectedWorkspaceFindingsObservation } - var selectedReviewChatIdentityForTesting: CodexReviewIdentity? { - selectedReviewChat.identity + var selectedCodexChatReviewIdentityForTesting: CodexReviewIdentity? { + selectedCodexChat.identity } - var selectedReviewChatIDForTesting: String? { - selectedReviewChat.chat?.id.rawValue + var selectedCodexChatIDForTesting: String? { + selectedCodexChat.chat?.id.rawValue } - var selectedReviewChatPhaseForTesting: CodexDataPhase { - selectedReviewChat.phase + var selectedCodexChatPhaseForTesting: CodexDataPhase { + selectedCodexChat.phase } - var selectedReviewChatItemTextsForTesting: [String] { - selectedReviewChat.chat?.items.compactMap(\.text) ?? [] + var selectedCodexChatItemTextsForTesting: [String] { + selectedCodexChat.chat?.items.compactMap(\.text) ?? [] } var observationForExpectedRenderedStateForTesting: PortableObservationTracking.Token? { diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift similarity index 97% rename from Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift rename to Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 65b0a3da..3f48daa7 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -6,7 +6,7 @@ import ReviewMonitorRendering @MainActor @Observable -final class ReviewMonitorSelectedReviewChat { +final class ReviewMonitorSelectedCodexChat { private enum BindingTarget: Equatable { case review(CodexReviewIdentity) case chat(CodexThreadID) @@ -59,7 +59,7 @@ final class ReviewMonitorSelectedReviewChat { @ObservationIgnored private var observationTask: Task? @ObservationIgnored - private var documentProjection = ReviewMonitorSelectedReviewChatDocumentProjection() + private var documentProjection = ReviewMonitorSelectedCodexChatDocumentProjection() @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? diff --git a/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift similarity index 99% rename from Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift rename to Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift index 48933c0d..cbcb3938 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedReviewChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift @@ -3,7 +3,7 @@ import Foundation import ReviewMonitorRendering @MainActor -struct ReviewMonitorSelectedReviewChatDocumentProjection { +struct ReviewMonitorSelectedCodexChatDocumentProjection { private struct ItemKey: Hashable { var id: String var turnID: CodexTurnID? diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift similarity index 88% rename from Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift rename to Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 738f49f5..2c45688d 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedReviewChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -4,9 +4,9 @@ import Testing @_spi(Testing) @testable import CodexReviewKit @testable import ReviewUI -@Suite("ReviewMonitor selected review chat", .serialized) +@Suite("ReviewMonitor selected Codex chat", .serialized) @MainActor -struct ReviewMonitorSelectedReviewChatTests { +struct ReviewMonitorSelectedCodexChatTests { @Test func selectedReviewJobObservesActiveCodexChat() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext @@ -52,17 +52,17 @@ struct ReviewMonitorSelectedReviewChatTests { uiState.selection = .job(job) try await waitForCondition { - transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loading - && transport.selectedReviewChatItemTextsForTesting == ["Review snapshot"] + transport.selectedCodexChatIDForTesting == "review-thread" + && transport.selectedCodexChatPhaseForTesting == .loading + && transport.selectedCodexChatItemTextsForTesting == ["Review snapshot"] } #expect( - transport.selectedReviewChatIdentityForTesting?.activeTurnThreadID == "review-thread" + transport.selectedCodexChatReviewIdentityForTesting?.activeTurnThreadID == "review-thread" ) #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) } - @Test func clearingSelectionDetachesSelectedReviewChat() async throws { + @Test func clearingSelectionDetachesSelectedCodexChat() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -84,14 +84,14 @@ struct ReviewMonitorSelectedReviewChatTests { turnID: "turn-1" )) try await waitForCondition { - transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loaded + transport.selectedCodexChatIDForTesting == "review-thread" + && transport.selectedCodexChatPhaseForTesting == .loaded } uiState.selection = nil try await waitForCondition { - transport.selectedReviewChatIDForTesting == nil && transport.selectedReviewChatIdentityForTesting == nil - && transport.selectedReviewChatPhaseForTesting == .idle + transport.selectedCodexChatIDForTesting == nil && transport.selectedCodexChatReviewIdentityForTesting == nil + && transport.selectedCodexChatPhaseForTesting == .idle } } @@ -139,16 +139,16 @@ struct ReviewMonitorSelectedReviewChatTests { )) try await waitForCondition { - transport.selectedReviewChatIdentityForTesting?.activeTurnThreadID == "review-thread" + transport.selectedCodexChatReviewIdentityForTesting?.activeTurnThreadID == "review-thread" } - #expect(transport.selectedReviewChatIDForTesting == nil) + #expect(transport.selectedCodexChatIDForTesting == nil) modelSource.install(container: CodexModelContainer(appServer: runtime.server)) try await waitForCondition { - transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loading - && transport.selectedReviewChatItemTextsForTesting == ["Late source"] + transport.selectedCodexChatIDForTesting == "review-thread" + && transport.selectedCodexChatPhaseForTesting == .loading + && transport.selectedCodexChatItemTextsForTesting == ["Late source"] } } @@ -195,7 +195,7 @@ struct ReviewMonitorSelectedReviewChatTests { ) uiState.selection = .job(job) - #expect(transport.selectedReviewChatIdentityForTesting == nil) + #expect(transport.selectedCodexChatReviewIdentityForTesting == nil) job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" @@ -203,9 +203,9 @@ struct ReviewMonitorSelectedReviewChatTests { job.appendLogEntry(.init(kind: .agentMessage, text: "Legacy trigger")) try await waitForCondition { - transport.selectedReviewChatIDForTesting == "review-thread" - && transport.selectedReviewChatPhaseForTesting == .loading - && transport.selectedReviewChatItemTextsForTesting == ["Arrived after selection"] + transport.selectedCodexChatIDForTesting == "review-thread" + && transport.selectedCodexChatPhaseForTesting == .loading + && transport.selectedCodexChatItemTextsForTesting == ["Arrived after selection"] } } @@ -330,8 +330,8 @@ struct ReviewMonitorSelectedReviewChatTests { snapshot.log.contains("Generic chat snapshot") } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) - #expect(transport.selectedReviewChatIdentityForTesting == nil) - #expect(transport.selectedReviewChatIDForTesting == "chat-thread") + #expect(transport.selectedCodexChatReviewIdentityForTesting == nil) + #expect(transport.selectedCodexChatIDForTesting == "chat-thread") try await runtime.transport.emitServerNotification( method: "item/updated", From 144618516f25aef2de1f1c4e25e8f2d89e59ddf4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:24:54 +0900 Subject: [PATCH 101/359] refactor(review-ui): share selected log rendering --- ...ReviewMonitorTransportViewController.swift | 57 ++++++------------- 1 file changed, 18 insertions(+), 39 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index db2f0a05..282ddf9e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -6,6 +6,11 @@ import ReviewMonitorRendering @MainActor final class ReviewMonitorTransportViewController: NSViewController { + private enum LogRenderTarget: Equatable { + case job(String) + case chat(CodexThreadID) + } + private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let selectedCodexChat: ReviewMonitorSelectedCodexChat @@ -212,8 +217,9 @@ final class ReviewMonitorTransportViewController: NSViewController { let timeline = selectedJob.timeline _ = timeline.revision let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) - self.renderBoundJobLog( + self.renderBoundLog( timelineDocument: timelineDocument, + target: .job(selectedJob.id), restorationTarget: eventKind == .initial ? self.restorationTarget(selectedJob) : self.logScrollView.currentScrollRestorationTarget, @@ -243,9 +249,9 @@ final class ReviewMonitorTransportViewController: NSViewController { guard let timelineDocument = self.selectedCodexChat.timelineDocument else { return } - self.renderSelectedChatLog( + self.renderBoundLog( timelineDocument: timelineDocument, - chatID: selectedChat.id, + target: .chat(selectedChat.id), restorationTarget: eventKind == .initial ? .bottom : self.logScrollView.currentScrollRestorationTarget, @@ -436,26 +442,22 @@ final class ReviewMonitorTransportViewController: NSViewController { } @discardableResult - private func renderBoundJobLog( + private func renderBoundLog( timelineDocument: ReviewTimelineDocument, + target: LogRenderTarget, 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 renderedDocument = await renderer.render(timelineDocument: timelineDocument) guard Task.isCancelled == false, let self, self.logRenderGeneration == generation, - self.boundJob?.id == jobID + self.isCurrentLogRenderTarget(target) else { return } @@ -471,36 +473,13 @@ final class ReviewMonitorTransportViewController: NSViewController { return true } - @discardableResult - private func renderSelectedChatLog( - timelineDocument: ReviewTimelineDocument, - chatID: CodexThreadID, - restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, - allowIncrementalUpdate: Bool - ) -> Bool { - logRenderGeneration &+= 1 - let generation = logRenderGeneration - let renderer = logRenderer - logRenderTask?.cancel() - logRenderTask = Task { @MainActor [weak self] in - let renderedDocument = await renderer.render(timelineDocument: timelineDocument) - guard Task.isCancelled == false, - let self, - self.logRenderGeneration == generation, - self.boundChatID == chatID - else { - return - } - _ = self.logScrollView.render( - sourceDocument: renderedDocument.source, - displayDocument: renderedDocument.display, - restoring: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate && self.hasAppliedBoundLog - ) - self.appliedLogRenderGeneration = generation - self.hasAppliedBoundLog = true + private func isCurrentLogRenderTarget(_ target: LogRenderTarget) -> Bool { + switch target { + case .job(let id): + boundJob?.id == id + case .chat(let id): + boundChatID == id } - return true } private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { From 2e24c2cbcb92a6ff34b0c9b33e9811826ba1fdd0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:28:55 +0900 Subject: [PATCH 102/359] refactor(review-ui): remove entry rendering from log scroll view --- .../Detail/ReviewMonitorLogScrollView.swift | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift index 7c3d2f14..3aea3e7d 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift @@ -1,6 +1,5 @@ import AppKit import ObjectiveC.runtime -import CodexReviewKit @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? @@ -172,7 +170,6 @@ final class ReviewMonitorLogScrollView: NSScrollView { @discardableResult func clear() -> Bool { - logProjection = ReviewMonitorLog.Projection() displayedRevision = nil displayedPresentationSignature = nil displayedFinderSupplementSignature = nil @@ -187,20 +184,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, From 959edab50ffcf9fc1e3edfda3145324727303882 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:29:57 +0900 Subject: [PATCH 103/359] refactor(review-ui): keep entry log renderer helpers test-only --- Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift index cda5d099..73f80b65 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift @@ -17,14 +17,17 @@ actor ReviewMonitorLogRenderer { displayDocument = ReviewMonitorLog.Document() } +#if DEBUG func render(entries: [ReviewLogEntry]) -> ReviewMonitorRenderedLogDocument { renderedDocument(from: projection.render(entries: entries)) } +#endif func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorRenderedLogDocument { renderedDocument(from: timelineProjection.render(timelineDocument: timelineDocument)) } +#if DEBUG func append( entries: [ReviewLogEntry], sourceRange: Range @@ -70,6 +73,7 @@ actor ReviewMonitorLogRenderer { } return documents } +#endif private func currentRenderedDocument() -> ReviewMonitorRenderedLogDocument { .init( From 413d0710bcbe40318408f7ed5b448ac25cc17dec Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:32:18 +0900 Subject: [PATCH 104/359] refactor(review-ui): remove release fallback to review timeline --- .../ReviewMonitorTransportViewController.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 282ddf9e..46b6d0f5 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -215,8 +215,13 @@ final class ReviewMonitorTransportViewController: NSViewController { } self.selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) let timeline = selectedJob.timeline +#if DEBUG _ = timeline.revision - let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) +#endif + guard let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) else { + self.logScrollView.clear() + return + } self.renderBoundLog( timelineDocument: timelineDocument, target: .job(selectedJob.id), @@ -482,12 +487,16 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument { + private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument? { if let document = selectedCodexChat.timelineDocument { return document } +#if DEBUG return ReviewTimelineDocumentRenderer().document(from: timeline) +#else + return nil +#endif } private func cacheBoundJobScrollTarget() { From 4d9722f339dedb6d7c860eb3a746c0542849546e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:33:47 +0900 Subject: [PATCH 105/359] docs(architecture): clarify Codex chat UI ownership --- Docs/architecture.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index 9d1ead92..5d4fa4e7 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -13,11 +13,12 @@ raw app-server wire -> high-level app-server review stream -> domain review event -> observable review timeline - -> UI / MCP / rendering / legacy log projections + -> UI / MCP / rendering projections + -> legacy log projection only at old log API edges ``` 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 +source of truth for ReviewMonitor UI or MCP responses after they have been normalized into high-level app-server review streams and converted into domain events. @@ -28,7 +29,7 @@ This table describes intended ownership. | Target | Responsibility | | --- | --- | | `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server review notification schema, high-level review stream/session API, and Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. Raw review DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | -| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and legacy log compatibility projections. It has no app-server wire, UI, or MCP dependencies | +| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and old log API edge projections. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | | `CodexReviewHost` | Runtime composition for ReviewMonitor | @@ -73,20 +74,24 @@ sessions into `CodexReviewKit` backend events. It consumes `CodexReviewSession` values from `CodexAppServerKit` and hands application-facing review events to the store backend. -`ReviewTimeline` and application/store state are the observable source of truth -after conversion. UI views, rendering helpers, MCP server projections, and -legacy log support project from the timeline; they do not parse raw app-server -events and do not make string logs authoritative again. +`ReviewTimeline` and `CodexDataKit` model state are the observable sources of +truth after conversion. ReviewMonitor UI should render selected chats through +`CodexChat` observation and `ReviewTimelineDocument` projection; release UI +does not fall back to raw review log entries when a selected Codex chat is not +available. Rendering helpers and MCP server projections project from semantic +state; 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: +Old log API support remains only at explicit API edges: - `ReviewLogEntryTimelineProjection` rebuilds semantic timeline state from - existing log entries during migration or compatibility paths. + existing log entries for persisted or test-created job state that predates + direct timeline events. - `ReviewTimelineLegacyLogProjection` derives legacy log entries from timeline - items when old APIs need them. + items when `review_read.logs` needs the old log shape. -Those projections are compatibility edges. New behavior should prefer domain -events and timeline state as the owner. +Those projections are not ReviewMonitor UI owners. New behavior should prefer +domain events, `CodexDataKit` models, and timeline documents as the owner. ## Target Graph @@ -104,7 +109,7 @@ flowchart TB subgraph Product["CodexReviewKit product API"] PublicStore["CodexReviewStore"] - LegacyProjection["Legacy log projections"] + LegacyProjection["Old log API projections"] end subgraph Kit["CodexAppServerKit"] @@ -277,9 +282,9 @@ The public tool surface is: The current ReviewMonitor UI model boundary is therefore: - `CodexDataKit`: generic observable app-server models and fetch/query owners. -- `CodexReviewKit`: review-job, review-timeline, review-auth compatibility, - settings compatibility, and product command state until the matching generic - CodexDataKit owners are available. +- `CodexReviewKit`: review-job, review-timeline, review-auth/settings product + state, old log API edge projections, and product command state until the + matching generic CodexDataKit owners are available. - `ReviewUI`: native AppKit/SwiftUI rendering state such as sidebar selection, filters, installed controllers, row views, and transient presentation. From 59ab18b3d76846b3c3af7b8c19abd1f94a3b3195 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:38:24 +0900 Subject: [PATCH 106/359] refactor(review-core): name timeline text retention explicitly --- .../CodexReviewKit/Model/CodexReviewJob.swift | 74 +++++++++---------- .../ReviewLogEntryTimelineProjection.swift | 22 +++--- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index afd5b0a6..66d5cf9e 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -38,11 +38,11 @@ public final class CodexReviewJob: Identifiable, Hashable { @ObservationIgnored package var directTimelineTextItemIDs: Set @ObservationIgnored - package var directTimelineTextItemIDsWithCompatibilityLog: Set + package var directTimelineTextItemIDsWithRetainedLogText: Set @ObservationIgnored - package var directTimelineTextCompatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] + package var retainedTimelineTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] @ObservationIgnored - private var pendingDirectTimelineTextItemIDsForCompatibilityLog: [ReviewTimelineItem.ID] + private var pendingDirectTimelineTextItemIDsForLogRetention: [ReviewTimelineItem.ID] @ObservationIgnored private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID] @ObservationIgnored @@ -96,9 +96,9 @@ public final class CodexReviewJob: Identifiable, Hashable { self.pendingLegacyTimelineProjectionSuppressions = 0 self.pendingTerminalFailureLogTimelineProjectionSuppressions = 0 self.directTimelineTextItemIDs = [] - self.directTimelineTextItemIDsWithCompatibilityLog = [] - self.directTimelineTextCompatibilityItemIDsByLogEntryID = [:] - self.pendingDirectTimelineTextItemIDsForCompatibilityLog = [] + self.directTimelineTextItemIDsWithRetainedLogText = [] + self.retainedTimelineTextItemIDsByLogEntryID = [:] + self.pendingDirectTimelineTextItemIDsForLogRetention = [] self.latestDirectTimelineTextItemIDs = [] self.legacyProjectedTimelineTextItemIDs = [] self.legacyLogBuffer = initialLogBuffer @@ -132,9 +132,9 @@ public final class CodexReviewJob: Identifiable, Hashable { pendingLegacyTimelineProjectionSuppressions = 0 pendingTerminalFailureLogTimelineProjectionSuppressions = 0 directTimelineTextItemIDs.removeAll(keepingCapacity: true) - directTimelineTextItemIDsWithCompatibilityLog.removeAll(keepingCapacity: true) - directTimelineTextCompatibilityItemIDsByLogEntryID.removeAll(keepingCapacity: true) - pendingDirectTimelineTextItemIDsForCompatibilityLog.removeAll(keepingCapacity: true) + directTimelineTextItemIDsWithRetainedLogText.removeAll(keepingCapacity: true) + retainedTimelineTextItemIDsByLogEntryID.removeAll(keepingCapacity: true) + pendingDirectTimelineTextItemIDsForLogRetention.removeAll(keepingCapacity: true) latestDirectTimelineTextItemIDs.removeAll(keepingCapacity: true) legacyProjectedTimelineTextItemIDs.removeAll(keepingCapacity: true) } @@ -151,16 +151,16 @@ public final class CodexReviewJob: Identifiable, Hashable { let entry = appendResult.entry syncLogEntriesFromBuffer() if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { - let allowsPendingFallback = suppressTimelineProjection || pendingLegacyTimelineProjectionSuppressions > 0 + let allowsPendingRetainedText = suppressTimelineProjection || pendingLegacyTimelineProjectionSuppressions > 0 if entry.retainedTimelineText != nil { - recordDirectTimelineTextCompatibilityLog( + recordDirectTimelineTextRetention( for: entry, - allowsPendingFallback: allowsPendingFallback + allowsPendingRetainedText: allowsPendingRetainedText ) } else { - discardDirectTimelineTextCompatibilityLog( + discardDirectTimelineTextRetention( for: entry, - allowsPendingFallback: allowsPendingFallback + allowsPendingRetainedText: allowsPendingRetainedText ) } } @@ -215,7 +215,7 @@ public final class CodexReviewJob: Identifiable, Hashable { } latestDirectTimelineTextItemIDs = directTextItemIDsApplied if legacyProjectionSuppressionCount > 0 { - appendPendingDirectTimelineTextItemIDsForCompatibilityLog(directTextItemIDsApplied) + appendPendingDirectTimelineTextItemIDsForLogRetention(directTextItemIDsApplied) } } @@ -229,61 +229,61 @@ public final class CodexReviewJob: Identifiable, Hashable { } } - private func recordDirectTimelineTextCompatibilityLog( + private func recordDirectTimelineTextRetention( for entry: ReviewLogEntry, - allowsPendingFallback: Bool + allowsPendingRetainedText: Bool ) { var itemIDs: Set = [] for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) { itemIDs.insert(itemID) } if itemIDs.isEmpty, - allowsPendingFallback, - let pendingItemID = popPendingDirectTimelineTextItemIDForCompatibilityLog() { + allowsPendingRetainedText, + let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() { itemIDs.insert(pendingItemID) } guard itemIDs.isEmpty == false else { return } - directTimelineTextItemIDsWithCompatibilityLog.formUnion(itemIDs) - directTimelineTextCompatibilityItemIDsByLogEntryID[entry.id, default: []].formUnion(itemIDs) - removePendingDirectTimelineTextItemIDsForCompatibilityLog(itemIDs) + directTimelineTextItemIDsWithRetainedLogText.formUnion(itemIDs) + retainedTimelineTextItemIDsByLogEntryID[entry.id, default: []].formUnion(itemIDs) + removePendingDirectTimelineTextItemIDsForLogRetention(itemIDs) } - private func discardDirectTimelineTextCompatibilityLog( + private func discardDirectTimelineTextRetention( for entry: ReviewLogEntry, - allowsPendingFallback: Bool + allowsPendingRetainedText: Bool ) { var itemIDs: Set = [] for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) { itemIDs.insert(itemID) } if itemIDs.isEmpty, - allowsPendingFallback, - let pendingItemID = popPendingDirectTimelineTextItemIDForCompatibilityLog() { + allowsPendingRetainedText, + let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() { itemIDs.insert(pendingItemID) } - removePendingDirectTimelineTextItemIDsForCompatibilityLog(itemIDs) + removePendingDirectTimelineTextItemIDsForLogRetention(itemIDs) } - private func appendPendingDirectTimelineTextItemIDsForCompatibilityLog(_ itemIDs: [ReviewTimelineItem.ID]) { - for itemID in itemIDs where pendingDirectTimelineTextItemIDsForCompatibilityLog.contains(itemID) == false { - pendingDirectTimelineTextItemIDsForCompatibilityLog.append(itemID) + private func appendPendingDirectTimelineTextItemIDsForLogRetention(_ itemIDs: [ReviewTimelineItem.ID]) { + for itemID in itemIDs where pendingDirectTimelineTextItemIDsForLogRetention.contains(itemID) == false { + pendingDirectTimelineTextItemIDsForLogRetention.append(itemID) } } - private func popPendingDirectTimelineTextItemIDForCompatibilityLog() -> ReviewTimelineItem.ID? { - guard pendingDirectTimelineTextItemIDsForCompatibilityLog.isEmpty == false else { + private func popPendingDirectTimelineTextItemIDForLogRetention() -> ReviewTimelineItem.ID? { + guard pendingDirectTimelineTextItemIDsForLogRetention.isEmpty == false else { return nil } - return pendingDirectTimelineTextItemIDsForCompatibilityLog.removeFirst() + return pendingDirectTimelineTextItemIDsForLogRetention.removeFirst() } - private func removePendingDirectTimelineTextItemIDsForCompatibilityLog(_ itemIDs: Set) { + private func removePendingDirectTimelineTextItemIDsForLogRetention(_ itemIDs: Set) { guard itemIDs.isEmpty == false else { return } - pendingDirectTimelineTextItemIDsForCompatibilityLog.removeAll { itemIDs.contains($0) } + pendingDirectTimelineTextItemIDsForLogRetention.removeAll { itemIDs.contains($0) } } private func recordLegacyProjectedTimelineTextItemIDsFromLogEntries() { @@ -297,12 +297,12 @@ public final class CodexReviewJob: Identifiable, Hashable { package func suppressNextLegacyTimelineProjection() { pendingLegacyTimelineProjectionSuppressions += 1 - appendPendingDirectTimelineTextItemIDsForCompatibilityLog(latestDirectTimelineTextItemIDs) + appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } package func suppressNextTerminalFailureLogTimelineProjection() { pendingTerminalFailureLogTimelineProjectionSuppressions += 1 - appendPendingDirectTimelineTextItemIDsForCompatibilityLog(latestDirectTimelineTextItemIDs) + appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } package func discardPendingLegacyTimelineProjectionSuppression() { diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift index 8ffb145a..4f820334 100644 --- a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift @@ -61,8 +61,8 @@ package struct ReviewLogEntryTimelineProjector { package func trimTextContent( to entries: [ReviewLogEntry], directTimelineTextItemIDs: Set, - directTimelineTextItemIDsWithCompatibilityLog: Set, - directTimelineTextCompatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set], + directTimelineTextItemIDsWithRetainedLogText: Set, + retainedTimelineTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set], legacyProjectedTimelineTextItemIDs: Set ) { guard directTimelineTextItemIDs.isEmpty == false @@ -76,7 +76,7 @@ package struct ReviewLogEntryTimelineProjector { } for itemID in directTimelineTextCandidateIDs( for: entry, - compatibilityItemIDsByLogEntryID: directTimelineTextCompatibilityItemIDsByLogEntryID + retainedTextItemIDsByLogEntryID: retainedTimelineTextItemIDsByLogEntryID ) { if entry.shouldAppendRetainedTimelineText { textByItemID[itemID, default: ""] += retainedTimelineText @@ -89,7 +89,7 @@ package struct ReviewLogEntryTimelineProjector { let text: String if let retainedText = textByItemID[itemID] { text = retainedText - } else if directTimelineTextItemIDsWithCompatibilityLog.contains(itemID) { + } else if directTimelineTextItemIDsWithRetainedLogText.contains(itemID) { text = "" } else { continue @@ -143,11 +143,11 @@ package struct ReviewLogEntryTimelineProjector { private func directTimelineTextCandidateIDs( for entry: ReviewLogEntry, - compatibilityItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] + retainedTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] ) -> [ReviewTimelineItem.ID] { var ids = entry.directTimelineTextCandidateIDs - if let compatibilityItemIDs = compatibilityItemIDsByLogEntryID[entry.id] { - ids.append(contentsOf: compatibilityItemIDs) + if let retainedTextItemIDs = retainedTextItemIDsByLogEntryID[entry.id] { + ids.append(contentsOf: retainedTextItemIDs) } var seen: Set = [] return ids.filter { seen.insert($0).inserted } @@ -175,8 +175,8 @@ extension CodexReviewJob { ReviewLogEntryTimelineProjector(timeline: timeline).trimTextContent( to: logEntries, directTimelineTextItemIDs: directTimelineTextItemIDs, - directTimelineTextItemIDsWithCompatibilityLog: directTimelineTextItemIDsWithCompatibilityLog, - directTimelineTextCompatibilityItemIDsByLogEntryID: directTimelineTextCompatibilityItemIDsByLogEntryID, + directTimelineTextItemIDsWithRetainedLogText: directTimelineTextItemIDsWithRetainedLogText, + retainedTimelineTextItemIDsByLogEntryID: retainedTimelineTextItemIDsByLogEntryID, legacyProjectedTimelineTextItemIDs: legacyProjectedTimelineTextItemIDs ) } @@ -252,7 +252,7 @@ package extension ReviewLogEntry { if kind == .todoList { ids.append(.init(rawValue: "\(rawID):turn/plan/updated")) } - if isMCPToolProgressCompatibilityLog { + if isMCPToolProgressSummaryLog { ids.append(.init(rawValue: "\(rawID):progress")) } } @@ -261,7 +261,7 @@ package extension ReviewLogEntry { return ids.filter { seen.insert($0).inserted } } - var isMCPToolProgressCompatibilityLog: Bool { + var isMCPToolProgressSummaryLog: Bool { kind == .toolCall && metadata?.sourceType == "mcpToolCall" && metadata?.title?.trimmingCharacters(in: .whitespacesAndNewlines) == "Tool progress" From 7627f3aafd4414f84f08e181143e89b2b7d34ea6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:54:01 +0900 Subject: [PATCH 107/359] refactor(review-core): remove legacy log projection names --- Docs/architecture.md | 20 ++-- .../AppServerCodexReviewBackend.swift | 28 ++--- .../ReviewBackendEventSession.swift | 16 +-- .../CodexReviewKit/CodexReviewBackend.swift | 2 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 4 +- .../CodexReviewKit/Model/CodexReviewJob.swift | 70 ++++++------ ...yLogBuffer.swift => ReviewLogBuffer.swift} | 22 ++-- .../ReviewLogEntryTimelineProjection.swift | 8 +- ...wift => ReviewTimelineLogProjection.swift} | 18 +-- .../Store/CodexReviewStoreReviews.swift | 12 +- .../Detail/ReviewMonitorLogScrollView.swift | 2 +- .../ReviewMonitorTimelineLogProjection.swift | 10 +- ...ReviewMonitorTransportViewController.swift | 4 +- .../AppServerClientTests.swift | 4 +- .../ReviewBackendEventSessionTests.swift | 2 +- .../CodexReviewStoreCommandTests.swift | 106 +++++++++--------- .../CodexReviewMCPHTTPServerTests.swift | 2 +- .../ReviewTimelineDocumentRendererTests.swift | 2 +- .../ReviewMonitorSelectedCodexChatTests.swift | 8 +- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 20 files changed, 171 insertions(+), 171 deletions(-) rename Sources/CodexReviewKit/Model/{ReviewLegacyLogBuffer.swift => ReviewLogBuffer.swift} (97%) rename Sources/CodexReviewKit/Model/{ReviewTimelineLegacyLogProjection.swift => ReviewTimelineLogProjection.swift} (93%) diff --git a/Docs/architecture.md b/Docs/architecture.md index 5d4fa4e7..980e8336 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -14,7 +14,7 @@ raw app-server wire -> domain review event -> observable review timeline -> UI / MCP / rendering projections - -> legacy log projection only at old log API edges + -> log-entry projection only at explicit `review_read` edges ``` Raw JSON-RPC notifications are an input boundary only. They must not become the @@ -29,7 +29,7 @@ This table describes intended ownership. | Target | Responsibility | | --- | --- | | `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server review notification schema, high-level review stream/session API, and Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. Raw review DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | -| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and old log API edge projections. It has no app-server wire, UI, or MCP dependencies | +| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and `review_read` log-entry projections. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | | `CodexReviewHost` | Runtime composition for ReviewMonitor | @@ -82,13 +82,13 @@ available. Rendering helpers and MCP server projections project from semantic state; they do not parse raw app-server events and do not make string logs authoritative again. -Old log API support remains only at explicit API edges: +Log-entry support remains only at explicit `review_read` API edges: - `ReviewLogEntryTimelineProjection` rebuilds semantic timeline state from existing log entries for persisted or test-created job state that predates direct timeline events. -- `ReviewTimelineLegacyLogProjection` derives legacy log entries from timeline - items when `review_read.logs` needs the old log shape. +- `ReviewTimelineLogProjection` derives log entries from timeline + items when `review_read.logs` needs paged log-entry output. Those projections are not ReviewMonitor UI owners. New behavior should prefer domain events, `CodexDataKit` models, and timeline documents as the owner. @@ -109,7 +109,7 @@ flowchart TB subgraph Product["CodexReviewKit product API"] PublicStore["CodexReviewStore"] - LegacyProjection["Old log API projections"] + LogProjection["Log-entry projections"] end subgraph Kit["CodexAppServerKit"] @@ -145,7 +145,7 @@ flowchart TB DomainEvents --> Timeline Timeline --> AppStore Timeline --> PublicStore - Timeline --> LegacyProjection + Timeline --> LogProjection Timeline --> MCPServer Timeline --> Renderer PublicStore --> UI @@ -187,7 +187,7 @@ source of truth. `CodexReviewKit` 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`. +diagnostics, and log store projections through `CodexReviewStore`. `CodexReviewStore` remains the command owner for `review_start`, `review_await`, `review_read`, `review_list`, `review_cancel`, session close, @@ -232,7 +232,7 @@ recovery on top of that generic boundary. The intended ownership for review logs is that `CodexAppServerKit` supplies generic app-server thread events, log entries, and high-level review session events, while ReviewMonitor-specific targets adapt those values into -`ReviewDomainEvent`, `ReviewTimeline`, and legacy review log projections. +`ReviewDomainEvent`, `ReviewTimeline`, and review log-entry projections. Fake and live tests use the same transport protocol. @@ -283,7 +283,7 @@ The current ReviewMonitor UI model boundary is therefore: - `CodexDataKit`: generic observable app-server models and fetch/query owners. - `CodexReviewKit`: review-job, review-timeline, review-auth/settings product - state, old log API edge projections, and product command state until the + state, `review_read` log-entry projections, and product command state until the matching generic CodexDataKit owners are available. - `ReviewUI`: native AppKit/SwiftUI rendering state such as sidebar selection, filters, installed controllers, row views, and transient presentation. diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 677c7b03..c722ecdf 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -564,11 +564,11 @@ private enum AppServerTypedReviewEventAdapter { case .completed: .itemCompleted(seed) } - let legacyEvents = legacyLogEvents(for: item, phase: phase) + let logEvents = logProjectionEvents(for: item, phase: phase) return [.domainEvents( [domainEvent], - legacyProjectionSuppressionCount: legacyEvents.legacyTimelineProjectionCount - )] + legacyEvents.addingTerminalFailureLogProjectionSuppressionIfNeeded + logProjectionSuppressionCount: logEvents.immediateLogTimelineProjectionCount + )] + logEvents.addingTerminalFailureLogProjectionSuppressionIfNeeded } private static func messageEvents( @@ -602,7 +602,7 @@ private enum AppServerTypedReviewEventAdapter { delta: delta.text ) return [ - .domainEvents([domainEvent], legacyProjectionSuppressionCount: 1), + .domainEvents([domainEvent], logProjectionSuppressionCount: 1), .messageDelta(delta.text, itemID: itemID), ] } @@ -623,7 +623,7 @@ private enum AppServerTypedReviewEventAdapter { phase: .running, content: .reasoning(.init(text: "", style: style)) ) - return [.domainEvents([.itemStarted(seed)], legacyProjectionSuppressionCount: 0)] + return [.domainEvents([.itemStarted(seed)], logProjectionSuppressionCount: 0)] } private static func reasoningDeltaEvents( @@ -650,7 +650,7 @@ private enum AppServerTypedReviewEventAdapter { delta: delta.delta ) return [ - .domainEvents([domainEvent], legacyProjectionSuppressionCount: 1), + .domainEvents([domainEvent], logProjectionSuppressionCount: 1), .logEntry(kind: kind, text: delta.delta, groupID: delta.id, replacesGroup: false), ] } @@ -673,7 +673,7 @@ private enum AppServerTypedReviewEventAdapter { rawKind: .init(rawValue: raw.method) )) ) - return [.domainEvents([.itemUpdated(seed)], legacyProjectionSuppressionCount: 0)] + return [.domainEvents([.itemUpdated(seed)], logProjectionSuppressionCount: 0)] } private static func timelineSeed( @@ -689,7 +689,7 @@ private enum AppServerTypedReviewEventAdapter { ) } - private static func legacyLogEvents( + private static func logProjectionEvents( for item: CodexThreadItem, phase: AppServerTypedItemPhase ) -> [CodexReviewBackendModel.Review.Event] { @@ -737,7 +737,7 @@ private enum AppServerTypedReviewEventAdapter { case .fileChange(let fileChange): let text = fileChange.output?.nilIfEmpty ?? fileChange.path?.nilIfEmpty - ?? "File changes \(item.legacyStatus(phase: phase) ?? "updated")." + ?? "File changes \(item.logStatus(phase: phase) ?? "updated")." let kind: ReviewLogEntry.Kind = fileChange.output?.nilIfEmpty == nil ? .toolCall : .commandOutput return [item.logEntry( kind: kind, @@ -749,7 +749,7 @@ private enum AppServerTypedReviewEventAdapter { let label = item.toolLabel let text = toolCall.error?.nilIfEmpty ?? toolCall.result?.nilIfEmpty - ?? "\(label) \(item.legacyStatus(phase: phase) ?? "updated")." + ?? "\(label) \(item.logStatus(phase: phase) ?? "updated")." return [item.logEntry( kind: .toolCall, text: text, @@ -759,7 +759,7 @@ private enum AppServerTypedReviewEventAdapter { case .contextCompaction(let text): return [item.logEntry( kind: .contextCompaction, - text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.legacyStatus(phase: phase)), + text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.logStatus(phase: phase)), phase: phase, title: nil )] @@ -897,7 +897,7 @@ private extension CodexThreadItem { case .contextCompaction(let text): .contextCompaction(.init( title: text?.nilIfEmpty ?? appServerContextCompactionStartedText, - status: legacyStatus(phase: .updated).map { .init(rawValue: $0) } + status: logStatus(phase: .updated).map { .init(rawValue: $0) } )) case .diagnostic(let text): .diagnostic(.init( @@ -954,7 +954,7 @@ private extension CodexThreadItem { .nilIfEmpty ?? kind.rawValue } - func legacyStatus(phase: AppServerTypedItemPhase) -> String? { + func logStatus(phase: AppServerTypedItemPhase) -> String? { if let status = statusRaw?.nilIfEmpty { return status } @@ -997,7 +997,7 @@ private extension CodexThreadItem { title: String?, phase: AppServerTypedItemPhase ) -> ReviewLogEntry.Metadata { - let status = legacyStatus(phase: phase) + let status = logStatus(phase: phase) let command: CodexCommand? let fileChange: CodexFileChange? let toolCall: CodexToolCall? diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 0c748911..6cd0f47f 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -215,7 +215,7 @@ package actor ReviewBackendEventSession { case .completed(let summary, nil): return .completed(summary: summary, result: typedReviewResultText) case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .started, .log, @@ -391,7 +391,7 @@ package actor ReviewBackendEventSession { case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .message, .messageDelta, @@ -481,7 +481,7 @@ private struct PendingStreamedLogEntry: Sendable { metadata: metadata ) return suppressesTimelineProjection - ? [.suppressNextLegacyTimelineProjection, logEntry] + ? [.suppressNextLogTimelineProjection, logEntry] : [logEntry] } @@ -522,9 +522,9 @@ private struct PendingStreamedLogEntry: Sendable { } package extension Array where Element == CodexReviewBackendModel.Review.Event { - var legacyTimelineProjectionCount: Int { + var immediateLogTimelineProjectionCount: Int { reduce(0) { count, event in - count + (event.createsImmediateLegacyTimelineProjection ? 1 : 0) + count + (event.createsImmediateLogTimelineProjection ? 1 : 0) } } @@ -544,7 +544,7 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .started, .message, @@ -562,7 +562,7 @@ private extension CodexReviewBackendModel.Review.Event { return result?.nilIfEmpty == nil } - var createsImmediateLegacyTimelineProjection: Bool { + var createsImmediateLogTimelineProjection: Bool { guard PendingStreamedLogEntry(self) == nil else { return false } @@ -570,7 +570,7 @@ private extension CodexReviewBackendModel.Review.Event { case .message, .messageDelta, .log, .logEntry: true case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .started, .completed, diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 07957978..8f6fdca7 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -174,7 +174,7 @@ package actor BackendReviewEventMailbox { case .completed, .failed, .cancelled: return true case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .started, .message, diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 30a5fce5..d3ad3ba9 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -277,8 +277,8 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { - case domainEvents([ReviewDomainEvent], legacyProjectionSuppressionCount: Int) - case suppressNextLegacyTimelineProjection + case domainEvents([ReviewDomainEvent], logProjectionSuppressionCount: Int) + case suppressNextLogTimelineProjection case suppressNextTerminalFailureLogTimelineProjection case started(turnID: String, reviewThreadID: String?, model: String?) case message(String) diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 66d5cf9e..22cf2aa3 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -15,7 +15,7 @@ public final class CodexReviewJob: Identifiable, Hashable { } @ObservationIgnored - private var legacyLogBuffer: ReviewLegacyLogBuffer + private var logBuffer: ReviewLogBuffer public nonisolated let id: String public let timeline: ReviewTimeline @@ -32,7 +32,7 @@ public final class CodexReviewJob: Identifiable, Hashable { @ObservationIgnored package private(set) var usesDirectTimelineEvents: Bool @ObservationIgnored - private var pendingLegacyTimelineProjectionSuppressions: Int + private var pendingLogTimelineProjectionSuppressions: Int @ObservationIgnored private var pendingTerminalFailureLogTimelineProjectionSuppressions: Int @ObservationIgnored @@ -46,7 +46,7 @@ public final class CodexReviewJob: Identifiable, Hashable { @ObservationIgnored private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID] @ObservationIgnored - package var legacyProjectedTimelineTextItemIDs: Set + package var logProjectedTimelineTextItemIDs: Set public private(set) var logEntries: [ReviewLogEntry] public private(set) var logText: String public private(set) var rawLogText: String @@ -80,7 +80,7 @@ public final class CodexReviewJob: Identifiable, Hashable { cancellationRequested: Bool = false, logEntries: [ReviewLogEntry] ) { - let initialLogBuffer = ReviewLegacyLogBuffer(entries: logEntries) + let initialLogBuffer = ReviewLogBuffer(entries: logEntries) let initialLogSnapshot = initialLogBuffer.snapshot self.id = id self.timeline = ReviewTimeline() @@ -93,15 +93,15 @@ public final class CodexReviewJob: Identifiable, Hashable { self.agentMessagesByItemID = [:] self.completedAgentMessageItemIDs = [] self.usesDirectTimelineEvents = false - self.pendingLegacyTimelineProjectionSuppressions = 0 + self.pendingLogTimelineProjectionSuppressions = 0 self.pendingTerminalFailureLogTimelineProjectionSuppressions = 0 self.directTimelineTextItemIDs = [] self.directTimelineTextItemIDsWithRetainedLogText = [] self.retainedTimelineTextItemIDsByLogEntryID = [:] self.pendingDirectTimelineTextItemIDsForLogRetention = [] self.latestDirectTimelineTextItemIDs = [] - self.legacyProjectedTimelineTextItemIDs = [] - self.legacyLogBuffer = initialLogBuffer + self.logProjectedTimelineTextItemIDs = [] + self.logBuffer = initialLogBuffer self.logEntries = initialLogSnapshot.entries self.logText = initialLogSnapshot.logText self.rawLogText = initialLogSnapshot.rawLogText @@ -125,18 +125,18 @@ public final class CodexReviewJob: Identifiable, Hashable { } package func replaceLogEntries(_ entries: [ReviewLogEntry], resetDirectTimeline: Bool = false) { - legacyLogBuffer.replace(with: entries) + logBuffer.replace(with: entries) syncLogEntriesFromBuffer() if resetDirectTimeline { usesDirectTimelineEvents = false - pendingLegacyTimelineProjectionSuppressions = 0 + pendingLogTimelineProjectionSuppressions = 0 pendingTerminalFailureLogTimelineProjectionSuppressions = 0 directTimelineTextItemIDs.removeAll(keepingCapacity: true) directTimelineTextItemIDsWithRetainedLogText.removeAll(keepingCapacity: true) retainedTimelineTextItemIDsByLogEntryID.removeAll(keepingCapacity: true) pendingDirectTimelineTextItemIDsForLogRetention.removeAll(keepingCapacity: true) latestDirectTimelineTextItemIDs.removeAll(keepingCapacity: true) - legacyProjectedTimelineTextItemIDs.removeAll(keepingCapacity: true) + logProjectedTimelineTextItemIDs.removeAll(keepingCapacity: true) } if usesDirectTimelineEvents { trimTimelineTextContentToLogEntries() @@ -147,11 +147,11 @@ public final class CodexReviewJob: Identifiable, Hashable { } package func appendLogEntry(_ entry: ReviewLogEntry, suppressTimelineProjection: Bool = false) { - let appendResult = legacyLogBuffer.append(entry) + let appendResult = logBuffer.append(entry) let entry = appendResult.entry syncLogEntriesFromBuffer() if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { - let allowsPendingRetainedText = suppressTimelineProjection || pendingLegacyTimelineProjectionSuppressions > 0 + let allowsPendingRetainedText = suppressTimelineProjection || pendingLogTimelineProjectionSuppressions > 0 if entry.retainedTimelineText != nil { recordDirectTimelineTextRetention( for: entry, @@ -165,10 +165,10 @@ public final class CodexReviewJob: Identifiable, Hashable { } } if suppressTimelineProjection == false, - consumeLegacyTimelineProjectionSuppression() == false { + consumeLogTimelineProjectionSuppression() == false { applyTimelineEntry(entry) if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { - legacyProjectedTimelineTextItemIDs.insert(entry.timelineItemID) + logProjectedTimelineTextItemIDs.insert(entry.timelineItemID) } } let didTrim = core.lifecycle.status.isTerminal ? trimReviewLogToLimit() : false @@ -179,7 +179,7 @@ public final class CodexReviewJob: Identifiable, Hashable { } package func closeActiveCommandLogEntries(status: String, completedAt: Date) { - let replacements = ReviewLegacyLogBuffer.activeCommandClosingEntries( + let replacements = ReviewLogBuffer.activeCommandClosingEntries( entries: logEntries, status: status, completedAt: completedAt @@ -191,7 +191,7 @@ public final class CodexReviewJob: Identifiable, Hashable { package func applyDirectTimelineEvents( _ events: [ReviewDomainEvent], - legacyProjectionSuppressionCount: Int, + logProjectionSuppressionCount: Int, at timestamp: Date ) { let directEvents = events.filter { @@ -201,10 +201,10 @@ public final class CodexReviewJob: Identifiable, Hashable { return } if usesDirectTimelineEvents == false { - recordLegacyProjectedTimelineTextItemIDsFromLogEntries() + recordLogProjectedTimelineTextItemIDsFromLogEntries() } usesDirectTimelineEvents = true - pendingLegacyTimelineProjectionSuppressions += max(0, legacyProjectionSuppressionCount) + pendingLogTimelineProjectionSuppressions += max(0, logProjectionSuppressionCount) var directTextItemIDsApplied: [ReviewTimelineItem.ID] = [] for event in directEvents { if let itemID = event.directTimelineTextItemID { @@ -214,7 +214,7 @@ public final class CodexReviewJob: Identifiable, Hashable { timeline.apply(event, at: timestamp) } latestDirectTimelineTextItemIDs = directTextItemIDsApplied - if legacyProjectionSuppressionCount > 0 { + if logProjectionSuppressionCount > 0 { appendPendingDirectTimelineTextItemIDsForLogRetention(directTextItemIDsApplied) } } @@ -286,17 +286,17 @@ public final class CodexReviewJob: Identifiable, Hashable { pendingDirectTimelineTextItemIDsForLogRetention.removeAll { itemIDs.contains($0) } } - private func recordLegacyProjectedTimelineTextItemIDsFromLogEntries() { + private func recordLogProjectedTimelineTextItemIDsFromLogEntries() { for entry in logEntries where entry.retainedTimelineText != nil { let itemID = entry.timelineItemID if timeline.item(for: itemID) != nil { - legacyProjectedTimelineTextItemIDs.insert(itemID) + logProjectedTimelineTextItemIDs.insert(itemID) } } } - package func suppressNextLegacyTimelineProjection() { - pendingLegacyTimelineProjectionSuppressions += 1 + package func suppressNextLogTimelineProjection() { + pendingLogTimelineProjectionSuppressions += 1 appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } @@ -305,8 +305,8 @@ public final class CodexReviewJob: Identifiable, Hashable { appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } - package func discardPendingLegacyTimelineProjectionSuppression() { - _ = consumeLegacyTimelineProjectionSuppression() + package func discardPendingLogTimelineProjectionSuppression() { + _ = consumeLogTimelineProjectionSuppression() } package func consumeTerminalFailureLogTimelineProjectionSuppression() -> Bool { @@ -317,11 +317,11 @@ public final class CodexReviewJob: Identifiable, Hashable { return true } - private func consumeLegacyTimelineProjectionSuppression() -> Bool { - guard pendingLegacyTimelineProjectionSuppressions > 0 else { + private func consumeLogTimelineProjectionSuppression() -> Bool { + guard pendingLogTimelineProjectionSuppressions > 0 else { return false } - pendingLegacyTimelineProjectionSuppressions -= 1 + pendingLogTimelineProjectionSuppressions -= 1 return true } @@ -336,7 +336,7 @@ public final class CodexReviewJob: Identifiable, Hashable { @discardableResult private func trimReviewLogToLimit() -> Bool { - guard legacyLogBuffer.applyLimit() else { + guard logBuffer.applyLimit() else { return false } syncLogEntriesFromBuffer() @@ -353,9 +353,9 @@ public final class CodexReviewJob: Identifiable, Hashable { keeping direction: TruncationDirection, overflowBytes: Int ) { - guard legacyLogBuffer.truncateOrRemoveEntry( + guard logBuffer.truncateOrRemoveEntry( at: index, - keeping: ReviewLegacyLogBuffer.TruncationDirection(direction), + keeping: ReviewLogBuffer.TruncationDirection(direction), overflowBytes: overflowBytes ) else { return @@ -370,11 +370,11 @@ public final class CodexReviewJob: Identifiable, Hashable { } private func syncLogEntriesFromBuffer() { - logEntries = legacyLogBuffer.entries + logEntries = logBuffer.entries } private func syncLogSnapshot(mutation: LogMutation) { - let snapshot = legacyLogBuffer.snapshot + let snapshot = logBuffer.snapshot logEntries = snapshot.entries logText = snapshot.logText rawLogText = snapshot.rawLogText @@ -389,7 +389,7 @@ public final class CodexReviewJob: Identifiable, Hashable { } private extension CodexReviewJob.LogMutation { - init(_ mutation: ReviewLegacyLogBuffer.Mutation) { + init(_ mutation: ReviewLogBuffer.Mutation) { switch mutation { case .reload: self = .reload @@ -399,7 +399,7 @@ private extension CodexReviewJob.LogMutation { } } -private extension ReviewLegacyLogBuffer.TruncationDirection { +private extension ReviewLogBuffer.TruncationDirection { init(_ direction: CodexReviewJob.TruncationDirection) { switch direction { case .prefix: diff --git a/Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift b/Sources/CodexReviewKit/Model/ReviewLogBuffer.swift similarity index 97% rename from Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift rename to Sources/CodexReviewKit/Model/ReviewLogBuffer.swift index 51a836f4..a4c5ed19 100644 --- a/Sources/CodexReviewKit/Model/ReviewLegacyLogBuffer.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogBuffer.swift @@ -1,6 +1,6 @@ import Foundation -package struct ReviewLegacyLogBuffer { +package struct ReviewLogBuffer { package enum TruncationDirection { case prefix case suffix @@ -27,7 +27,7 @@ package struct ReviewLegacyLogBuffer { } package private(set) var entries: [ReviewLogEntry] - private var projectionState: ReviewLegacyLogProjectionState + private var projectionState: ReviewLogProjectionState package init(entries: [ReviewLogEntry]) { let trimmed = Self.trimmedEntriesAndProjection(entries: entries) @@ -65,7 +65,7 @@ package struct ReviewLegacyLogBuffer { } entries.append(entry) if supportsIncrementalAppend == false { - projectionState = ReviewLegacyLogProjectionState(entries: entries) + projectionState = ReviewLogProjectionState(entries: entries) } return AppendResult( entry: entry, @@ -155,7 +155,7 @@ package struct ReviewLegacyLogBuffer { private struct TrimmedEntriesAndProjection { var entries: [ReviewLogEntry] - var projectionState: ReviewLegacyLogProjectionState + var projectionState: ReviewLogProjectionState } private static func trimmedEntriesAndProjection( @@ -164,7 +164,7 @@ package struct ReviewLegacyLogBuffer { var entries = initialEntries.map { $0.clampingUnownedRetainedMetadata(maxBytes: limitBytes) } - var projectionState = ReviewLegacyLogProjectionState(entries: entries) + var projectionState = ReviewLogProjectionState(entries: entries) while projectionState.cappedBytes > limitBytes { let overflowBytes = projectionState.cappedBytes - limitBytes @@ -172,7 +172,7 @@ package struct ReviewLegacyLogBuffer { break } entries = trimmedEntries - projectionState = ReviewLegacyLogProjectionState(entries: entries) + projectionState = ReviewLogProjectionState(entries: entries) } return .init(entries: entries, projectionState: projectionState) @@ -201,7 +201,7 @@ package struct ReviewLegacyLogBuffer { } if let index = entries.firstIndex(where: { - ReviewLegacyLogProjectionState.prefixTrimmableCappedKinds.contains($0.kind) + ReviewLogProjectionState.prefixTrimmableCappedKinds.contains($0.kind) }) { return trimEntry( entries: entries, @@ -232,7 +232,7 @@ package struct ReviewLegacyLogBuffer { let entry = entries[index] let hasNewerEntryOfSameKind = entries.dropFirst(index + 1).contains { $0.kind == kind } let hasOtherCappedEntries = entries.contains { - $0.id != entry.id && ReviewLegacyLogProjectionState.cappedLogKinds.contains($0.kind) + $0.id != entry.id && ReviewLogProjectionState.cappedLogKinds.contains($0.kind) } if hasNewerEntryOfSameKind || hasOtherCappedEntries { @@ -327,7 +327,7 @@ package struct ReviewLegacyLogBuffer { package static let limitBytes = 256 * 1024 } -package struct ReviewLegacyLogProjectionState { +package struct ReviewLogProjectionState { private struct GroupKey: Hashable { var kind: ReviewLogEntry.Kind var groupID: String @@ -490,8 +490,8 @@ package struct ReviewLegacyLogProjectionState { self.cappedProjection = cappedProjection } - private static func rebuild(entries: [ReviewLogEntry]) -> ReviewLegacyLogProjectionState { - var state = ReviewLegacyLogProjectionState( + private static func rebuild(entries: [ReviewLogEntry]) -> ReviewLogProjectionState { + var state = ReviewLogProjectionState( blocks: [], indexByGroup: [:], logProjection: .init(joinMode: .rendered), diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift index 4f820334..bbd6dd99 100644 --- a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift @@ -63,10 +63,10 @@ package struct ReviewLogEntryTimelineProjector { directTimelineTextItemIDs: Set, directTimelineTextItemIDsWithRetainedLogText: Set, retainedTimelineTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set], - legacyProjectedTimelineTextItemIDs: Set + logProjectedTimelineTextItemIDs: Set ) { guard directTimelineTextItemIDs.isEmpty == false - || legacyProjectedTimelineTextItemIDs.isEmpty == false else { + || logProjectedTimelineTextItemIDs.isEmpty == false else { return } var textByItemID: [ReviewTimelineItem.ID: String] = [:] @@ -101,7 +101,7 @@ package struct ReviewLogEntryTimelineProjector { } timeline.updateItemContent(trimmedContent, for: itemID) } - for itemID in legacyProjectedTimelineTextItemIDs { + for itemID in logProjectedTimelineTextItemIDs { guard let item = timeline.item(for: itemID), let trimmedContent = item.content.replacingTimelineText(textByItemID[itemID] ?? "") else { @@ -177,7 +177,7 @@ extension CodexReviewJob { directTimelineTextItemIDs: directTimelineTextItemIDs, directTimelineTextItemIDsWithRetainedLogText: directTimelineTextItemIDsWithRetainedLogText, retainedTimelineTextItemIDsByLogEntryID: retainedTimelineTextItemIDsByLogEntryID, - legacyProjectedTimelineTextItemIDs: legacyProjectedTimelineTextItemIDs + logProjectedTimelineTextItemIDs: logProjectedTimelineTextItemIDs ) } diff --git a/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift b/Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift similarity index 93% rename from Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift rename to Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift index 5b7ad1cd..869f6b6e 100644 --- a/Sources/CodexReviewKit/Model/ReviewTimelineLegacyLogProjection.swift +++ b/Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift @@ -1,7 +1,7 @@ import Foundation @MainActor -package struct ReviewTimelineLegacyLogProjector { +package struct ReviewTimelineLogProjector { private let timeline: ReviewTimeline package init(timeline: ReviewTimeline) { @@ -16,7 +16,7 @@ package struct ReviewTimelineLegacyLogProjector { @MainActor extension CodexReviewJob { package var timelineLogEntries: [ReviewLogEntry] { - ReviewTimelineLegacyLogProjector(timeline: timeline).logEntries + ReviewTimelineLogProjector(timeline: timeline).logEntries } } @@ -59,11 +59,11 @@ private extension ReviewLogEntry { ) )] case .plan(let plan): - return [entry(item: item, kind: legacyKind(for: item), text: plan.markdown)] + return [entry(item: item, kind: logKind(for: item), text: plan.markdown)] case .reasoning(let reasoning): return [entry( item: item, - kind: legacyKind(for: item, fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary), + kind: logKind(for: item, fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary), text: reasoning.text )] case .toolCall(let toolCall): @@ -109,7 +109,7 @@ private extension ReviewLogEntry { 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)] + return [entry(item: item, kind: logKind(for: item), text: diagnostic.message)] case .approval(let approval): return [entry( item: item, @@ -119,7 +119,7 @@ private extension ReviewLogEntry { case .unknown(let unknown): return [entry( item: item, - kind: legacyKind(for: item), + kind: logKind(for: item), text: [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n") )] } @@ -155,7 +155,7 @@ private extension ReviewLogEntry { ReviewLogEntry( id: stableLogEntryID(item: item, kind: kind), kind: kind, - groupID: legacyGroupID(for: item, kind: kind), + groupID: logGroupID(for: item, kind: kind), replacesGroup: false, text: text, metadata: metadata, @@ -208,7 +208,7 @@ private extension ReviewLogEntry { } @MainActor - private static func legacyKind( + private static func logKind( for item: ReviewTimelineItem, fallback: ReviewLogEntry.Kind = .event ) -> ReviewLogEntry.Kind { @@ -216,7 +216,7 @@ private extension ReviewLogEntry { } @MainActor - private static func legacyGroupID( + private static func logGroupID( for item: ReviewTimelineItem, kind: ReviewLogEntry.Kind ) -> String { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 50112a78..df562a23 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -835,14 +835,14 @@ extension CodexReviewStore { } var updatedRun = currentRun switch event { - case .domainEvents(let events, let legacyProjectionSuppressionCount): + case .domainEvents(let events, let logProjectionSuppressionCount): job.applyDirectTimelineEvents( events, - legacyProjectionSuppressionCount: legacyProjectionSuppressionCount, + logProjectionSuppressionCount: logProjectionSuppressionCount, at: clock.now() ) - case .suppressNextLegacyTimelineProjection: - job.suppressNextLegacyTimelineProjection() + case .suppressNextLogTimelineProjection: + job.suppressNextLogTimelineProjection() case .suppressNextTerminalFailureLogTimelineProjection: job.suppressNextTerminalFailureLogTimelineProjection() case .started(let turnID, let reviewThreadID, let model): @@ -865,7 +865,7 @@ extension CodexReviewStore { 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() + job.discardPendingLogTimelineProjectionSuppression() return updatedRun } job.core.output.lastAgentMessage = updatedMessage @@ -1071,7 +1071,7 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection, .started, .message, diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift index 3aea3e7d..42c9ee47 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift @@ -1253,7 +1253,7 @@ extension ReviewMonitorLogScrollView { false } - var usesLegacyLayoutManagerForTesting: Bool { + var usesLogLayoutManagerForTesting: Bool { false } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift index dbe8bae5..a6538df8 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift @@ -112,7 +112,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { case .diagnostic(let diagnostic): return [projectedBlock( block, - kind: legacyKind(for: block, fallback: .diagnostic), + kind: logKind(for: block, fallback: .diagnostic), text: diagnostic.message, metadata: metadata( for: block, @@ -136,11 +136,11 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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)] + return [projectedBlock(block, kind: logKind(for: block, fallback: .plan), text: plan.markdown)] case .reasoning(let reasoning): return [projectedBlock( block, - kind: legacyKind( + kind: logKind( for: block, fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary ), @@ -188,7 +188,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { case .unknown(let unknown): return [projectedBlock( block, - kind: legacyKind(for: block, fallback: .event), + kind: logKind(for: block, fallback: .event), text: [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n"), metadata: metadata( for: block, @@ -224,7 +224,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { ReviewMonitorLog.BlockID("\(prefix):\(block.id.rawValue)") } - private static func legacyKind( + private static func logKind( for block: ReviewTimelineDocument.Block, fallback: ReviewLogEntry.Kind ) -> ReviewLogEntry.Kind { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 46b6d0f5..f5ec984c 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -839,8 +839,8 @@ extension ReviewMonitorTransportViewController { logScrollView.usesTextViewForTesting } - var logUsesLegacyLayoutManagerForTesting: Bool { - logScrollView.usesLegacyLayoutManagerForTesting + var logUsesLogLayoutManagerForTesting: Bool { + logScrollView.usesLogLayoutManagerForTesting } var logIsEditableForTesting: Bool { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index b4c1e589..595b078e 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -15,7 +15,7 @@ private struct BackendReviewEventSequence: AsyncSequence { if includesDomainEvents == false { switch event { case .domainEvents, - .suppressNextLegacyTimelineProjection, + .suppressNextLogTimelineProjection, .suppressNextTerminalFailureLogTimelineProjection: continue case .started, @@ -271,7 +271,7 @@ struct AppServerClientTests { guard case .domainEvents = try await nextEvent(from: attempt.events), case .domainEvents = try await nextEvent(from: attempt.events) else { - Issue.record("expected direct timeline updates before coalesced legacy log") + Issue.record("expected direct timeline updates before coalesced log") return } guard case .logEntry(.commandOutput, let text, let groupID, let replacesGroup, let metadata) = diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index 6f4ca06c..fbcb7e01 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -176,7 +176,7 @@ private func commandDomainEvent(output: String) -> CodexReviewBackendModel.Revie phase: .running, content: .command(.init(command: "swift test", output: output)) )), - ], legacyProjectionSuppressionCount: 1) + ], logProjectionSuppressionCount: 1) } private func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 4c1eb92f..d7892d9c 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -66,7 +66,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func domainEventsMutateTimelineAndSuppressLegacyLogProjection() async throws { + @Test func domainEventsMutateTimelineAndSuppressLogProjection() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -90,7 +90,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .message(.init(text: "")) )), - ], legacyProjectionSuppressionCount: 0)) + ], logProjectionSuppressionCount: 0)) #expect(await waitUntil { store.job(id: "job-1")?.timeline.item(for: itemID) != nil }) @@ -104,7 +104,7 @@ struct CodexReviewStoreCommandTests { content: .message(.init(text: "")), delta: "domain text" ), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) #expect(await waitUntil { guard let item = store.job(id: "job-1")?.timeline.item(for: itemID), case .message(let message) = item.content @@ -118,12 +118,12 @@ struct CodexReviewStoreCommandTests { await backend.yield(.logEntry( kind: .agentMessage, - text: " legacy text", + text: " log text", groupID: "msg-1", replacesGroup: false )) #expect(await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == " legacy text" } == true + store.job(id: "job-1")?.logEntries.contains { $0.text == " log text" } == true }) let job = try #require(store.job(id: "job-1")) @@ -137,7 +137,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.logEntry( kind: .diagnostic, - text: "legacy-only diagnostic", + text: "log-only diagnostic", groupID: nil, replacesGroup: false )) @@ -148,7 +148,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func terminalCommandCompatibilityLogUpdatesDirectTimelineItem() async throws { + @Test func terminalCommandRetainedLogTextUpdatesDirectTimelineItem() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -171,7 +171,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .command(.init(command: "swift test")) )), - ], legacyProjectionSuppressionCount: 0)) + ], logProjectionSuppressionCount: 0)) #expect(await waitUntil { store.job(id: "job-1")?.timeline.activeItemIDs.contains(itemID) == true }) @@ -203,7 +203,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func skippedLegacyDeltaConsumesDirectProjectionSuppression() async throws { + @Test func skippedLogDeltaConsumesDirectProjectionSuppression() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -226,7 +226,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: "final")) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: "final", @@ -245,7 +245,7 @@ struct CodexReviewStoreCommandTests { content: .message(.init(text: "")), delta: "late" ), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.messageDelta("late", itemID: "msg-1")) #expect(await waitUntil { store.job(id: "job-1")?.logEntries.contains { $0.text == "late" } == false @@ -260,7 +260,7 @@ struct CodexReviewStoreCommandTests { let timelineCount = try #require(store.job(id: "job-1")?.timeline.items.count) await backend.yield(.logEntry( kind: .diagnostic, - text: "legacy-only diagnostic", + text: "log-only diagnostic", groupID: nil, replacesGroup: false )) @@ -293,7 +293,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .message(.init(text: "partial")) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: "partial", @@ -309,7 +309,7 @@ struct CodexReviewStoreCommandTests { content: .message(.init(text: "")), delta: " delta" ), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.messageDelta(" delta", itemID: "msg-1")) #expect(await waitUntil { @@ -326,7 +326,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func directTerminalErrorSuppressesCompatibleErrorLogTimelineProjection() async throws { + @Test func directTerminalErrorSuppressesErrorLogTimelineProjection() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -350,7 +350,7 @@ struct CodexReviewStoreCommandTests { phase: .failed, content: .diagnostic(.init(message: longError)) )), - ], legacyProjectionSuppressionCount: 0)) + ], logProjectionSuppressionCount: 0)) await backend.yield(.suppressNextTerminalFailureLogTimelineProjection) await backend.yield(.failed(longError)) @@ -400,7 +400,7 @@ struct CodexReviewStoreCommandTests { content: .message(.init(text: "")), delta: longText ), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: longText, @@ -453,7 +453,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: longText, @@ -521,7 +521,7 @@ struct CodexReviewStoreCommandTests { content: .command(.init(command: "swift test")), delta: secondChunk ), - ], legacyProjectionSuppressionCount: 2)) + ], logProjectionSuppressionCount: 2)) let outputMetadata = ReviewLogEntry.Metadata( sourceType: "commandExecution", title: "Command output", @@ -563,7 +563,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func syntheticDirectTimelineTextIsTrimmedThroughSuppressedCompatibilityLog() async throws { + @Test func syntheticDirectTimelineTextIsTrimmedThroughSuppressedRetainedLogText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -587,7 +587,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: longText, @@ -608,7 +608,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func eventCompatibilityLogTrimsDirectDiagnosticTimelineText() async throws { + @Test func eventRetainedLogTextTrimsDirectDiagnosticTimelineText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -632,7 +632,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .diagnostic(.init(message: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .event, text: longText, @@ -656,7 +656,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func diffCompatibilityLogTrimsDirectFileChangeTimelineText() async throws { + @Test func diffRetainedLogTextTrimsDirectFileChangeTimelineText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -680,7 +680,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .fileChange(.init(title: "", output: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .event, text: longText, @@ -704,7 +704,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func fileChangeOutputCompatibilityLogTrimsDirectFileChangeTimelineText() async throws { + @Test func fileChangeOutputRetainedLogTextTrimsDirectFileChangeTimelineText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -728,7 +728,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .fileChange(.init(title: "Sources/App.swift", output: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .commandOutput, text: longText, @@ -753,7 +753,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func fileChangeStatusCompatibilityLogDoesNotTrimDirectPatchText() async throws { + @Test func fileChangeStatusRetainedLogTextDoesNotTrimDirectPatchText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -777,7 +777,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .fileChange(.init(title: "Sources/App.swift", output: patchText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: "File changes updated.", @@ -806,7 +806,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func searchCompatibilityLogTrimsDirectSearchResult() async throws { + @Test func searchRetainedLogTextTrimsDirectSearchResult() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -830,7 +830,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .search(.init(query: "ReviewTimeline", result: longResult)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: longResult, @@ -877,7 +877,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func searchCompletionSummaryCompatibilityLogDoesNotClearDirectResult() async throws { + @Test func searchCompletionSummaryRetainedLogTextDoesNotClearDirectResult() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -900,7 +900,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .search(.init(query: "ReviewTimeline", result: "short result")) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: "Web search completed: ReviewTimeline.", @@ -935,7 +935,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func unownedLargeResultMetadataIsDroppedBeforeLegacyTimelineProjection() async throws { + @Test func unownedLargeResultMetadataIsDroppedBeforeLogTimelineProjection() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -980,7 +980,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func directRawReasoningTimelineTextTrimUsesLegacyGroupIDAndBumpsRevision() async throws { + @Test func directRawReasoningTimelineTextTrimUsesLogGroupIDAndBumpsRevision() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1004,7 +1004,7 @@ struct CodexReviewStoreCommandTests { content: .reasoning(.init(text: "", style: .raw)), delta: longText ), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .rawReasoning, text: longText, @@ -1060,7 +1060,7 @@ struct CodexReviewStoreCommandTests { phase: .running, content: .plan(.init(markdown: longText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .todoList, text: longText, @@ -1111,7 +1111,7 @@ struct CodexReviewStoreCommandTests { progress: "Reading review job" )) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: "Reading review job", @@ -1356,7 +1356,7 @@ struct CodexReviewStoreCommandTests { error: longError )) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: longError, @@ -1417,7 +1417,7 @@ struct CodexReviewStoreCommandTests { result: longResult )) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .toolCall, text: longResult, @@ -1450,7 +1450,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func mappedDirectTimelineTextClearsWhenCompatibilityLogIsRemovedByLimit() async throws { + @Test func mappedDirectTimelineTextClearsWhenRetainedLogTextIsRemovedByLimit() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1476,7 +1476,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: firstText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: firstText, @@ -1491,7 +1491,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: secondText)) )), - ], legacyProjectionSuppressionCount: 1)) + ], logProjectionSuppressionCount: 1)) await backend.yield(.logEntry( kind: .agentMessage, text: secondText, @@ -1517,7 +1517,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func legacyTimelineTextFromBeforeDirectEventsIsTrimmed() async throws { + @Test func logTimelineTextFromBeforeDirectEventsIsTrimmed() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1531,11 +1531,11 @@ struct CodexReviewStoreCommandTests { ) _ = try await result - let longText = String(repeating: "legacy", count: 60_000) + let longText = String(repeating: "log", count: 120_000) await backend.yield(.logEntry( kind: .agentMessage, text: longText, - groupID: "legacy-message", + groupID: "log-message", replacesGroup: true )) #expect(await waitUntil { @@ -1546,7 +1546,7 @@ struct CodexReviewStoreCommandTests { return message.text == longText } == true }) - let legacyItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in + let logItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in guard case .message(let message) = item.content else { return false } @@ -1561,14 +1561,14 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: "direct")) )), - ], legacyProjectionSuppressionCount: 0)) + ], logProjectionSuppressionCount: 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)) + let item = try #require(job.timeline.item(for: logItemID)) guard case .message(let message) = item.content else { Issue.record("expected message timeline content") return @@ -1577,7 +1577,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func legacyProjectedTimelineTextIsTrimmedAfterDirectEvents() async throws { + @Test func logProjectedTimelineTextIsTrimmedAfterDirectEvents() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1599,7 +1599,7 @@ struct CodexReviewStoreCommandTests { phase: .completed, content: .message(.init(text: "direct")) )), - ], legacyProjectionSuppressionCount: 0)) + ], logProjectionSuppressionCount: 0)) let longText = String(repeating: "y", count: 300_000) await backend.yield(.logEntry( @@ -1616,7 +1616,7 @@ struct CodexReviewStoreCommandTests { return diagnostic.message == longText } == true }) - let legacyItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in + let logItemID = try #require(store.job(id: "job-1")?.timeline.items.first { item in guard case .diagnostic(let diagnostic) = item.content else { return false } @@ -1627,7 +1627,7 @@ struct CodexReviewStoreCommandTests { #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)) + let item = try #require(store.job(id: "job-1")?.timeline.item(for: logItemID)) guard case .diagnostic(let diagnostic) = item.content else { Issue.record("expected diagnostic timeline content") return diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 86fca926..12580d9d 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -482,7 +482,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadAddsSemanticTimelineWithoutChangingLegacyShape() async throws { + @Test func streamableHTTPReviewReadAddsSemanticTimelineWithoutChangingLogShape() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) diff --git a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift index 9d13079e..979df3a6 100644 --- a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift +++ b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift @@ -272,7 +272,7 @@ struct ReviewTimelineDocumentRendererTests { ]) } - @Test func plainTextIsDerivedFromDocumentAndMatchesLegacyText() throws { + @Test func plainTextIsDerivedFromDocumentAndMatchesLogText() throws { let timeline = ReviewTimeline() complete( diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 2c45688d..68d95737 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -200,7 +200,7 @@ struct ReviewMonitorSelectedCodexChatTests { job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" job.core.run.turnID = "turn-1" - job.appendLogEntry(.init(kind: .agentMessage, text: "Legacy trigger")) + job.appendLogEntry(.init(kind: .agentMessage, text: "Log trigger")) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" @@ -251,13 +251,13 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: "review-thread", turnID: "turn-1" ) - job.appendLogEntry(.init(kind: .agentMessage, text: "Legacy fallback")) + job.appendLogEntry(.init(kind: .agentMessage, text: "Log fallback")) uiState.selection = .job(job) let initialSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Chat snapshot") } - #expect(initialSnapshot.log.contains("Legacy fallback") == false) + #expect(initialSnapshot.log.contains("Log fallback") == false) try await runtime.transport.emitServerNotification( method: "item/updated", @@ -277,7 +277,7 @@ struct ReviewMonitorSelectedCodexChatTests { snapshot.log.contains("Chat stream update") } #expect(updatedSnapshot.log.contains("Chat snapshot") == false) - #expect(updatedSnapshot.log.contains("Legacy fallback") == false) + #expect(updatedSnapshot.log.contains("Log fallback") == false) } @Test func selectedCodexChatRendersThreadAndLiveUpdates() async throws { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 63f2e84c..2068bd74 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5784,7 +5784,7 @@ struct ReviewUITests { #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) From 216d4522548a3479e1bccbba0a983698b18cc277 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:54:30 +0900 Subject: [PATCH 108/359] refactor(review-host): require persisted account kind --- .../LiveCodexReviewStoreBackend.swift | 16 +----- .../CodexReviewHostTests.swift | 49 +------------------ 2 files changed, 2 insertions(+), 63 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 9ef5ed7e..0bf8f769 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1714,8 +1714,7 @@ 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) - self.kind = try container.decodeIfPresent(Kind.self, forKey: .kind) - ?? Kind.legacyDefault(accountKey: accountKey, email: email) + self.kind = try container.decode(Kind.self, forKey: .kind) self.planType = try container.decodeIfPresent(String.self, forKey: .planType) self.lastActivatedAt = try container.decodeIfPresent(Date.self, forKey: .lastActivatedAt) self.lastRateLimitFetchAt = try container.decodeIfPresent(Date.self, forKey: .lastRateLimitFetchAt) @@ -1754,19 +1753,6 @@ private enum CodexReviewAccountRegistry { } } - 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 - } - } } private struct SavedRateLimitWindow: Codable { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 486c6d2a..89dfa4b0 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -471,53 +471,6 @@ 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: FakeCodexAppServerTransport() - ) - - 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 = FakeCodexAppServerTransport() try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") @@ -1433,7 +1386,7 @@ struct CodexReviewHostTests { #expect(await transport.recordedRequests().map(\.method).contains("turn/interrupt")) } - @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutLegacyCleanup() async throws { + @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutAppServerCleanup() async throws { let homeURL = try temporaryHome() let networkMonitor = ManualCodexReviewNetworkMonitor() let transport = FakeCodexAppServerTransport() From e0fcfad9e0c6f57a7580ad98a806f1e23fe768bd Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:56:17 +0900 Subject: [PATCH 109/359] refactor(review-appserver): remove unused finish continuation flag --- .../AppServerCodexReviewBackend.swift | 21 +++++-------------- .../ReviewBackendEventSession.swift | 5 +---- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index c722ecdf..43c0c0cf 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -129,8 +129,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { _ = try await cancelReviewTurn(for: run) await finishReviewEventStream( threadID: run.threadID, - cancellationMessage: reason.message, - buffersMissingContinuation: true + cancellationMessage: reason.message ) } catch { await session.clearCancellationRequest() @@ -361,16 +360,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { 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 - ) + await session.finish(cancellationMessage: cancellationMessage) } private func cancelReviewTurn( @@ -1084,15 +1079,9 @@ private actor AppServerReviewEventSession { await pipeline.clearCancellationRequest() } - func finish( - cancellationMessage: String?, - buffersMissingContinuation: Bool = false - ) async { + func finish(cancellationMessage: String?) async { cancelTypedReviewStream() - await pipeline.finish( - cancellationMessage: cancellationMessage, - buffersMissingContinuation: buffersMissingContinuation - ) + await pipeline.finish(cancellationMessage: cancellationMessage) } func finish(throwing error: (any Error)?) async { diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 6cd0f47f..dcba2d3c 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -93,10 +93,7 @@ package actor ReviewBackendEventSession { cancellationRequestedMessage = nil } - package func finish( - cancellationMessage: String?, - buffersMissingContinuation _: Bool = false - ) async { + package func finish(cancellationMessage: String?) async { let precedingEvents = drainPendingStreamedLogEvents() if cancellationMessage == nil { cancelPendingStreamedLogFlush() From 4cdb3e599ba9312d860fdf6ad17bb3cfd70285ce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:17:42 +0900 Subject: [PATCH 110/359] refactor(review-appserver): simplify run thread fallback --- .../AppServerCodexReviewBackend.swift | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 43c0c0cf..942ff92a 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1177,36 +1177,14 @@ private extension CodexReviewBackendModel.Review.Run { if let identity = appServerReviewIdentity { return identity.associatedThreadIDs.map(\.rawValue) } - return fallbackThreadIDs(sourceLast: false) + return [threadID] } var appServerCleanupThreadIDs: [String] { if let identity = appServerReviewIdentity { return identity.cleanupThreadIDs.map(\.rawValue) } - return fallbackThreadIDs(sourceLast: true) - } - - private func fallbackThreadIDs(sourceLast: Bool) -> [String] { - var seen: Set = [] - var threadIDs: [String] = [] - let reviewThreadID = reviewThreadID?.nilIfEmpty - if sourceLast, - let reviewThreadID, - reviewThreadID != threadID, - seen.insert(reviewThreadID).inserted { - threadIDs.append(reviewThreadID) - } - if seen.insert(threadID).inserted { - threadIDs.append(threadID) - } - if sourceLast == false, - let reviewThreadID, - reviewThreadID != threadID, - seen.insert(reviewThreadID).inserted { - threadIDs.append(reviewThreadID) - } - return threadIDs + return [threadID] } } From 0bb2073c0941461a81757c1919380148cbe4018b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:18:14 +0900 Subject: [PATCH 111/359] refactor(review-mcp): remove read log projection API --- Docs/architecture.md | 19 +- Docs/mcp.md | 28 +- .../Model/ReviewTimelineLogProjection.swift | 263 ----------- .../ReviewAPI/CodexReviewAPI.swift | 1 - .../ReviewAPI/ReviewResults.swift | 110 +---- .../Store/CodexReviewStoreReviews.swift | 103 +---- .../CodexReviewMCPServer.swift | 12 +- .../CodexReviewMCPToolRequests.swift | 48 +- .../CodexReviewMCPToolResults.swift | 94 +--- .../CodexReviewMCPToolSchemas.swift | 16 - .../CodexReviewJobRenderingTests.swift | 23 +- .../CodexReviewStoreCommandTests.swift | 417 +----------------- .../CodexReviewMCPHTTPServerTests.swift | 327 +------------- 13 files changed, 72 insertions(+), 1389 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index 980e8336..96689716 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -14,7 +14,6 @@ raw app-server wire -> domain review event -> observable review timeline -> UI / MCP / rendering projections - -> log-entry projection only at explicit `review_read` edges ``` Raw JSON-RPC notifications are an input boundary only. They must not become the @@ -29,7 +28,7 @@ This table describes intended ownership. | Target | Responsibility | | --- | --- | | `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server review notification schema, high-level review stream/session API, and Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. Raw review DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | -| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, auth/settings/runtime product state, and `review_read` log-entry projections. It has no app-server wire, UI, or MCP dependencies | +| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, and auth/settings/runtime product state. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | | `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | | `CodexReviewHost` | Runtime composition for ReviewMonitor | @@ -82,15 +81,11 @@ available. Rendering helpers and MCP server projections project from semantic state; they do not parse raw app-server events and do not make string logs authoritative again. -Log-entry support remains only at explicit `review_read` API edges: - - `ReviewLogEntryTimelineProjection` rebuilds semantic timeline state from existing log entries for persisted or test-created job state that predates direct timeline events. -- `ReviewTimelineLogProjection` derives log entries from timeline - items when `review_read.logs` needs paged log-entry output. -Those projections are not ReviewMonitor UI owners. New behavior should prefer +This projection is not a ReviewMonitor UI owner. New behavior should prefer domain events, `CodexDataKit` models, and timeline documents as the owner. ## Target Graph @@ -109,7 +104,6 @@ flowchart TB subgraph Product["CodexReviewKit product API"] PublicStore["CodexReviewStore"] - LogProjection["Log-entry projections"] end subgraph Kit["CodexAppServerKit"] @@ -145,7 +139,6 @@ flowchart TB DomainEvents --> Timeline Timeline --> AppStore Timeline --> PublicStore - Timeline --> LogProjection Timeline --> MCPServer Timeline --> Renderer PublicStore --> UI @@ -229,10 +222,10 @@ the store. ReviewMonitor backend events and adds ReviewMonitor-specific cleanup and recovery on top of that generic boundary. -The intended ownership for review logs is that `CodexAppServerKit` supplies +The intended ownership for review progress is that `CodexAppServerKit` supplies generic app-server thread events, log entries, and high-level review session events, while ReviewMonitor-specific targets adapt those values into -`ReviewDomainEvent`, `ReviewTimeline`, and review log-entry projections. +`ReviewDomainEvent` and `ReviewTimeline`. Fake and live tests use the same transport protocol. @@ -283,8 +276,8 @@ The current ReviewMonitor UI model boundary is therefore: - `CodexDataKit`: generic observable app-server models and fetch/query owners. - `CodexReviewKit`: review-job, review-timeline, review-auth/settings product - state, `review_read` log-entry projections, and product command state until the - matching generic CodexDataKit owners are available. + state, and product command state until the matching generic CodexDataKit + owners are available. - `ReviewUI`: native AppKit/SwiftUI rendering state such as sidebar selection, filters, installed controllers, row views, and transient presentation. diff --git a/Docs/mcp.md b/Docs/mcp.md index 07ee69dd..41a2c250 100644 --- a/Docs/mcp.md +++ b/Docs/mcp.md @@ -62,8 +62,7 @@ Notes: 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 the detailed timeline for a job. If you are unsure how to build the `target` object, read: @@ -85,8 +84,8 @@ Inputs: - `jobId` or `jobID` 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. +`lifecycle`, and `output`. Use `review_read` when timeline item details are +needed. If the job is still running after the bounded wait, call `review_await` again with the same `jobId`. @@ -96,31 +95,14 @@ with the same `jobId`. 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 - Returns: - `jobId` - `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 +- `timeline` with ordered item IDs, active item IDs, terminal summary/result, + and item details. ### `review_list` diff --git a/Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift b/Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift deleted file mode 100644 index 869f6b6e..00000000 --- a/Sources/CodexReviewKit/Model/ReviewTimelineLogProjection.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Foundation - -@MainActor -package struct ReviewTimelineLogProjector { - private let timeline: ReviewTimeline - - package init(timeline: ReviewTimeline) { - self.timeline = timeline - } - - package var logEntries: [ReviewLogEntry] { - timeline.items.flatMap { ReviewLogEntry.projectingTimelineItem($0) } - } -} - -@MainActor -extension CodexReviewJob { - package var timelineLogEntries: [ReviewLogEntry] { - ReviewTimelineLogProjector(timeline: timeline).logEntries - } -} - -private extension ReviewLogEntry { - @MainActor - static func projectingTimelineItem(_ item: ReviewTimelineItem) -> [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: logKind(for: item), text: plan.markdown)] - case .reasoning(let reasoning): - return [entry( - item: item, - kind: logKind(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: logKind(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: logKind(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: logGroupID(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 logKind( - for item: ReviewTimelineItem, - fallback: ReviewLogEntry.Kind = .event - ) -> ReviewLogEntry.Kind { - ReviewLogEntry.Kind(rawValue: item.kind.rawValue) ?? fallback - } - - @MainActor - private static func logGroupID( - 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/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift index 6a9e5c0e..be135de9 100644 --- a/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift +++ b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift @@ -1,7 +1,6 @@ package enum CodexReviewAPI { package enum Start {} package enum Read {} - package enum Log {} package enum Job {} package enum List {} package enum Cancel {} diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift index d7c5c076..cbe7bba0 100644 --- a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift +++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift @@ -6,125 +6,17 @@ struct Result: Codable, Sendable, Hashable { 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 + cancellable: Bool ) { 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 - } } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index df562a23..fa08365d 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -274,37 +274,23 @@ 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(jobID: String) throws -> CodexReviewAPI.Read.Result { + try readReview(sessionID: nil, jobID: jobID) } package func readReview( sessionID: String?, - jobID: String, - logFilter: CodexReviewAPI.Log.Filter = .defaultSetting, - logPage: CodexReviewAPI.Log.PageRequest = .default + jobID: 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) 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.. [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 shouldCompleteAgentMessageReplacement(metadata: ReviewLogEntry.Metadata?) -> Bool { guard let status = metadata?.status?.nilIfEmpty else { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 60fcf4ba..4134381f 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -31,7 +31,7 @@ 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 reviewRead(sessionID: String?, jobID: String) case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewJobState]?, limit: Int?) case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Job.Selector, reason: ReviewCancellation) } @@ -53,7 +53,7 @@ package extension CodexReviewMCP.Tool { internal enum Response: Equatable, Sendable { case reviewStart(ReviewSnapshot) case reviewAwait(ReviewSnapshot) - case reviewRead(ReviewSnapshot, timelinePage: CodexReviewAPI.Log.PageRequest) + case reviewRead(ReviewSnapshot) case reviewList(CodexReviewAPI.List.Result) case reviewCancel(CodexReviewAPI.Cancel.Outcome) } @@ -105,17 +105,15 @@ package final class CodexReviewMCPServer { sessionID: sessionID ) return .reviewAwait(snapshot) - case .reviewRead(let sessionID, let jobID, let logFilter, let logPage): + case .reviewRead(let sessionID, let jobID): let snapshot = try reviewSnapshot( try store.readReview( sessionID: sessionID, - jobID: jobID, - logFilter: logFilter, - logPage: logPage + jobID: jobID ), sessionID: sessionID ) - return .reviewRead(snapshot, timelinePage: logPage) + return .reviewRead(snapshot) case .reviewList(let sessionID, let cwd, let statuses, let limit): return .reviewList(store.listReviews( sessionID: sessionID, diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift index 61948400..a30ceb98 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -27,9 +27,7 @@ func toolRequest( 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) + jobID: try requiredJobID(in: arguments) ) case .reviewList: return .reviewList( @@ -76,50 +74,6 @@ func requiredJobID(in arguments: [String: Value]) throws -> String { return jobID } -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 -} - -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) -} - func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target { switch object["type"]?.stringValue { case "uncommittedChanges": diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index bf80f142..1cccc4f0 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -12,8 +12,8 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul value = snapshot.result.structuredContentForStartOrAwait(timeline: snapshot.timeline) text = snapshot.result.textContentForStartOrAwait() isError = snapshot.result.core.lifecycle.status == .failed - case .reviewRead(let snapshot, let timelinePage): - value = snapshot.result.structuredContentForRead(timeline: snapshot.timeline, timelinePage: timelinePage) + case .reviewRead(let snapshot): + value = snapshot.result.structuredContentForRead(timeline: snapshot.timeline) text = snapshot.result.textContentForRead() isError = snapshot.result.core.lifecycle.status == .failed case .reviewList(let result): @@ -54,31 +54,11 @@ private extension CodexReviewAPI.Read.Result { 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 { @@ -89,23 +69,18 @@ private extension CodexReviewAPI.Read.Result { ) } - func structuredContentForRead( - timeline: ReviewMCPProjection, - timelinePage: CodexReviewAPI.Log.PageRequest? - ) -> Value { + func structuredContentForRead(timeline: ReviewMCPProjection) -> Value { structuredContent( includeDetails: true, includeNextAction: false, - timeline: timeline, - timelinePage: timelinePage ?? .default + timeline: timeline ) } func structuredContent( includeDetails: Bool, includeNextAction: Bool, - timeline: ReviewMCPProjection, - timelinePage: CodexReviewAPI.Log.PageRequest? = nil + timeline: ReviewMCPProjection ) -> Value { var object: [String: Value] = [ "jobId": .string(jobID), @@ -116,13 +91,8 @@ private extension CodexReviewAPI.Read.Result { ), "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.structuredContentWithItems() : timeline.structuredContent() if includeNextAction { object["nextAction"] = .object([ @@ -161,7 +131,7 @@ private extension ReviewMCPProjection { return .object(object) } - func structuredContent(pageRequest: CodexReviewAPI.Log.PageRequest) -> Value { + func structuredContentWithItems() -> Value { var truncatedFields: [String] = [] var object: [String: Value] = [ "revision": timelineRevision.rawValue.structuredRevisionValue(), @@ -180,8 +150,17 @@ private extension ReviewMCPProjection { truncatedFields: &truncatedFields ), ] - let page = TimelineItemPage(pageRequest: pageRequest, total: items.count) - object["items"] = .array(items[page.range].map { $0.structuredContent() }) + let page = TimelineItemPage( + total: items.count, + offset: 0, + limit: items.count, + returned: items.count, + hasMoreBefore: false, + hasMoreAfter: false, + previousOffset: nil, + nextOffset: nil + ) + object["items"] = .array(items.map { $0.structuredContent() }) object["itemsPage"] = page.structuredContent() object["truncatedFields"] = .array(truncatedFields.map(Value.string)) return .object(object) @@ -443,20 +422,6 @@ private struct TimelineItemPage { ) } - 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), @@ -514,27 +479,6 @@ private extension UInt64 { } } -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 { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift index 025ad64c..3b0c1806 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift @@ -43,22 +43,6 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { "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: diff --git a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift index 03d4efc4..9836cd5f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift @@ -33,7 +33,7 @@ struct CodexReviewJobRenderingTests { """) } - @Test func outputOnlyCommandLogsDoNotSynthesizePlaceholderCommandLine() throws { + @Test func outputOnlyCommandLogsProjectToTimelineWithoutPlaceholderCommandLine() throws { let outputOnlyJob = CodexReviewJob.makeForTesting( id: "job-output-only-command", cwd: "/tmp/workspace", @@ -60,26 +60,7 @@ struct CodexReviewJobRenderingTests { 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"]) + #expect(outputOnlyCommand.output == "Build complete\n") } @Test func fileChangeCommandOutputChunksAccumulateInTimelineProjection() throws { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index d7892d9c..4a48d4fb 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1151,127 +1151,9 @@ struct CodexReviewStoreCommandTests { 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( @@ -1322,10 +1204,6 @@ struct CodexReviewStoreCommandTests { } 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 { @@ -1814,11 +1692,6 @@ struct CodexReviewStoreCommandTests { #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") } @@ -1842,7 +1715,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "second") #expect(read.core.reviewText == "second") - #expect(try store.readReview(jobID: "job-1").logs.map(\.text) == ["first", "second"]) } } @@ -1868,7 +1740,6 @@ struct CodexReviewStoreCommandTests { #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"]) } } @@ -1909,277 +1780,6 @@ struct CodexReviewStoreCommandTests { } } - @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( @@ -2603,7 +2203,7 @@ struct CodexReviewStoreCommandTests { false } }) - let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n") + let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("completed review text") == false) } } @@ -2670,7 +2270,7 @@ struct CodexReviewStoreCommandTests { false } }) - let logText = try store.readReview(jobID: "job-1").logs.map(\.text).joined(separator: "\n") + let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("completed during settle") == false) } } @@ -2915,7 +2515,7 @@ struct CodexReviewStoreCommandTests { #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") + let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("Network unavailable; waiting to reconnect.")) #expect(logText.contains("Network restored; restarting review.")) #expect(logText.contains("stale aborted output") == false) @@ -2972,7 +2572,7 @@ struct CodexReviewStoreCommandTests { #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") + let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("stale output") == false) #expect(logText.contains("fresh review")) } @@ -3594,7 +3194,7 @@ struct CodexReviewStoreCommandTests { #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") + let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("stale old attempt output") == false) } } @@ -3664,7 +3264,9 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .failed) #expect(read.core.lifecycle.errorMessage == "Rollback failed") - #expect(read.logs.contains { $0.kind == .error && $0.text == "Rollback failed" }) + #expect(try #require(store.job(id: "job-1")).logEntries.contains { + $0.kind == .error && $0.text == "Rollback failed" + }) } } @@ -3714,7 +3316,7 @@ struct CodexReviewStoreCommandTests { jobID: "job-1", cancellation: .mcpClient(message: "Stop") ) - let read = try store.readReview(jobID: "job-1", logFilter: .all) + let read = try store.readReview(jobID: "job-1") let commandLogs = try #require(store.job(id: "job-1")) .logEntries .filter { $0.kind == .command && $0.groupID == "cmd-1" } @@ -3809,7 +3411,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .failed) #expect(read.core.output.lastAgentMessage == "partial review") - #expect(read.logs.map(\.text).contains("partial review")) } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 12580d9d..81fa0433 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -43,8 +43,9 @@ 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]) @@ -443,37 +444,12 @@ struct CodexReviewMCPHTTPServerTests { ], ] ) - 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", "logs"]) == nil) + #expect(allowed.value(for: ["result", "structuredContent", "logsPage"]) == nil) + let timelineIDs = allowed.value(for: ["result", "structuredContent", "timeline", "orderedItemIds"]) as? [String] + #expect(timelineIDs?.contains("cmd-1") == true) let readText = (allowed.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String #expect(readText == "Done") #expect(readText?.contains("rawLogText") == false) @@ -482,7 +458,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadAddsSemanticTimelineWithoutChangingLogShape() async throws { + @Test func streamableHTTPReviewReadAddsSemanticTimeline() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -545,24 +521,6 @@ struct CodexReviewMCPHTTPServerTests { "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, ], ], ] @@ -572,23 +530,18 @@ struct CodexReviewMCPHTTPServerTests { #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"]) + #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 timeline = try #require(allResponse.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) + let timeline = try #require(defaultResponse.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["limit"] as? Int == 1) #expect(itemsPage["returned"] as? Int == 1) let items = try #require(timeline["items"] as? [[String: Any]]) let commandItem = try #require(items.first) @@ -652,15 +605,14 @@ struct CodexReviewMCPHTTPServerTests { "jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": [ - "name": "review_read", - "arguments": [ - "jobId": "job-tool-progress", - "logFilter": "all", + "params": [ + "name": "review_read", + "arguments": [ + "jobId": "job-tool-progress", + ], ], - ], - ] - ) + ] + ) let timeline = try #require(response.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) #expect(timeline["activeItemIds"] as? [String] == ["tool-1:progress"]) @@ -678,245 +630,6 @@ struct CodexReviewMCPHTTPServerTests { } } - @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.") - } - } - @Test func streamableHTTPCancelsReviewByTransportScopedSelector() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( From d346ee6d29ef7db579242549f78c6d134511685b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:35:20 +0900 Subject: [PATCH 112/359] refactor(review-ui): remove log-entry rendering path --- Docs/architecture.md | 22 +- .../AppServerCodexReviewBackend.swift | 329 +-- .../ReviewBackendEventSession.swift | 155 +- .../CodexReviewKit/CodexReviewBackend.swift | 29 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 6 +- .../CodexReviewKit/Model/CodexReviewJob.swift | 140 +- .../ReviewLogEntryTimelineProjection.swift | 595 ----- .../Model/ReviewTimelineTextRetention.swift | 268 +++ .../Store/CodexReviewStoreReviews.swift | 166 +- ...wMonitorCommandOutputDisplayDocument.swift | 333 +-- .../Detail/ReviewMonitorLogDocumentView.swift | 919 ++++---- .../Detail/ReviewMonitorLogProjection.swift | 1459 ++++-------- .../Detail/ReviewMonitorLogRenderer.swift | 64 - .../ReviewMonitorTimelineLogProjection.swift | 304 +-- ...ReviewMonitorTransportViewController.swift | 1338 +++++------ .../ReviewMonitorPreviewContent.swift | 377 ++- .../ReviewMonitorSelectedCodexChat.swift | 46 +- .../AppServerClientTests.swift | 154 +- .../ReviewBackendEventSessionTests.swift | 105 +- .../CodexReviewJobRenderingTests.swift | 170 +- .../CodexReviewStoreCommandTests.swift | 2060 +++++++++-------- .../CodexReviewMCPHTTPServerTests.swift | 287 ++- .../ReviewMonitorLogProjectionTests.swift | 1442 ------------ .../ReviewMonitorLogTesting.swift | 8 - .../ReviewMonitorSelectedCodexChatTests.swift | 23 +- .../ReviewMonitorTimelineTesting.swift | 266 +++ Tests/ReviewUITests/ReviewUITests.swift | 1640 +++++++------ 27 files changed, 5538 insertions(+), 7167 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift create mode 100644 Sources/CodexReviewKit/Model/ReviewTimelineTextRetention.swift delete mode 100644 Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift delete mode 100644 Tests/ReviewUITests/ReviewMonitorLogTesting.swift create mode 100644 Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift diff --git a/Docs/architecture.md b/Docs/architecture.md index 96689716..e37e6132 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -75,18 +75,16 @@ the store backend. `ReviewTimeline` and `CodexDataKit` model state are the observable sources of truth after conversion. ReviewMonitor UI should render selected chats through -`CodexChat` observation and `ReviewTimelineDocument` projection; release UI -does not fall back to raw review log entries when a selected Codex chat is not -available. Rendering helpers and MCP server projections project from semantic -state; they do not parse raw app-server events and do not make string logs -authoritative again. - -- `ReviewLogEntryTimelineProjection` rebuilds semantic timeline state from - existing log entries for persisted or test-created job state that predates - direct timeline events. - -This projection is not a ReviewMonitor UI owner. New behavior should prefer -domain events, `CodexDataKit` models, and timeline documents as the owner. +the `CodexChat` snapshot/change stream and `ReviewTimelineDocument` projection. +`ReviewLogEntry` is not a ReviewMonitor rendering contract and must not rebuild +or author semantic timeline state. Rendering helpers and MCP server projections +project from semantic state; they do not parse raw app-server events and do not +make string logs authoritative again. + +Collection/list style UI observes source-of-truth models through +ObservationBridge. Large transcript/log surfaces consume stream APIs that emit +an initial snapshot followed by changes so the UI can apply append/replacement +updates without re-reading the entire model. ## Target Graph diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 942ff92a..ecd22bac 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -41,7 +41,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) } - package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws -> CodexReviewBackendModel.Settings.Snapshot { + package func applySettings(_ change: CodexReviewBackendModel.Settings.Change) async throws + -> CodexReviewBackendModel.Settings.Snapshot + { var patch = CodexConfigurationPatch() if change.updatesModel { patch.setReviewModel(change.model?.nilIfEmpty) @@ -68,7 +70,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { try await appServer.rateLimits() } - package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws -> CodexReviewBackendModel.Login.Challenge { + package func startLogin(_: CodexReviewBackendModel.Login.Request) async throws + -> CodexReviewBackendModel.Login.Challenge + { let handle = try await appServer.loginChatGPT() return try handle.backendChallenge( nativeWebAuthenticationCallbackScheme: nil @@ -119,7 +123,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) } - package func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws { + package func interruptReview( + _ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason + ) async throws { guard abandonedReviewAttemptIDs.contains(run.attemptID) == false else { return } @@ -286,7 +292,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { ) { reviewEventSessionsByAttemptID[run.attemptID] = session let activeThreadIDs = Set(run.appServerAssociatedThreadIDs) - for threadID in activeThreadIDsByAttemptID[run.attemptID] ?? [] where activeThreadIDs.contains(threadID) == false { + for threadID in activeThreadIDsByAttemptID[run.attemptID] ?? [] + where activeThreadIDs.contains(threadID) == false { if activeReviewAttemptIDByThreadID[threadID] == run.attemptID { activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) } @@ -297,7 +304,8 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } reviewEventSessionCanonicalThreadIDByThreadID[run.threadID] = run.threadID if let reviewThreadID = run.reviewThreadID, - reviewThreadID != run.threadID { + reviewThreadID != run.threadID + { reviewEventSessionCanonicalThreadIDByThreadID[reviewThreadID] = run.threadID } } @@ -316,8 +324,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return reviewEventSessionsByAttemptID[attemptID] } - private func unregisterReviewEventSession(for run: CodexReviewBackendModel.Review.Run) -> AppServerReviewEventSession? { - let threadIDs = activeThreadIDsByAttemptID.removeValue(forKey: run.attemptID) + 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 { @@ -375,9 +386,10 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.") } if let session = reviewEventSessionsByAttemptID[run.attemptID], - let cancellation = try await session.cancelReview( + let cancellation = try await session.cancelReview( expectedTurnID: identity.turnID.rawValue - ) { + ) + { return cancellation } let review = try await appServer.resumeReview( @@ -448,11 +460,13 @@ private enum AppServerTypedReviewEventAdapter { run: CodexReviewBackendModel.Review.Run ) -> AppServerTypedReviewEvent { .init( - events: [.started( - turnID: review.turnID.rawValue, - reviewThreadID: review.reviewThreadID.rawValue, - model: run.model - )], + events: [ + .started( + turnID: review.turnID.rawValue, + reviewThreadID: review.reviewThreadID.rawValue, + model: run.model + ) + ], controlThreadID: review.reviewThreadID.rawValue ) } @@ -488,12 +502,15 @@ private enum AppServerTypedReviewEventAdapter { case .statusChanged(.closed): .init(events: [.failed("Review thread is no longer loaded.")], controlThreadID: controlThreadID) case .statusChanged(.unknown(let status)): - .init(events: [.logEntry( - kind: .diagnostic, - text: "Review thread status changed: \(status).", - groupID: review.turnID.rawValue, - replacesGroup: false - )], controlThreadID: controlThreadID) + .init( + events: [ + .logEntry( + kind: .diagnostic, + text: "Review thread status changed: \(status).", + groupID: review.turnID.rawValue, + replacesGroup: false + ) + ], controlThreadID: controlThreadID) case .closed: .init(events: [.failed("Review thread closed.")], controlThreadID: controlThreadID) case .unknown(let raw): @@ -513,12 +530,14 @@ private enum AppServerTypedReviewEventAdapter { message: response.status?.rawValue ?? "Failed." ) } - return [.completed( - summary: "Succeeded.", - result: response.finalAnswer?.nilIfEmpty - ?? response.transcript.finalAnswer?.nilIfEmpty - ?? response.transcript.responseText?.nilIfEmpty - )] + return [ + .completed( + summary: "Succeeded.", + result: response.finalAnswer?.nilIfEmpty + ?? response.transcript.finalAnswer?.nilIfEmpty + ?? response.transcript.responseText?.nilIfEmpty + ) + ] } private static func terminalFailureEvents( @@ -541,36 +560,41 @@ private enum AppServerTypedReviewEventAdapter { return [] } if phase == .completed, item.kind.rawValue == "exitedReviewMode" { - return [.completed( - summary: "Succeeded.", - result: item.text?.nilIfEmpty - )] + return [ + .completed( + summary: "Succeeded.", + result: item.text?.nilIfEmpty + ) + ] } guard item.kind != .userMessage, - let seed = timelineSeed(for: item, phase: phase) + let seed = timelineSeed(for: item, phase: phase) else { return [] } - let domainEvent: ReviewDomainEvent = switch phase { - case .started: - .itemStarted(seed) - case .updated: - .itemUpdated(seed) - case .completed: - .itemCompleted(seed) - } - let logEvents = logProjectionEvents(for: item, phase: phase) - return [.domainEvents( - [domainEvent], - logProjectionSuppressionCount: logEvents.immediateLogTimelineProjectionCount - )] + logEvents.addingTerminalFailureLogProjectionSuppressionIfNeeded + let domainEvent: ReviewDomainEvent = + switch phase { + case .started: + .itemStarted(seed) + case .updated: + .itemUpdated(seed) + case .completed: + .itemCompleted(seed) + } + let logEvents = logEntryEvents(for: item, phase: phase) + return [ + .domainEvents( + [domainEvent], + retainedLogEntryCount: logEvents.immediateTimelineTextRetentionCount + ) + ] + logEvents.addingTerminalFailureTimelineTextRetentionIfNeeded } private static func messageEvents( _ message: CodexMessage ) -> [CodexReviewBackendModel.Review.Event] { guard message.role != .user, - message.text.isEmpty == false + message.text.isEmpty == false else { return [] } @@ -597,7 +621,7 @@ private enum AppServerTypedReviewEventAdapter { delta: delta.text ) return [ - .domainEvents([domainEvent], logProjectionSuppressionCount: 1), + .domainEvents([domainEvent], retainedLogEntryCount: 1), .messageDelta(delta.text, itemID: itemID), ] } @@ -605,12 +629,13 @@ private enum AppServerTypedReviewEventAdapter { private static func reasoningPartEvents( _ part: CodexReasoningPart ) -> [CodexReviewBackendModel.Review.Event] { - let style: ReviewTimelineItem.Reasoning.Style = switch part.kind { - case .summary: - .summary - case .text: - .raw - } + let style: ReviewTimelineItem.Reasoning.Style = + switch part.kind { + case .summary: + .summary + case .text: + .raw + } let seed = ReviewTimelineItemSeed( id: .init(rawValue: part.id), kind: .reasoning, @@ -618,7 +643,7 @@ private enum AppServerTypedReviewEventAdapter { phase: .running, content: .reasoning(.init(text: "", style: style)) ) - return [.domainEvents([.itemStarted(seed)], logProjectionSuppressionCount: 0)] + return [.domainEvents([.itemStarted(seed)], retainedLogEntryCount: 0)] } private static func reasoningDeltaEvents( @@ -645,7 +670,7 @@ private enum AppServerTypedReviewEventAdapter { delta: delta.delta ) return [ - .domainEvents([domainEvent], logProjectionSuppressionCount: 1), + .domainEvents([domainEvent], retainedLogEntryCount: 1), .logEntry(kind: kind, text: delta.delta, groupID: delta.id, replacesGroup: false), ] } @@ -653,7 +678,8 @@ private enum AppServerTypedReviewEventAdapter { private static func unknownEvents( _ raw: CodexRawNotification ) -> [CodexReviewBackendModel.Review.Event] { - let itemID = raw.turnID?.rawValue + let itemID = + raw.turnID?.rawValue ?? raw.threadID?.rawValue ?? raw.method let detail = String(data: raw.params, encoding: .utf8) @@ -662,13 +688,14 @@ private enum AppServerTypedReviewEventAdapter { kind: .init(rawValue: raw.method), family: .unknown, phase: .running, - content: .unknown(.init( - title: raw.method, - detail: detail, - rawKind: .init(rawValue: raw.method) - )) + content: .unknown( + .init( + title: raw.method, + detail: detail, + rawKind: .init(rawValue: raw.method) + )) ) - return [.domainEvents([.itemUpdated(seed)], logProjectionSuppressionCount: 0)] + return [.domainEvents([.itemUpdated(seed)], retainedLogEntryCount: 0)] } private static func timelineSeed( @@ -684,23 +711,25 @@ private enum AppServerTypedReviewEventAdapter { ) } - private static func logProjectionEvents( + private static func logEntryEvents( for item: CodexThreadItem, phase: AppServerTypedItemPhase ) -> [CodexReviewBackendModel.Review.Event] { switch item.content { case .message(let message): guard message.role != .user, - message.text.isEmpty == false + message.text.isEmpty == false else { return [] } - return [item.logEntry( - kind: .agentMessage, - text: message.text, - phase: phase, - title: nil - )] + return [ + item.logEntry( + kind: .agentMessage, + text: message.text, + phase: phase, + title: nil + ) + ] case .plan(let text): return text.nilIfEmpty.map { [item.logEntry(kind: .plan, text: $0, phase: phase, title: nil)] @@ -730,34 +759,42 @@ private enum AppServerTypedReviewEventAdapter { case .command(let command): return commandLogEvents(item: item, command: command, phase: phase) case .fileChange(let fileChange): - let text = fileChange.output?.nilIfEmpty + let text = + fileChange.output?.nilIfEmpty ?? fileChange.path?.nilIfEmpty ?? "File changes \(item.logStatus(phase: phase) ?? "updated")." let kind: ReviewLogEntry.Kind = fileChange.output?.nilIfEmpty == nil ? .toolCall : .commandOutput - return [item.logEntry( - kind: kind, - text: text, - phase: phase, - title: "File changes" - )] + return [ + item.logEntry( + kind: kind, + text: text, + phase: phase, + title: "File changes" + ) + ] case .toolCall(let toolCall): let label = item.toolLabel - let text = toolCall.error?.nilIfEmpty + let text = + toolCall.error?.nilIfEmpty ?? toolCall.result?.nilIfEmpty ?? "\(label) \(item.logStatus(phase: phase) ?? "updated")." - return [item.logEntry( - kind: .toolCall, - text: text, - phase: phase, - title: label - )] + return [ + item.logEntry( + kind: .toolCall, + text: text, + phase: phase, + title: label + ) + ] case .contextCompaction(let text): - return [item.logEntry( - kind: .contextCompaction, - text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.logStatus(phase: phase)), - phase: phase, - title: nil - )] + return [ + item.logEntry( + kind: .contextCompaction, + text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.logStatus(phase: phase)), + phase: phase, + title: nil + ) + ] case .diagnostic(let text): return text.nilIfEmpty.map { [item.logEntry(kind: .diagnostic, text: $0, phase: phase, title: nil)] @@ -780,24 +817,26 @@ private enum AppServerTypedReviewEventAdapter { ) -> [CodexReviewBackendModel.Review.Event] { var events: [CodexReviewBackendModel.Review.Event] = [] if command.command.isEmpty == false { - events.append(item.logEntry( - kind: .command, - text: "$ \(command.command)", - phase: phase, - title: nil - )) + events.append( + item.logEntry( + kind: .command, + text: "$ \(command.command)", + phase: phase, + title: nil + )) } let output = command.output?.nilIfEmpty if let output { if command.command.isEmpty { events.append(item.commandOutputDeltaLogEntry(text: output)) } else { - events.append(item.logEntry( - kind: .commandOutput, - text: output, - phase: phase, - title: nil - )) + events.append( + item.logEntry( + kind: .commandOutput, + text: output, + phase: phase, + title: nil + )) } } return events @@ -860,53 +899,60 @@ private extension CodexThreadItem { case .plan(let text): .plan(.init(markdown: text)) case .reasoning(let reasoning): - .reasoning(.init( - text: reasoning.text, - style: reasoning.summary.isEmpty ? .raw : .summary - )) + .reasoning( + .init( + text: reasoning.text, + style: reasoning.summary.isEmpty ? .raw : .summary + )) case .command(let command): - .command(.init( - command: command.command, - cwd: command.cwd, - output: command.output ?? "", - exitCode: command.exitCode, - status: command.status.map { .init(rawValue: $0.rawValue) } - )) + .command( + .init( + command: command.command, + cwd: command.cwd, + output: command.output ?? "", + exitCode: command.exitCode, + status: command.status.map { .init(rawValue: $0.rawValue) } + )) case .fileChange(let fileChange): - .fileChange(.init( - title: "File changes", - output: fileChange.output ?? "", - paths: fileChange.path.map { [$0] } ?? [], - status: fileChange.status.map { .init(rawValue: $0.rawValue) } - )) + .fileChange( + .init( + title: "File changes", + output: fileChange.output ?? "", + paths: fileChange.path.map { [$0] } ?? [], + status: fileChange.status.map { .init(rawValue: $0.rawValue) } + )) case .toolCall(let toolCall): - .toolCall(.init( - namespace: toolCall.namespace, - server: toolCall.server, - tool: toolCall.name, - arguments: toolCall.arguments, - result: toolCall.result, - error: toolCall.error, - status: toolCall.status.map { .init(rawValue: $0.rawValue) } - )) + .toolCall( + .init( + namespace: toolCall.namespace, + server: toolCall.server, + tool: toolCall.name, + arguments: toolCall.arguments, + result: toolCall.result, + error: toolCall.error, + status: toolCall.status.map { .init(rawValue: $0.rawValue) } + )) case .contextCompaction(let text): - .contextCompaction(.init( - title: text?.nilIfEmpty ?? appServerContextCompactionStartedText, - status: logStatus(phase: .updated).map { .init(rawValue: $0) } - )) + .contextCompaction( + .init( + title: text?.nilIfEmpty ?? appServerContextCompactionStartedText, + status: logStatus(phase: .updated).map { .init(rawValue: $0) } + )) case .diagnostic(let text): - .diagnostic(.init( - message: text, - severity: kind == .error ? .error : nil - )) + .diagnostic( + .init( + message: text, + severity: kind == .error ? .error : nil + )) case .log(let text): .unknown(.init(title: kind.rawValue, detail: text, rawKind: .init(rawValue: kind.rawValue))) case .unknown(let raw): - .unknown(.init( - title: raw.rawType, - detail: raw.text, - rawKind: .init(rawValue: raw.rawType) - )) + .unknown( + .init( + title: raw.rawType, + detail: raw.text, + rawKind: .init(rawValue: raw.rawType) + )) } } @@ -1210,7 +1256,8 @@ private extension CodexModel { CodexReviewSettings.ReasoningOption(reasoningEffort: $0, description: option.description) } } - let defaultReasoningEffort = defaultReasoningEffort + let defaultReasoningEffort = + defaultReasoningEffort .flatMap { CodexReviewSettings.ReasoningEffort(rawValue: $0.rawValue) } ?? reasoningOptions.first?.reasoningEffort ?? .medium diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index dcba2d3c..c465cf84 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -15,17 +15,19 @@ package struct ReviewBackendEventSessionMetrics: Equatable, Sendable { 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 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 } + recordFinished: + @escaping @Sendable ( + _ run: CodexReviewBackendModel.Review.Run, + _ metrics: ReviewBackendEventSessionMetrics + ) async -> Void = { _, _ in } ) { self.recordTurnStarted = recordTurnStarted self.recordFinished = recordFinished @@ -61,7 +63,8 @@ package actor ReviewBackendEventSession { self.mailbox = mailbox self.callbacks = callbacks if let reviewThreadID = run.reviewThreadID?.nilIfEmpty, - reviewThreadID != run.threadID { + reviewThreadID != run.threadID + { self.reviewThreadIDsForCleanup.append(reviewThreadID) } } @@ -161,9 +164,10 @@ package actor ReviewBackendEventSession { let followingEvents = events[events.index(after: index)...] if shouldFlushPendingStreamedLogBeforeDomainEvent( followingEvents: followingEvents, - suppressTimelineProjection: false + retainTimelineText: false ), - await flushPendingStreamedLog(controlThreadID: controlThreadID) { + await flushPendingStreamedLog(controlThreadID: controlThreadID) + { return } if await emit(event, controlThreadID: controlThreadID) { @@ -212,14 +216,14 @@ package actor ReviewBackendEventSession { case .completed(let summary, nil): return .completed(summary: summary, result: typedReviewResultText) case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .log, - .logEntry, - .completed, - .failed, - .cancelled: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .started, + .log, + .logEntry, + .completed, + .failed, + .cancelled: return event } } @@ -245,8 +249,8 @@ package actor ReviewBackendEventSession { private func noteReviewThreadIDForCleanup(_ reviewThreadID: String?) { guard let reviewThreadID = reviewThreadID?.nilIfEmpty, - reviewThreadID != run.threadID, - reviewThreadIDsForCleanup.contains(reviewThreadID) == false + reviewThreadID != run.threadID, + reviewThreadIDsForCleanup.contains(reviewThreadID) == false else { return } @@ -266,7 +270,7 @@ package actor ReviewBackendEventSession { private func shouldFlushPendingStreamedLogBeforeDomainEvent( followingEvents: ArraySlice, - suppressTimelineProjection: Bool + retainTimelineText: Bool ) -> Bool { guard pendingStreamedLogEntries.isEmpty == false else { return false @@ -274,19 +278,21 @@ package actor ReviewBackendEventSession { return followingEvents.contains { canCoalescePendingStreamedLog( with: $0, - suppressTimelineProjection: suppressTimelineProjection + retainTimelineText: retainTimelineText ) } == false } private func canCoalescePendingStreamedLog( with event: CodexReviewBackendModel.Review.Event, - suppressTimelineProjection: Bool + retainTimelineText: Bool ) -> Bool { - guard let entry = PendingStreamedLogEntry( - event, - suppressesTimelineProjection: suppressTimelineProjection - ) else { + guard + let entry = PendingStreamedLogEntry( + event, + retainsTimelineText: retainTimelineText + ) + else { return false } return pendingStreamedLogIndexByKey[entry.key] != nil @@ -294,18 +300,20 @@ package actor ReviewBackendEventSession { private func bufferStreamedLog( _ event: CodexReviewBackendModel.Review.Event, - suppressTimelineProjection: Bool = false + retainTimelineText: Bool = false ) -> Bool { - guard let entry = PendingStreamedLogEntry( - event, - suppressesTimelineProjection: suppressTimelineProjection - ) else { + guard + let entry = PendingStreamedLogEntry( + event, + retainsTimelineText: retainTimelineText + ) + else { return false } if let index = pendingStreamedLogIndexByKey[entry.key] { pendingStreamedLogEntries[index].append(entry.text) - if suppressTimelineProjection { - pendingStreamedLogEntries[index].suppressTimelineProjection() + if retainTimelineText { + pendingStreamedLogEntries[index].retainTimelineText() } } else { pendingStreamedLogIndexByKey[entry.key] = pendingStreamedLogEntries.count @@ -388,12 +396,12 @@ package actor ReviewBackendEventSession { case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .message, - .messageDelta, - .log, - .logEntry: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .message, + .messageDelta, + .log, + .logEntry: break } } @@ -427,7 +435,7 @@ package actor ReviewBackendEventSession { private static func isCommandTimeoutWarning(_ event: CodexReviewBackendModel.Review.Event) -> Bool { guard case .logEntry(_, _, _, _, let metadata) = event, - metadata?.sourceType == "commandExecution" + metadata?.sourceType == "commandExecution" else { return false } @@ -458,7 +466,7 @@ private struct PendingStreamedLogEntry: Sendable { var text: String var groupID: String var metadata: ReviewLogEntry.Metadata? - var suppressesTimelineProjection: Bool + var retainsTimelineText: Bool var key: Key { .init( @@ -477,58 +485,59 @@ private struct PendingStreamedLogEntry: Sendable { replacesGroup: false, metadata: metadata ) - return suppressesTimelineProjection - ? [.suppressNextLogTimelineProjection, logEntry] + return retainsTimelineText + ? [.retainNextTimelineTextFromLogEntry, logEntry] : [logEntry] } - init?(_ event: CodexReviewBackendModel.Review.Event, suppressesTimelineProjection: Bool = false) { + init?(_ event: CodexReviewBackendModel.Review.Event, retainsTimelineText: Bool = false) { guard case .logEntry(let kind, let text, let groupID, let replacesGroup, let metadata) = event, - text.isEmpty == false, - replacesGroup == false, - let groupID + text.isEmpty == false, + replacesGroup == false, + let groupID else { return nil } switch kind { case .commandOutput: guard metadata?.sourceType == "commandExecution", - metadata?.title == "Command output" + metadata?.title == "Command output" else { return nil } case .reasoningSummary, .rawReasoning: break - case .agentMessage, .command, .plan, .reasoning, .todoList, .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction: + case .agentMessage, .command, .plan, .reasoning, .todoList, .toolCall, .diagnostic, .error, .progress, .event, + .contextCompaction: return nil } self.kind = kind self.text = text self.groupID = groupID self.metadata = metadata - self.suppressesTimelineProjection = suppressesTimelineProjection + self.retainsTimelineText = retainsTimelineText } mutating func append(_ suffix: String) { text += suffix } - mutating func suppressTimelineProjection() { - suppressesTimelineProjection = true + mutating func retainTimelineText() { + retainsTimelineText = true } } package extension Array where Element == CodexReviewBackendModel.Review.Event { - var immediateLogTimelineProjectionCount: Int { + var immediateTimelineTextRetentionCount: Int { reduce(0) { count, event in - count + (event.createsImmediateLogTimelineProjection ? 1 : 0) + count + (event.needsImmediateTimelineTextRetention ? 1 : 0) } } - var addingTerminalFailureLogProjectionSuppressionIfNeeded: [Element] { + var addingTerminalFailureTimelineTextRetentionIfNeeded: [Element] { flatMap { event -> [Element] in if case .failed = event { - return [.suppressNextTerminalFailureLogTimelineProjection, event] + return [.retainNextTerminalFailureTimelineTextFromLogEntry, event] } return [event] } @@ -541,13 +550,13 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .message, - .messageDelta, - .log, - .logEntry: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .started, + .message, + .messageDelta, + .log, + .logEntry: false } } @@ -559,7 +568,7 @@ private extension CodexReviewBackendModel.Review.Event { return result?.nilIfEmpty == nil } - var createsImmediateLogTimelineProjection: Bool { + var needsImmediateTimelineTextRetention: Bool { guard PendingStreamedLogEntry(self) == nil else { return false } @@ -567,12 +576,12 @@ private extension CodexReviewBackendModel.Review.Event { case .message, .messageDelta, .log, .logEntry: true case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .completed, - .failed, - .cancelled: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .started, + .completed, + .failed, + .cancelled: false } } @@ -603,7 +612,7 @@ private final class ReviewCompletionCoordinator { func flushPendingCompletion() -> CodexReviewBackendModel.Review.Event? { guard finished == false, - let event = pendingCompletion + let event = pendingCompletion else { return nil } diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 8f6fdca7..77b60255 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -2,16 +2,20 @@ 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 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 prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken + 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 @@ -67,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) @@ -174,13 +179,13 @@ package actor BackendReviewEventMailbox { case .completed, .failed, .cancelled: return true case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .message, - .messageDelta, - .log, - .logEntry: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .started, + .message, + .messageDelta, + .log, + .logEntry: return false } } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index d3ad3ba9..b095c378 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -277,9 +277,9 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { - case domainEvents([ReviewDomainEvent], logProjectionSuppressionCount: Int) - case suppressNextLogTimelineProjection - case suppressNextTerminalFailureLogTimelineProjection + case domainEvents([ReviewDomainEvent], retainedLogEntryCount: Int) + case retainNextTimelineTextFromLogEntry + case retainNextTerminalFailureTimelineTextFromLogEntry case started(turnID: String, reviewThreadID: String?, model: String?) case message(String) case messageDelta(String, itemID: String) diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 22cf2aa3..0dfc7c31 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -30,11 +30,9 @@ public final class CodexReviewJob: Identifiable, Hashable { @ObservationIgnored package var completedAgentMessageItemIDs: Set @ObservationIgnored - package private(set) var usesDirectTimelineEvents: Bool + private var pendingTimelineTextRetentionEntries: Int @ObservationIgnored - private var pendingLogTimelineProjectionSuppressions: Int - @ObservationIgnored - private var pendingTerminalFailureLogTimelineProjectionSuppressions: Int + private var pendingTerminalFailureTimelineTextRetentionEntries: Int @ObservationIgnored package var directTimelineTextItemIDs: Set @ObservationIgnored @@ -45,8 +43,6 @@ public final class CodexReviewJob: Identifiable, Hashable { private var pendingDirectTimelineTextItemIDsForLogRetention: [ReviewTimelineItem.ID] @ObservationIgnored private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID] - @ObservationIgnored - package var logProjectedTimelineTextItemIDs: Set public private(set) var logEntries: [ReviewLogEntry] public private(set) var logText: String public private(set) var rawLogText: String @@ -92,15 +88,13 @@ public final class CodexReviewJob: Identifiable, Hashable { self.cancellationRequested = cancellationRequested self.agentMessagesByItemID = [:] self.completedAgentMessageItemIDs = [] - self.usesDirectTimelineEvents = false - self.pendingLogTimelineProjectionSuppressions = 0 - self.pendingTerminalFailureLogTimelineProjectionSuppressions = 0 + self.pendingTimelineTextRetentionEntries = 0 + self.pendingTerminalFailureTimelineTextRetentionEntries = 0 self.directTimelineTextItemIDs = [] self.directTimelineTextItemIDsWithRetainedLogText = [] self.retainedTimelineTextItemIDsByLogEntryID = [:] self.pendingDirectTimelineTextItemIDsForLogRetention = [] self.latestDirectTimelineTextItemIDs = [] - self.logProjectedTimelineTextItemIDs = [] self.logBuffer = initialLogBuffer self.logEntries = initialLogSnapshot.entries self.logText = initialLogSnapshot.logText @@ -111,9 +105,7 @@ public final class CodexReviewJob: Identifiable, Hashable { self.cappedLogBytes = initialLogSnapshot.cappedBytes self.logRevision = 0 self.lastLogMutation = .reload - if usesDirectTimelineEvents == false { - rebuildTimelineFromLogEntries() - } + syncTimelineTerminalStateFromCore() } public nonisolated static func == (lhs: CodexReviewJob, rhs: CodexReviewJob) -> Bool { @@ -128,30 +120,26 @@ public final class CodexReviewJob: Identifiable, Hashable { logBuffer.replace(with: entries) syncLogEntriesFromBuffer() if resetDirectTimeline { - usesDirectTimelineEvents = false - pendingLogTimelineProjectionSuppressions = 0 - pendingTerminalFailureLogTimelineProjectionSuppressions = 0 + pendingTimelineTextRetentionEntries = 0 + pendingTerminalFailureTimelineTextRetentionEntries = 0 directTimelineTextItemIDs.removeAll(keepingCapacity: true) directTimelineTextItemIDsWithRetainedLogText.removeAll(keepingCapacity: true) retainedTimelineTextItemIDsByLogEntryID.removeAll(keepingCapacity: true) pendingDirectTimelineTextItemIDsForLogRetention.removeAll(keepingCapacity: true) latestDirectTimelineTextItemIDs.removeAll(keepingCapacity: true) - logProjectedTimelineTextItemIDs.removeAll(keepingCapacity: true) - } - if usesDirectTimelineEvents { - trimTimelineTextContentToLogEntries() - } else { - rebuildTimelineFromLogEntries() + timeline.reset(keepingTerminal: core.lifecycle.status.isTerminal) + syncTimelineTerminalStateFromCore() } + trimTimelineTextContentToLogEntries() syncLogSnapshot(mutation: .reload) } - package func appendLogEntry(_ entry: ReviewLogEntry, suppressTimelineProjection: Bool = false) { + package func appendLogEntry(_ entry: ReviewLogEntry, retainTimelineText: Bool = false) { let appendResult = logBuffer.append(entry) let entry = appendResult.entry syncLogEntriesFromBuffer() - if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { - let allowsPendingRetainedText = suppressTimelineProjection || pendingLogTimelineProjectionSuppressions > 0 + if entry.canProvideDirectTimelineText { + let allowsPendingRetainedText = retainTimelineText || pendingTimelineTextRetentionEntries > 0 if entry.retainedTimelineText != nil { recordDirectTimelineTextRetention( for: entry, @@ -164,17 +152,10 @@ public final class CodexReviewJob: Identifiable, Hashable { ) } } - if suppressTimelineProjection == false, - consumeLogTimelineProjectionSuppression() == false { - applyTimelineEntry(entry) - if usesDirectTimelineEvents, entry.canProvideDirectTimelineText { - logProjectedTimelineTextItemIDs.insert(entry.timelineItemID) - } + if retainTimelineText == false { + _ = consumeTimelineTextRetention() } let didTrim = core.lifecycle.status.isTerminal ? trimReviewLogToLimit() : false - if didTrim { - rebuildTimelineFromLogEntries() - } syncLogSnapshot(mutation: didTrim ? .reload : LogMutation(appendResult.mutation)) } @@ -191,7 +172,7 @@ public final class CodexReviewJob: Identifiable, Hashable { package func applyDirectTimelineEvents( _ events: [ReviewDomainEvent], - logProjectionSuppressionCount: Int, + retainedLogEntryCount: Int, at timestamp: Date ) { let directEvents = events.filter { @@ -200,11 +181,7 @@ public final class CodexReviewJob: Identifiable, Hashable { guard directEvents.isEmpty == false else { return } - if usesDirectTimelineEvents == false { - recordLogProjectedTimelineTextItemIDsFromLogEntries() - } - usesDirectTimelineEvents = true - pendingLogTimelineProjectionSuppressions += max(0, logProjectionSuppressionCount) + pendingTimelineTextRetentionEntries += max(0, retainedLogEntryCount) var directTextItemIDsApplied: [ReviewTimelineItem.ID] = [] for event in directEvents { if let itemID = event.directTimelineTextItemID { @@ -214,7 +191,7 @@ public final class CodexReviewJob: Identifiable, Hashable { timeline.apply(event, at: timestamp) } latestDirectTimelineTextItemIDs = directTextItemIDsApplied - if logProjectionSuppressionCount > 0 { + if retainedLogEntryCount > 0 { appendPendingDirectTimelineTextItemIDsForLogRetention(directTextItemIDsApplied) } } @@ -222,7 +199,7 @@ public final class CodexReviewJob: Identifiable, Hashable { private func shouldApplyDirectTimelineEvent(_ event: ReviewDomainEvent) -> Bool { switch event { case .textDelta(let itemID, _, let family, _, _) - where family == .message && completedAgentMessageItemIDs.contains(itemID.rawValue): + where family == .message && completedAgentMessageItemIDs.contains(itemID.rawValue): false default: true @@ -238,8 +215,9 @@ public final class CodexReviewJob: Identifiable, Hashable { itemIDs.insert(itemID) } if itemIDs.isEmpty, - allowsPendingRetainedText, - let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() { + allowsPendingRetainedText, + let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() + { itemIDs.insert(pendingItemID) } guard itemIDs.isEmpty == false else { @@ -259,8 +237,9 @@ public final class CodexReviewJob: Identifiable, Hashable { itemIDs.insert(itemID) } if itemIDs.isEmpty, - allowsPendingRetainedText, - let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() { + allowsPendingRetainedText, + let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() + { itemIDs.insert(pendingItemID) } removePendingDirectTimelineTextItemIDsForLogRetention(itemIDs) @@ -286,42 +265,33 @@ public final class CodexReviewJob: Identifiable, Hashable { pendingDirectTimelineTextItemIDsForLogRetention.removeAll { itemIDs.contains($0) } } - private func recordLogProjectedTimelineTextItemIDsFromLogEntries() { - for entry in logEntries where entry.retainedTimelineText != nil { - let itemID = entry.timelineItemID - if timeline.item(for: itemID) != nil { - logProjectedTimelineTextItemIDs.insert(itemID) - } - } - } - - package func suppressNextLogTimelineProjection() { - pendingLogTimelineProjectionSuppressions += 1 + package func retainNextTimelineTextFromLogEntry() { + pendingTimelineTextRetentionEntries += 1 appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } - package func suppressNextTerminalFailureLogTimelineProjection() { - pendingTerminalFailureLogTimelineProjectionSuppressions += 1 + package func retainNextTerminalFailureTimelineTextFromLogEntry() { + pendingTerminalFailureTimelineTextRetentionEntries += 1 appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) } - package func discardPendingLogTimelineProjectionSuppression() { - _ = consumeLogTimelineProjectionSuppression() + package func discardPendingTimelineTextRetention() { + _ = consumeTimelineTextRetention() } - package func consumeTerminalFailureLogTimelineProjectionSuppression() -> Bool { - guard pendingTerminalFailureLogTimelineProjectionSuppressions > 0 else { + package func consumeTerminalFailureTimelineTextRetention() -> Bool { + guard pendingTerminalFailureTimelineTextRetentionEntries > 0 else { return false } - pendingTerminalFailureLogTimelineProjectionSuppressions -= 1 + pendingTerminalFailureTimelineTextRetentionEntries -= 1 return true } - private func consumeLogTimelineProjectionSuppression() -> Bool { - guard pendingLogTimelineProjectionSuppressions > 0 else { + private func consumeTimelineTextRetention() -> Bool { + guard pendingTimelineTextRetentionEntries > 0 else { return false } - pendingLogTimelineProjectionSuppressions -= 1 + pendingTimelineTextRetentionEntries -= 1 return true } @@ -340,11 +310,7 @@ public final class CodexReviewJob: Identifiable, Hashable { return false } syncLogEntriesFromBuffer() - if usesDirectTimelineEvents { - trimTimelineTextContentToLogEntries() - } else { - rebuildTimelineFromLogEntries() - } + trimTimelineTextContentToLogEntries() return true } @@ -353,19 +319,17 @@ public final class CodexReviewJob: Identifiable, Hashable { keeping direction: TruncationDirection, overflowBytes: Int ) { - guard logBuffer.truncateOrRemoveEntry( - at: index, - keeping: ReviewLogBuffer.TruncationDirection(direction), - overflowBytes: overflowBytes - ) else { + guard + logBuffer.truncateOrRemoveEntry( + at: index, + keeping: ReviewLogBuffer.TruncationDirection(direction), + overflowBytes: overflowBytes + ) + else { return } syncLogEntriesFromBuffer() - if usesDirectTimelineEvents { - trimTimelineTextContentToLogEntries() - } else { - rebuildTimelineFromLogEntries() - } + trimTimelineTextContentToLogEntries() syncLogSnapshot(mutation: .reload) } @@ -423,8 +387,8 @@ private extension ReviewDomainEvent { var directTimelineTextItemID: ReviewTimelineItem.ID? { switch self { case .itemStarted(let seed), - .itemUpdated(let seed), - .itemCompleted(let seed): + .itemUpdated(let seed), + .itemCompleted(let seed): seed.hasTrimmableTimelineText ? seed.id : nil case .textDelta(let itemID, _, _, _, _): itemID @@ -454,8 +418,8 @@ private extension ReviewTimelineItem.Content { case .diagnostic(let diagnostic): diagnostic.message.isEmpty == false case .message, - .plan, - .reasoning: + .plan, + .reasoning: true case .search(let search): search.result?.isEmpty == false @@ -466,8 +430,8 @@ private extension ReviewTimelineItem.Content { case .fileChange: false case .approval, - .contextCompaction, - .unknown: + .contextCompaction, + .unknown: false } } diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift b/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift deleted file mode 100644 index bbd6dd99..00000000 --- a/Sources/CodexReviewKit/Model/ReviewLogEntryTimelineProjection.swift +++ /dev/null @@ -1,595 +0,0 @@ -import Foundation - -@MainActor -package struct ReviewLogEntryTimelineProjector { - private let timeline: ReviewTimeline - - package init(timeline: ReviewTimeline) { - self.timeline = timeline - } - - package func rebuild( - from entries: [ReviewLogEntry], - core: ReviewJobCore, - keepingTerminal: Bool = true - ) { - timeline.reset(keepingTerminal: keepingTerminal && core.lifecycle.status.isTerminal) - for entry in entries { - apply(entry) - } - syncTerminalState(from: core, fallbackTimestamp: entries.last?.timestamp) - } - - package func apply(_ 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 trimTextContent( - to entries: [ReviewLogEntry], - directTimelineTextItemIDs: Set, - directTimelineTextItemIDsWithRetainedLogText: Set, - retainedTimelineTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set], - logProjectedTimelineTextItemIDs: Set - ) { - guard directTimelineTextItemIDs.isEmpty == false - || logProjectedTimelineTextItemIDs.isEmpty == false else { - return - } - var textByItemID: [ReviewTimelineItem.ID: String] = [:] - for entry in entries { - guard let retainedTimelineText = entry.retainedTimelineText else { - continue - } - for itemID in directTimelineTextCandidateIDs( - for: entry, - retainedTextItemIDsByLogEntryID: retainedTimelineTextItemIDsByLogEntryID - ) { - 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 directTimelineTextItemIDsWithRetainedLogText.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 logProjectedTimelineTextItemIDs { - guard let item = timeline.item(for: itemID), - let trimmedContent = item.content.replacingTimelineText(textByItemID[itemID] ?? "") - else { - continue - } - timeline.updateItemContent(trimmedContent, for: itemID) - } - } - - package func syncTerminalState(from core: ReviewJobCore, fallbackTimestamp: Date?) { - guard core.lifecycle.status.isTerminal else { - return - } - let timestamp = core.lifecycle.endedAt ?? fallbackTimestamp ?? 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, - retainedTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] - ) -> [ReviewTimelineItem.ID] { - var ids = entry.directTimelineTextCandidateIDs - if let retainedTextItemIDs = retainedTextItemIDsByLogEntryID[entry.id] { - ids.append(contentsOf: retainedTextItemIDs) - } - var seen: Set = [] - return ids.filter { seen.insert($0).inserted } - } -} - -@MainActor -extension CodexReviewJob { - package func rebuildTimelineFromLogEntries(keepingTerminal: Bool = true) { - guard usesDirectTimelineEvents == false else { - return - } - ReviewLogEntryTimelineProjector(timeline: timeline).rebuild( - from: logEntries, - core: core, - keepingTerminal: keepingTerminal - ) - } - - package func applyTimelineEntry(_ entry: ReviewLogEntry) { - ReviewLogEntryTimelineProjector(timeline: timeline).apply(entry) - } - - package func trimTimelineTextContentToLogEntries() { - ReviewLogEntryTimelineProjector(timeline: timeline).trimTextContent( - to: logEntries, - directTimelineTextItemIDs: directTimelineTextItemIDs, - directTimelineTextItemIDsWithRetainedLogText: directTimelineTextItemIDsWithRetainedLogText, - retainedTimelineTextItemIDsByLogEntryID: retainedTimelineTextItemIDsByLogEntryID, - logProjectedTimelineTextItemIDs: logProjectedTimelineTextItemIDs - ) - } - - package func syncTimelineTerminalStateFromCore() { - ReviewLogEntryTimelineProjector(timeline: timeline).syncTerminalState( - from: core, - fallbackTimestamp: logEntries.last?.timestamp - ) - } -} - -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 isMCPToolProgressSummaryLog { - ids.append(.init(rawValue: "\(rawID):progress")) - } - } - ids.append(timelineItemID) - var seen: Set = [] - return ids.filter { seen.insert($0).inserted } - } - - var isMCPToolProgressSummaryLog: 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[.. [ReviewTimelineItem.ID] { + var ids = entry.directTimelineTextCandidateIDs + if let retainedTextItemIDs = retainedTimelineTextItemIDsByLogEntryID[entry.id] { + ids.append(contentsOf: retainedTextItemIDs) + } + var seen: Set = [] + return ids.filter { seen.insert($0).inserted } + } +} + +package extension ReviewLogEntry { + var retainedTimelineText: String? { + guard canProvideDirectTimelineText else { + return nil + } + switch directTimelineFamily { + 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] { + guard let rawID = metadata?.itemID?.nilIfEmpty ?? groupID?.nilIfEmpty else { + return [] + } + + var ids: [ReviewTimelineItem.ID] = [.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 isMCPToolProgressSummaryLog { + ids.append(.init(rawValue: "\(rawID):progress")) + } + var seen: Set = [] + return ids.filter { seen.insert($0).inserted } + } + + var isMCPToolProgressSummaryLog: Bool { + kind == .toolCall + && metadata?.sourceType == "mcpToolCall" + && metadata?.title?.trimmingCharacters(in: .whitespacesAndNewlines) == "Tool progress" + } + + var shouldAppendRetainedTimelineText: Bool { + guard replacesGroup == false else { + return false + } + switch kind { + case .agentMessage, + .commandOutput, + .plan, + .rawReasoning, + .reasoning, + .reasoningSummary, + .todoList: + return true + case .command, + .contextCompaction, + .diagnostic, + .error, + .event, + .progress, + .toolCall: + return false + } + } + + private var directTimelineFamily: 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 + } + } +} + +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[.. Bool { guard let status = metadata?.status?.nilIfEmpty else { return true @@ -976,13 +987,13 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection, - .started, - .message, - .messageDelta, - .log, - .logEntry: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry, + .started, + .message, + .messageDelta, + .log, + .logEntry: false } } @@ -1120,7 +1131,7 @@ private struct ReviewNetworkRecoveryLoopState { _ snapshot: CodexReviewNetworkSnapshot ) -> ReviewWorkerEventStreamFailure? { guard snapshot.status == .satisfied, - isWaitingForNetworkRecovery == false + isWaitingForNetworkRecovery == false else { return nil } @@ -1221,7 +1232,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) @@ -1347,27 +1359,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( @@ -1381,11 +1397,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/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 47abcc2e..13146a00 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -1,5 +1,4 @@ import Foundation -import CodexReviewKit 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,7 +576,7 @@ enum ReviewMonitorCommandOutputDisplayDocument { } private static func commandOutputIsActive( - _ metadata: ReviewLogEntry.Metadata?, + _ metadata: ReviewMonitorLog.Metadata?, hasOutput: Bool ) -> Bool { guard let metadata else { @@ -591,7 +600,7 @@ enum ReviewMonitorCommandOutputDisplayDocument { } private static func commandDurationText( - metadata: ReviewLogEntry.Metadata?, + metadata: ReviewMonitorLog.Metadata?, isActive: Bool, currentDate: Date ) -> String? { @@ -599,10 +608,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 +627,8 @@ enum ReviewMonitorCommandOutputDisplayDocument { commandTextByGroupID: [String: String] ) -> String { if let groupID = panelSource.anchor.groupID, - let commandText = commandTextByGroupID[groupID] { + let commandText = commandTextByGroupID[groupID] + { return commandText } @@ -640,7 +652,7 @@ 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)" } @@ -666,13 +678,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 +699,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 +755,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 +785,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 +805,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 +835,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 +857,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 +876,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 +921,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 +956,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 } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift index e201e8ab..099d4d96 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift @@ -1,8 +1,7 @@ import AppKit -import CodexReviewKit 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( @@ -51,10 +52,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 +381,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 = [] @@ -399,14 +400,16 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p currentCommandOutputPanels = 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, @@ -556,8 +559,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 +570,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 +600,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 +616,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 +633,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 } @@ -675,17 +681,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 +815,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 +870,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 +891,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 +910,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 +935,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p animationSpans: [ReviewMonitorLog.AnimationSpan] ) -> [NSRange] { guard shouldAnimateGlow, - animationSpans.isEmpty == false + animationSpans.isEmpty == false else { return [] } @@ -958,12 +967,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 +989,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p limit: Int ) -> [NSRange] { guard localLineRange.length > 0, - limit > 0 + limit > 0 else { return [] } @@ -997,8 +1007,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 +1021,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 +1075,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 +1124,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 +1140,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 +1156,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 +1169,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p private func startGlowTimerIfNeeded() { guard glowTimer == nil, - hasActiveGlowAnimations + hasActiveGlowAnimations else { return } @@ -1199,9 +1212,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 +1285,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 +1390,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 +1460,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 +1521,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 +1685,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 +1743,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 +1754,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 +1809,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 +1831,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 +1868,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 +2047,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 +2111,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 +2191,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 +2228,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 +2333,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 +2375,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 +2401,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 +2437,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] { @@ -2448,410 +2457,408 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, @p 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() + } + + var wordGlowCountForTesting: Int { + wordFadeAnimations.count + } - 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 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/ReviewMonitorLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift index 9da2bf14..7f783591 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift @@ -1,289 +1,403 @@ import Foundation -import CodexReviewKit 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 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 + } -struct AnimationSpan: Equatable, Sendable { - enum Kind: Equatable, Sendable { - case wordFade + var kind: Kind + var range: NSRange } - 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 + 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) } - private static func utf16Length(_ text: String) -> Int { - (text as NSString).length + struct Decoration: Equatable, Sendable { + var blockID: ReviewMonitorLog.BlockID + var range: NSRange + var style: ReviewMonitorLog.DecorationStyle } - 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 [] + 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 + ) + ) + ] } - 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 let wordFadeKinds: Set = [ + .reasoning, + .reasoningSummary, + .rawReasoning, + ] } - private static func utf16Length(_ text: String) -> Int { - (text as NSString).length - } -} + struct Replacement: Equatable, Sendable { + var kind: ReviewMonitorLog.Kind + var blockID: ReviewMonitorLog.BlockID + var range: NSRange + var text: String + var textUTF16Length: Int -enum Change: Equatable, Sendable { - case reload - case append(ReviewMonitorLog.Append) - case replace(ReviewMonitorLog.Replacement) -} + 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) + } -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 + } } - 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) } - 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) + 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 } - } - mutating func rebuildPresentation(forBlockAt blockIndex: Int) { - guard blocks.indices.contains(blockIndex) else { - rebuildPresentation() - return + private static func utf16Length(_ text: String) -> Int { + (text as NSString).length } - 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 + 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) + } } - commandOutputPanels.removeAll { - $0.blockID == block.id || NSIntersectionRange($0.range, block.range).length > 0 + + 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) } - ReviewMonitorLogStyler.appendPresentation(for: block, to: &self) - } - var finderSupplementSignature: Int { - 0 + var finderSupplementSignature: Int { + 0 + } } } -} enum ReviewMonitorLogStyler { struct Presentation { @@ -293,7 +407,7 @@ enum ReviewMonitorLogStyler { } static func renderedText( - for kind: ReviewLogEntry.Kind, + for kind: ReviewMonitorLog.Kind, source: String, blockID: ReviewMonitorLog.BlockID ) -> String { @@ -302,11 +416,11 @@ enum ReviewMonitorLogStyler { 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 + block.range.length >= 0, + NSMaxRange(block.range) <= document.textUTF16Length, + block.sourceRange.location >= 0, + block.sourceRange.length >= 0, + NSMaxRange(block.sourceRange) <= document.sourceTextUTF16Length else { return } @@ -315,15 +429,16 @@ enum ReviewMonitorLogStyler { 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 - ) - )) + 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 { @@ -338,16 +453,17 @@ enum ReviewMonitorLogStyler { guard range.length > 0 else { continue } - document.decorations.append(.init( - blockID: decoration.blockID, - range: range, - style: decoration.style - )) + document.decorations.append( + .init( + blockID: decoration.blockID, + range: range, + style: decoration.style + )) } } private static func presentation( - for kind: ReviewLogEntry.Kind, + for kind: ReviewMonitorLog.Kind, source: String, blockID: ReviewMonitorLog.BlockID ) -> Presentation { @@ -361,7 +477,7 @@ enum ReviewMonitorLogStyler { } } - private static func baseTextStyle(for kind: ReviewLogEntry.Kind) -> ReviewMonitorLog.TextStyle { + private static func baseTextStyle(for kind: ReviewMonitorLog.Kind) -> ReviewMonitorLog.TextStyle { switch kind { case .agentMessage: .body @@ -385,9 +501,9 @@ enum ReviewMonitorLogStyler { } private static func decorationStyle( - for kind: ReviewLogEntry.Kind, + for kind: ReviewMonitorLog.Kind, source: String, - metadata: ReviewLogEntry.Metadata? + metadata: ReviewMonitorLog.Metadata? ) -> ReviewMonitorLog.DecorationStyle { let tone = statusTone(for: metadata) switch kind { @@ -417,7 +533,7 @@ enum ReviewMonitorLogStyler { } } - private static func contextCompactionIsCompleted(_ metadata: ReviewLogEntry.Metadata?) -> Bool { + private static func contextCompactionIsCompleted(_ metadata: ReviewMonitorLog.Metadata?) -> Bool { let normalized = metadata?.status? .lowercased() .replacingOccurrences(of: "_", with: "") @@ -432,7 +548,7 @@ enum ReviewMonitorLogStyler { } } - private static func statusTone(for metadata: ReviewLogEntry.Metadata?) -> ReviewMonitorLog.StatusTone { + private static func statusTone(for metadata: ReviewMonitorLog.Metadata?) -> ReviewMonitorLog.StatusTone { if let exitCode = metadata?.exitCode { return exitCode == 0 ? .success : .failure } @@ -485,12 +601,9 @@ enum ReviewMonitorLogStyler { } private static func containsMarkdownSyntax(in source: String) -> Bool { - if source.contains("```") || - source.contains("`") || - source.contains("**") || - source.contains("__") || - source.contains("~~") || - source.contains("](") { + if source.contains("```") || source.contains("`") || source.contains("**") || source.contains("__") + || source.contains("~~") || source.contains("](") + { return true } @@ -539,10 +652,11 @@ enum ReviewMonitorLogStyler { let length = utf16Length(renderedLine) if length > 0, status != nil { - result.styleRuns.append(.init( - range: NSRange(location: offset, length: length), - style: .plan(status: status) - )) + result.styleRuns.append( + .init( + range: NSRange(location: offset, length: length), + style: .plan(status: status) + )) } result.text += renderedLine offset += length @@ -562,10 +676,11 @@ enum ReviewMonitorLogStyler { planStatusAndContent(in: line)?.status } - private static func planStatusAndContent(in line: String) -> (status: ReviewMonitorLog.PlanStatus, content: String)? { + 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: "]") + let closingIndex = trimmed.firstIndex(of: "]") else { return nil } @@ -759,10 +874,10 @@ enum ReviewMonitorLogStyler { } switch (previous.kind, kind) { case (.unorderedListItem(_, let previousList), .unorderedListItem(_, let currentList)) - where previousList == currentList: + where previousList == currentList: return 1 case (.orderedListItem(_, let previousList), .orderedListItem(_, let currentList)) - where previousList == currentList: + where previousList == currentList: return 1 default: return 2 @@ -901,757 +1016,3 @@ enum ReviewMonitorLogStyler { } } - -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 index 73f80b65..8f21d02f 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift @@ -1,4 +1,3 @@ -import CodexReviewKit import ReviewMonitorRendering struct ReviewMonitorRenderedLogDocument: Equatable, Sendable { @@ -7,81 +6,18 @@ struct ReviewMonitorRenderedLogDocument: Equatable, Sendable { } 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() } -#if DEBUG - func render(entries: [ReviewLogEntry]) -> ReviewMonitorRenderedLogDocument { - renderedDocument(from: projection.render(entries: entries)) - } -#endif - func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorRenderedLogDocument { renderedDocument(from: timelineProjection.render(timelineDocument: timelineDocument)) } -#if DEBUG - 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 - } -#endif - - private func currentRenderedDocument() -> ReviewMonitorRenderedLogDocument { - .init( - source: projection.currentDocument, - display: displayDocument - ) - } - private func renderedDocument( from source: ReviewMonitorLog.Document ) -> ReviewMonitorRenderedLogDocument { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift index a6538df8..34978774 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift @@ -5,10 +5,10 @@ import ReviewMonitorRendering struct ReviewMonitorTimelineLogProjection: Sendable { private struct ProjectedBlock: Equatable, Sendable { var id: ReviewMonitorLog.BlockID - var kind: ReviewLogEntry.Kind + var kind: ReviewMonitorLog.Kind var groupID: String? var text: String - var metadata: ReviewLogEntry.Metadata? + var metadata: ReviewMonitorLog.Metadata? } private var document = ReviewMonitorLog.Document() @@ -48,18 +48,20 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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 + 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) @@ -72,7 +74,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { groupID: groupID, text: command.output, metadata: outputOnlyCommandMetadata(for: block, command: command) - ), + ) ] } guard let commandLine else { @@ -85,128 +87,143 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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 - )) + 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 + 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: logKind(for: block, fallback: .diagnostic), - text: diagnostic.message, - metadata: metadata( - for: block, - sourceType: block.kind.rawValue, - title: diagnostic.message, - status: block.phase.rawValue + return [ + projectedBlock( + block, + kind: logKind(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 + 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: logKind(for: block, fallback: .plan), text: plan.markdown)] case .reasoning(let reasoning): - return [projectedBlock( - block, - kind: logKind( - for: block, - fallback: reasoning.style == .raw ? .rawReasoning : .reasoningSummary - ), - text: reasoning.text - )] + return [ + projectedBlock( + block, + kind: logKind( + 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 + 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 + 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: logKind(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 + return [ + projectedBlock( + block, + kind: logKind(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, + kind: ReviewMonitorLog.Kind, groupID: String? = nil, text: String, - metadata: ReviewLogEntry.Metadata? = nil + metadata: ReviewMonitorLog.Metadata? = nil ) -> ProjectedBlock { ProjectedBlock( id: derivedBlockID(prefix: kind.rawValue, from: block), @@ -226,15 +243,15 @@ struct ReviewMonitorTimelineLogProjection: Sendable { private static func logKind( for block: ReviewTimelineDocument.Block, - fallback: ReviewLogEntry.Kind - ) -> ReviewLogEntry.Kind { - ReviewLogEntry.Kind(rawValue: block.kind.rawValue) ?? fallback + fallback: ReviewMonitorLog.Kind + ) -> ReviewMonitorLog.Kind { + ReviewMonitorLog.Kind(rawValue: block.kind.rawValue) ?? fallback } private static func commandMetadata( for block: ReviewTimelineDocument.Block, command: ReviewTimelineDocument.Command - ) -> ReviewLogEntry.Metadata { + ) -> ReviewMonitorLog.Metadata { let status = commandStatus(for: block, command: command) return .init( sourceType: "commandExecution", @@ -259,7 +276,8 @@ struct ReviewMonitorTimelineLogProjection: Sendable { return block.phase.rawValue } if let status = command.status?.rawValue, - ReviewItemPhase.normalized(status).isTerminal { + ReviewItemPhase.normalized(status).isTerminal + { return status } if let exitCode = command.exitCode { @@ -279,13 +297,13 @@ struct ReviewMonitorTimelineLogProjection: Sendable { private static func outputOnlyCommandMetadata( for block: ReviewTimelineDocument.Block, command: ReviewTimelineDocument.Command - ) -> ReviewLogEntry.Metadata { + ) -> ReviewMonitorLog.Metadata { guard hasStructuredOutputOnlyCommandMetadata(for: block, command: command) else { return genericCommandOutputMetadata(for: block) } let normalizedStatus = commandStatus(for: block, command: command) - return ReviewLogEntry.Metadata( + return ReviewMonitorLog.Metadata( sourceType: "commandExecution", status: normalizedStatus, itemID: block.sourceItemID.rawValue, @@ -303,21 +321,14 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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 + 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 { + ) -> ReviewMonitorLog.Metadata { .init( sourceType: "command", title: "Command output", @@ -331,7 +342,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { title: String?, status: String?, path: String? - ) -> ReviewLogEntry.Metadata { + ) -> ReviewMonitorLog.Metadata { .init( sourceType: "fileChange", title: title, @@ -352,7 +363,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { private static func commandAction( _ action: ReviewTimelineDocument.Command.Action - ) -> ReviewLogEntry.Metadata.CommandAction { + ) -> ReviewMonitorLog.Metadata.CommandAction { .init( kind: commandActionKind(action.kind), command: action.command, @@ -364,7 +375,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { private static func commandActionKind( _ kind: ReviewCommandActionKind - ) -> ReviewLogEntry.Metadata.CommandAction.Kind { + ) -> ReviewMonitorLog.Metadata.CommandAction.Kind { switch kind.rawValue { case ReviewCommandActionKind.read.rawValue: return .read @@ -390,7 +401,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { tool: String? = nil, resultText: String? = nil, errorText: String? = nil - ) -> ReviewLogEntry.Metadata { + ) -> ReviewMonitorLog.Metadata { .init( sourceType: sourceType, title: title, @@ -428,7 +439,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { current: ReviewMonitorLog.Document ) -> ReviewMonitorLog.Append? { guard current.textUTF16Length > previous.textUTF16Length, - current.text.hasPrefix(previous.text) + current.text.hasPrefix(previous.text) else { return nil } @@ -439,11 +450,13 @@ struct ReviewMonitorTimelineLogProjection: Sendable { let block = current.blocks.first { NSIntersectionRange($0.range, suffixRange).length > 0 } - guard existingPresentationUnchanged( - previous: previous, - current: current, - suffixBlockID: block?.id - ) else { + guard + existingPresentationUnchanged( + previous: previous, + current: current, + suffixBlockID: block?.id + ) + else { return nil } return .init( @@ -482,12 +495,12 @@ struct ReviewMonitorTimelineLogProjection: Sendable { } 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 + 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 } @@ -504,8 +517,8 @@ struct ReviewMonitorTimelineLogProjection: Sendable { ) -> 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 + currentBlock.range.location == previousBlock.range.location, + NSMaxRange(currentBlock.range) <= current.textUTF16Length else { continue } @@ -548,11 +561,8 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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 + 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 { @@ -624,7 +634,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { return "\n\n" + blockText } - private static func isVisible(kind: ReviewLogEntry.Kind, text: String) -> Bool { + private static func isVisible(kind: ReviewMonitorLog.Kind, text: String) -> Bool { if kind == .diagnostic { return true } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index f5ec984c..3e1774de 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -23,6 +23,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private var selectedJobObservation: PortableObservationTracking.Token? private var selectedChatObservation: PortableObservationTracking.Token? private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? + private var selectedCodexChatDocumentTask: Task? private var boundJob: CodexReviewJob? private var boundChatID: CodexThreadID? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? @@ -32,6 +33,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 private var hasAppliedBoundLog = false + private var selectedCodexChatDocumentHasRendered = false convenience init( store: CodexReviewStore, @@ -66,6 +68,7 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation?.cancel() selectedChatObservation?.cancel() selectedWorkspaceFindingsObservation?.cancel() + selectedCodexChatDocumentTask?.cancel() logRenderTask?.cancel() } @@ -105,17 +108,17 @@ final class ReviewMonitorTransportViewController: NSViewController { 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), - ] + + [ + 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), + ] ) } @@ -201,35 +204,45 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation = nil selectedChatObservation?.cancel() selectedChatObservation = nil + selectedCodexChatDocumentTask?.cancel() + selectedCodexChatDocumentTask = nil boundChatID = nil resetLogRenderer() boundJob = selectedJob selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) + startSelectedCodexChatDocumentStream( + target: .job(selectedJob.id), + initialRestorationTarget: restorationTarget(selectedJob) + ) selectedJobObservation = withPortableContinuousObservation { [weak self] event in let eventKind = event.kind guard let self, - self.boundJob === selectedJob + self.boundJob === selectedJob else { return } self.selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) let timeline = selectedJob.timeline -#if DEBUG - _ = timeline.revision -#endif - guard let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) else { - self.logScrollView.clear() + #if DEBUG + _ = timeline.revision + #endif + guard self.selectedCodexChatDocumentHasRendered == false else { return } - self.renderBoundLog( - timelineDocument: timelineDocument, - target: .job(selectedJob.id), - restorationTarget: eventKind == .initial - ? self.restorationTarget(selectedJob) - : self.logScrollView.currentScrollRestorationTarget, - allowIncrementalUpdate: eventKind != .initial - ) + if let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) { + self.renderBoundLog( + timelineDocument: timelineDocument, + target: .job(selectedJob.id), + restorationTarget: eventKind == .initial + ? self.restorationTarget(selectedJob) + : self.logScrollView.currentScrollRestorationTarget, + allowIncrementalUpdate: eventKind != .initial + ) + return + } + + self.logScrollView.clear() } } @@ -239,30 +252,12 @@ final class ReviewMonitorTransportViewController: NSViewController { } selectedChatObservation?.cancel() selectedChatObservation = nil + selectedCodexChatDocumentTask?.cancel() + selectedCodexChatDocumentTask = nil resetLogRenderer() boundChatID = selectedChat.id selectedCodexChat.bind(toChatID: selectedChat.id) - - selectedChatObservation = withPortableContinuousObservation { [weak self] event in - let eventKind = event.kind - guard let self, - self.boundChatID == selectedChat.id - else { - return - } - self.selectedCodexChat.bind(toChatID: selectedChat.id) - guard let timelineDocument = self.selectedCodexChat.timelineDocument else { - return - } - self.renderBoundLog( - timelineDocument: timelineDocument, - target: .chat(selectedChat.id), - restorationTarget: eventKind == .initial - ? .bottom - : self.logScrollView.currentScrollRestorationTarget, - allowIncrementalUpdate: eventKind != .initial - ) - } + startSelectedCodexChatDocumentStream(target: .chat(selectedChat.id), initialRestorationTarget: .bottom) } private func clearDisplayedLogSelection() { @@ -271,6 +266,9 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation = nil selectedChatObservation?.cancel() selectedChatObservation = nil + selectedCodexChatDocumentTask?.cancel() + selectedCodexChatDocumentTask = nil + selectedCodexChatDocumentHasRendered = false boundJob = nil boundChatID = nil selectedCodexChat.unbind() @@ -299,7 +297,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func bindWorkspaceSectionObservation(_ section: ReviewMonitorWorkspaceSectionSelection) { selectedWorkspaceFindingsObservation = withPortableContinuousObservation { [weak self] _ in guard let self, - self.boundWorkspaceSection?.id == section.id + self.boundWorkspaceSection?.id == section.id else { return } @@ -357,7 +355,7 @@ final class ReviewMonitorTransportViewController: NSViewController { ) -> [ReviewMonitorWorkspaceFindingsView.Entry] { store.orderedJobs(in: workspace).flatMap { job -> [ReviewMonitorWorkspaceFindingsView.Entry] in guard let result = job.core.output.reviewResult, - result.state == .hasFindings + result.state == .hasFindings else { return [] } @@ -413,11 +411,12 @@ final class ReviewMonitorTransportViewController: NSViewController { let workspaceComponents = workspaceURL.pathComponents let fileComponents = fileURL.pathComponents guard fileComponents.count > workspaceComponents.count, - fileComponents.starts(with: workspaceComponents) + fileComponents.starts(with: workspaceComponents) else { return nil } - return fileComponents + return + fileComponents .dropFirst(workspaceComponents.count) .joined(separator: "/") } @@ -460,9 +459,9 @@ final class ReviewMonitorTransportViewController: NSViewController { logRenderTask = Task { @MainActor [weak self] in let renderedDocument = await renderer.render(timelineDocument: timelineDocument) guard Task.isCancelled == false, - let self, - self.logRenderGeneration == generation, - self.isCurrentLogRenderTarget(target) + let self, + self.logRenderGeneration == generation, + self.isCurrentLogRenderTarget(target) else { return } @@ -478,6 +477,35 @@ final class ReviewMonitorTransportViewController: NSViewController { return true } + private func startSelectedCodexChatDocumentStream( + target: LogRenderTarget, + initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget + ) { + selectedCodexChatDocumentTask?.cancel() + selectedCodexChatDocumentHasRendered = false + let stream = selectedCodexChat.timelineDocumentStream() + selectedCodexChatDocumentTask = Task { @MainActor [weak self] in + var didRenderInitialDocument = false + for await timelineDocument in stream { + guard let self, + self.isCurrentLogRenderTarget(target) + else { + return + } + self.selectedCodexChatDocumentHasRendered = true + self.renderBoundLog( + timelineDocument: timelineDocument, + target: target, + restorationTarget: didRenderInitialDocument + ? self.logScrollView.currentScrollRestorationTarget + : initialRestorationTarget, + allowIncrementalUpdate: didRenderInitialDocument + ) + didRenderInitialDocument = true + } + } + } + private func isCurrentLogRenderTarget(_ target: LogRenderTarget) -> Bool { switch target { case .job(let id): @@ -488,15 +516,12 @@ final class ReviewMonitorTransportViewController: NSViewController { } private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument? { - if let document = selectedCodexChat.timelineDocument { - return document - } - -#if DEBUG - return ReviewTimelineDocumentRenderer().document(from: timeline) -#else - return nil -#endif + #if DEBUG + let document = ReviewTimelineDocumentRenderer().document(from: timeline) + return document.blocks.isEmpty ? nil : document + #else + return nil + #endif } private func cacheBoundJobScrollTarget() { @@ -551,768 +576,769 @@ 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 - } + @MainActor + extension ReviewMonitorTransportViewController { + struct RenderSnapshotForTesting: Sendable, Equatable { + let title: String? + let summary: String? + let log: String + let isShowingEmptyState: Bool + } - enum DisplayedSelectionForTesting: Sendable, Equatable { - case job(String) - case workspaceSection(String) - case workspace(String) - case chat(String) - } + struct WorkspaceFindingSnapshotForTesting: Sendable, Equatable { + let text: String + let isShowingNoFindingsState: Bool + let isShowingFindingsList: Bool + } - struct RenderedStateForTesting: Sendable, Equatable { - let snapshot: RenderSnapshotForTesting - let selection: DisplayedSelectionForTesting? - } + enum DisplayedSelectionForTesting: Sendable, Equatable { + case job(String) + case workspaceSection(String) + case workspace(String) + case chat(String) + } - var selectionObservationForTesting: PortableObservationTracking.Token? { - selectionObservation - } + struct RenderedStateForTesting: Sendable, Equatable { + let snapshot: RenderSnapshotForTesting + let selection: DisplayedSelectionForTesting? + } - var selectedJobObservationForTesting: PortableObservationTracking.Token? { - selectedJobObservation - } + var selectionObservationForTesting: PortableObservationTracking.Token? { + selectionObservation + } - var selectedChatObservationForTesting: PortableObservationTracking.Token? { - selectedChatObservation - } + var selectedJobObservationForTesting: PortableObservationTracking.Token? { + selectedJobObservation + } - var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { - selectedWorkspaceFindingsObservation - } + var selectedChatObservationForTesting: PortableObservationTracking.Token? { + selectedChatObservation + } - var selectedCodexChatReviewIdentityForTesting: CodexReviewIdentity? { - selectedCodexChat.identity - } + var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { + selectedWorkspaceFindingsObservation + } - var selectedCodexChatIDForTesting: String? { - selectedCodexChat.chat?.id.rawValue - } + var selectedCodexChatReviewIdentityForTesting: CodexReviewIdentity? { + selectedCodexChat.identity + } - var selectedCodexChatPhaseForTesting: CodexDataPhase { - selectedCodexChat.phase - } + var selectedCodexChatIDForTesting: String? { + selectedCodexChat.chat?.id.rawValue + } - var selectedCodexChatItemTextsForTesting: [String] { - selectedCodexChat.chat?.items.compactMap(\.text) ?? [] - } + var selectedCodexChatPhaseForTesting: CodexDataPhase { + selectedCodexChat.phase + } - var observationForExpectedRenderedStateForTesting: PortableObservationTracking.Token? { - let expectedSelection = expectedRenderedStateForTesting.selection - if displayedSelectionForTesting != expectedSelection { - return selectionObservation + var selectedCodexChatItemTextsForTesting: [String] { + selectedCodexChat.chat?.items.compactMap(\.text) ?? [] } - switch expectedSelection { - case .job: - return selectedJobObservation ?? selectionObservation - case .workspaceSection: - return selectedWorkspaceFindingsObservation ?? selectionObservation - case .workspace, .chat: - return selectionObservation - case nil: - return selectionObservation + + 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 .workspace, .chat: + return selectionObservation + case nil: + return selectionObservation + } } - } - var displayedTitleForTesting: String? { - nil - } + var displayedTitleForTesting: String? { + nil + } - var displayedLogForTesting: String { - logScrollView.displayedTextForTesting - } + var displayedLogForTesting: String { + logScrollView.displayedTextForTesting + } - var displayedWorkspaceFindingsForTesting: String { - workspaceFindingsView.displayedTextForTesting - } + var displayedWorkspaceFindingsForTesting: String { + workspaceFindingsView.displayedTextForTesting + } - var displayedSummaryForTesting: String? { - nil - } + var displayedSummaryForTesting: String? { + nil + } - var isShowingEmptyStateForTesting: Bool { - placeholderViewController.view.isHidden == false && - placeholderViewController.content == .noSelection - } + var isShowingEmptyStateForTesting: Bool { + placeholderViewController.view.isHidden == false && placeholderViewController.content == .noSelection + } - var emptyStateFrameForTesting: NSRect { - placeholderViewController.view.frame - } + var emptyStateFrameForTesting: NSRect { + placeholderViewController.view.frame + } - var isShowingNoFindingsStateForTesting: Bool { - placeholderViewController.view.isHidden == false && - placeholderViewController.content == .noFindings - } + var isShowingNoFindingsStateForTesting: Bool { + placeholderViewController.view.isHidden == false && placeholderViewController.content == .noFindings + } - var isShowingWorkspaceFindingsListForTesting: Bool { - workspaceFindingsView.isShowingFindingsListForTesting - } + var isShowingWorkspaceFindingsListForTesting: Bool { + workspaceFindingsView.isShowingFindingsListForTesting + } - var logAppendCountForTesting: Int { - logScrollView.appendCount - } + var logAppendCountForTesting: Int { + logScrollView.appendCount + } - var logReplaceCountForTesting: Int { - logScrollView.replaceCount - } + var logReplaceCountForTesting: Int { + logScrollView.replaceCount + } - var logReloadCountForTesting: Int { - logScrollView.reloadCount - } + var logReloadCountForTesting: Int { + logScrollView.reloadCount + } - var logAutoFollowCountForTesting: Int { - logScrollView.autoFollowCount - } + var logAutoFollowCountForTesting: Int { + logScrollView.autoFollowCount + } - var logWordGlowCountForTesting: Int { - logScrollView.wordGlowCountForTesting - } + var logWordGlowCountForTesting: Int { + logScrollView.wordGlowCountForTesting + } - var logWordFadeRenderingAttributeRangeCountForTesting: Int { - logScrollView.wordFadeRenderingAttributeRangeCountForTesting - } + var logWordFadeRenderingAttributeRangeCountForTesting: Int { + logScrollView.wordFadeRenderingAttributeRangeCountForTesting + } - var logWordFadeStorageUsesOpaqueTextColorForTesting: Bool { - logScrollView.wordFadeStorageUsesOpaqueTextColorForTesting - } + var logWordFadeStorageUsesOpaqueTextColorForTesting: Bool { + logScrollView.wordFadeStorageUsesOpaqueTextColorForTesting + } - var logWordFadeDisplayInvalidationCountForTesting: Int { - logScrollView.wordFadeDisplayInvalidationCountForTesting - } + var logWordFadeDisplayInvalidationCountForTesting: Int { + logScrollView.wordFadeDisplayInvalidationCountForTesting + } - var logCommandOutputPanelCountForTesting: Int { - logScrollView.commandOutputPanelCountForTesting - } + var logCommandOutputPanelCountForTesting: Int { + logScrollView.commandOutputPanelCountForTesting + } - var logTerminalDecorationRectCountForTesting: Int { - logScrollView.terminalDecorationRectCountForTesting - } + var logTerminalDecorationRectCountForTesting: Int { + logScrollView.terminalDecorationRectCountForTesting + } - var logExpandedCommandOutputPanelCountForTesting: Int { - logScrollView.expandedCommandOutputPanelCountForTesting - } + var logExpandedCommandOutputPanelCountForTesting: Int { + logScrollView.expandedCommandOutputPanelCountForTesting + } - var logCommandOutputPanelUsesTextKit2ForTesting: Bool { - logScrollView.commandOutputPanelUsesTextKit2ForTesting - } + var logCommandOutputPanelUsesTextKit2ForTesting: Bool { + logScrollView.commandOutputPanelUsesTextKit2ForTesting + } - var logCommandOutputPanelUsesInlineAttachmentForTesting: Bool { - logScrollView.commandOutputPanelUsesInlineAttachmentForTesting - } + var logCommandOutputPanelUsesInlineAttachmentForTesting: Bool { + logScrollView.commandOutputPanelUsesInlineAttachmentForTesting + } - var logCommandOutputPanelUsesButtonAttachmentForTesting: Bool { - logScrollView.commandOutputPanelUsesButtonAttachmentForTesting - } + var logCommandOutputPanelUsesButtonAttachmentForTesting: Bool { + logScrollView.commandOutputPanelUsesButtonAttachmentForTesting + } - var logCollapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? { - logScrollView.collapsedCommandOutputPanelAttachmentLineHeightForTesting - } + var logCollapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? { + logScrollView.collapsedCommandOutputPanelAttachmentLineHeightForTesting + } - var logCollapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool { - logScrollView.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting - } + var logCollapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool { + logScrollView.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting + } - var logCommandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool { - logScrollView.commandOutputPanelUsesSystemMaterialBackgroundForTesting - } + var logCommandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool { + logScrollView.commandOutputPanelUsesSystemMaterialBackgroundForTesting + } - var logCommandOutputPanelVisibleLineCapacityForTesting: Int { - logScrollView.commandOutputPanelVisibleLineCapacityForTesting - } + var logCommandOutputPanelVisibleLineCapacityForTesting: Int { + logScrollView.commandOutputPanelVisibleLineCapacityForTesting + } - var logCommandOutputPanelResultTextForTesting: String? { - logScrollView.commandOutputPanelResultTextForTesting - } + var logCommandOutputPanelResultTextForTesting: String? { + logScrollView.commandOutputPanelResultTextForTesting + } - var logCommandOutputPanelTerminalTextForTesting: String? { - logScrollView.commandOutputPanelTerminalTextForTesting - } + var logCommandOutputPanelTerminalTextForTesting: String? { + logScrollView.commandOutputPanelTerminalTextForTesting + } - func logCommandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? { - logScrollView.commandOutputPanelTerminalTextForTesting(blockID: blockID) - } + func logCommandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? { + logScrollView.commandOutputPanelTerminalTextForTesting(blockID: blockID) + } - var logCommandOutputPanelCommandLineTextForTesting: String? { - logScrollView.commandOutputPanelCommandLineTextForTesting - } + var logCommandOutputPanelCommandLineTextForTesting: String? { + logScrollView.commandOutputPanelCommandLineTextForTesting + } - var logCommandOutputPanelOutputScrollTextForTesting: String? { - logScrollView.commandOutputPanelOutputScrollTextForTesting - } + var logCommandOutputPanelOutputScrollTextForTesting: String? { + logScrollView.commandOutputPanelOutputScrollTextForTesting + } - var logCommandOutputPanelOutputScrollIsScrollableForTesting: Bool { - logScrollView.commandOutputPanelOutputScrollIsScrollableForTesting - } + var logCommandOutputPanelOutputScrollIsScrollableForTesting: Bool { + logScrollView.commandOutputPanelOutputScrollIsScrollableForTesting + } - var logCommandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool { - logScrollView.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting - } + var logCommandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool { + logScrollView.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting + } - var logCommandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? { - logScrollView.commandOutputPanelOutputScrollVerticalOffsetForTesting - } + var logCommandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? { + logScrollView.commandOutputPanelOutputScrollVerticalOffsetForTesting + } - var logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? { - logScrollView.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting - } + var logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? { + logScrollView.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting + } - func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool { - logScrollView.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY) - } + func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool { + logScrollView.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY) + } - var logCommandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool { - logScrollView.commandOutputPanelOutputHitTestTargetsTextViewForTesting - } + var logCommandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool { + logScrollView.commandOutputPanelOutputHitTestTargetsTextViewForTesting + } - func logFinderRectsForTesting(_ range: NSRange) -> [NSRect] { - logScrollView.finderRectsForTesting(range) - } + func logFinderRectsForTesting(_ range: NSRange) -> [NSRect] { + logScrollView.finderRectsForTesting(range) + } - var logFirstCommandOutputPanelRectForTesting: NSRect? { - logScrollView.firstCommandOutputPanelRectForTesting - } + var logFirstCommandOutputPanelRectForTesting: NSRect? { + logScrollView.firstCommandOutputPanelRectForTesting + } - var logCommandOutputPanelToggleSymbolNameForTesting: String? { - logScrollView.commandOutputPanelToggleSymbolNameForTesting - } + var logCommandOutputPanelToggleSymbolNameForTesting: String? { + logScrollView.commandOutputPanelToggleSymbolNameForTesting + } - var logCommandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelLeadingAlignmentDeltaForTesting - } + var logCommandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? { + logScrollView.commandOutputPanelLeadingAlignmentDeltaForTesting + } - var logCommandOutputPanelChevronSizeDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelChevronSizeDeltaForTesting - } + var logCommandOutputPanelChevronSizeDeltaForTesting: CGFloat? { + logScrollView.commandOutputPanelChevronSizeDeltaForTesting + } - var logCommandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelChevronVerticalAlignmentDeltaForTesting - } + var logCommandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? { + logScrollView.commandOutputPanelChevronVerticalAlignmentDeltaForTesting + } - func logHitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool { - logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) - } + func logHitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool { + logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) + } - func toggleFirstLogCommandOutputPanelForTesting() { - logScrollView.toggleFirstCommandOutputPanelForTesting() - } + func toggleFirstLogCommandOutputPanelForTesting() { + logScrollView.toggleFirstCommandOutputPanelForTesting() + } - @discardableResult - func clickFirstLogCommandOutputPanelHeaderForTesting() -> Bool { - logScrollView.clickFirstCommandOutputPanelHeaderForTesting() - } + @discardableResult + func clickFirstLogCommandOutputPanelHeaderForTesting() -> Bool { + logScrollView.clickFirstCommandOutputPanelHeaderForTesting() + } - @discardableResult - func clickLogCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool { - logScrollView.clickCommandOutputPanelHeaderForTesting(blockID: blockID) - } + @discardableResult + func clickLogCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool { + logScrollView.clickCommandOutputPanelHeaderForTesting(blockID: blockID) + } - func completeLogWordGlowAnimationsForTesting() { - logScrollView.completeWordGlowAnimationsForTesting() - } + func completeLogWordGlowAnimationsForTesting() { + logScrollView.completeWordGlowAnimationsForTesting() + } - func advanceLogWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) { - logScrollView.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay) - } + func advanceLogWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) { + logScrollView.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay) + } - func setLogReduceMotionForTesting(_ reduceMotion: Bool?) { - logScrollView.setReduceMotionForTesting(reduceMotion) - } + func setLogReduceMotionForTesting(_ reduceMotion: Bool?) { + logScrollView.setReduceMotionForTesting(reduceMotion) + } - var logUsesCustomTextKit2SurfaceForTesting: Bool { - logScrollView.usesCustomTextKit2SurfaceForTesting - } + var logUsesCustomTextKit2SurfaceForTesting: Bool { + logScrollView.usesCustomTextKit2SurfaceForTesting + } - var logUsesTextViewForTesting: Bool { - logScrollView.usesTextViewForTesting - } + var logUsesTextViewForTesting: Bool { + logScrollView.usesTextViewForTesting + } - var logUsesLogLayoutManagerForTesting: Bool { - logScrollView.usesLogLayoutManagerForTesting - } + var logUsesLogLayoutManagerForTesting: Bool { + logScrollView.usesLogLayoutManagerForTesting + } - var logIsEditableForTesting: Bool { - logScrollView.isEditableForTesting - } + var logIsEditableForTesting: Bool { + logScrollView.isEditableForTesting + } - var logIsSelectableForTesting: Bool { - logScrollView.isSelectableForTesting - } + var logIsSelectableForTesting: Bool { + logScrollView.isSelectableForTesting + } - var logUsesFindBarForTesting: Bool { - logScrollView.usesFindBarForTesting - } + var logUsesFindBarForTesting: Bool { + logScrollView.usesFindBarForTesting + } - var logIsIncrementalSearchingEnabledForTesting: Bool { - logScrollView.isIncrementalSearchingEnabledForTesting - } + var logIsIncrementalSearchingEnabledForTesting: Bool { + logScrollView.isIncrementalSearchingEnabledForTesting + } - var logFindBarVisibleForTesting: Bool { - logScrollView.isFindBarVisibleForTesting - } + var logFindBarVisibleForTesting: Bool { + logScrollView.isFindBarVisibleForTesting + } - var logTextFinderIdentifierForTesting: ObjectIdentifier { - logScrollView.textFinderIdentifierForTesting - } + var logTextFinderIdentifierForTesting: ObjectIdentifier { + logScrollView.textFinderIdentifierForTesting + } - var logFindVisibleCharacterRangesForTesting: [NSRange] { - logScrollView.findVisibleCharacterRangesForTesting - } + var logFindVisibleCharacterRangesForTesting: [NSRange] { + logScrollView.findVisibleCharacterRangesForTesting + } - var logFindStringLengthForTesting: Int { - logScrollView.findStringLengthForTesting - } + var logFindStringLengthForTesting: Int { + logScrollView.findStringLengthForTesting + } - var logFindClientUsesSnapshotForTesting: Bool { - logScrollView.findClientUsesSnapshotForTesting - } + var logFindClientUsesSnapshotForTesting: Bool { + logScrollView.findClientUsesSnapshotForTesting + } - var logFindClientSnapshotMapsToDocumentForTesting: Bool { - logScrollView.findClientSnapshotMapsToDocumentForTesting - } + var logFindClientSnapshotMapsToDocumentForTesting: Bool { + logScrollView.findClientSnapshotMapsToDocumentForTesting + } - var logFindClientFirstSelectedRangeForTesting: NSRange { - logScrollView.findClientFirstSelectedRangeForTesting - } + var logFindClientFirstSelectedRangeForTesting: NSRange { + logScrollView.findClientFirstSelectedRangeForTesting + } - var logHasActiveFindQueryForTesting: Bool { - logScrollView.hasActiveFindQueryForTesting - } + var logHasActiveFindQueryForTesting: Bool { + logScrollView.hasActiveFindQueryForTesting + } - var logVisibleFindBarSearchStringForTesting: String? { - logScrollView.visibleFindBarSearchStringForTesting - } + var logVisibleFindBarSearchStringForTesting: String? { + logScrollView.visibleFindBarSearchStringForTesting + } - @discardableResult - func setLogVisibleFindBarSearchStringForTesting(_ string: String) -> Bool { - logScrollView.setVisibleFindBarSearchStringForTesting(string) - } + @discardableResult + func setLogVisibleFindBarSearchStringForTesting(_ string: String) -> Bool { + logScrollView.setVisibleFindBarSearchStringForTesting(string) + } - var logFindIndicatorInvalidationCountForTesting: Int { - logScrollView.findIndicatorInvalidationCountForTesting - } + var logFindIndicatorInvalidationCountForTesting: Int { + logScrollView.findIndicatorInvalidationCountForTesting + } - var logFindIncrementalMatchRangeCountForTesting: Int { - logScrollView.findIncrementalMatchRangeCountForTesting - } + var logFindIncrementalMatchRangeCountForTesting: Int { + logScrollView.findIncrementalMatchRangeCountForTesting + } - var logFindBarContainerContentViewIsTextContentViewForTesting: Bool { - logScrollView.findBarContainerContentViewIsTextContentViewForTesting - } + var logFindBarContainerContentViewIsTextContentViewForTesting: Bool { + logScrollView.findBarContainerContentViewIsTextContentViewForTesting + } - var logFindIncrementalSearchUsesSystemHighlightingForTesting: Bool { - logScrollView.findIncrementalSearchUsesSystemHighlightingForTesting - } + var logFindIncrementalSearchUsesSystemHighlightingForTesting: Bool { + logScrollView.findIncrementalSearchUsesSystemHighlightingForTesting + } - var logHitTestTargetsDocumentViewForTesting: Bool { - logScrollView.hitTestTargetsDocumentViewForTesting - } + var logHitTestTargetsDocumentViewForTesting: Bool { + logScrollView.hitTestTargetsDocumentViewForTesting + } - var logWritingToolsDisabledForTesting: Bool { - logScrollView.writingToolsDisabledForTesting - } + var logWritingToolsDisabledForTesting: Bool { + logScrollView.writingToolsDisabledForTesting + } - var logOverlayScrollerHideRequestCountForTesting: Int { - logScrollView.overlayScrollerHideRequestCountForTesting - } + var logOverlayScrollerHideRequestCountForTesting: Int { + logScrollView.overlayScrollerHideRequestCountForTesting + } - var logRenderIsIdleForTesting: Bool { - appliedLogRenderGeneration == logRenderGeneration - } + var logRenderIsIdleForTesting: Bool { + appliedLogRenderGeneration == logRenderGeneration + } - var logFrameForTesting: NSRect { - logScrollView.frame - } + var logFrameForTesting: NSRect { + logScrollView.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 { + logScrollView.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( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: true + ) + } return .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: true + title: displayedTitleForTesting, + summary: displayedSummaryForTesting, + 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 + snapshot: renderSnapshotForTesting, + selection: displayedSelectionForTesting ) - case .workspaceSection: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: false - ) - case .workspace: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: false - ) - case .chat: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: false - ) - case nil: + } + + var expectedRenderSnapshotForTesting: RenderSnapshotForTesting { + switch uiState.selection { + case .job(let job): + .init( + title: nil, + summary: nil, + log: { + let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) + if timelineDocument.blocks.isEmpty == false { + var projection = ReviewMonitorTimelineLogProjection() + let document = projection.render(timelineDocument: timelineDocument) + return logScrollView.displayTextForTesting(sourceDocument: document) + } + return "" + }(), + isShowingEmptyState: false + ) + case .workspaceSection: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: false + ) + case .workspace: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: false + ) + case .chat: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: false + ) + case nil: + .init( + title: nil, + summary: nil, + log: "", + isShowingEmptyState: true + ) + } + } + + var expectedRenderedStateForTesting: RenderedStateForTesting { .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: true + snapshot: expectedRenderSnapshotForTesting, + selection: expectedDisplayedSelectionForTesting ) } - } - - 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 .workspace(let id): - .workspace(id.rawValue) - case .chat(let id): - .chat(id.rawValue) - case nil: - nil + private var displayedSelectionForTesting: DisplayedSelectionForTesting? { + switch displayedSelection { + case .job(let id): + .job(id) + case .workspaceSection(let id): + .workspaceSection(id) + case .workspace(let id): + .workspace(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 .workspace(let workspace): - .workspace(workspace.id.rawValue) - case .chat(let chat): - .chat(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 .workspace(let workspace): + .workspace(workspace.id.rawValue) + case .chat(let chat): + .chat(chat.id.rawValue) + case nil: + nil + } } - } - var workspaceFindingSnapshotForTesting: WorkspaceFindingSnapshotForTesting { - .init( - text: displayedWorkspaceFindingsForTesting, - isShowingNoFindingsState: isShowingNoFindingsStateForTesting, - isShowingFindingsList: isShowingWorkspaceFindingsListForTesting - ) - } + var workspaceFindingSnapshotForTesting: WorkspaceFindingSnapshotForTesting { + .init( + text: displayedWorkspaceFindingsForTesting, + isShowingNoFindingsState: isShowingNoFindingsStateForTesting, + isShowingFindingsList: isShowingWorkspaceFindingsListForTesting + ) + } - var workspaceFindingsContentWidthForTesting: CGFloat { - view.layoutSubtreeIfNeeded() - return workspaceFindingsView.contentWidthForTesting - } + var workspaceFindingsContentWidthForTesting: CGFloat { + view.layoutSubtreeIfNeeded() + return workspaceFindingsView.contentWidthForTesting + } - var workspaceFindingsFrameForTesting: NSRect { - workspaceFindingsView.frame - } + var workspaceFindingsFrameForTesting: NSRect { + workspaceFindingsView.frame + } - var workspaceFindingsTextContainerWidthForTesting: CGFloat { - view.layoutSubtreeIfNeeded() - return workspaceFindingsView.textContainerWidthForTesting - } + var workspaceFindingsTextContainerWidthForTesting: CGFloat { + view.layoutSubtreeIfNeeded() + return workspaceFindingsView.textContainerWidthForTesting + } - var workspaceFindingsScrollFrameForTesting: NSRect { - workspaceFindingsView.scrollFrameForTesting - } + var workspaceFindingsScrollFrameForTesting: NSRect { + workspaceFindingsView.scrollFrameForTesting + } - var workspaceFindingsDocumentFrameForTesting: NSRect { - workspaceFindingsView.documentFrameForTesting - } + var workspaceFindingsDocumentFrameForTesting: NSRect { + workspaceFindingsView.documentFrameForTesting + } - var workspaceFindingsNoFindingsFrameForTesting: NSRect { - placeholderViewController.view.frame - } + var workspaceFindingsNoFindingsFrameForTesting: NSRect { + placeholderViewController.view.frame + } - var workspaceFindingsContentInsetsForTesting: NSEdgeInsets { - workspaceFindingsView.contentInsetsForTesting - } + var workspaceFindingsContentInsetsForTesting: NSEdgeInsets { + workspaceFindingsView.contentInsetsForTesting + } - var workspaceFindingsVerticalScrollOffsetForTesting: CGFloat { - workspaceFindingsView.verticalScrollOffsetForTesting - } + var workspaceFindingsVerticalScrollOffsetForTesting: CGFloat { + workspaceFindingsView.verticalScrollOffsetForTesting + } - var workspaceFindingsMinimumVerticalScrollOffsetForTesting: CGFloat { - workspaceFindingsView.minimumVerticalScrollOffsetForTesting - } + var workspaceFindingsMinimumVerticalScrollOffsetForTesting: CGFloat { + workspaceFindingsView.minimumVerticalScrollOffsetForTesting + } - var workspaceFindingsMaximumVerticalScrollOffsetForTesting: CGFloat { - workspaceFindingsView.maximumVerticalScrollOffsetForTesting - } + var workspaceFindingsMaximumVerticalScrollOffsetForTesting: CGFloat { + workspaceFindingsView.maximumVerticalScrollOffsetForTesting + } - var workspaceFindingsAutomaticallyAdjustsContentInsetsForTesting: Bool { - workspaceFindingsView.automaticallyAdjustsContentInsetsForTesting - } + var workspaceFindingsAutomaticallyAdjustsContentInsetsForTesting: Bool { + workspaceFindingsView.automaticallyAdjustsContentInsetsForTesting + } - var workspaceFindingsTextIsSelectableForTesting: Bool { - workspaceFindingsView.isTextSelectableForTesting - } + var workspaceFindingsTextIsSelectableForTesting: Bool { + workspaceFindingsView.isTextSelectableForTesting + } - var workspaceFindingsTextIsEditableForTesting: Bool { - workspaceFindingsView.isTextEditableForTesting - } + var workspaceFindingsTextIsEditableForTesting: Bool { + workspaceFindingsView.isTextEditableForTesting + } - var workspaceFindingsUsesFindBarForTesting: Bool { - workspaceFindingsView.usesFindBarForTesting - } + var workspaceFindingsUsesFindBarForTesting: Bool { + workspaceFindingsView.usesFindBarForTesting + } - var workspaceFindingsIsIncrementalSearchingEnabledForTesting: Bool { - workspaceFindingsView.isIncrementalSearchingEnabledForTesting - } + var workspaceFindingsIsIncrementalSearchingEnabledForTesting: Bool { + workspaceFindingsView.isIncrementalSearchingEnabledForTesting + } - var workspaceFindingsFindBarVisibleForTesting: Bool { - workspaceFindingsView.isFindBarVisibleForTesting - } + var workspaceFindingsFindBarVisibleForTesting: Bool { + workspaceFindingsView.isFindBarVisibleForTesting + } - var workspaceFindingsPriorityPrefixCountForTesting: Int { - workspaceFindingsView.priorityPrefixCountForTesting - } + var workspaceFindingsPriorityPrefixCountForTesting: Int { + workspaceFindingsView.priorityPrefixCountForTesting + } - var workspaceFindingsTextAttachmentCountForTesting: Int { - workspaceFindingsView.textAttachmentCountForTesting - } + var workspaceFindingsTextAttachmentCountForTesting: Int { + workspaceFindingsView.textAttachmentCountForTesting + } - var workspaceFindingsThreadBackgroundRangeCountForTesting: Int { - workspaceFindingsView.threadBackgroundRangeCountForTesting - } + var workspaceFindingsThreadBackgroundRangeCountForTesting: Int { + workspaceFindingsView.threadBackgroundRangeCountForTesting + } - var workspaceFindingsAccessibilityValueForTesting: String? { - workspaceFindingsView.accessibilityValueForTesting - } + var workspaceFindingsAccessibilityValueForTesting: String? { + workspaceFindingsView.accessibilityValueForTesting + } - var workspaceFindingsRenderedStorageStringForTesting: String { - workspaceFindingsView.renderedStorageStringForTesting - } + var workspaceFindingsRenderedStorageStringForTesting: String { + workspaceFindingsView.renderedStorageStringForTesting + } - func scrollLogToTopForTesting() { - logScrollView.scrollToTopForTesting() - } + func scrollLogToTopForTesting() { + logScrollView.scrollToTopForTesting() + } - func scrollLogToOffsetForTesting(_ y: CGFloat) { - logScrollView.scrollToOffsetForTesting(y) - } + func scrollLogToOffsetForTesting(_ y: CGFloat) { + logScrollView.scrollToOffsetForTesting(y) + } - var logVerticalScrollOffsetForTesting: CGFloat { - logScrollView.verticalScrollOffsetForTesting - } + var logVerticalScrollOffsetForTesting: CGFloat { + logScrollView.verticalScrollOffsetForTesting + } - var logViewportHeightForTesting: CGFloat { - logScrollView.viewportHeightForTesting - } + var logViewportHeightForTesting: CGFloat { + logScrollView.viewportHeightForTesting + } - var logMinimumVerticalScrollOffsetForTesting: CGFloat { - logScrollView.minimumVerticalScrollOffsetForTesting - } + var logMinimumVerticalScrollOffsetForTesting: CGFloat { + logScrollView.minimumVerticalScrollOffsetForTesting + } - var logMaximumVerticalScrollOffsetForTesting: CGFloat { - logScrollView.maximumVerticalScrollOffsetForTesting - } + var logMaximumVerticalScrollOffsetForTesting: CGFloat { + logScrollView.maximumVerticalScrollOffsetForTesting + } - var logTextContentFrameForTesting: NSRect { - logScrollView.textContentFrameForTesting - } + var logTextContentFrameForTesting: NSRect { + logScrollView.textContentFrameForTesting + } - var logDocumentViewFrameForTesting: NSRect { - logScrollView.documentViewFrameForTesting - } + var logDocumentViewFrameForTesting: NSRect { + logScrollView.documentViewFrameForTesting + } - var logContentInsetsForTesting: NSEdgeInsets { - logScrollView.contentInsetsForTesting - } + var logContentInsetsForTesting: NSEdgeInsets { + logScrollView.contentInsetsForTesting + } - var logAutomaticallyAdjustsContentInsetsForTesting: Bool { - logScrollView.automaticallyAdjustsContentInsetsForTesting - } + var logAutomaticallyAdjustsContentInsetsForTesting: Bool { + logScrollView.automaticallyAdjustsContentInsetsForTesting + } - var logTextContainerSizeForTesting: NSSize { - logScrollView.textContainerSizeForTesting - } + var logTextContainerSizeForTesting: NSSize { + logScrollView.textContainerSizeForTesting + } - var logTextContainerInsetForTesting: NSSize { - logScrollView.textContainerInsetForTesting - } + var logTextContainerInsetForTesting: NSSize { + logScrollView.textContainerInsetForTesting + } - var logVisibleFragmentViewCountForTesting: Int { - logScrollView.visibleFragmentViewCountForTesting - } + var logVisibleFragmentViewCountForTesting: Int { + logScrollView.visibleFragmentViewCountForTesting + } - var logVisibleFragmentViewCountWithoutForcingLayoutForTesting: Int { - logScrollView.visibleFragmentViewCountWithoutForcingLayoutForTesting - } + var logVisibleFragmentViewCountWithoutForcingLayoutForTesting: Int { + logScrollView.visibleFragmentViewCountWithoutForcingLayoutForTesting + } - var logVisibleFragmentBoundsForTesting: NSRect { - logScrollView.visibleFragmentBoundsForTesting - } + var logVisibleFragmentBoundsForTesting: NSRect { + logScrollView.visibleFragmentBoundsForTesting + } - var logVisibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect { - logScrollView.visibleFragmentBoundsWithoutForcingLayoutForTesting - } + var logVisibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect { + logScrollView.visibleFragmentBoundsWithoutForcingLayoutForTesting + } - var logStaleFragmentViewCountForTesting: Int { - logScrollView.staleFragmentViewCountForTesting - } + var logStaleFragmentViewCountForTesting: Int { + logScrollView.staleFragmentViewCountForTesting + } - var logProgrammaticScrollCountForTesting: Int { - logScrollView.programmaticScrollCountForTesting - } + var logProgrammaticScrollCountForTesting: Int { + logScrollView.programmaticScrollCountForTesting + } - var logAccessibilityValueForTesting: String? { - logScrollView.accessibilityValueForTesting - } + var logAccessibilityValueForTesting: String? { + logScrollView.accessibilityValueForTesting + } - var logSelectedTextForTesting: String? { - logScrollView.selectedTextForTesting - } + var logSelectedTextForTesting: String? { + logScrollView.selectedTextForTesting + } - var logSelectedRangeForTesting: NSRange { - logScrollView.selectedRangeForTesting - } + var logSelectedRangeForTesting: NSRange { + logScrollView.selectedRangeForTesting + } - var logFindStringForTesting: String { - logScrollView.findStringForTesting - } + var logFindStringForTesting: String { + logScrollView.findStringForTesting + } - func selectAllLogForTesting() { - logScrollView.selectAllForTesting() - } + func selectAllLogForTesting() { + logScrollView.selectAllForTesting() + } - func setSelectedLogRangeForTesting(_ range: NSRange) { - logScrollView.setSelectedLogRangeForTesting(range) - } + func setSelectedLogRangeForTesting(_ range: NSRange) { + logScrollView.setSelectedLogRangeForTesting(range) + } - var logDocumentViewExportsUserInterfaceValidationForTesting: Bool { - logScrollView.documentViewExportsUserInterfaceValidationForTesting - } + var logDocumentViewExportsUserInterfaceValidationForTesting: Bool { + logScrollView.documentViewExportsUserInterfaceValidationForTesting + } - func validateLogDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool { - logScrollView.validateDocumentUserInterfaceItemForTesting(item) - } + func validateLogDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool { + logScrollView.validateDocumentUserInterfaceItemForTesting(item) + } - func clearLogFinderSelectedRangesForTesting() { - logScrollView.clearFinderSelectedRangesForTesting() - } + func clearLogFinderSelectedRangesForTesting() { + logScrollView.clearFinderSelectedRangesForTesting() + } - func setLogFinderSelectedRangeForTesting(_ range: NSRange) { - logScrollView.setFinderSelectedRangeForTesting(range) - } + func setLogFinderSelectedRangeForTesting(_ range: NSRange) { + logScrollView.setFinderSelectedRangeForTesting(range) + } - func simulateLogFinderEmptySelectedRangesForTesting() { - logScrollView.simulateFinderEmptySelectedRangesForTesting() - } + func simulateLogFinderEmptySelectedRangesForTesting() { + logScrollView.simulateFinderEmptySelectedRangesForTesting() + } - func performLogKeyboardCommandForTesting(_ selector: Selector) { - logScrollView.performKeyboardCommandForTesting(selector) - } + func performLogKeyboardCommandForTesting(_ selector: Selector) { + logScrollView.performKeyboardCommandForTesting(selector) + } - @discardableResult - func renderLogForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool { - logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate) - } + @discardableResult + func renderLogForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool { + logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate) + } - func copyLogSelectionForTesting() { - logScrollView.copySelectionForTesting() - } + func copyLogSelectionForTesting() { + logScrollView.copySelectionForTesting() + } - func beginLogLiveResizeForTesting() { - logScrollView.beginLiveResizeForTesting() - } + func beginLogLiveResizeForTesting() { + logScrollView.beginLiveResizeForTesting() + } - func endLogLiveResizeForTesting() { - logScrollView.endLiveResizeForTesting() - } + func endLogLiveResizeForTesting() { + logScrollView.endLiveResizeForTesting() + } - func scrollLogToBottomForTesting() { - logScrollView.scrollToBottomForTesting() - } + func scrollLogToBottomForTesting() { + logScrollView.scrollToBottomForTesting() + } - var isLogPinnedToBottomForTesting: Bool { - logScrollView.isPinnedToBottomForTesting - } + var isLogPinnedToBottomForTesting: Bool { + logScrollView.isPinnedToBottomForTesting + } - func setLogScrollerStyleForTesting(_ style: NSScroller.Style) { - logScrollView.setScrollerStyleForTesting(style) - } + func setLogScrollerStyleForTesting(_ style: NSScroller.Style) { + logScrollView.setScrollerStyleForTesting(style) + } - func setLogOverlayScrollersShownForTesting(_ isShown: Bool?) { - logScrollView.setOverlayScrollersShownForTesting(isShown) - } + func setLogOverlayScrollersShownForTesting(_ isShown: Bool?) { + logScrollView.setOverlayScrollersShownForTesting(isShown) + } - func setLogOverlayScrollerBridgeModeForTesting( - _ mode: ReviewMonitorLogScrollView.OverlayScrollerBridgeModeForTesting - ) { - logScrollView.setOverlayScrollerBridgeModeForTesting(mode) + func setLogOverlayScrollerBridgeModeForTesting( + _ mode: ReviewMonitorLogScrollView.OverlayScrollerBridgeModeForTesting + ) { + logScrollView.setOverlayScrollerBridgeModeForTesting(mode) + } } -} #endif diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 1b4e5283..4e65f8a7 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -176,7 +176,9 @@ public enum ReviewMonitorPreviewContent { 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)) + let entry = streamEntry(from: frame.step, for: job, cycle: frame.cycle) + job.appendLogEntry(entry) + appendPreviewTimelineEntry(entry, to: job) } } return nextTick @@ -245,7 +247,7 @@ public enum ReviewMonitorPreviewContent { completedAt: Date() ) case .agentMessage, .plan, .todoList, .reasoning, .reasoningSummary, .rawReasoning, - .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction: + .toolCall, .diagnostic, .error, .progress, .event, .contextCompaction: return step.metadata } } @@ -309,13 +311,14 @@ public enum ReviewMonitorPreviewContent { 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 - )) + timeline.append( + PreviewStreamStep( + kind: template.kind, + groupName: template.groupName, + text: chunk, + metadata: template.metadata, + replacesGroup: template.replacesGroup + )) } } return timeline @@ -349,10 +352,10 @@ public enum ReviewMonitorPreviewContent { kind: .plan, groupName: "plan", text: """ - [completed] Inspect ReviewMonitor log rendering - [in_progress] Preserve active find UI while streaming - [pending] Run focused UI tests - """, + [completed] Inspect ReviewMonitor log rendering + [in_progress] Preserve active find UI while streaming + [pending] Run focused UI tests + """, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -365,10 +368,10 @@ public enum ReviewMonitorPreviewContent { 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. - """, + 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", @@ -385,7 +388,8 @@ public enum ReviewMonitorPreviewContent { .init( kind: .reasoningSummary, groupName: "reasoning-summary", - text: "Checking whether append-only log updates are notifying NSTextFinder while an incremental search is active.\n", + text: + "Checking whether append-only log updates are notifying NSTextFinder while an incremental search is active.\n", chunkByWord: true, delayBeforeFrameCount: interItemDelayFrameCount ), @@ -399,14 +403,14 @@ public enum ReviewMonitorPreviewContent { kind: .commandOutput, groupName: "command-open-log-scroll", text: """ - import AppKit - import ObjectiveC.runtime - import CodexReviewKit - - @MainActor - final class ReviewMonitorLogScrollView: NSScrollView { - private let logDocumentView = ReviewMonitorLogDocumentView() - """, + import AppKit + import ObjectiveC.runtime + import CodexReviewKit + + @MainActor + final class ReviewMonitorLogScrollView: NSScrollView { + private let logDocumentView = ReviewMonitorLogDocumentView() + """, metadata: .init( sourceType: "command", title: "Ran command for 2s", @@ -447,21 +451,24 @@ public enum ReviewMonitorPreviewContent { .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", + 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", + 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", + text: + "The preview stream now mixes commands, tool events, reasoning summaries, and visible assistant output instead of one repeated message kind.\n", delayBeforeFrameCount: interItemDelayFrameCount ), ] @@ -470,35 +477,36 @@ public enum ReviewMonitorPreviewContent { 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 + Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor - Resolve Package Graph + Resolve Package Graph - Resolved source packages: - ObservationBridge: /Users/kn/Dev/ObservationBridge - CodexReviewKit: /Users/kn/Dev/CodexReviewKit + 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. - """ + 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. + Checking the command output rendering path. - - Keep the transcript readable. - - Collapse terminal output by default. - - Expand into a bounded TextKit 2 scroll view. - """ + - 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" + text: + "$ xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor" ), .init( kind: .commandOutput, @@ -662,7 +670,7 @@ public enum ReviewMonitorPreviewContent { lastAgentMessage: String, logEntries: [ReviewLogEntry] ) -> CodexReviewJob { - CodexReviewJob.makeForTesting( + let job = CodexReviewJob.makeForTesting( id: id, cwd: cwd, targetSummary: targetSummary, @@ -678,6 +686,236 @@ public enum ReviewMonitorPreviewContent { lastAgentMessage: lastAgentMessage, logEntries: logEntries ) + seedPreviewTimeline(logEntries, in: job) + return job + } + + private static func seedPreviewTimeline(_ logEntries: [ReviewLogEntry], in job: CodexReviewJob) { + for entry in logEntries { + appendPreviewTimelineEntry(entry, to: job) + } + } + + private static func appendPreviewTimelineEntry(_ entry: ReviewLogEntry, to job: CodexReviewJob) { + let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) + let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content + job.timeline.apply( + .itemUpdated( + .init( + id: itemID, + kind: previewTimelineKind(for: entry), + family: previewTimelineFamily(for: entry), + phase: previewTimelinePhase(for: entry), + content: previewTimelineContent(for: entry, existing: existingContent), + startedAt: entry.metadata?.startedAt, + completedAt: entry.metadata?.completedAt, + durationMs: entry.metadata?.durationMs + ))) + } + + private static func previewTimelineKind(for entry: ReviewLogEntry) -> ReviewItemKind { + switch entry.kind { + case .command, .commandOutput: + .commandExecution + case .agentMessage: + .agentMessage + case .plan, .todoList: + .plan + case .reasoning, .reasoningSummary, .rawReasoning: + .reasoning + case .contextCompaction: + .contextCompaction + case .toolCall: + .mcpToolCall + case .diagnostic, .error, .progress, .event: + ReviewItemKind(rawValue: entry.kind.rawValue) + } + } + + private static func previewTimelineFamily(for entry: ReviewLogEntry) -> ReviewItemFamily { + switch entry.kind { + case .command, .commandOutput: + .command + case .agentMessage: + .message + case .plan, .todoList: + .plan + case .reasoning, .reasoningSummary, .rawReasoning: + .reasoning + case .contextCompaction: + .contextCompaction + case .toolCall: + .tool + case .diagnostic, .error, .progress, .event: + .diagnostic + } + } + + private static func previewTimelineContent( + for entry: ReviewLogEntry, + existing: ReviewTimelineItem.Content? + ) -> ReviewTimelineItem.Content { + switch entry.kind { + case .command: + return .command( + .init( + command: previewCommandText(for: entry), + cwd: entry.metadata?.cwd, + output: "", + exitCode: entry.metadata?.exitCode, + status: previewCommandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) + case .commandOutput: + let existingOutput: String + let existingCommand: String? + if case .command(let command) = existing { + existingOutput = command.output + existingCommand = command.command + } else { + existingOutput = "" + existingCommand = nil + } + return .command( + .init( + command: entry.metadata?.command ?? existingCommand ?? "Command", + cwd: entry.metadata?.cwd, + output: existingOutput + entry.text, + exitCode: entry.metadata?.exitCode, + status: previewCommandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) + case .agentMessage: + return .message(.init(text: previewExistingText(existing, message: "") + entry.text)) + case .plan, .todoList: + return .plan(.init(markdown: previewExistingText(existing, plan: "") + entry.text)) + case .reasoning: + return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .raw)) + case .reasoningSummary: + return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .summary)) + case .rawReasoning: + return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .raw)) + case .contextCompaction: + return .contextCompaction( + .init( + title: entry.text, + status: previewContextCompactionStatus(for: entry) + )) + case .toolCall: + return .toolCall( + .init( + result: entry.text, + status: previewToolCallStatus(for: entry) + )) + case .diagnostic, .error, .progress, .event: + return .diagnostic(.init(message: previewExistingText(existing, diagnostic: "") + entry.text)) + } + } + + private static func previewExistingText( + _ content: ReviewTimelineItem.Content?, + message defaultValue: String + ) -> String { + if case .message(let message) = content { + return message.text + } + return defaultValue + } + + private static func previewExistingText( + _ content: ReviewTimelineItem.Content?, + plan defaultValue: String + ) -> String { + if case .plan(let plan) = content { + return plan.markdown + } + return defaultValue + } + + private static func previewExistingText( + _ content: ReviewTimelineItem.Content?, + reasoning defaultValue: String + ) -> String { + if case .reasoning(let reasoning) = content { + return reasoning.text + } + return defaultValue + } + + private static func previewExistingText( + _ content: ReviewTimelineItem.Content?, + diagnostic defaultValue: String + ) -> String { + if case .diagnostic(let diagnostic) = content { + return diagnostic.message + } + return defaultValue + } + + private static func previewCommandText(for entry: ReviewLogEntry) -> 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 static func previewTimelinePhase(for entry: ReviewLogEntry) -> ReviewItemPhase { + let status = entry.metadata?.commandStatus ?? entry.metadata?.status + switch status { + case "inProgress", "running", "started": + return .running + case "failed": + return .failed + case "cancelled": + return .cancelled + default: + return entry.kind == .command ? .running : .completed + } + } + + private static func previewCommandStatus(for entry: ReviewLogEntry) -> ReviewCommandStatus? { + guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { + return nil + } + switch rawValue { + case "succeeded", "success": + return .completed + default: + return .init(rawValue: rawValue) + } + } + + private static func previewContextCompactionStatus(for entry: ReviewLogEntry) -> ReviewContextCompactionStatus? { + guard let rawValue = entry.metadata?.status else { + return nil + } + switch rawValue { + case "inProgress", "running": + return .inProgress + case "completed", "succeeded", "success": + return .completed + default: + return .init(rawValue: rawValue) + } + } + + private static func previewToolCallStatus(for entry: ReviewLogEntry) -> ReviewToolCallStatus? { + guard let rawValue = entry.metadata?.status else { + return nil + } + switch rawValue { + case "inProgress", "running": + return .inProgress + case "completed", "succeeded", "success": + return .completed + case "failed": + return .failed + default: + return .init(rawValue: rawValue) + } } private static func makeReviewResult( @@ -686,7 +924,7 @@ public enum ReviewMonitorPreviewContent { cwd: String ) -> ParsedReviewResult? { guard workspaceIndex == 0, - jobIndex == 3 + jobIndex == 3 else { return nil } @@ -697,7 +935,8 @@ public enum ReviewMonitorPreviewContent { 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.", + 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", @@ -746,10 +985,10 @@ public enum ReviewMonitorPreviewContent { 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. - """, + Building for debugging... + Test Suite 'ReviewUITests' started. + ReviewMonitorContentPreviewTests.testPreviewStore failed: expected command output panel metadata. + """, metadata: .init( sourceType: "command", title: "Ran command for 10s", @@ -778,10 +1017,10 @@ public enum ReviewMonitorPreviewContent { kind: .commandOutput, groupID: "preview-complete-command-\(workspaceName)-\(definition.targetSummary)", text: """ - Test Suite 'ReviewUITests' started. - Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed. - Test Suite 'ReviewUITests' passed. - """, + Test Suite 'ReviewUITests' started. + Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed. + Test Suite 'ReviewUITests' passed. + """, metadata: .init( sourceType: "command", title: "Ran command for 4s", @@ -804,7 +1043,8 @@ public enum ReviewMonitorPreviewContent { workspaceName: String ) -> [ReviewLogEntry] { let sourceReadGroupID = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)" - let sourceReadCommand = "sed -n '1,260p' Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift" + 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)"), @@ -824,10 +1064,10 @@ public enum ReviewMonitorPreviewContent { groupID: "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)", replacesGroup: true, text: """ - [completed] Inspect changed files - [in_progress] Check ReviewMonitor log behavior - [pending] Run focused tests - """ + [completed] Inspect changed files + [in_progress] Check ReviewMonitor log behavior + [pending] Run focused tests + """ ), .init( kind: .command, @@ -838,13 +1078,13 @@ public enum ReviewMonitorPreviewContent { 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 | 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 - """, + 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", @@ -877,7 +1117,8 @@ public enum ReviewMonitorPreviewContent { .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." + text: + "I am comparing the current UI state with the streaming log updates before changing the finder integration." ), .init( kind: .agentMessage, diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 3f48daa7..6e1305a0 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -62,6 +62,8 @@ final class ReviewMonitorSelectedCodexChat { private var documentProjection = ReviewMonitorSelectedCodexChatDocumentProjection() @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? + @ObservationIgnored + private var documentContinuations: [UUID: AsyncStream.Continuation] = [:] init(modelSource: ReviewMonitorCodexModelSource?) { self.modelSource = modelSource @@ -88,6 +90,21 @@ final class ReviewMonitorSelectedCodexChat { refreshBinding() } + func timelineDocumentStream() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) + documentContinuations[id] = pair.continuation + if let timelineDocument { + pair.continuation.yield(timelineDocument) + } + pair.continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.documentContinuations.removeValue(forKey: id) + } + } + return pair.stream + } + private func bindModelSource() { guard let modelSource else { return @@ -109,7 +126,7 @@ final class ReviewMonitorSelectedCodexChat { identity = nextTarget?.reviewIdentity chatID = nextTarget?.chatID chat = nil - timelineDocument = nil + publishTimelineDocument(nil) documentProjection.reset() boundModelContext = nextModelContext @@ -134,17 +151,18 @@ final class ReviewMonitorSelectedCodexChat { self.observation = observation for await change in observation.changes { guard Task.isCancelled == false, - self.target == nextTarget, - self.chat === nextChat + self.target == nextTarget, + self.chat === nextChat else { break } - self.timelineDocument = self.documentProjection.apply( - change, - activeTurnID: nextTarget.activeTurnID, - chatCreatedAt: nextChat.createdAt, - chatUpdatedAt: nextChat.updatedAt - ) + self.publishTimelineDocument( + self.documentProjection.apply( + change, + activeTurnID: nextTarget.activeTurnID, + chatCreatedAt: nextChat.createdAt, + chatUpdatedAt: nextChat.updatedAt + )) } } catch is CancellationError { } catch { @@ -159,6 +177,16 @@ final class ReviewMonitorSelectedCodexChat { observation = nil } + private func publishTimelineDocument(_ document: ReviewTimelineDocument?) { + timelineDocument = document + guard let document else { + return + } + for continuation in documentContinuations.values { + continuation.yield(document) + } + } + private static func observe( _ target: BindingTarget, modelContext: CodexModelContext, diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 595b078e..462e4bdd 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -15,21 +15,21 @@ private struct BackendReviewEventSequence: AsyncSequence { if includesDomainEvents == false { switch event { case .domainEvents, - .suppressNextLogTimelineProjection, - .suppressNextTerminalFailureLogTimelineProjection: + .retainNextTimelineTextFromLogEntry, + .retainNextTerminalFailureTimelineTextFromLogEntry: continue case .started, - .message, - .messageDelta, - .log, - .logEntry, - .completed, - .failed, - .cancelled: + .message, + .messageDelta, + .log, + .logEntry, + .completed, + .failed, + .cancelled: break } } - if case .suppressNextTerminalFailureLogTimelineProjection = event { + if case .retainNextTerminalFailureTimelineTextFromLogEntry = event { continue } return event @@ -122,8 +122,10 @@ struct AppServerClientTests { ) ) - #expect(try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() else { + #expect( + try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() + else { Issue.record("expected typed command domain event") return } @@ -139,33 +141,38 @@ struct AppServerClientTests { } #expect(command.command == "swift test") #expect(command.output == "passed") - #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: "passed", - groupID: "cmd-1", - replacesGroup: true, - metadata: .init( - sourceType: "commandExecution", - status: "completed", - itemID: "cmd-1", - command: "swift test", - commandStatus: "completed" - ) - )) - guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() else { + #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: "passed", + groupID: "cmd-1", + replacesGroup: true, + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd-1", + command: "swift test", + commandStatus: "completed" + ) + )) + guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() + else { Issue.record("expected typed message delta domain event") return } @@ -213,10 +220,12 @@ struct AppServerClientTests { ) var firstIterator = eventSequence(firstAttempt, includingDomainEvents: true).makeAsyncIterator() - #expect(try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) + #expect( + try await firstIterator.next() + == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) guard case .domainEvents(let firstDomainEvents, _) = try await firstIterator.next(), - case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), - case .command(let firstCommand) = firstSeed.content + case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), + case .command(let firstCommand) = firstSeed.content else { Issue.record("expected first command output domain event") return @@ -224,10 +233,12 @@ struct AppServerClientTests { #expect(firstCommand.output == "first") var secondIterator = eventSequence(secondAttempt, includingDomainEvents: true).makeAsyncIterator() - #expect(try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) + #expect( + try await secondIterator.next() + == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) guard case .domainEvents(let secondDomainEvents, _) = try await secondIterator.next(), - case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), - case .command(let secondCommand) = secondSeed.content + case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), + case .command(let secondCommand) = secondSeed.content else { Issue.record("expected second command output domain event") return @@ -243,11 +254,13 @@ struct AppServerClientTests { let backend = AppServerCodexReviewBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - #expect(try await nextEvent(from: attempt.events) == .started( - turnID: "turn-1", - reviewThreadID: "review-thread", - model: "gpt-5" - )) + #expect( + try await nextEvent(from: attempt.events) + == .started( + turnID: "turn-1", + reviewThreadID: "review-thread", + model: "gpt-5" + )) try await runtime.transport.emitServerNotification( method: "item/commandExecution/outputDelta", @@ -269,13 +282,14 @@ struct AppServerClientTests { ) guard case .domainEvents = try await nextEvent(from: attempt.events), - case .domainEvents = try await nextEvent(from: attempt.events) + case .domainEvents = try await nextEvent(from: attempt.events) else { Issue.record("expected direct timeline updates before coalesced log") return } - guard case .logEntry(.commandOutput, let text, let groupID, let replacesGroup, let metadata) = - try await nextEvent(from: attempt.events) + guard + case .logEntry(.commandOutput, let text, let groupID, let replacesGroup, let metadata) = + try await nextEvent(from: attempt.events) else { Issue.record("expected coalesced command output log") return @@ -444,18 +458,19 @@ struct AppServerClientTests { #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", - ]) + #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 } @@ -532,10 +547,11 @@ struct AppServerClientTests { } await runtime.transport.waitForRequest(method: "review/start", count: 2) - await backend.cleanupActiveReviewsForShutdown(.init( - reason: .init(message: "Review runtime stopped."), - recoveryWaitingRuns: [attempt.run] - )) + await backend.cleanupActiveReviewsForShutdown( + .init( + reason: .init(message: "Review runtime stopped."), + recoveryWaitingRuns: [attempt.run] + )) #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) await reviewStartGate.open() diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index fbcb7e01..c19a6d51 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -19,20 +19,25 @@ struct ReviewBackendEventSessionTests { ) let attempt = await session.attempt() - await session.receive([ - .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), - .completed(summary: "Succeeded.", result: "Looks good."), - ], 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( - summary: "Succeeded.", - result: "Looks good." - )) + await session.receive( + [ + .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), + .completed(summary: "Succeeded.", result: "Looks good."), + ], 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( + summary: "Succeeded.", + result: "Looks good." + )) #expect(await recorder.startedTurnIDs() == ["turn-1"]) #expect(await recorder.finishedRun() == makeRun()) @@ -50,19 +55,21 @@ struct ReviewBackendEventSessionTests { let session = ReviewBackendEventSession(run: makeRun()) let attempt = await session.attempt() - await session.receive([ - commandDomainEvent(output: "first"), - commandOutputDeltaLogEntry(text: "first"), - ], controlThreadID: "review-thread") - await session.receive([ - commandDomainEvent(output: "firstsecond"), - commandOutputDeltaLogEntry(text: "second"), - ], controlThreadID: "review-thread") + await session.receive( + [ + commandDomainEvent(output: "first"), + commandOutputDeltaLogEntry(text: "first"), + ], controlThreadID: "review-thread") + await session.receive( + [ + commandDomainEvent(output: "firstsecond"), + commandOutputDeltaLogEntry(text: "second"), + ], controlThreadID: "review-thread") await session.finish(throwing: nil) guard case .domainEvents(let firstDomainEvents, _) = try await nextEvent(from: attempt.events), - case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), - case .command(let firstCommand) = firstSeed.content + case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), + case .command(let firstCommand) = firstSeed.content else { Issue.record("expected first domain event") return @@ -70,21 +77,23 @@ struct ReviewBackendEventSessionTests { #expect(firstCommand.output == "first") guard case .domainEvents(let secondDomainEvents, _) = try await nextEvent(from: attempt.events), - case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), - case .command(let secondCommand) = secondSeed.content + case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), + case .command(let secondCommand) = secondSeed.content else { Issue.record("expected second domain event") return } #expect(secondCommand.output == "firstsecond") - #expect(try await nextEvent(from: attempt.events) == .logEntry( - kind: .commandOutput, - text: "firstsecond", - groupID: "cmd-1", - replacesGroup: false, - metadata: commandOutputMetadata() - )) + #expect( + try await nextEvent(from: attempt.events) + == .logEntry( + kind: .commandOutput, + text: "firstsecond", + groupID: "cmd-1", + replacesGroup: false, + metadata: commandOutputMetadata() + )) #expect(try await nextEvent(from: attempt.events) == nil) } @@ -98,10 +107,12 @@ struct ReviewBackendEventSessionTests { #expect(try await nextEvent(from: attempt.events) == .messageDelta("Looks ", itemID: "msg-1")) #expect(try await nextEvent(from: attempt.events) == .messageDelta("good.", itemID: "msg-1")) - #expect(try await nextEvent(from: attempt.events) == .completed( - summary: "Succeeded.", - result: "Looks good." - )) + #expect( + try await nextEvent(from: attempt.events) + == .completed( + summary: "Succeeded.", + result: "Looks good." + )) } } @@ -168,15 +179,17 @@ private func makeRun() -> CodexReviewBackendModel.Review.Run { } private func commandDomainEvent(output: String) -> CodexReviewBackendModel.Review.Event { - .domainEvents([ - .itemUpdated(.init( - id: "cmd-1", - kind: .commandExecution, - family: .command, - phase: .running, - content: .command(.init(command: "swift test", output: output)) - )), - ], logProjectionSuppressionCount: 1) + .domainEvents( + [ + .itemUpdated( + .init( + id: "cmd-1", + kind: .commandExecution, + family: .command, + phase: .running, + content: .command(.init(command: "swift test", output: output)) + )) + ], retainedLogEntryCount: 1) } private func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { diff --git a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift index 9836cd5f..6c3ad8cc 100644 --- a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift @@ -19,155 +19,20 @@ struct CodexReviewJobRenderingTests { ] ) - #expect(job.logText == """ - $ git diff --stat + #expect( + job.logText == """ + $ git diff --stat - README.md | 1 + + README.md | 1 + - No correctness issues found. - """) - #expect(job.activityLogText == """ - $ git diff --stat + No correctness issues found. + """) + #expect( + job.activityLogText == """ + $ git diff --stat - README.md | 1 + - """) - } - - @Test func outputOnlyCommandLogsProjectToTimelineWithoutPlaceholderCommandLine() 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(outputOnlyCommand.output == "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) + README.md | 1 + + """) } @Test func prebuiltTerminalJobsInitializeTimelineTerminalState() { @@ -255,12 +120,13 @@ struct CodexReviewJobRenderingTests { ) let initialRevision = job.logRevision - job.appendLogEntry(.init( - kind: .plan, - groupID: "plan-1", - replacesGroup: true, - text: "- updated" - )) + job.appendLogEntry( + .init( + kind: .plan, + groupID: "plan-1", + replacesGroup: true, + text: "- updated" + )) #expect(job.logRevision == initialRevision + 1) #expect(job.lastLogMutation == .reload) diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 4a48d4fb..4cb85a31 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -28,11 +28,14 @@ struct CodexReviewStoreCommandTests { #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" - )))) + #expect( + commands.contains( + .cleanupReview( + .init( + threadID: "thread-1", + turnID: "turn-1", + reviewThreadID: "review-thread-1" + )))) } } @@ -66,7 +69,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func domainEventsMutateTimelineAndSuppressLogProjection() async throws { + @Test func domainEventsMutateTimelineAndLogEntriesDoNotProject() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -82,49 +85,58 @@ struct CodexReviewStoreCommandTests { _ = 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: "")) - )), - ], logProjectionSuppressionCount: 0)) - #expect(await waitUntil { - store.job(id: "job-1")?.timeline.item(for: itemID) != nil - }) + await backend.yield( + .domainEvents( + [ + .itemStarted( + .init( + id: itemID, + kind: .agentMessage, + family: .message, + phase: .running, + content: .message(.init(text: "")) + )) + ], retainedLogEntryCount: 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" - ), - ], logProjectionSuppressionCount: 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" - }) + await backend.yield( + .domainEvents( + [ + .textDelta( + itemID: itemID, + kind: .agentMessage, + family: .message, + content: .message(.init(text: "")), + delta: "domain text" + ) + ], retainedLogEntryCount: 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: " log text", - groupID: "msg-1", - replacesGroup: false - )) - #expect(await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == " log text" } == true - }) + await backend.yield( + .logEntry( + kind: .agentMessage, + text: " log text", + groupID: "msg-1", + replacesGroup: false + )) + #expect( + await waitUntil { + store.job(id: "job-1")?.logEntries.contains { $0.text == " log text" } == true + }) let job = try #require(store.job(id: "job-1")) #expect(job.timeline.items.count == 1) @@ -135,15 +147,18 @@ struct CodexReviewStoreCommandTests { } #expect(message.text == "domain text") - await backend.yield(.logEntry( - kind: .diagnostic, - text: "log-only diagnostic", - groupID: nil, - replacesGroup: false - )) - #expect(await waitUntil { - store.job(id: "job-1")?.timeline.items.count == 2 - }) + await backend.yield( + .logEntry( + kind: .diagnostic, + text: "log-only diagnostic", + groupID: nil, + replacesGroup: false + )) + #expect( + await waitUntil { + store.job(id: "job-1")?.logEntries.contains { $0.text == "log-only diagnostic" } == true + }) + #expect(store.job(id: "job-1")?.timeline.items.count == 1) #expect(store.job(id: "job-1")?.timeline.item(for: itemID) === originalItem) } } @@ -163,47 +178,65 @@ struct CodexReviewStoreCommandTests { _ = 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")) - )), - ], logProjectionSuppressionCount: 0)) - #expect(await waitUntil { - store.job(id: "job-1")?.timeline.activeItemIDs.contains(itemID) == true - }) + await backend.yield( + .domainEvents( + [ + .itemStarted( + .init( + id: itemID, + kind: .commandExecution, + family: .command, + phase: .running, + content: .command(.init(command: "swift test")) + )) + ], retainedLogEntryCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemCompleted( + .init( + id: itemID, + kind: .commandExecution, + family: .command, + phase: .completed, + content: .command(.init(command: "swift test")) + )) + ], retainedLogEntryCount: 1)) + 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 skippedLogDeltaConsumesDirectProjectionSuppression() async throws { + @Test func skippedLogDeltaConsumesTimelineTextRetention() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -218,38 +251,46 @@ struct CodexReviewStoreCommandTests { _ = 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")) - )), - ], logProjectionSuppressionCount: 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, + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: "final")) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: "late" - ), - ], logProjectionSuppressionCount: 1)) + 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" + ) + ], retainedLogEntryCount: 1)) await backend.yield(.messageDelta("late", itemID: "msg-1")) - #expect(await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == "late" } == false - }) + #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") @@ -258,15 +299,18 @@ struct CodexReviewStoreCommandTests { #expect(message.text == "final") let timelineCount = try #require(store.job(id: "job-1")?.timeline.items.count) - await backend.yield(.logEntry( - kind: .diagnostic, - text: "log-only diagnostic", - groupID: nil, - replacesGroup: false - )) - #expect(await waitUntil { - store.job(id: "job-1")?.timeline.items.count == timelineCount + 1 - }) + await backend.yield( + .logEntry( + kind: .diagnostic, + text: "log-only diagnostic", + groupID: nil, + replacesGroup: false + )) + #expect( + await waitUntil { + store.job(id: "job-1")?.logEntries.contains { $0.text == "log-only diagnostic" } == true + }) + #expect(store.job(id: "job-1")?.timeline.items.count == timelineCount) } } @@ -285,48 +329,55 @@ struct CodexReviewStoreCommandTests { _ = 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")) - )), - ], logProjectionSuppressionCount: 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, + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .agentMessage, + family: .message, + phase: .running, + content: .message(.init(text: "partial")) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: " delta" - ), - ], logProjectionSuppressionCount: 1)) + 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" + ) + ], retainedLogEntryCount: 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" } - }) + #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 directTerminalErrorSuppressesErrorLogTimelineProjection() async throws { + @Test func directTerminalErrorRetainsTimelineTextFromErrorLogEntry() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -342,21 +393,25 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 0)) - await backend.yield(.suppressNextTerminalFailureLogTimelineProjection) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .init(rawValue: "error"), + family: .diagnostic, + phase: .failed, + content: .diagnostic(.init(message: longError)) + )) + ], retainedLogEntryCount: 0)) + await backend.yield(.retainNextTerminalFailureTimelineTextFromLogEntry) await backend.yield(.failed(longError)) - #expect(await waitUntil { - store.job(id: "job-1")?.core.lifecycle.status == .failed - }) + #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)) @@ -385,41 +440,47 @@ struct CodexReviewStoreCommandTests { 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, + 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 + ), + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: longText - ), - ], logProjectionSuppressionCount: 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 - }) + 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 - }) + #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") @@ -445,34 +506,40 @@ struct CodexReviewStoreCommandTests { let itemID = ReviewTimelineItem.ID(rawValue: "msg-1") let longText = String(repeating: "x", count: 300_000) - await backend.yield(.domainEvents([ - .itemUpdated(.init( - id: itemID, + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: longText)) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: longText)) - )), - ], logProjectionSuppressionCount: 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 - }) + 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 - }) + #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") @@ -499,58 +566,65 @@ struct CodexReviewStoreCommandTests { 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 - ), - ], logProjectionSuppressionCount: 2)) + 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 + ), + ], retainedLogEntryCount: 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 - }) + 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()) @@ -579,26 +653,31 @@ struct CodexReviewStoreCommandTests { let itemID = ReviewTimelineItem.ID(rawValue: "msg-1") let longText = String(repeating: "x", count: 300_000) - await backend.yield(.domainEvents([ - .itemUpdated(.init( - id: itemID, + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: longText)) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: longText)) - )), - ], logProjectionSuppressionCount: 1)) - await backend.yield(.logEntry( - kind: .agentMessage, - text: longText, - groupID: nil, - replacesGroup: true - )) + text: longText, + groupID: nil, + replacesGroup: true + )) await backend.yield(.failed("Failed.")) - #expect(await waitUntil { - store.job(id: "job-1")?.core.lifecycle.status == .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") @@ -624,26 +703,31 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .init(rawValue: "turn/diff/updated"), + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: longText)) + )) + ], retainedLogEntryCount: 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()) @@ -672,26 +756,31 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .init(rawValue: "turn/diff/updated"), + family: .fileChange, + phase: .running, + content: .fileChange(.init(title: "", output: longText)) + )) + ], retainedLogEntryCount: 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()) @@ -720,27 +809,32 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .fileChange, + family: .fileChange, + phase: .running, + content: .fileChange(.init(title: "Sources/App.swift", output: longText)) + )) + ], retainedLogEntryCount: 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()) @@ -769,31 +863,37 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + 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)) + )) + ], retainedLogEntryCount: 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()) @@ -822,46 +922,52 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .webSearch, + family: .search, + phase: .completed, + content: .search(.init(query: "ReviewTimeline", result: longResult)) + )) + ], retainedLogEntryCount: 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()) @@ -892,37 +998,43 @@ struct CodexReviewStoreCommandTests { _ = 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")) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemCompleted( + .init( + id: itemID, + kind: .webSearch, + family: .search, + phase: .completed, + content: .search(.init(query: "ReviewTimeline", result: "short result")) + )) + ], retainedLogEntryCount: 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()) @@ -935,7 +1047,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func unownedLargeResultMetadataIsDroppedBeforeLogTimelineProjection() async throws { + @Test func unownedLargeResultMetadataIsDroppedBeforeLogStorage() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -950,33 +1062,28 @@ struct CodexReviewStoreCommandTests { _ = 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 - }) + 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) } } @@ -996,32 +1103,36 @@ struct CodexReviewStoreCommandTests { 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 - ), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .textDelta( + itemID: itemID, + kind: .reasoning, + family: .reasoning, + content: .reasoning(.init(text: "", style: .raw)), + delta: longText + ) + ], retainedLogEntryCount: 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 @@ -1052,26 +1163,31 @@ struct CodexReviewStoreCommandTests { 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)) - )), - ], logProjectionSuppressionCount: 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 - }) + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: itemID, + kind: .plan, + family: .plan, + phase: .running, + content: .plan(.init(markdown: longText)) + )) + ], retainedLogEntryCount: 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()) @@ -1099,49 +1215,57 @@ struct CodexReviewStoreCommandTests { _ = 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( + 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" + )) + )) + ], retainedLogEntryCount: 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", - progress: "Reading review job" - )) - )), - ], logProjectionSuppressionCount: 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 - }) + 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()) @@ -1154,58 +1278,6 @@ struct CodexReviewStoreCommandTests { } } - @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") - } - } - @Test func directToolCallErrorTextIsTrimmedWhenReviewLogLimitApplies() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( @@ -1222,38 +1294,44 @@ struct CodexReviewStoreCommandTests { 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( + 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 + )) + )) + ], retainedLogEntryCount: 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", - error: longError - )) - )), - ], logProjectionSuppressionCount: 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 - }) + 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()) @@ -1283,38 +1361,44 @@ struct CodexReviewStoreCommandTests { 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( + 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 + )) + )) + ], retainedLogEntryCount: 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", - result: longResult - )) - )), - ], logProjectionSuppressionCount: 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 - }) + 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()) @@ -1346,46 +1430,55 @@ struct CodexReviewStoreCommandTests { 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, + await backend.yield( + .domainEvents( + [ + .itemUpdated( + .init( + id: firstItemID, + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: firstText)) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: firstText)) - )), - ], logProjectionSuppressionCount: 1)) - await backend.yield(.logEntry( - kind: .agentMessage, - text: firstText, - groupID: "msg-1", - replacesGroup: true - )) - await backend.yield(.domainEvents([ - .itemUpdated(.init( - id: secondItemID, + 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)) + )) + ], retainedLogEntryCount: 1)) + await backend.yield( + .logEntry( kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: secondText)) - )), - ], logProjectionSuppressionCount: 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 - }) + 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 + case .message(let secondMessage) = secondItem.content else { Issue.record("expected message timeline content") return @@ -1395,125 +1488,6 @@ struct CodexReviewStoreCommandTests { } } - @Test func logTimelineTextFromBeforeDirectEventsIsTrimmed() 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: "log", count: 120_000) - await backend.yield(.logEntry( - kind: .agentMessage, - text: longText, - groupID: "log-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 logItemID = 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")) - )), - ], logProjectionSuppressionCount: 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: logItemID)) - guard case .message(let message) = item.content else { - Issue.record("expected message timeline content") - return - } - #expect(message.text.count < longText.count) - } - } - - @Test func logProjectedTimelineTextIsTrimmedAfterDirectEvents() 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")) - )), - ], logProjectionSuppressionCount: 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 logItemID = 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: logItemID)) - 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( @@ -1681,12 +1655,13 @@ struct CodexReviewStoreCommandTests { 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( + .logEntry( + kind: .reasoningSummary, + text: " with space", + groupID: "reasoning-1", + replacesGroup: false + )) await backend.yield(.completed(summary: "Succeeded.", result: nil)) let read = try await result @@ -1729,12 +1704,13 @@ struct CodexReviewStoreCommandTests { 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( + .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 @@ -1757,18 +1733,20 @@ struct CodexReviewStoreCommandTests { 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( + .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")) @@ -1791,11 +1769,14 @@ struct CodexReviewStoreCommandTests { 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. - """)) + 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) @@ -1874,10 +1855,11 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "second")) _ = try await second - #expect(store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary) == [ - "Uncommitted changes", - "Base branch: main", - ]) + #expect( + store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary) == [ + "Uncommitted changes", + "Base branch: main", + ]) } } @@ -1948,17 +1930,20 @@ struct CodexReviewStoreCommandTests { 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, 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) } @@ -2022,10 +2007,12 @@ struct CodexReviewStoreCommandTests { #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") - ))) + #expect( + commands.contains( + .interruptReview( + .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + .init(message: "Stop") + ))) } } @@ -2043,21 +2030,24 @@ struct CodexReviewStoreCommandTests { 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 - }) + 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") @@ -2109,13 +2099,14 @@ struct CodexReviewStoreCommandTests { } #expect(attemptedRecovery == false) 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 .prepareReviewRestart = command { + true + } else { + false + } + } == false) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let read = try await result @@ -2196,13 +2187,14 @@ struct CodexReviewStoreCommandTests { #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 .restartPreparedReview = command { - true - } else { - false - } - }) + #expect( + commands.contains { command in + if case .restartPreparedReview = command { + true + } else { + false + } + }) let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("completed review text") == false) } @@ -2247,9 +2239,10 @@ struct CodexReviewStoreCommandTests { try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) await sleeper.blockFutureSleeps() networkMonitor.yield(.satisfied()) - #expect(await waitUntil { - store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review." - }) + #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) @@ -2263,13 +2256,14 @@ struct CodexReviewStoreCommandTests { #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 .restartPreparedReview = command { - true - } else { - false - } - }) + #expect( + commands.contains { command in + if case .restartPreparedReview = command { + true + } else { + false + } + }) let logText = try #require(store.job(id: "job-1")).logText #expect(logText.contains("completed during settle") == false) } @@ -2314,9 +2308,10 @@ struct CodexReviewStoreCommandTests { try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) await sleeper.blockFutureSleeps() networkMonitor.yield(.satisfied()) - #expect(await waitUntil { - store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review." - }) + #expect( + await waitUntil { + store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review." + }) networkMonitor.yield(.satisfied()) await settleGate.open() try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) @@ -2360,20 +2355,21 @@ struct CodexReviewStoreCommandTests { 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) + 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.waitForPrepareReviewRestart(timeout: .seconds(2)) @@ -2425,14 +2421,16 @@ struct CodexReviewStoreCommandTests { 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" - }) + 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.waitForPrepareReviewRestart(timeout: .seconds(2)) @@ -2500,12 +2498,13 @@ struct CodexReviewStoreCommandTests { await backend.yield(.cancelled("Network lost"), for: initialRun) networkMonitor.yield(.satisfied()) try await backend.waitForRestartPreparedReview(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" - }) + #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) @@ -2554,9 +2553,10 @@ struct CodexReviewStoreCommandTests { 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) + 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.waitForPrepareReviewRestart(timeout: .seconds(2)) @@ -2761,14 +2761,18 @@ struct CodexReviewStoreCommandTests { #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( + .interruptReview( + initialRun, + .init(message: "Stop") + )) == false) + #expect( + commands.contains( + .interruptReview( + recoveredRun, + .init(message: "Stop") + ))) #expect(commands.contains(.cleanupReview(recoveredRun))) } } @@ -2819,10 +2823,11 @@ struct CodexReviewStoreCommandTests { return true } } - try #require(await waitUntil { - let state = store.runtimeJobState(jobID: "job-1") - return state.hasActiveWorker == false && state.activeRun == nil - }) + try #require( + await waitUntil { + let state = store.runtimeJobState(jobID: "job-1") + return state.hasActiveWorker == false && state.activeRun == nil + }) await recoverGate.open() let cleanup = await cleanupTask.value let read = try await result @@ -2834,10 +2839,12 @@ struct CodexReviewStoreCommandTests { #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( + .interruptReview( + recoveredRun, + .init(message: "Review runtime stopped.") + ))) #expect(commands.contains(.cleanupReview(recoveredRun))) } } @@ -3224,20 +3231,22 @@ struct CodexReviewStoreCommandTests { #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) + #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) } } @@ -3264,9 +3273,10 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .failed) #expect(read.core.lifecycle.errorMessage == "Rollback failed") - #expect(try #require(store.job(id: "job-1")).logEntries.contains { - $0.kind == .error && $0.text == "Rollback failed" - }) + #expect( + try #require(store.job(id: "job-1")).logEntries.contains { + $0.kind == .error && $0.text == "Rollback failed" + }) } } @@ -3360,12 +3370,13 @@ struct CodexReviewStoreCommandTests { _ = try await result let commands = await backend.recordedCommands() - #expect(commands.contains { - if case .interruptReview = $0 { - return true - } - return false - } == false) + #expect( + commands.contains { + if case .interruptReview = $0 { + return true + } + return false + } == false) } } @@ -3457,7 +3468,8 @@ struct CodexReviewStoreCommandTests { await outageSleepStarted.wait() await backend.finishEvents(throwing: StreamClosedError(), for: initialRun) - let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store) + let failedBeforeOutageConfirmed = + await StoreSnapshotProbe(store: store) .waitUntilJobStatus(.failed, jobID: "job-1", timeout: .milliseconds(100)) != nil #expect(failedBeforeOutageConfirmed == false) @@ -3492,10 +3504,12 @@ struct CodexReviewStoreCommandTests { #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.") - ))) + #expect( + commands.contains( + .interruptReview( + .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + .init(message: "Cancellation requested.") + ))) } } @@ -3517,10 +3531,12 @@ struct CodexReviewStoreCommandTests { #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.") - ))) + #expect( + commands.contains( + .interruptReview( + .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + .init(message: "Cancellation requested.") + ))) } } @@ -3629,15 +3645,20 @@ struct CodexReviewStoreCommandTests { #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" - )))) + #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" + )))) } } @@ -3720,10 +3741,11 @@ struct CodexReviewStoreCommandTests { ) ) - #expect(store.auth.persistedAccounts.map(\.accountKey) == [ - inactive.accountKey, - 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) } @@ -3767,16 +3789,17 @@ struct CodexReviewStoreCommandTests { displayName: "GPT-5.5", hidden: false, supportedReasoningEfforts: [ - .init(reasoningEffort: .medium, description: "Balanced"), + .init(reasoningEffort: .medium, description: "Balanced") ], defaultReasoningEffort: .medium, supportedServiceTiers: [.fast], isDefault: true ) - let backend = FakeCodexReviewBackend(settings: .init( - fallbackModel: "gpt-5.5", - models: [model] - )) + let backend = FakeCodexReviewBackend( + settings: .init( + fallbackModel: "gpt-5.5", + models: [model] + )) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -3825,12 +3848,13 @@ struct CodexReviewStoreCommandTests { #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 - }) + #expect( + commands.contains { command in + if case .startLogin = command { + return true + } + return false + }) } } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 81fa0433..cf873d46 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -80,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) @@ -119,10 +121,12 @@ 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 { @@ -163,14 +167,20 @@ struct CodexReviewMCPHTTPServerTests { #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", "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", "output", "review"]) as? String == "review text") 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( + jobID: "job-1", + sessionID: sessionID, + request: .init( + cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) + )))) } } @@ -213,7 +223,8 @@ struct CodexReviewMCPHTTPServerTests { #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")) let awaited = try await postJSONRPC( @@ -226,17 +237,22 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_await", "arguments": [ - "jobId": "job-1", + "jobId": "job-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", "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", "logs"]) == nil) } } @@ -276,11 +292,15 @@ struct CodexReviewMCPHTTPServerTests { #expect(resolved.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-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( + jobID: "job-1", + sessionID: sessionID, + request: .init( + cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) + )))) } } @@ -448,13 +468,13 @@ struct CodexReviewMCPHTTPServerTests { #expect(allowed.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-in-session") #expect(allowed.value(for: ["result", "structuredContent", "logs"]) == nil) #expect(allowed.value(for: ["result", "structuredContent", "logsPage"]) == nil) - let timelineIDs = allowed.value(for: ["result", "structuredContent", "timeline", "orderedItemIds"]) as? [String] - #expect(timelineIDs?.contains("cmd-1") == true) 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 + == "Job job-other-session was not found.") } } @@ -481,33 +501,50 @@ struct CodexReviewMCPHTTPServerTests { durationMs: 3000, commandStatus: "completed" ) + let job = 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 + ), + ] + ) + job.timeline.apply( + .itemCompleted( + .init( + id: "cmd-1", + kind: .commandExecution, + family: .command, + phase: .completed, + content: .command( + .init( + command: "swift test", + cwd: "/tmp/project", + output: longOutput, + exitCode: 0 + )), + startedAt: startedAt, + completedAt: completedAt, + durationMs: 3000 + ))) 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 - ), - ] - ), - ] + jobs: [job] ) let defaultResponse = try await postJSONRPC( @@ -520,7 +557,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_read", "arguments": [ - "jobId": "job-semantic", + "jobId": "job-semantic" ], ], ] @@ -528,13 +565,17 @@ struct CodexReviewMCPHTTPServerTests { #expect(defaultResponse.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-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", "lifecycle", "status"]) as? String + == "succeeded") + #expect( + defaultResponse.value(for: ["result", "structuredContent", "output", "summary"]) as? String == "Done") #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 timeline = try #require(defaultResponse.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) + let timeline = try #require( + defaultResponse.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) @@ -580,18 +621,21 @@ struct CodexReviewMCPHTTPServerTests { 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" - )) - ))) + 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")], @@ -605,16 +649,17 @@ struct CodexReviewMCPHTTPServerTests { "jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": [ - "name": "review_read", - "arguments": [ - "jobId": "job-tool-progress", - ], + "params": [ + "name": "review_read", + "arguments": [ + "jobId": "job-tool-progress" ], - ] - ) + ], + ] + ) - let timeline = try #require(response.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) + 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]]) @@ -688,10 +733,12 @@ struct CodexReviewMCPHTTPServerTests { #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") + ))) } } @@ -1094,7 +1141,7 @@ struct CodexReviewMCPHTTPServerTests { "id": 3, "method": "resources/read", "params": [ - "uri": "codex-review://help/targets/custom", + "uri": "codex-review://help/targets/custom" ], ] ) @@ -1121,10 +1168,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}", + ]) } } @@ -1240,14 +1288,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 { @@ -1318,16 +1366,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/ReviewUITests/ReviewMonitorLogProjectionTests.swift b/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift deleted file mode 100644 index b6038172..00000000 --- a/Tests/ReviewUITests/ReviewMonitorLogProjectionTests.swift +++ /dev/null @@ -1,1442 +0,0 @@ -import Foundation -import Testing -@_spi(Testing) @testable import CodexReviewKit -@_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 c2658522..00000000 --- a/Tests/ReviewUITests/ReviewMonitorLogTesting.swift +++ /dev/null @@ -1,8 +0,0 @@ -import CodexReviewKit -@_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/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 68d95737..b720d68d 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -200,7 +200,7 @@ struct ReviewMonitorSelectedCodexChatTests { job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" job.core.run.turnID = "turn-1" - job.appendLogEntry(.init(kind: .agentMessage, text: "Log trigger")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline trigger")) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" @@ -251,13 +251,13 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: "review-thread", turnID: "turn-1" ) - job.appendLogEntry(.init(kind: .agentMessage, text: "Log fallback")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline fallback")) uiState.selection = .job(job) let initialSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Chat snapshot") } - #expect(initialSnapshot.log.contains("Log fallback") == false) + #expect(initialSnapshot.log.contains("Timeline fallback") == false) try await runtime.transport.emitServerNotification( method: "item/updated", @@ -317,14 +317,15 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - uiState.selection = .chat(.init( - rowID: .chat(CodexThreadID(rawValue: "chat-thread")), - id: CodexThreadID(rawValue: "chat-thread"), - title: "Generic chat", - preview: nil, - workspaceCWD: "/tmp/project", - updatedAt: nil - )) + uiState.selection = .chat( + .init( + rowID: .chat(CodexThreadID(rawValue: "chat-thread")), + id: CodexThreadID(rawValue: "chat-thread"), + title: "Generic chat", + preview: nil, + workspaceCWD: "/tmp/project", + updatedAt: nil + )) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Generic chat snapshot") diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift new file mode 100644 index 00000000..b5fef43b --- /dev/null +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -0,0 +1,266 @@ +import Foundation +import CodexReviewKit +import ReviewMonitorRendering +@_spi(PreviewSupport) @testable import ReviewUI + +@MainActor +func seedTimelineForTesting( + _ job: CodexReviewJob, + logText: String = "", + rawLogText: String = "" +) { + let trimmedLogText = logText.trimmingCharacters(in: .newlines) + if trimmedLogText.isEmpty == false { + job.timeline.apply(.itemCompleted(.init( + id: .init(rawValue: "fixture-log-\(job.id)"), + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: trimmedLogText)) + ))) + } + + for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + job.timeline.apply(.itemCompleted(.init( + id: .init(rawValue: "fixture-diagnostic-\(job.id)-\(index)"), + kind: .dynamicToolCall, + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: String(line))) + ))) + } +} + +@MainActor +func seedTimelineForTesting(_ job: CodexReviewJob, logEntries: [ReviewLogEntry]) { + guard job.timeline.items.isEmpty else { + return + } + for entry in logEntries { + appendTimelineLogEntryForTesting(job, entry) + } +} + +@MainActor +func appendTimelineLogEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewLogEntry) { + let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) + let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content + let phase = timelinePhase(for: entry) + job.timeline.apply(.itemUpdated(.init( + id: itemID, + kind: timelineKind(for: entry), + family: timelineFamily(for: entry), + phase: phase, + content: timelineContent(for: entry, existing: existingContent), + startedAt: entry.metadata?.startedAt, + completedAt: entry.metadata?.completedAt, + durationMs: entry.metadata?.durationMs + ))) +} + +@MainActor +func replaceTimelineLogTextForTesting(_ job: CodexReviewJob, _ text: String) { + job.timeline.apply(.itemUpdated(.init( + id: .init(rawValue: "fixture-log-\(job.id)"), + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: text.trimmingCharacters(in: .newlines))) + ))) +} + +@MainActor +func reviewMonitorLogText(for job: CodexReviewJob) -> String { + var projection = ReviewMonitorTimelineLogProjection() + let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) + return projection.render(timelineDocument: timelineDocument).text +} + +private func timelineKind(for entry: ReviewLogEntry) -> ReviewItemKind { + switch entry.kind { + case .command, .commandOutput: + .commandExecution + case .agentMessage: + .agentMessage + case .plan, .todoList: + .plan + case .reasoning, .reasoningSummary, .rawReasoning: + .reasoning + case .contextCompaction: + .contextCompaction + case .toolCall: + .mcpToolCall + case .diagnostic, .error, .progress, .event: + ReviewItemKind(rawValue: entry.kind.rawValue) + } +} + +private func timelineFamily(for entry: ReviewLogEntry) -> ReviewItemFamily { + switch entry.kind { + case .command, .commandOutput: + .command + case .agentMessage: + .message + case .plan, .todoList: + .plan + case .reasoning, .reasoningSummary, .rawReasoning: + .reasoning + case .contextCompaction: + .contextCompaction + case .toolCall: + .tool + case .diagnostic, .error, .progress, .event: + .diagnostic + } +} + +private func timelineContent( + for entry: ReviewLogEntry, + existing: ReviewTimelineItem.Content? +) -> ReviewTimelineItem.Content { + switch entry.kind { + case .command: + return .command(.init( + command: commandText(for: entry), + cwd: entry.metadata?.cwd, + output: "", + exitCode: entry.metadata?.exitCode, + status: commandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) + case .commandOutput: + let existingOutput: String + let existingCommand: String? + if case .command(let command) = existing { + existingOutput = command.output + existingCommand = command.command + } else { + existingOutput = "" + existingCommand = nil + } + return .command(.init( + command: entry.metadata?.command ?? existingCommand ?? "Command", + cwd: entry.metadata?.cwd, + output: existingOutput + entry.text, + exitCode: entry.metadata?.exitCode, + status: commandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) + case .agentMessage: + return .message(.init(text: existingText(existing, message: "") + entry.text)) + case .plan, .todoList: + return .plan(.init(markdown: existingText(existing, plan: "") + entry.text)) + case .reasoning: + return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .raw)) + case .reasoningSummary: + return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .summary)) + case .rawReasoning: + return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .raw)) + case .contextCompaction: + return .contextCompaction(.init( + title: entry.text, + status: contextCompactionStatus(for: entry) + )) + case .toolCall: + return .toolCall(.init( + result: entry.text, + status: toolCallStatus(for: entry) + )) + case .diagnostic, .error, .progress, .event: + return .diagnostic(.init(message: existingText(existing, diagnostic: "") + entry.text)) + } +} + +private func existingText(_ content: ReviewTimelineItem.Content?, message defaultValue: String) -> String { + if case .message(let message) = content { + return message.text + } + return defaultValue +} + +private func existingText(_ content: ReviewTimelineItem.Content?, plan defaultValue: String) -> String { + if case .plan(let plan) = content { + return plan.markdown + } + return defaultValue +} + +private func existingText(_ content: ReviewTimelineItem.Content?, reasoning defaultValue: String) -> String { + if case .reasoning(let reasoning) = content { + return reasoning.text + } + return defaultValue +} + +private func existingText(_ content: ReviewTimelineItem.Content?, diagnostic defaultValue: String) -> String { + if case .diagnostic(let diagnostic) = content { + return diagnostic.message + } + return defaultValue +} + +private func commandText(for entry: ReviewLogEntry) -> 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 timelinePhase(for entry: ReviewLogEntry) -> ReviewItemPhase { + let status = entry.metadata?.commandStatus ?? entry.metadata?.status + switch status { + case "inProgress", "running", "started": + return .running + case "failed": + return .failed + case "cancelled": + return .cancelled + default: + return entry.kind == .command ? .running : .completed + } +} + +private func commandStatus(for entry: ReviewLogEntry) -> ReviewCommandStatus? { + guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { + return nil + } + switch rawValue { + case "succeeded", "success": + return .completed + default: + return .init(rawValue: rawValue) + } +} + +private func contextCompactionStatus(for entry: ReviewLogEntry) -> ReviewContextCompactionStatus? { + guard let rawValue = entry.metadata?.status else { + return nil + } + switch rawValue { + case "inProgress", "running": + return .inProgress + case "completed", "succeeded", "success": + return .completed + default: + return .init(rawValue: rawValue) + } +} + +private func toolCallStatus(for entry: ReviewLogEntry) -> ReviewToolCallStatus? { + guard let rawValue = entry.metadata?.status else { + return nil + } + switch rawValue { + case "inProgress", "running": + return .inProgress + case "completed", "succeeded", "success": + return .completed + case "failed": + return .failed + default: + return .init(rawValue: rawValue) + } +} diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 2068bd74..d6ec9d94 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -42,13 +42,15 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - #expect(viewController.sidebarViewControllerForTesting.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", - ]) + #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) @@ -74,7 +76,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -88,10 +91,11 @@ struct ReviewUITests { #expect(sidebar.performWorkspaceDropForTesting(workspaceAlpha, toIndex: store.workspaces.count)) await Task.yield() - #expect(sidebar.displayedSectionTitlesForTesting == [ - "workspace-beta", - "workspace-alpha", - ]) + #expect( + sidebar.displayedSectionTitlesForTesting == [ + "workspace-beta", + "workspace-alpha", + ]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop) #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) @@ -115,22 +119,24 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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" }) + 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", - ]) + #expect( + sidebar.displayedSectionTitlesForTesting == [ + "workspace-beta", + "workspace-alpha", + ]) } @Test func workspaceMembershipChangeUsesRootInsertWithoutFullReload() async throws { @@ -154,13 +160,15 @@ struct ReviewUITests { workspaces: [alphaWorkspace], jobs: [alphaJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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 + let incrementalMembershipChangeCountBeforeMembershipChange = sidebar + .sidebarIncrementalMembershipChangeCountForTesting store.loadForTesting( serverState: .running, @@ -177,13 +185,16 @@ struct ReviewUITests { sidebar.displayedSectionTitlesForTesting } - #expect(sidebar.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", - ]) + #expect( + sidebar.displayedSectionTitlesForTesting == [ + "workspace-alpha", + "workspace-beta", + ]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeMembershipChange) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChange) - #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeMembershipChange + 1) + #expect( + sidebar.sidebarIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeMembershipChange + 1) } @Test func workspaceSameMembershipSortOrderChangeMovesRowsWithoutReload() async throws { @@ -207,7 +218,8 @@ struct ReviewUITests { workspaces: [alphaWorkspace, betaWorkspace], jobs: [alphaJob, betaJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -231,7 +243,8 @@ struct ReviewUITests { } #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange) - #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange) + #expect( + sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange + 1) } @@ -253,7 +266,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -283,7 +297,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -309,24 +324,26 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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 + 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 - )) + #expect( + sidebar.performWorkspaceDropForTesting( + workspaceAlpha, + proposedJob: betaJob, + hoveringBelowMidpoint: true + )) await Task.yield() #expect(sidebar.displayedSectionTitlesForTesting == [workspaceBeta.displayTitle, workspaceAlpha.displayTitle]) } @@ -351,7 +368,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [firstJob, secondJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -381,7 +399,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [firstJob, secondJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -392,18 +411,20 @@ struct ReviewUITests { 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.performJobDropForTesting( + firstJob, + proposedJob: secondJob, + hoveringBelowMidpoint: false + ) == false) #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-1", "job-2"]) - #expect(sidebar.performJobDropForTesting( - firstJob, - proposedJob: secondJob, - hoveringBelowMidpoint: true - )) + #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") @@ -460,10 +481,11 @@ struct ReviewUITests { ) { sidebar.displayedJobIDsForTesting(in: alphaWorkspace) } - #expect(sidebar.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", - ]) + #expect( + sidebar.displayedSectionTitlesForTesting == [ + "workspace-alpha", + "workspace-beta", + ]) #expect(sidebar.displayedJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-running", "job-alpha-queued"]) #expect(sidebar.displayedJobIDsForTesting(in: betaWorkspace) == []) } @@ -632,11 +654,12 @@ struct ReviewUITests { ) { sidebar.displayedJobIDsForTesting(in: alphaWorkspace) } - #expect(sidebar.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", - "workspace-gamma", - ]) + #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) == []) @@ -742,11 +765,12 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == [ - "job-running", - "job-queued", - "job-finished-latest", - ]) + #expect( + sidebar.displayedJobIDsForTesting(in: workspace) == [ + "job-running", + "job-queued", + "job-finished-latest", + ]) } @Test func jobDropWhileFilteredMapsVisibleIndexToStoreOrder() async throws { @@ -795,49 +819,54 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) - #expect(sidebar.performJobDropForTesting( - runningA, - proposedJob: runningB, - hoveringBelowMidpoint: false - ) == false) + #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 - )) + #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( + 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", - ]) + #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() { @@ -924,11 +953,12 @@ struct ReviewUITests { ) { sidebar.displayedJobIDsForTesting(in: workspace) } - #expect(store.orderedJobs(in: workspace).map(\.id) == [ - "job-finished-older", - "job-finished-latest", - "job-running", - ]) + #expect( + store.orderedJobs(in: workspace).map(\.id) == [ + "job-finished-older", + "job-finished-latest", + "job-running", + ]) } @Test func jobSameMembershipSortOrderChangeMovesRowsWithoutReloadingWorkspace() async throws { @@ -951,7 +981,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [firstJob, secondJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -977,7 +1008,8 @@ struct ReviewUITests { #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange) #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeChange) - #expect(sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange) + #expect( + sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange + 1) } @@ -1001,7 +1033,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [firstJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -1028,7 +1061,9 @@ struct ReviewUITests { #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.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange + + 1) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange) } @@ -1237,10 +1272,11 @@ struct ReviewUITests { ) #expect(accountsViewController.accountListUsesOutlineViewForTesting) - #expect(accountsViewController.displayedAccountEmailsForTesting == [ - "active@example.com", - "other@example.com", - ]) + #expect( + accountsViewController.displayedAccountEmailsForTesting == [ + "active@example.com", + "other@example.com", + ]) #expect(accountsViewController.accountRowUsesReviewMonitorAccountCellViewForTesting(displayedActiveAccount)) #expect(accountsViewController.accountRowUsesSwiftUIRowViewForTesting(displayedActiveAccount)) } @@ -1272,15 +1308,17 @@ struct ReviewUITests { .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", - ]) + #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, [ @@ -1293,8 +1331,8 @@ struct ReviewUITests { } #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeDrop) #expect( - accountsViewController.accountIncrementalMembershipChangeCountForTesting == - incrementalMembershipChangeCountBeforeDrop + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeDrop ) #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) } @@ -1322,20 +1360,23 @@ struct ReviewUITests { 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", - ]) + #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, [ @@ -1346,11 +1387,12 @@ struct ReviewUITests { ) { accountsViewController.displayedAccountEmailsForTesting } - #expect(accountsViewController.displayedAccountEmailsForTesting == [ - "second@example.com", - "first@example.com", - "detached@example.com", - ]) + #expect( + accountsViewController.displayedAccountEmailsForTesting == [ + "second@example.com", + "first@example.com", + "detached@example.com", + ]) } @Test func jobCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { @@ -1407,7 +1449,8 @@ struct ReviewUITests { workspaces: [alphaWorkspace, betaWorkspace], jobs: [alphaJob, betaJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -1438,7 +1481,8 @@ struct ReviewUITests { workspaces: [alphaWorkspace, betaWorkspace], jobs: [alphaJob, betaJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -1460,7 +1504,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [job] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -1485,7 +1530,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [job] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1513,7 +1559,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [job] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1539,7 +1586,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [job] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1560,7 +1608,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1596,7 +1645,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [secondaryJob] + primaryJobs) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1624,7 +1674,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: jobs) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1652,7 +1703,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: jobs) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1682,7 +1734,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: jobs) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1714,7 +1767,8 @@ struct ReviewUITests { jobs: [job] ) let storedWorkspace = try #require(store.workspaces.first) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1752,7 +1806,8 @@ struct ReviewUITests { 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)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -1789,7 +1844,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job) @@ -1816,7 +1872,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job) @@ -1840,7 +1897,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -1910,12 +1968,13 @@ struct ReviewUITests { #expect(accountsViewController.hasTemporaryContextMenuForTesting) } - #expect(presentedTitles == [ - "other@example.com", - "Switch", - "Refresh", - "Sign Out", - ]) + #expect( + presentedTitles == [ + "other@example.com", + "Switch", + "Refresh", + "Sign Out", + ]) #expect(presentedHostingMenu) #expect(accountsViewController.isPresentingContextMenuForTesting == false) #expect(accountsViewController.acceptsFirstResponderForTesting) @@ -1995,7 +2054,9 @@ struct ReviewUITests { accountsViewController.selectedAccountEmailForTesting == "active@example.com" } - #expect(accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount) == displayedOtherAccount.accountKey) + #expect( + accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount) + == displayedOtherAccount.accountKey) #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") #expect(store.auth.selectedAccount?.email == "active@example.com") } @@ -2086,10 +2147,11 @@ struct ReviewUITests { #expect(accountsViewController.displayedAccountEmailsForTesting == displayedEmails) #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeSelectionChange) #expect( - accountsViewController.accountIncrementalMembershipChangeCountForTesting == - incrementalMembershipChangeCountBeforeSelectionChange + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeSelectionChange ) - #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange) + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange) } @Test func accountContentUpdateDoesNotReloadOutlineTopology() async throws { @@ -2126,8 +2188,8 @@ struct ReviewUITests { #expect(accountsViewController.displayedAccountEmailsForTesting == ["active@example.com"]) #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeUpdate) #expect( - accountsViewController.accountIncrementalMembershipChangeCountForTesting == - incrementalMembershipChangeCountBeforeUpdate + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeUpdate ) #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeUpdate) } @@ -2169,17 +2231,19 @@ struct ReviewUITests { accountsViewController.displayedAccountEmailsForTesting } - #expect(accountsViewController.displayedAccountEmailsForTesting == [ - "saved@example.com", - "detached@example.com", - ]) + #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.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeMembershipChanges + 1 ) - #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) store.auth.selectPersistedAccount(savedAccount.accountKey) try await waitForObservedValue( @@ -2193,10 +2257,11 @@ struct ReviewUITests { #expect(accountsViewController.selectedAccountEmailForTesting == "saved@example.com") #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges) #expect( - accountsViewController.accountIncrementalMembershipChangeCountForTesting == - incrementalMembershipChangeCountBeforeMembershipChanges + 2 + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeMembershipChanges + 2 ) - #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) } @Test func accountActionAlertRestoresSelectionToAuthenticatedAccount() async throws { @@ -2249,7 +2314,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) @@ -2279,12 +2345,13 @@ struct ReviewUITests { let selectedSnapshot = try await awaitTransportRender(transport) #expect( - selectedSnapshot == .init( - title: nil, - summary: nil, - log: recentJob.logText, - isShowingEmptyState: false - ) + selectedSnapshot + == .init( + title: nil, + summary: nil, + log: reviewMonitorLogText(for: recentJob), + isShowingEmptyState: false + ) ) #expect(window.title == recentJob.targetSummary) #expect(window.subtitle == recentJob.cwd) @@ -2305,7 +2372,8 @@ struct ReviewUITests { #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")) + appendTimelineLogEntryForTesting( + recentJob, .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") @@ -2342,7 +2410,7 @@ struct ReviewUITests { endLine: 12 ), rawText: "" - ) + ), ], source: .parsedFinalReviewText ) @@ -2382,7 +2450,10 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectWorkspaceForTesting(workspace) _ = try await awaitTransportRender(transport) - #expect(viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [workspaceCWD]) + #expect( + viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ + workspaceCWD + ]) #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) #expect(transport.workspaceFindingsTextIsSelectableForTesting) #expect(transport.workspaceFindingsTextIsEditableForTesting == false) @@ -2395,8 +2466,7 @@ struct ReviewUITests { #expect(transport.workspaceFindingSnapshotForTesting.isShowingNoFindingsState == false) #expect(transport.workspaceFindingSnapshotForTesting.isShowingFindingsList) try await waitForCondition { - window.title == workspace.displayTitle && - window.subtitle == workspace.cwd + window.title == workspace.displayTitle && window.subtitle == workspace.cwd } #expect(window.title == workspace.displayTitle) #expect(window.subtitle == workspace.cwd) @@ -2491,8 +2561,7 @@ struct ReviewUITests { #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" + window.title == "CodexReviewKit" && window.subtitle == "2 workspaces" } } @@ -2607,41 +2676,48 @@ struct ReviewUITests { 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 - )) + #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() - #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", - ]) + #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 { @@ -2777,7 +2853,8 @@ struct ReviewUITests { workspaces: [firstWorkspace, secondWorkspace, standaloneWorkspace], jobs: [firstJob, secondJob, standaloneJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit", "Standalone"]) @@ -2824,25 +2901,28 @@ struct ReviewUITests { workspaces: [firstWorkspace, secondWorkspace, standaloneWorkspace], jobs: [firstJob, secondJob, standaloneJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) 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 - )) + #expect( + sidebar.performWorkspaceSectionDropForTesting( + containing: firstWorkspace, + toIndex: 2 + )) await Task.yield() #expect(sidebar.displayedSectionTitlesForTesting == ["Standalone", "CodexReviewKit"]) - #expect(store.orderedWorkspaces.map(\.cwd) == [ - standaloneWorkspace.cwd, - firstWorkspace.cwd, - secondWorkspace.cwd, - ]) + #expect( + store.orderedWorkspaces.map(\.cwd) == [ + standaloneWorkspace.cwd, + firstWorkspace.cwd, + secondWorkspace.cwd, + ]) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) } @@ -2889,24 +2969,27 @@ struct ReviewUITests { workspaces: [firstWorkspace, standaloneBWorkspace, standaloneCWorkspace, secondWorkspace], jobs: [firstJob, standaloneBJob, standaloneCJob, secondJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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 - )) + #expect( + sidebar.performWorkspaceSectionDropForTesting( + containing: firstWorkspace, + toIndex: 2 + )) await Task.yield() #expect(sidebar.displayedSectionTitlesForTesting == ["StandaloneB", "CodexReviewKit", "StandaloneC"]) - #expect(store.orderedWorkspaces.map(\.cwd) == [ - standaloneBWorkspace.cwd, - firstWorkspace.cwd, - secondWorkspace.cwd, - standaloneCWorkspace.cwd, - ]) + #expect( + store.orderedWorkspaces.map(\.cwd) == [ + standaloneBWorkspace.cwd, + firstWorkspace.cwd, + secondWorkspace.cwd, + standaloneCWorkspace.cwd, + ]) } @Test func workspaceSectionJobDropUsesRootChildIndexesForLaterWorkspaceJobs() async throws { @@ -2940,39 +3023,45 @@ struct ReviewUITests { workspaces: [firstWorkspace, secondWorkspace], jobs: [firstWorkspaceJob, secondWorkspaceFirstJob, secondWorkspaceSecondJob] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + 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 - )) + #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( + sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [ + "job-second-workspace-second", + "job-second-workspace-first", + ]) } @Test func workspaceFindingsTextWrapsWithinDetailWidth() async throws { @@ -3078,10 +3167,11 @@ struct ReviewUITests { #expect(transport.workspaceFindingsAutomaticallyAdjustsContentInsetsForTesting) #expect(contentInsets.top > 0) #expect(abs(transport.workspaceFindingsVerticalScrollOffsetForTesting + contentInsets.top) < 0.5) - #expect(abs( - transport.workspaceFindingsMaximumVerticalScrollOffsetForTesting - - transport.workspaceFindingsMinimumVerticalScrollOffsetForTesting - ) < 0.5) + #expect( + abs( + transport.workspaceFindingsMaximumVerticalScrollOffsetForTesting + - transport.workspaceFindingsMinimumVerticalScrollOffsetForTesting + ) < 0.5) } @Test func selectingWorkspaceWithoutStructuredFindingsShowsNoFindingsState() async throws { @@ -3117,11 +3207,12 @@ struct ReviewUITests { let safeAreaFrame = transport.safeAreaFrameForTesting #expect( - transport.workspaceFindingSnapshotForTesting == .init( - text: "", - isShowingNoFindingsState: true, - isShowingFindingsList: false - ) + transport.workspaceFindingSnapshotForTesting + == .init( + text: "", + isShowingNoFindingsState: true, + isShowingFindingsList: false + ) ) #expect(abs(findingsFrame.minX - safeAreaFrame.minX) < 0.5) #expect(abs(findingsFrame.maxX - safeAreaFrame.maxX) < 0.5) @@ -3133,8 +3224,7 @@ struct ReviewUITests { #expect(abs(noFindingsPlaceholderFrame.maxY - viewBounds.maxY) < 0.5) #expect(safeAreaFrame.maxY < viewBounds.maxY) try await waitForCondition { - window.title == workspace.displayTitle && - window.subtitle == workspace.cwd + window.title == workspace.displayTitle && window.subtitle == workspace.cwd } #expect(window.title == workspace.displayTitle) #expect(window.subtitle == workspace.cwd) @@ -3150,7 +3240,8 @@ struct ReviewUITests { 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 viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting let transport = viewController.transportViewControllerForTesting @@ -3171,9 +3262,8 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, workspaces: []) try await waitForCondition { - sidebar.selectedWorkspaceSectionForTesting == nil && - sidebar.selectedJobForTesting == nil && - transport.isShowingEmptyStateForTesting + sidebar.selectedWorkspaceSectionForTesting == nil && sidebar.selectedJobForTesting == nil + && transport.isShowingEmptyStateForTesting } #expect(sidebar.selectedWorkspaceSectionForTesting == nil) @@ -3219,7 +3309,7 @@ struct ReviewUITests { commandStatus: "completed" ) ), - .init(kind: .agentMessage, text: "No correctness issues found.") + .init(kind: .agentMessage, text: "No correctness issues found."), ] ) let store = CodexReviewStore.makePreviewStore() @@ -3282,35 +3372,40 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) let initialLogEntries = job.logEntries - job.timeline.apply(.itemCompleted(.init( - id: "message-direct", - kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: "Timeline-only detail update")) - ))) + 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 - ))) + 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 + ))) snapshot = try await awaitTransportRender(transport) { $0.log.contains("Ran swift test for 2s") @@ -3325,8 +3420,12 @@ struct ReviewUITests { let panelBlockID = ReviewMonitorLog.BlockID("commandOutput: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) + #expect( + transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("$ swift test") + == true) + #expect( + transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed") + == true) } @Test func directTimelineFailedCommandPreservesFailedPanelStatus() async throws { @@ -3358,19 +3457,22 @@ struct ReviewUITests { _ = 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 - ))) + 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 + ))) let snapshot = try await awaitTransportRender(transport) { $0.log.contains("Ran swift test for 4s") @@ -3382,7 +3484,9 @@ struct ReviewUITests { #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Failed") - #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests failed") == true) + #expect( + transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests failed") + == true) } @Test func directTimelineRunningCommandOutputStaysActive() async throws { @@ -3413,17 +3517,20 @@ struct ReviewUITests { 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) - ))) + 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") @@ -3437,7 +3544,9 @@ struct ReviewUITests { #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("Building...") + == true) } @Test func directTimelineTerminalPhaseOverridesStaleCommandStatus() async throws { @@ -3468,17 +3577,20 @@ struct ReviewUITests { 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 - )) - ))) + 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") @@ -3490,7 +3602,9 @@ struct ReviewUITests { #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Success") - #expect(transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed") == true) + #expect( + transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("Tests passed") + == true) } @Test func timelineProjectionDoesNotAppendWhenExistingBlockPresentationChanges() { @@ -3528,13 +3642,14 @@ struct ReviewUITests { 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 - )), + content: .toolCall( + .init( + namespace: "codex_review", + server: "codex_review", + name: "review_start", + result: "Tool output", + status: status + )), createdAt: timestamp, updatedAt: timestamp ) @@ -3543,7 +3658,7 @@ struct ReviewUITests { let initialDocument = document( revision: 1, blocks: [ - toolBlock(phase: .running, isActive: true, status: .inProgress), + toolBlock(phase: .running, isActive: true, status: .inProgress) ] ) _ = projection.render(timelineDocument: initialDocument) @@ -3599,28 +3714,31 @@ struct ReviewUITests { 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 - )), + 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." - )) + 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.") @@ -3649,20 +3767,21 @@ struct ReviewUITests { isActive: false, primaryText: "Command output", rawTranscriptText: "stderr", - content: .command(.init( - title: "", - command: "", - output: "stderr", - exitCode: 2, - status: .failed, - durationMs: 4_000 - )), + 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 - ), + ) ] ) @@ -3703,15 +3822,16 @@ struct ReviewUITests { isActive: true, primaryText: "Running swift test", rawTranscriptText: "$ swift test\nTests failed", - content: .command(.init( - title: "Command", - command: "swift test", - output: "Tests failed", - exitCode: 1 - )), + content: .command( + .init( + title: "Command", + command: "swift test", + output: "Tests failed", + exitCode: 1 + )), createdAt: Date(timeIntervalSince1970: 400), updatedAt: Date(timeIntervalSince1970: 400) - ), + ) ] ) @@ -3748,14 +3868,15 @@ struct ReviewUITests { isActive: false, primaryText: "Running swift test", rawTranscriptText: "$ swift test", - content: .command(.init( - title: "Command", - command: "swift test", - status: .inProgress - )), + content: .command( + .init( + title: "Command", + command: "swift test", + status: .inProgress + )), createdAt: Date(timeIntervalSince1970: 400), updatedAt: Date(timeIntervalSince1970: 400) - ), + ) ] ) @@ -3798,18 +3919,21 @@ struct ReviewUITests { 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 - )) - ))) + 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 + )) + ))) let snapshot = try await awaitTransportRender(transport) { $0.log.contains("Updated Sources/App.swift") @@ -3850,7 +3974,7 @@ struct ReviewUITests { status: "inProgress", itemID: "compact_1" ) - ), + ) ] ) let store = CodexReviewStore.makePreviewStore() @@ -3873,17 +3997,19 @@ struct ReviewUITests { #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" - ) - )) + appendTimelineLogEntryForTesting( + job, + .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) #expect(transport.displayedLogForTesting == "Context automatically compacted") @@ -3900,7 +4026,9 @@ struct ReviewUITests { 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", @@ -3919,7 +4047,7 @@ 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() @@ -3985,19 +4113,24 @@ 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 - )) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .commandOutput, + groupID: "cmd_1", + text: "\noutput line 10", + metadata: commandMetadata + )) _ = try await awaitTransportRender(transport) await awaitNativeLayoutTurn() #expect(transport.logReloadCountForTesting == expandedOutputAppendReloadCount) @@ -4015,8 +4148,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,13 +4159,15 @@ 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.")) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .commandOutput, + groupID: "cmd_1", + text: "\noutput line 11", + metadata: commandMetadata + )) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) _ = try await awaitTransportRender(transport) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 11") == true) @@ -4093,16 +4230,19 @@ struct ReviewUITests { #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 { @@ -4139,12 +4279,21 @@ struct ReviewUITests { #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) - )) + appendTimelineLogEntryForTesting( + job, + .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" + ) + )) _ = try await awaitTransportRender(transport) #expect(transport.logCommandOutputPanelCountForTesting == 1) #expect(transport.displayedLogForTesting.contains("Ran swift test")) @@ -4167,7 +4316,7 @@ struct ReviewUITests { summary: "Running review.", logEntries: [ .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() @@ -4189,10 +4338,10 @@ struct ReviewUITests { try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) #expect(transport.logFindBarVisibleForTesting) - #expect(transport.setLogVisibleFindBarSearchStringForTesting("Running swift test")) + #expect(transport.setLogVisibleFindBarSearchStringForTesting("Ran swift test")) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Running swift test")) + #expect(transport.logFindStringForTesting.contains("Ran swift test")) #expect(transport.logFindStringForTesting.contains("$ swift test") == false) #expect(transport.logFindStringForTesting.contains("output line 3") == false) @@ -4201,17 +4350,18 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Running swift test")) + #expect(transport.logFindStringForTesting.contains("Ran swift test")) #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")) + appendTimelineLogEntryForTesting( + job, .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6")) _ = try await awaitTransportRender(transport) await awaitNativeLayoutTurn() #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Running swift test")) + #expect(transport.logFindStringForTesting.contains("Ran swift test")) #expect(transport.logFindStringForTesting.contains("output line 6") == false) } } @@ -4252,12 +4402,13 @@ struct ReviewUITests { let recentSnapshot = try await awaitTransportRender(transport) #expect( - recentSnapshot == .init( - title: nil, - summary: nil, - log: recentJob.logText, - isShowingEmptyState: false - ) + recentSnapshot + == .init( + title: nil, + summary: nil, + log: reviewMonitorLogText(for: recentJob), + isShowingEmptyState: false + ) ) #expect(window.title == recentJob.targetSummary) #expect(window.subtitle == recentJob.cwd) @@ -4274,7 +4425,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4309,7 +4461,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4356,7 +4509,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4373,7 +4527,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) - activeJob.appendLogEntry(.init(kind: .progress, text: "Newest active line")) + appendTimelineLogEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) let snapshot = try await awaitTransportRender(transport) @@ -4392,7 +4546,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4415,7 +4570,7 @@ struct ReviewUITests { ) store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [replacement])) - #expect(transport.displayedLogForTesting == longLog) + #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: replacement)) #expect(transport.logVerticalScrollOffsetForTesting == preservedOffset) } @@ -4437,7 +4592,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4487,7 +4643,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(longJob) let longSnapshot = try await awaitTransportRender(transport) - #expect(longSnapshot.log == longLog) + #expect(longSnapshot.log == reviewMonitorLogText(for: longJob)) #expect(transport.isLogPinnedToBottomForTesting) expectLogVisibleFragmentsWithoutForcingLayout(transport) } @@ -4511,7 +4667,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [shortJob, recentJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4524,14 +4681,15 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) expectLogVisibleFragmentsWithoutForcingLayout(transport) - shortJob.replaceLogEntries([.init(kind: .agentMessage, text: longLog)]) + replaceTimelineLogTextForTesting(shortJob, longLog) viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) _ = try await awaitTransportRender(transport) - #expect(abs( - transport.logVerticalScrollOffsetForTesting - - transport.logMinimumVerticalScrollOffsetForTesting - ) < 0.5) + #expect( + abs( + transport.logVerticalScrollOffsetForTesting + - transport.logMinimumVerticalScrollOffsetForTesting + ) < 0.5) #expect(transport.isLogPinnedToBottomForTesting == false) expectLogVisibleFragmentsWithoutForcingLayout(transport) } @@ -4553,7 +4711,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4564,8 +4723,8 @@ struct ReviewUITests { _ = 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")) + appendTimelineLogEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) + appendTimelineLogEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("fresh update") @@ -4587,7 +4746,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4619,7 +4779,8 @@ struct ReviewUITests { workspaces: [workspace], jobs: [job] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -4632,14 +4793,18 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(workspace) _ = try await awaitTransportRender(transport) - #expect(viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [workspace.cwd]) + #expect( + viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ + workspace.cwd + ]) #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) #expect( - transport.workspaceFindingSnapshotForTesting == .init( - text: "", - isShowingNoFindingsState: true, - isShowingFindingsList: false - ) + transport.workspaceFindingSnapshotForTesting + == .init( + text: "", + isShowingNoFindingsState: true, + isShowingFindingsList: false + ) ) } @@ -4647,7 +4812,8 @@ struct ReviewUITests { let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, workspaces: []) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) @@ -4683,7 +4849,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting @@ -4765,7 +4932,8 @@ struct ReviewUITests { serverState: .running, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -4777,12 +4945,12 @@ struct ReviewUITests { status: .succeeded, summary: "Review completed successfully." ) - job.replaceLogEntries([.init(kind: .agentMessage, text: "Updated log")]) + replaceTimelineLogTextForTesting(job, "Updated log") let updatedSnapshot = try await awaitTransportRender(transport) #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == "job-1") #expect(updatedSnapshot.summary == nil) - #expect(updatedSnapshot.log == "Updated log") + #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) } @Test func selectedJobLogAppendUsesAppendPath() async throws { @@ -4801,7 +4969,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 900, height: 360)) @@ -4813,7 +4982,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: " log")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "Initial log") @@ -4838,13 +5007,14 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) let wordGlowCount = transport.logWordGlowCountForTesting - job.appendLogEntry(.init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.hasSuffix("stream.tick 001")) @@ -4869,7 +5039,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -4900,13 +5071,14 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + 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")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "Initial one two") @@ -4928,13 +5100,14 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + 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")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.hasSuffix("stream.tick 002")) @@ -4956,19 +5129,22 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) 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) - )) + appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .progress, + groupID: "progress_1", + text: String(repeating: "progress ", count: 20) + )) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.contains("progress progress")) @@ -4997,17 +5173,24 @@ struct ReviewUITests { 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" - )) + #expect( + abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) + < 0.5) + appendTimelineLogEntryForTesting( + job, + .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) 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 { @@ -5026,7 +5209,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -5034,7 +5218,8 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - job.appendLogEntry(.init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) + appendTimelineLogEntryForTesting( + job, .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "- updated") @@ -5081,28 +5266,32 @@ struct ReviewUITests { 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." - )) + appendTimelineLogEntryForTesting( + job, + .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" + ) + )) + appendTimelineLogEntryForTesting( + job, + .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")) @@ -5140,24 +5329,28 @@ struct ReviewUITests { 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." - )) + appendTimelineLogEntryForTesting( + job, + .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" + ) + )) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .rawReasoning, + groupID: "reasoning_2", + text: "Inspecting details after the command starts." + )) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.contains("Need to inspect files.")) @@ -5183,7 +5376,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -5191,7 +5385,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - job.appendLogEntry(.init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) + appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "bold") @@ -5200,26 +5394,6 @@ struct ReviewUITests { #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", @@ -5236,22 +5410,25 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + 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" - )) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .plan, + groupID: "plan_1", + replacesGroup: true, + text: "- updated with longer replacement text" + )) _ = try await awaitTransportRender(transport) let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - job.appendLogEntry(.init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) + appendTimelineLogEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Command output") } @@ -5275,7 +5452,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -5284,7 +5462,7 @@ struct ReviewUITests { let reloadCount = transport.logReloadCountForTesting job.updateStateForTesting(summary: "Updated summary.") - #expect(transport.displayedLogForTesting == "Initial log") + #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: job)) #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReloadCountForTesting == reloadCount) } @@ -5305,13 +5483,15 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) transport.setLogReduceMotionForTesting(false) - job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) + appendTimelineLogEntryForTesting( + job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 2) @@ -5319,13 +5499,14 @@ struct ReviewUITests { transport.completeLogWordGlowAnimationsForTesting() #expect(transport.logWordGlowCountForTesting == 0) - job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) + appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 1) transport.setLogReduceMotionForTesting(true) - job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) + appendTimelineLogEntryForTesting( + job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 0) @@ -5360,7 +5541,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting transport.setLogReduceMotionForTesting(false) @@ -5369,13 +5551,14 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) _ = try await awaitTransportRender(transport) - firstJob.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) + appendTimelineLogEntryForTesting( + firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 0) - firstJob.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) + appendTimelineLogEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting > 0) } @@ -5406,7 +5589,8 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting - job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) + appendTimelineLogEntryForTesting( + job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting > 0) @@ -5446,7 +5630,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) transport.setLogReduceMotionForTesting(false) - job.appendLogEntry(.init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting > 0) @@ -5465,7 +5649,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5482,7 +5667,7 @@ struct ReviewUITests { transport.scrollLogToTopForTesting() #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Unpinned update")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == unpinnedAutoFollow) #expect(transport.isLogPinnedToBottomForTesting == false) @@ -5490,7 +5675,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) let pinnedAutoFollow = transport.logAutoFollowCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Pinned update")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) @@ -5521,7 +5706,7 @@ struct ReviewUITests { let wrappedLine = (0..<140) .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") - job.appendLogEntry(.init(kind: .progress, text: wrappedLine)) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) @@ -5543,7 +5728,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5559,10 +5745,12 @@ 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" - )) + appendTimelineLogEntryForTesting( + job, + .init( + kind: .progress, + text: "Near-bottom append should not snap inertial or manual scrolling to the document end" + )) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == autoFollowBeforeAppend) @@ -5582,7 +5770,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5596,7 +5785,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Newest line")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.isLogPinnedToBottomForTesting) @@ -5614,7 +5803,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5628,7 +5818,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Newest line")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5644,7 +5834,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5657,7 +5848,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "short update")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "short update")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5681,7 +5872,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5715,7 +5907,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5729,7 +5922,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Newest line")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5746,7 +5939,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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)) @@ -5760,7 +5954,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Newest line")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5776,7 +5970,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -5849,7 +6044,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) let appendCount = transport.logAppendCountForTesting - job.appendLogEntry(.init(kind: .progress, text: "Newest fragment line")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) _ = try await awaitTransportRender(transport) #expect(transport.logAppendCountForTesting == appendCount + 1) @@ -5867,7 +6062,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) @@ -5876,7 +6072,7 @@ struct ReviewUITests { #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.showFindInterface))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.nextMatch))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.replace)) == false) - #expect(transport.logAccessibilityValueForTesting == job.logText) + #expect(transport.logAccessibilityValueForTesting == reviewMonitorLogText(for: job)) #expect(transport.logDocumentViewExportsUserInterfaceValidationForTesting) let copyItem = commandMenuItemForTesting("copy:") @@ -5891,7 +6087,7 @@ struct ReviewUITests { #expect(transport.validateLogDocumentUserInterfaceItemForTesting(deleteItem) == false) transport.selectAllLogForTesting() - #expect(transport.logSelectedTextForTesting == job.logText) + #expect(transport.logSelectedTextForTesting == reviewMonitorLogText(for: job)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(cutItem) == false) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(pasteItem) == false) @@ -5899,30 +6095,35 @@ struct ReviewUITests { NSPasteboard.general.clearContents() transport.copyLogSelectionForTesting() - #expect(NSPasteboard.general.string(forType: .string) == job.logText) + #expect(NSPasteboard.general.string(forType: .string) == reviewMonitorLogText(for: job)) 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}") } @@ -5953,14 +6154,16 @@ struct ReviewUITests { #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,7 +6171,8 @@ 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( @@ -5980,7 +6184,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 900, height: 360)) @@ -6011,7 +6216,7 @@ struct ReviewUITests { } #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) - job.appendLogEntry(.init(kind: .progress, text: "needle appended")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) let appendedLength = (reviewMonitorLogText(for: job) as NSString).length @@ -6031,11 +6236,14 @@ 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")) + appendTimelineLogEntryForTesting( + job, .init(kind: .progress, text: "needle appended while the log is not following bottom")) _ = try await awaitTransportRender(transport) #expect(abs(transport.logVerticalScrollOffsetForTesting - offsetBeforeMiddleAppend) < 0.5) @@ -6057,10 +6265,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 +6281,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,10 +6304,11 @@ 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) } @@ -6118,7 +6330,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6133,7 +6346,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - firstJob.appendLogEntry(.init(kind: .progress, text: "needle appended")) + appendTimelineLogEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6169,7 +6382,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6184,9 +6398,12 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - firstJob.appendLogEntry(.init(kind: .progress, text: appendedLine)) + appendTimelineLogEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) _ = try await awaitTransportRender(transport) - #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: secondJob)) + #expect( + transport.displayedLogForTesting.trimmingCharacters(in: .newlines) + == reviewMonitorLogText(for: secondJob).trimmingCharacters(in: .newlines) + ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6216,7 +6433,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6255,7 +6473,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6270,7 +6489,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - job.appendLogEntry(.init(kind: .progress, text: "needle appended")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6291,7 +6510,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6306,7 +6526,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - job.appendLogEntry(.init(kind: .progress, text: "needle appended into snapshot")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6316,7 +6536,7 @@ 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")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6334,7 +6554,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6349,7 +6570,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - job.appendLogEntry(.init(kind: .progress, text: "needle appended into snapshot")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6360,7 +6581,7 @@ 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")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6379,7 +6600,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6395,7 +6617,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) - job.appendLogEntry(.init(kind: .progress, text: "future-only needle")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6414,7 +6636,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6431,7 +6654,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("core")) #expect(transport.logVisibleFindBarSearchStringForTesting == "core") #expect(transport.logHasActiveFindQueryForTesting) - job.appendLogEntry(.init(kind: .progress, text: "core appended while query is visible")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6452,7 +6675,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6469,7 +6693,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("alpha")) #expect(transport.logFindStringLengthForTesting == initialLength) - job.appendLogEntry(.init(kind: .progress, text: "beta appended after active search")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) _ = try await awaitTransportRender(transport) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindStringLengthForTesting == initialLength) @@ -6492,7 +6716,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6512,7 +6737,8 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(normalSelectionRange) #expect(transport.logSelectedTextForTesting == "copyable") #expect(transport.logHasActiveFindQueryForTesting == false) - job.appendLogEntry(.init(kind: .progress, text: "needle appended after normal selection")) + appendTimelineLogEntryForTesting( + job, .init(kind: .progress, text: "needle appended after normal selection")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6531,7 +6757,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6550,7 +6777,8 @@ struct ReviewUITests { #expect(transport.logHasActiveFindQueryForTesting) let initialLength = (reviewMonitorLogText(for: job) as NSString).length - job.appendLogEntry(.init(kind: .progress, text: "active query appears after no-result search")) + appendTimelineLogEntryForTesting( + job, .init(kind: .progress, text: "active query appears after no-result search")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6592,7 +6820,8 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } window.setContentSize(NSSize(width: 720, height: 320)) @@ -6606,7 +6835,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) - job.appendLogEntry(.init(kind: .progress, text: "needle first content")) + appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle first content")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6628,14 +6857,15 @@ struct ReviewUITests { authState: .signedOut, content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.summary == nil) - #expect(snapshot.log == "Authentication required. Sign in to ReviewMonitor and retry.") + #expect(snapshot.log == reviewMonitorLogText(for: job)) } @Test func authenticatedAuthFailedJobStillShowsNormalFailureDetails() async throws { @@ -6652,14 +6882,15 @@ struct ReviewUITests { authState: .signedIn(accountID: "review@example.com"), content: makeSidebarContent(from: [job]) ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.summary == nil) - #expect(snapshot.log == "Authentication required. Sign in to ReviewMonitor and retry.") + #expect(snapshot.log == reviewMonitorLogText(for: job)) } } @@ -6692,7 +6923,8 @@ func makeWindowHarness( authState: TestAuthState = .signedIn(accountID: "review@example.com"), contentSize: NSSize? = nil, sidebarJobFilterDefaults: UserDefaults? = nil, - contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator + contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController + .defaultContentTransitionAnimator ) -> ReviewMonitorWindowHarness { applyTestAuthState(auth: store.auth, state: authState) let windowController = ReviewMonitorWindowController( @@ -6757,7 +6989,6 @@ final class ManualContentTransitionAnimator { } } - @MainActor func waitForWindowShowingSplitView( _ rootViewController: ReviewMonitorRootViewController, @@ -7015,7 +7246,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), expected=\(expectedState)" + ) } @MainActor @@ -7092,7 +7327,7 @@ func makeJob( logText: String = "", rawLogText: String = "" ) -> CodexReviewJob { - CodexReviewJob.makeForTesting( + let job = CodexReviewJob.makeForTesting( id: id, cwd: cwd, targetSummary: targetSummary, @@ -7104,13 +7339,11 @@ func makeJob( 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)) - }), + logEntries: [], errorMessage: status == .failed ? summary ?? status.displayText : nil ) + seedTimelineForTesting(job, logText: logText, rawLogText: rawLogText) + return job } @MainActor @@ -7128,7 +7361,10 @@ func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] { @MainActor func makeSidebarContent(from jobs: [CodexReviewJob]) -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) { - (makeWorkspaces(from: jobs), Array(jobs.reversed())) + for job in jobs { + seedTimelineForTesting(job, logEntries: job.logEntries) + } + return (makeWorkspaces(from: jobs), Array(jobs.reversed())) } struct LinkedWorktreeFixtureForTesting { @@ -7146,16 +7382,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) @@ -7469,7 +7709,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.") } From 80b442d8d910d349197a269e06ca50064486ac72 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:43:08 +0900 Subject: [PATCH 113/359] refactor(review-mcp): remove log-entry output remnants --- .../CodexReviewMCPToolResults.swift | 22 +++------------ .../CodexReviewMCPHTTPServerTests.swift | 28 +------------------ 2 files changed, 5 insertions(+), 45 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 1cccc4f0..c1a80c9b 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -8,7 +8,7 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul let isError: Bool switch response { case .reviewStart(let snapshot), - .reviewAwait(let snapshot): + .reviewAwait(let snapshot): value = snapshot.result.structuredContentForStartOrAwait(timeline: snapshot.timeline) text = snapshot.result.textContentForStartOrAwait() isError = snapshot.result.core.lifecycle.status == .failed @@ -91,7 +91,8 @@ private extension CodexReviewAPI.Read.Result { ), "output": core.output.structuredContent(review: core.reviewText), ] - object["timeline"] = includeDetails + object["timeline"] = + includeDetails ? timeline.structuredContentWithItems() : timeline.structuredContent() if includeNextAction { @@ -479,7 +480,6 @@ private extension UInt64 { } } - private extension CodexReviewAPI.Job.ListItem { func structuredContent() -> Value { .object([ @@ -499,7 +499,7 @@ private extension CodexReviewAPI.Job.ListItem { private extension CodexReviewAPI.List.Result { func structuredContent() -> Value { .object([ - "items": .array(items.map { $0.structuredContent() }), + "items": .array(items.map { $0.structuredContent() }) ]) } } @@ -527,20 +527,6 @@ private extension CodexReviewAPI.Cancel.Outcome { } } -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([ diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index cf873d46..866a5b49 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -489,39 +489,13 @@ struct CodexReviewMCPHTTPServerTests { 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", - cwd: "/tmp/project", - exitCode: 0, - startedAt: startedAt, - completedAt: completedAt, - durationMs: 3000, - commandStatus: "completed" - ) let job = 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 - ), - ] + summary: "Done" ) job.timeline.apply( .itemCompleted( From 80fe94a88257c158b42f475fe4b6a4c3234fda91 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:05:39 +0900 Subject: [PATCH 114/359] refactor(review-ui): seed previews from timeline items --- .../CodexReviewKit/Model/CodexReviewJob.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 1079 +++++++++-------- .../ReviewMonitorSidebarViewController.swift | 3 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 210 ++-- 4 files changed, 672 insertions(+), 622 deletions(-) diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 0dfc7c31..93ad7a48 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -74,7 +74,7 @@ public final class CodexReviewJob: Identifiable, Hashable { targetSummary: String, core: ReviewJobCore, cancellationRequested: Bool = false, - logEntries: [ReviewLogEntry] + logEntries: [ReviewLogEntry] = [] ) { let initialLogBuffer = ReviewLogBuffer(entries: logEntries) let initialLogSnapshot = initialLogBuffer.snapshot diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 4e65f8a7..14e8020e 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -16,46 +16,129 @@ public enum ReviewMonitorPreviewContent { } private struct PreviewStreamTemplate { - let kind: ReviewLogEntry.Kind - let groupName: String? - let text: String - let metadata: ReviewLogEntry.Metadata? - let replacesGroup: Bool + let itemName: String? + let kind: ReviewItemKind + let family: ReviewItemFamily + let phase: ReviewItemPhase + let content: ReviewTimelineItem.Content + let mode: PreviewStreamMode + let deltaText: String? + let startedAt: Date? + let completedAt: Date? + let durationMs: Int? 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, + itemName: String? = nil, + kind: ReviewItemKind, + family: ReviewItemFamily, + phase: ReviewItemPhase = .completed, + content: ReviewTimelineItem.Content, + mode: PreviewStreamMode = .complete, + deltaText: String? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + durationMs: Int? = nil, chunkByWord: Bool = false, delayBeforeFrameCount: Int, chunkIntervalFrameCount: Int = 1 ) { + self.itemName = itemName self.kind = kind - self.groupName = groupName - self.text = text - self.metadata = metadata - self.replacesGroup = replacesGroup + self.family = family + self.phase = phase + self.content = content + self.mode = mode + self.deltaText = deltaText + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMs = durationMs 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 enum PreviewStreamMode { + case update + case complete + case textDelta + } + + private struct PreviewTimelineItemTemplate { + let itemName: String + let kind: ReviewItemKind + let family: ReviewItemFamily + let phase: ReviewItemPhase + let content: ReviewTimelineItem.Content + let startedAt: Date? + let completedAt: Date? + let durationMs: Int? + + init( + itemName: String, + kind: ReviewItemKind, + family: ReviewItemFamily, + phase: ReviewItemPhase, + content: ReviewTimelineItem.Content, + startedAt: Date? = nil, + completedAt: Date? = nil, + durationMs: Int? = nil + ) { + self.itemName = itemName + self.kind = kind + self.family = family + self.phase = phase + self.content = content + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMs = durationMs + } + + func seed(id: ReviewTimelineItem.ID) -> ReviewTimelineItemSeed { + .init( + id: id, + kind: kind, + family: family, + phase: phase, + content: content, + startedAt: startedAt, + completedAt: completedAt, + durationMs: durationMs + ) + } + } + + private struct PreviewTimelineStep { + let itemName: String + let kind: ReviewItemKind + let family: ReviewItemFamily + let phase: ReviewItemPhase + let content: ReviewTimelineItem.Content + let mode: PreviewStreamMode + let deltaText: String? + let startedAt: Date? + let completedAt: Date? + let durationMs: Int? + + func seed(id: ReviewTimelineItem.ID) -> ReviewTimelineItemSeed { + .init( + id: id, + kind: kind, + family: family, + phase: phase, + content: content, + startedAt: startedAt, + completedAt: completedAt, + durationMs: durationMs + ) + } } private struct PreviewStreamFrame { - let step: PreviewStreamStep + let step: PreviewTimelineStep let cycle: Int } @@ -143,7 +226,7 @@ public enum ReviewMonitorPreviewContent { hasFinalReview: false, reviewResult: nil, lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.", - logEntries: makeCommandOutputPreviewLogEntries() + timelineItems: makeCommandOutputPreviewTimelineItems() ) store.loadForTesting( serverState: .running, @@ -176,9 +259,7 @@ public enum ReviewMonitorPreviewContent { let nextTick = currentTick + 1 for (index, job) in runningJobs.enumerated() { if let frame = streamFrame(forJobAt: index, tick: nextTick) { - let entry = streamEntry(from: frame.step, for: job, cycle: frame.cycle) - job.appendLogEntry(entry) - appendPreviewTimelineEntry(entry, to: job) + applyPreviewStreamStep(frame.step, to: job, cycle: frame.cycle) } } return nextTick @@ -206,87 +287,39 @@ public enum ReviewMonitorPreviewContent { ) } - private static func streamEntry( - from step: PreviewStreamStep, - for job: CodexReviewJob, + private static func applyPreviewStreamStep( + _ step: PreviewTimelineStep, + to 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) + ) { + let itemID = previewTimelineItemID( + itemName: step.itemName, + jobID: job.id, + cycle: cycle ) - } - - 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 + switch step.mode { + case .update: + job.timeline.apply(.itemUpdated(step.seed(id: itemID))) + case .complete: + job.timeline.apply(.itemCompleted(step.seed(id: itemID))) + case .textDelta: + job.timeline.apply( + .textDelta( + itemID: itemID, + kind: step.kind, + family: step.family, + content: step.content, + delta: step.deltaText ?? "" + )) } } - 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 previewTimelineItemID( + itemName: String, + jobID: String, + cycle: Int + ) -> ReviewTimelineItem.ID { + .init(rawValue: "preview-\(itemName)-\(jobID)-\(cycle)") } private static func previewTurnID(_ tick: Int) -> String { @@ -299,25 +332,32 @@ public enum ReviewMonitorPreviewContent { return "preview-turn-\(tick)" } - private static func streamTimeline(from templates: [PreviewStreamTemplate]) -> [PreviewStreamStep?] { - var timeline: [PreviewStreamStep?] = [] - for template in templates { + private static func streamTimeline(from templates: [PreviewStreamTemplate]) -> [PreviewTimelineStep?] { + var timeline: [PreviewTimelineStep?] = [] + for (templateIndex, template) in templates.enumerated() { 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] + 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 { timeline.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1)) } timeline.append( - PreviewStreamStep( + PreviewTimelineStep( + itemName: itemName, kind: template.kind, - groupName: template.groupName, - text: chunk, - metadata: template.metadata, - replacesGroup: template.replacesGroup + family: template.family, + phase: template.phase, + content: template.content, + mode: template.mode, + deltaText: template.mode == .textDelta ? chunk : template.deltaText, + startedAt: template.startedAt, + completedAt: template.completedAt, + durationMs: template.durationMs )) } } @@ -344,138 +384,186 @@ public enum ReviewMonitorPreviewContent { private static let previewStreamTemplates: [PreviewStreamTemplate] = [ .init( - kind: .event, - text: "Turn started: \(previewTurnID(1))", + kind: ReviewItemKind(rawValue: "event"), + family: .diagnostic, + content: .diagnostic(.init(message: "Turn started: \(previewTurnID(1))")), delayBeforeFrameCount: 1 ), .init( + itemName: "plan", kind: .plan, - groupName: "plan", - text: """ - [completed] Inspect ReviewMonitor log rendering - [in_progress] Preserve active find UI while streaming - [pending] Run focused UI tests - """, + family: .plan, + content: .plan( + .init( + markdown: """ + [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\"", + itemName: "command-search-test", + kind: .commandExecution, + family: .command, + phase: .running, + content: .command( + .init( + command: + "/bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"", + status: .inProgress + )), + mode: .update, 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 - ), + itemName: "command-search-test", + kind: .commandExecution, + family: .command, + phase: .completed, + 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: .toolCall, - text: "MCP codex_review.review_read started.", + kind: .mcpToolCall, + family: .tool, + content: .toolCall( + .init( + result: "MCP codex_review.review_read started.", + status: .started + )), delayBeforeFrameCount: interItemDelayFrameCount ), .init( - kind: .reasoningSummary, - groupName: "reasoning-summary", - text: + itemName: "reasoning-summary", + kind: .reasoning, + family: .reasoning, + content: .reasoning(.init(text: "", style: .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( - kind: .command, - groupName: "command-open-log-scroll", - text: "$ /bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"", + itemName: "command-open-log-scroll", + kind: .commandExecution, + family: .command, + phase: .running, + content: .command( + .init( + command: + "/bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"", + status: .inProgress + )), + mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( - kind: .commandOutput, - groupName: "command-open-log-scroll", - text: """ - import AppKit - import ObjectiveC.runtime - import CodexReviewKit + itemName: "command-open-log-scroll", + kind: .commandExecution, + family: .command, + phase: .completed, + content: .command( + .init( + command: "", + output: """ + import AppKit + import ObjectiveC.runtime + import CodexReviewKit - @MainActor - final class ReviewMonitorLogScrollView: NSScrollView { - private let logDocumentView = ReviewMonitorLogDocumentView() - """, - metadata: .init( - sourceType: "command", - title: "Ran command for 2s", - status: "succeeded", - exitCode: 0 - ), + @MainActor + final class ReviewMonitorLogScrollView: NSScrollView { + private let logDocumentView = ReviewMonitorLogDocumentView() + """, + exitCode: 0, + status: .completed + )), delayBeforeFrameCount: commandCompletionDelayFrameCount ), .init( + itemName: "context-compaction", kind: .contextCompaction, - groupName: "context-compaction", - text: "Automatically compacting context", - metadata: .init( - sourceType: "contextCompaction", - status: "inProgress", - itemID: "preview-context-compaction" - ), - replacesGroup: true, + family: .contextCompaction, + phase: .running, + content: .contextCompaction( + .init( + title: "Automatically compacting context", + status: .inProgress + )), + mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( + itemName: "context-compaction", kind: .contextCompaction, - groupName: "context-compaction", - text: "Context automatically compacted", - metadata: .init( - sourceType: "contextCompaction", - status: "completed", - itemID: "preview-context-compaction" - ), - replacesGroup: true, + family: .contextCompaction, + phase: .completed, + content: .contextCompaction( + .init( + title: "Context automatically compacted", + status: .completed + )), delayBeforeFrameCount: compactionCompletionDelayFrameCount ), .init( - kind: .toolCall, - text: "File changes updated.", + kind: .mcpToolCall, + family: .tool, + content: .toolCall( + .init( + result: "File changes updated.", + status: .completed + )), delayBeforeFrameCount: interItemDelayFrameCount ), .init( - kind: .rawReasoning, - groupName: "raw-reasoning", - text: + itemName: "raw-reasoning", + kind: .reasoning, + family: .reasoning, + content: .reasoning(.init(text: "", style: .raw)), + 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( - kind: .rawReasoning, - groupName: "raw-reasoning-follow-up", - text: + itemName: "raw-reasoning-follow-up", + kind: .reasoning, + family: .reasoning, + content: .reasoning(.init(text: "", style: .raw)), + 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, - 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", + family: .message, + content: .message( + .init( + 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] { + private static func makeCommandOutputPreviewTimelineItems() -> [PreviewTimelineItemTemplate] { let output = """ Command line invocation: /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor @@ -491,9 +579,13 @@ public enum ReviewMonitorPreviewContent { Test Suite 'Selected tests' passed. """ return [ - .init(kind: .event, text: "Turn started: preview-command-output-panel"), - .init( - kind: .agentMessage, + diagnosticItem( + "command-output-event", + kind: ReviewItemKind(rawValue: "event"), + message: "Turn started: preview-command-output-panel" + ), + messageItem( + "command-output-intro", text: """ Checking the command output rendering path. @@ -502,27 +594,19 @@ public enum ReviewMonitorPreviewContent { - 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 - ) + commandStartedItem( + "preview-command-output", + command: + "xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor" ), - .init( - kind: .agentMessage, - text: "The output remains available without taking over the whole log." + 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."), ] } @@ -649,7 +733,7 @@ public enum ReviewMonitorPreviewContent { cwd: cwd ), lastAgentMessage: definition.lastAgentMessage, - logEntries: makePreviewLogEntries(for: definition, workspaceName: workspaceName) + timelineItems: makePreviewTimelineItems(for: definition, workspaceName: workspaceName) ) } } @@ -668,7 +752,7 @@ public enum ReviewMonitorPreviewContent { hasFinalReview: Bool, reviewResult: ParsedReviewResult?, lastAgentMessage: String, - logEntries: [ReviewLogEntry] + timelineItems: [PreviewTimelineItemTemplate] ) -> CodexReviewJob { let job = CodexReviewJob.makeForTesting( id: id, @@ -683,239 +767,158 @@ public enum ReviewMonitorPreviewContent { summary: summary, hasFinalReview: hasFinalReview, reviewResult: reviewResult, - lastAgentMessage: lastAgentMessage, - logEntries: logEntries + lastAgentMessage: lastAgentMessage ) - seedPreviewTimeline(logEntries, in: job) + seedPreviewTimeline(timelineItems, in: job) return job } - private static func seedPreviewTimeline(_ logEntries: [ReviewLogEntry], in job: CodexReviewJob) { - for entry in logEntries { - appendPreviewTimelineEntry(entry, to: job) - } - } - - private static func appendPreviewTimelineEntry(_ entry: ReviewLogEntry, to job: CodexReviewJob) { - let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) - let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content - job.timeline.apply( - .itemUpdated( - .init( - id: itemID, - kind: previewTimelineKind(for: entry), - family: previewTimelineFamily(for: entry), - phase: previewTimelinePhase(for: entry), - content: previewTimelineContent(for: entry, existing: existingContent), - startedAt: entry.metadata?.startedAt, - completedAt: entry.metadata?.completedAt, - durationMs: entry.metadata?.durationMs - ))) - } - - private static func previewTimelineKind(for entry: ReviewLogEntry) -> ReviewItemKind { - switch entry.kind { - case .command, .commandOutput: - .commandExecution - case .agentMessage: - .agentMessage - case .plan, .todoList: - .plan - case .reasoning, .reasoningSummary, .rawReasoning: - .reasoning - case .contextCompaction: - .contextCompaction - case .toolCall: - .mcpToolCall - case .diagnostic, .error, .progress, .event: - ReviewItemKind(rawValue: entry.kind.rawValue) - } - } - - private static func previewTimelineFamily(for entry: ReviewLogEntry) -> ReviewItemFamily { - switch entry.kind { - case .command, .commandOutput: - .command - case .agentMessage: - .message - case .plan, .todoList: - .plan - case .reasoning, .reasoningSummary, .rawReasoning: - .reasoning - case .contextCompaction: - .contextCompaction - case .toolCall: - .tool - case .diagnostic, .error, .progress, .event: - .diagnostic - } - } - - private static func previewTimelineContent( - for entry: ReviewLogEntry, - existing: ReviewTimelineItem.Content? - ) -> ReviewTimelineItem.Content { - switch entry.kind { - case .command: - return .command( - .init( - command: previewCommandText(for: entry), - cwd: entry.metadata?.cwd, - output: "", - exitCode: entry.metadata?.exitCode, - status: previewCommandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) - case .commandOutput: - let existingOutput: String - let existingCommand: String? - if case .command(let command) = existing { - existingOutput = command.output - existingCommand = command.command - } else { - existingOutput = "" - existingCommand = nil - } - return .command( - .init( - command: entry.metadata?.command ?? existingCommand ?? "Command", - cwd: entry.metadata?.cwd, - output: existingOutput + entry.text, - exitCode: entry.metadata?.exitCode, - status: previewCommandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) - case .agentMessage: - return .message(.init(text: previewExistingText(existing, message: "") + entry.text)) - case .plan, .todoList: - return .plan(.init(markdown: previewExistingText(existing, plan: "") + entry.text)) - case .reasoning: - return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .raw)) - case .reasoningSummary: - return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .summary)) - case .rawReasoning: - return .reasoning(.init(text: previewExistingText(existing, reasoning: "") + entry.text, style: .raw)) - case .contextCompaction: - return .contextCompaction( - .init( - title: entry.text, - status: previewContextCompactionStatus(for: entry) - )) - case .toolCall: - return .toolCall( - .init( - result: entry.text, - status: previewToolCallStatus(for: entry) - )) - case .diagnostic, .error, .progress, .event: - return .diagnostic(.init(message: previewExistingText(existing, diagnostic: "") + entry.text)) - } - } - - private static func previewExistingText( - _ content: ReviewTimelineItem.Content?, - message defaultValue: String - ) -> String { - if case .message(let message) = content { - return message.text + private static func seedPreviewTimeline(_ timelineItems: [PreviewTimelineItemTemplate], in job: CodexReviewJob) { + for item in timelineItems { + let itemID = previewTimelineItemID( + itemName: item.itemName, + jobID: job.id, + cycle: 0 + ) + let seed = item.seed(id: itemID) + let event: ReviewDomainEvent = item.phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed) + job.timeline.apply(event) } - return defaultValue } - private static func previewExistingText( - _ content: ReviewTimelineItem.Content?, - plan defaultValue: String - ) -> String { - if case .plan(let plan) = content { - return plan.markdown - } - return defaultValue + private static func diagnosticItem( + _ itemName: String, + kind: ReviewItemKind, + message: String, + phase: ReviewItemPhase = .completed + ) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: kind, + family: .diagnostic, + phase: phase, + content: .diagnostic(.init(message: message)) + ) } - private static func previewExistingText( - _ content: ReviewTimelineItem.Content?, - reasoning defaultValue: String - ) -> String { - if case .reasoning(let reasoning) = content { - return reasoning.text - } - return defaultValue + private static func messageItem(_ itemName: String, text: String) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: text)) + ) } - private static func previewExistingText( - _ content: ReviewTimelineItem.Content?, - diagnostic defaultValue: String - ) -> String { - if case .diagnostic(let diagnostic) = content { - return diagnostic.message - } - return defaultValue + private static func planItem(_ itemName: String, markdown: String) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .plan, + family: .plan, + phase: .completed, + content: .plan(.init(markdown: markdown)) + ) } - private static func previewCommandText(for entry: ReviewLogEntry) -> 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 static func reasoningItem( + _ itemName: String, + text: String, + style: ReviewTimelineItem.Reasoning.Style = .summary + ) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .reasoning, + family: .reasoning, + phase: .completed, + content: .reasoning(.init(text: text, style: style)) + ) } - private static func previewTimelinePhase(for entry: ReviewLogEntry) -> ReviewItemPhase { - let status = entry.metadata?.commandStatus ?? entry.metadata?.status - switch status { - case "inProgress", "running", "started": - return .running - case "failed": - return .failed - case "cancelled": - return .cancelled - default: - return entry.kind == .command ? .running : .completed - } + private static func contextCompactionItem( + _ itemName: String, + title: String, + status: ReviewContextCompactionStatus + ) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .contextCompaction, + family: .contextCompaction, + phase: status == .completed ? .completed : .running, + content: .contextCompaction(.init(title: title, status: status)) + ) } - private static func previewCommandStatus(for entry: ReviewLogEntry) -> ReviewCommandStatus? { - guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { - return nil - } - switch rawValue { - case "succeeded", "success": - return .completed - default: - return .init(rawValue: rawValue) - } + private static func commandStartedItem( + _ itemName: String, + command: String, + cwd: String? = nil, + startedAt: Date? = nil, + actions: [ReviewTimelineItem.CommandAction] = [] + ) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .commandExecution, + family: .command, + phase: .running, + content: .command( + .init( + command: command, + cwd: cwd, + status: .inProgress, + actions: actions + )), + startedAt: startedAt + ) } - private static func previewContextCompactionStatus(for entry: ReviewLogEntry) -> ReviewContextCompactionStatus? { - guard let rawValue = entry.metadata?.status else { - return nil - } - switch rawValue { - case "inProgress", "running": - return .inProgress - case "completed", "succeeded", "success": - return .completed - default: - return .init(rawValue: rawValue) - } + private static func commandCompletedItem( + _ itemName: String, + output: String, + exitCode: Int, + status: ReviewCommandStatus, + durationMs: Int? = nil + ) -> PreviewTimelineItemTemplate { + .init( + itemName: itemName, + kind: .commandExecution, + family: .command, + phase: status == .failed ? .failed : .completed, + content: .command( + .init( + command: "", + output: output, + exitCode: exitCode, + status: status, + durationMs: durationMs + )), + durationMs: durationMs + ) } - private static func previewToolCallStatus(for entry: ReviewLogEntry) -> ReviewToolCallStatus? { - guard let rawValue = entry.metadata?.status else { - return nil - } - switch rawValue { - case "inProgress", "running": - return .inProgress - case "completed", "succeeded", "success": - return .completed - case "failed": - return .failed - default: - return .init(rawValue: rawValue) - } + private static func toolCallItem( + _ itemName: String, + result: String, + status: ReviewToolCallStatus + ) -> PreviewTimelineItemTemplate { + let phase: ReviewItemPhase = + switch status { + case .started, .inProgress: + .running + case .failed: + .failed + case .cancelled: + .cancelled + default: + .completed + } + return PreviewTimelineItemTemplate( + itemName: itemName, + kind: .mcpToolCall, + family: .tool, + phase: phase, + content: .toolCall(.init(result: result, status: status)) + ) } private static func makeReviewResult( @@ -961,123 +964,144 @@ public enum ReviewMonitorPreviewContent { ) } - private static func makePreviewLogEntries( + private static func makePreviewTimelineItems( for definition: PreviewJobDefinition, workspaceName: String - ) -> [ReviewLogEntry] { + ) -> [PreviewTimelineItemTemplate] { switch definition.status { case .running: - makeRunningPreviewLogEntries(for: definition, workspaceName: workspaceName) + return makeRunningPreviewTimelineItems(for: definition, workspaceName: workspaceName) case .queued: - [ - .init(kind: .event, text: "Queued review for \(definition.targetSummary)."), - .init(kind: .progress, text: definition.summary), + return [ + diagnosticItem( + "queued-event-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "event"), + message: "Queued review for \(definition.targetSummary)." + ), + diagnosticItem( + "queued-progress-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "progress"), + message: 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\"" + let commandName = "preview-failed-command-\(workspaceName)-\(definition.targetSummary)" + return [ + diagnosticItem( + "failed-event-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "event"), + message: "Turn started: preview-failed-\(workspaceName.lowercased())" ), - .init( - kind: .commandOutput, - groupID: "preview-failed-command-\(workspaceName)-\(definition.targetSummary)", - text: """ + 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. """, - metadata: .init( - sourceType: "command", - title: "Ran command for 10s", - status: "failed", - exitCode: 1 - ) + exitCode: 1, + status: .failed, + durationMs: 10_000 + ), + diagnosticItem( + "failed-error-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "error"), + message: definition.summary ), - .init(kind: .error, text: definition.summary), - .init(kind: .agentMessage, text: definition.lastAgentMessage), + messageItem( + "failed-message-\(workspaceName)-\(definition.targetSummary)", 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), + return [ + diagnosticItem( + "cancelled-event-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "event"), + message: "Turn started: preview-cancelled-\(workspaceName.lowercased())" + ), + diagnosticItem( + "cancelled-progress-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "progress"), + message: definition.summary + ), + messageItem( + "cancelled-message-\(workspaceName)-\(definition.targetSummary)", 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\"" + let commandName = "preview-complete-command-\(workspaceName)-\(definition.targetSummary)" + return [ + diagnosticItem( + "complete-event-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "event"), + message: "Turn started: preview-complete-\(workspaceName.lowercased())" ), - .init( - kind: .commandOutput, - groupID: "preview-complete-command-\(workspaceName)-\(definition.targetSummary)", - text: """ + commandStartedItem( + commandName, + command: "/bin/zsh -lc \"swift test --filter ReviewUI\"" + ), + commandCompletedItem( + commandName, + output: """ Test Suite 'ReviewUITests' started. Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed. Test Suite 'ReviewUITests' passed. """, - metadata: .init( - sourceType: "command", - title: "Ran command for 4s", - status: "succeeded", - exitCode: 0 - ) + exitCode: 0, + status: .completed, + durationMs: 4_000 ), - .init( - kind: .reasoningSummary, - groupID: "preview-complete-summary-\(workspaceName)-\(definition.targetSummary)", + reasoningItem( + "complete-summary-\(workspaceName)-\(definition.targetSummary)", text: definition.summary ), - .init(kind: .agentMessage, text: definition.lastAgentMessage), + messageItem( + "complete-message-\(workspaceName)-\(definition.targetSummary)", text: definition.lastAgentMessage), ] } } - private static func makeRunningPreviewLogEntries( + private static func makeRunningPreviewTimelineItems( for definition: PreviewJobDefinition, workspaceName: String - ) -> [ReviewLogEntry] { - let sourceReadGroupID = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)" + ) -> [PreviewTimelineItemTemplate] { + 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 [ - .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)" - ) + diagnosticItem( + "running-event-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "event"), + message: "Turn started: preview-\(workspaceName.lowercased())" ), - .init( - kind: .plan, - groupID: "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)", - replacesGroup: true, - text: """ + diagnosticItem( + "running-progress-\(workspaceName)-\(definition.targetSummary)", + kind: ReviewItemKind(rawValue: "progress"), + message: "Reviewing \(definition.targetSummary)" + ), + contextCompactionItem( + "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)", + title: "Context automatically compacted", + status: .completed + ), + planItem( + "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)", + markdown: """ [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\"" + commandStartedItem( + initialCommandName, + command: "/bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\"" ), - .init( - kind: .commandOutput, - groupID: "preview-initial-command-\(workspaceName)-\(definition.targetSummary)", - text: """ + commandCompletedItem( + initialCommandName, + output: """ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift | 34 +++++++++++++++++ Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift | 18 ++++++++-- Tests/ReviewUITests/ReviewUITests.swift | 12 ++++++ @@ -1085,46 +1109,35 @@ public enum ReviewMonitorPreviewContent { 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 - ) + exitCode: 0, + status: .completed, + durationMs: 6_000 ), - .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" - ), + commandStartedItem( + sourceReadItemName, + command: "/bin/zsh -lc \"\(sourceReadCommand)\"", + startedAt: Date().addingTimeInterval(-88), + actions: [ + .init( + kind: .read, + command: sourceReadCommand, + name: "ReviewMonitorCommandOutputDisplayDocument.swift", + path: "Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift" + ) + ] ), - .init(kind: .toolCall, text: "MCP codex_review.review_start started."), - .init( - kind: .reasoningSummary, - groupID: "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)", + toolCallItem( + "running-tool-\(workspaceName)-\(definition.targetSummary)", + result: "MCP codex_review.review_start started.", + status: .started + ), + 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." ), - .init( - kind: .agentMessage, - groupID: "preview-initial-agent-\(workspaceName)-\(definition.targetSummary)", - text: definition.lastAgentMessage - ), + messageItem( + "preview-initial-agent-\(workspaceName)-\(definition.targetSummary)", text: definition.lastAgentMessage), ] } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 96fc0393..df96e400 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -63,8 +63,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD summary: "", lastAgentMessage: "Review output preview" ) - ), - logEntries: [] + ) ) let cellView = ReviewMonitorJobCellView() cellView.configure(with: job) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index c6c67e20..f366e45b 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -22,7 +22,8 @@ extension ReviewUITests { @Test func bindingStoreAppliesInitialState() { let store = CodexReviewStore.makePreviewStore() - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarTopAccessoryCountForTesting == 0) @@ -39,7 +40,8 @@ extension ReviewUITests { serverURL: URL(string: "http://localhost:9417/mcp"), workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.splitViewItems.count == 2) @@ -56,7 +58,8 @@ extension ReviewUITests { serverState: .failed("Embedded server is unavailable in preview mode."), workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.splitViewItems.count == 2) @@ -71,7 +74,8 @@ extension ReviewUITests { serverState: .starting, workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarPresentationForTesting == .unavailable) @@ -83,7 +87,8 @@ extension ReviewUITests { serverState: .stopped, workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarPresentationForTesting == .unavailable) @@ -96,7 +101,8 @@ extension ReviewUITests { serverURL: URL(string: "http://localhost:9417/mcp"), workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarPresentationForTesting == .jobList) @@ -231,7 +237,8 @@ extension ReviewUITests { serverURL: URL(string: "http://localhost:9417/mcp"), workspaces: [] ) - let viewController = ReviewMonitorSplitViewController(store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let viewController = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() #expect(viewController.sidebarPresentationForTesting == .jobList) @@ -274,13 +281,16 @@ extension ReviewUITests { #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.sidebarJobFilterToolbarItemIdentifierForTesting)) #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) @@ -303,12 +313,13 @@ extension ReviewUITests { sidebarItem.isCollapsed = false #expect(viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false) - #expect(viewController.sidebarJobFilterToolbarMenuItemTitlesForTesting == [ - "All Items", - "-", - "Running", - "Latest Finished", - ]) + #expect( + viewController.sidebarJobFilterToolbarMenuItemTitlesForTesting == [ + "All Items", + "-", + "Running", + "Latest Finished", + ]) #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting == false) #expect(viewController.selectedToolbarItemIdentifierForTesting == nil) @@ -326,7 +337,8 @@ extension ReviewUITests { viewController.sidebarJobFilterToolbarSelectedFilterForTesting == combinedFilter } #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting) - #expect(viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Running", "Latest Finished"]) + #expect( + viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Running", "Latest Finished"]) #expect(viewController.selectedToolbarItemIdentifierForTesting == nil) viewController.selectSidebarJobFilterForTesting(.running) @@ -1035,12 +1047,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() @@ -1098,10 +1112,11 @@ 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 { @@ -1139,7 +1154,8 @@ extension ReviewUITests { 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() + logText: Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40) + .joined() ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1185,7 +1201,8 @@ extension ReviewUITests { 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() + logText: Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40) + .joined() ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1383,7 +1400,9 @@ extension ReviewUITests { 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() + logText: Array( + repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40 + ).joined() ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1451,7 +1470,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 = ReviewMonitorSplitViewController( + store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1473,19 +1493,19 @@ extension ReviewUITests { store.orderedJobs.first(where: { $0.core.lifecycle.status == .running }) ) let initialLog = reviewMonitorLogText(for: runningJob) - let initialEntryCount = runningJob.logEntries.count + let initialItemCount = runningJob.timeline.items.count ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) let appendedText = String(reviewMonitorLogText(for: runningJob).dropFirst(initialLog.count)) - let appendedEntries = Array(runningJob.logEntries.dropFirst(initialEntryCount)) + let appendedItems = Array(runningJob.timeline.items.dropFirst(initialItemCount)) #expect(reviewMonitorLogText(for: runningJob) != initialLog) #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 == ReviewItemKind(rawValue: "event")) + #expect(appendedItems.first.map(diagnosticMessage)?.contains("preview-turn") == true) } @Test func previewStreamUsesMixedReviewLogKinds() throws { @@ -1495,41 +1515,32 @@ extension ReviewUITests { let runningJob = try #require( store.orderedJobs.first(where: { $0.core.lifecycle.status == .running }) ) - let initialEntryCount = runningJob.logEntries.count + let initialItemCount = runningJob.timeline.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)) + let appendedItems = Array(runningJob.timeline.items.dropFirst(initialItemCount)) + let appendedKinds = appendedItems.map(\.kind) + #expect(appendedKinds.contains(ReviewItemKind(rawValue: "event"))) + #expect(appendedKinds.contains(.commandExecution)) + #expect(appendedKinds.contains(.mcpToolCall)) #expect(appendedKinds.contains(.plan)) #expect(appendedKinds.contains(.contextCompaction)) - #expect(appendedKinds.contains(.reasoningSummary)) + #expect(appendedKinds.contains(.reasoning)) #expect(appendedKinds.contains(.agentMessage)) #expect(Set(appendedKinds).count >= 6) + #expect(Set(appendedItems.map(\.id)).count == appendedItems.count) - 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)) - } - - let compactionEntries = runningJob.logEntries - .dropFirst(initialEntryCount) + let compactionItems = runningJob.timeline.items + .dropFirst(initialItemCount) .filter { $0.kind == .contextCompaction } - #expect(compactionEntries.count >= 2) - #expect(compactionEntries.last?.text == "Context automatically compacted") - #expect(compactionEntries.last?.metadata?.status == "completed") + let compactionItem = try #require(compactionItems.last) + #expect(compactionItem.phase == .completed) + #expect(contextCompactionTitle(compactionItem) == "Context automatically compacted") + #expect(contextCompactionStatus(compactionItem) == .completed) let renderedLog = reviewMonitorLogText(for: runningJob) #expect(renderedLog.contains("Context automatically compacted")) #expect(renderedLog.contains("Automatically compacting context") == false) @@ -1542,56 +1553,51 @@ extension ReviewUITests { let runningJob = try #require( store.orderedJobs.first(where: { $0.core.lifecycle.status == .running }) ) - let initialEntryCount = runningJob.logEntries.count + let initialItemCount = runningJob.timeline.items.count var tick = 0 tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.logEntries.count == initialEntryCount + 1) - #expect(runningJob.logEntries.last?.kind == .event) + #expect(runningJob.timeline.items.count == initialItemCount + 1) + #expect(runningJob.timeline.items.last?.kind == ReviewItemKind(rawValue: "event")) for _ in 0..<38 { tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) } - #expect(runningJob.logEntries.count == initialEntryCount + 1) + #expect(runningJob.timeline.items.count == initialItemCount + 1) tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.logEntries.count == initialEntryCount + 2) - #expect(runningJob.logEntries.last?.kind == .plan) + #expect(runningJob.timeline.items.count == initialItemCount + 2) + #expect(runningJob.timeline.items.last?.kind == .plan) - var observedEntryCount = runningJob.logEntries.count - for _ in 0..<180 where runningJob.logEntries.last?.kind != .reasoningSummary { + for _ in 0..<180 where runningJob.timeline.items.last?.kind != .reasoning { tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - observedEntryCount = runningJob.logEntries.count } - let firstReasoning = try #require(runningJob.logEntries.last) - #expect(firstReasoning.kind == .reasoningSummary) - let reasoningGroupID = firstReasoning.groupID - var reasoningChunkCount = 1 + let firstReasoning = try #require(runningJob.timeline.items.last) + #expect(firstReasoning.kind == .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 { + let item = try #require(runningJob.timeline.item(for: 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 = runningJob.timeline.items.count for _ in 0..<37 { tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) } - #expect(runningJob.logEntries.count == countAfterReasoning) + #expect(runningJob.timeline.items.count == countAfterReasoning) tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.logEntries.count == countAfterReasoning + 1) - #expect(runningJob.logEntries.last?.kind == .command) + #expect(runningJob.timeline.items.count == countAfterReasoning + 1) + #expect(runningJob.timeline.items.last?.kind == .commandExecution) } @Test func previewFirstWorkspaceShowsStructuredFindingsWhenSelected() async throws { @@ -1614,6 +1620,38 @@ extension ReviewUITests { } } +@MainActor +private func diagnosticMessage(_ item: ReviewTimelineItem) -> String { + if case .diagnostic(let diagnostic) = item.content { + return diagnostic.message + } + return "" +} + +@MainActor +private func reasoningText(_ item: ReviewTimelineItem) -> String { + if case .reasoning(let reasoning) = item.content { + return reasoning.text + } + return "" +} + +@MainActor +private func contextCompactionTitle(_ item: ReviewTimelineItem) -> String { + if case .contextCompaction(let contextCompaction) = item.content { + return contextCompaction.title + } + return "" +} + +@MainActor +private func contextCompactionStatus(_ item: ReviewTimelineItem) -> ReviewContextCompactionStatus? { + if case .contextCompaction(let contextCompaction) = item.content { + return contextCompaction.status + } + return nil +} + private func makeSidebarJobFilterDefaultsForTesting() throws -> (defaults: UserDefaults, suiteName: String) { let suiteName = "ReviewMonitorSidebarJobFilterDefaultsTests-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) From 0b02fa0ceda9f1fb9631cd9287b9bbafc5a8179d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:18:17 +0900 Subject: [PATCH 115/359] test(review-ui): seed monitor fixtures from timeline entries --- .../ReviewMonitorSelectedCodexChatTests.swift | 4 +- .../ReviewMonitorTimelineTesting.swift | 157 +++++++++++++-- Tests/ReviewUITests/ReviewUITests.swift | 186 +++++++++--------- 3 files changed, 236 insertions(+), 111 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index b720d68d..f693e678 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -200,7 +200,7 @@ struct ReviewMonitorSelectedCodexChatTests { job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" job.core.run.turnID = "turn-1" - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline trigger")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline trigger")) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" @@ -251,7 +251,7 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: "review-thread", turnID: "turn-1" ) - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline fallback")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline fallback")) uiState.selection = .job(job) let initialSnapshot = try await awaitTransportRender(transport) { snapshot in diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index b5fef43b..7d723b6b 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -1,8 +1,139 @@ import Foundation -import CodexReviewKit +@_spi(Testing) @testable import CodexReviewKit import ReviewMonitorRendering @_spi(PreviewSupport) @testable import ReviewUI +struct ReviewTimelineEntryForTesting: Sendable, Hashable { + enum Kind: String, Sendable, Hashable { + case command + case commandOutput + 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 + } +} + +extension CodexReviewJob { + @MainActor + static func makeForTesting( + id: String = UUID().uuidString, + sessionID: String = "session-1", + cwd: String = "/tmp/repo", + targetSummary: String, + model: String? = "gpt-5", + threadID: String? = nil, + turnID: String? = nil, + status: ReviewJobState, + cancellationRequested: Bool = false, + cancellation: ReviewCancellation? = nil, + startedAt: Date? = nil, + endedAt: Date? = nil, + summary: String, + hasFinalReview: Bool = false, + reviewResult: ParsedReviewResult? = nil, + lastAgentMessage: String? = "", + timelineEntries: [ReviewTimelineEntryForTesting], + errorMessage: String? = nil, + exitCode: Int? = nil + ) -> CodexReviewJob { + let job = CodexReviewJob.makeForTesting( + id: id, + sessionID: sessionID, + cwd: cwd, + targetSummary: targetSummary, + model: model, + threadID: threadID, + turnID: turnID, + status: status, + cancellationRequested: cancellationRequested, + cancellation: cancellation, + startedAt: startedAt, + endedAt: endedAt, + summary: summary, + hasFinalReview: hasFinalReview, + reviewResult: reviewResult, + lastAgentMessage: lastAgentMessage, + logEntries: [], + errorMessage: errorMessage, + exitCode: exitCode + ) + seedTimelineForTesting(job, entries: timelineEntries) + return job + } +} + @MainActor func seedTimelineForTesting( _ job: CodexReviewJob, @@ -32,17 +163,17 @@ func seedTimelineForTesting( } @MainActor -func seedTimelineForTesting(_ job: CodexReviewJob, logEntries: [ReviewLogEntry]) { +func seedTimelineForTesting(_ job: CodexReviewJob, entries: [ReviewTimelineEntryForTesting]) { guard job.timeline.items.isEmpty else { return } - for entry in logEntries { - appendTimelineLogEntryForTesting(job, entry) + for entry in entries { + appendTimelineEntryForTesting(job, entry) } } @MainActor -func appendTimelineLogEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewLogEntry) { +func appendTimelineEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewTimelineEntryForTesting) { let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content let phase = timelinePhase(for: entry) @@ -76,7 +207,7 @@ func reviewMonitorLogText(for job: CodexReviewJob) -> String { return projection.render(timelineDocument: timelineDocument).text } -private func timelineKind(for entry: ReviewLogEntry) -> ReviewItemKind { +private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { switch entry.kind { case .command, .commandOutput: .commandExecution @@ -95,7 +226,7 @@ private func timelineKind(for entry: ReviewLogEntry) -> ReviewItemKind { } } -private func timelineFamily(for entry: ReviewLogEntry) -> ReviewItemFamily { +private func timelineFamily(for entry: ReviewTimelineEntryForTesting) -> ReviewItemFamily { switch entry.kind { case .command, .commandOutput: .command @@ -115,7 +246,7 @@ private func timelineFamily(for entry: ReviewLogEntry) -> ReviewItemFamily { } private func timelineContent( - for entry: ReviewLogEntry, + for entry: ReviewTimelineEntryForTesting, existing: ReviewTimelineItem.Content? ) -> ReviewTimelineItem.Content { switch entry.kind { @@ -199,7 +330,7 @@ private func existingText(_ content: ReviewTimelineItem.Content?, diagnostic def return defaultValue } -private func commandText(for entry: ReviewLogEntry) -> String { +private func commandText(for entry: ReviewTimelineEntryForTesting) -> String { if let command = entry.metadata?.command, command.isEmpty == false { return command } @@ -209,7 +340,7 @@ private func commandText(for entry: ReviewLogEntry) -> String { return entry.text } -private func timelinePhase(for entry: ReviewLogEntry) -> ReviewItemPhase { +private func timelinePhase(for entry: ReviewTimelineEntryForTesting) -> ReviewItemPhase { let status = entry.metadata?.commandStatus ?? entry.metadata?.status switch status { case "inProgress", "running", "started": @@ -223,7 +354,7 @@ private func timelinePhase(for entry: ReviewLogEntry) -> ReviewItemPhase { } } -private func commandStatus(for entry: ReviewLogEntry) -> ReviewCommandStatus? { +private func commandStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewCommandStatus? { guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { return nil } @@ -235,7 +366,7 @@ private func commandStatus(for entry: ReviewLogEntry) -> ReviewCommandStatus? { } } -private func contextCompactionStatus(for entry: ReviewLogEntry) -> ReviewContextCompactionStatus? { +private func contextCompactionStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewContextCompactionStatus? { guard let rawValue = entry.metadata?.status else { return nil } @@ -249,7 +380,7 @@ private func contextCompactionStatus(for entry: ReviewLogEntry) -> ReviewContext } } -private func toolCallStatus(for entry: ReviewLogEntry) -> ReviewToolCallStatus? { +private func toolCallStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewToolCallStatus? { guard let rawValue = entry.metadata?.status else { return nil } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index d6ec9d94..ef24bba7 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2371,8 +2371,8 @@ struct ReviewUITests { #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")]) - appendTimelineLogEntryForTesting( + replaceTimelineLogTextForTesting(activeJob, "Old selection log") + appendTimelineEntryForTesting( recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in @@ -3284,7 +3284,7 @@ struct ReviewUITests { summary: "Review completed.", hasFinalReview: true, lastAgentMessage: "No correctness issues found.", - logEntries: [ + timelineEntries: [ .init( kind: .command, groupID: "cmd_1", @@ -3353,7 +3353,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [] + timelineEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -3370,7 +3370,6 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) - let initialLogEntries = job.logEntries job.timeline.apply( .itemCompleted( @@ -3384,7 +3383,6 @@ struct ReviewUITests { 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( @@ -3414,7 +3412,6 @@ struct ReviewUITests { #expect(snapshot.log.contains("Ran swift test for 2s")) #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") @@ -3438,7 +3435,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [] + timelineEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -3499,7 +3496,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [] + timelineEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -3559,7 +3556,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [] + timelineEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -3901,7 +3898,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [] + timelineEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -3963,7 +3960,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init( kind: .contextCompaction, groupID: "compact_1", @@ -3997,7 +3994,7 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("Automatically compacting context")) #expect(transport.logCommandOutputPanelCountForTesting == 0) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .contextCompaction, @@ -4022,7 +4019,7 @@ struct ReviewUITests { let outputText = (1...9) .map { "output line \($0)" } .joined(separator: "\n") - let commandMetadata = ReviewLogEntry.Metadata( + let commandMetadata = ReviewTimelineEntryForTesting.Metadata( sourceType: "command", title: "Ran command for 17s", status: "succeeded", @@ -4039,7 +4036,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( kind: .commandOutput, @@ -4123,7 +4120,7 @@ struct ReviewUITests { transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) #expect(scrolledOutputScrollOffset < initialOutputScrollMaximumOffset) let expandedOutputAppendReloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .commandOutput, @@ -4159,7 +4156,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("output line 9") == false) #expect(transport.logFindStringForTesting.contains("output line 9") == false) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .commandOutput, @@ -4167,7 +4164,7 @@ struct ReviewUITests { text: "\noutput line 11", metadata: commandMetadata )) - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) _ = try await awaitTransportRender(transport) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 11") == true) @@ -4191,7 +4188,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( kind: .commandOutput, @@ -4255,7 +4252,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test") ] ) @@ -4279,7 +4276,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("Running swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .commandOutput, @@ -4314,7 +4311,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init(kind: .commandOutput, groupID: "cmd_1", text: outputText), ] @@ -4354,7 +4351,7 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("$ swift test") == false) #expect(transport.logFindStringForTesting.contains("output line 3") == false) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6")) _ = try await awaitTransportRender(transport) await awaitNativeLayoutTurn() @@ -4527,7 +4524,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) - appendTimelineLogEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) + appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) let snapshot = try await awaitTransportRender(transport) @@ -4723,8 +4720,8 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) - appendTimelineLogEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) + appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) + appendTimelineEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("fresh update") @@ -4913,7 +4910,7 @@ struct ReviewUITests { #expect(window.title == "") #expect(window.subtitle == "") job.updateStateForTesting(summary: "Deselected summary") - job.replaceLogEntries([.init(kind: .agentMessage, text: "Deselected log")]) + replaceTimelineLogTextForTesting(job, "Deselected log") #expect(contentPane.selectedJobObservationForTesting == nil) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) @@ -4963,7 +4960,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -4982,7 +4979,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "Initial log") @@ -5001,7 +4998,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -5014,7 +5011,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) let wordGlowCount = transport.logWordGlowCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.hasSuffix("stream.tick 001")) @@ -5033,7 +5030,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: decomposedPrefix) ] ) @@ -5065,7 +5062,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -5077,8 +5074,8 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "Initial one two") @@ -5094,7 +5091,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -5106,8 +5103,8 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) + appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log.hasSuffix("stream.tick 002")) @@ -5123,7 +5120,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -5137,8 +5134,8 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) transport.setLogReduceMotionForTesting(false) let wordGlowCount = transport.logWordGlowCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + appendTimelineEntryForTesting( job, .init( kind: .progress, @@ -5176,7 +5173,7 @@ struct ReviewUITests { #expect( abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .progress, @@ -5203,7 +5200,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) @@ -5218,7 +5215,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) let snapshot = try await awaitTransportRender(transport) @@ -5240,7 +5237,7 @@ struct ReviewUITests { status: .running, startedAt: startedAt, summary: "Running review.", - logEntries: [ + timelineEntries: [ .init( kind: .command, groupID: "cmd_1", @@ -5266,7 +5263,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .command, @@ -5285,7 +5282,7 @@ struct ReviewUITests { commandStatus: "completed" ) )) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .rawReasoning, @@ -5309,7 +5306,7 @@ struct ReviewUITests { status: .running, startedAt: startedAt, summary: "Running review.", - logEntries: [ + timelineEntries: [ .init( kind: .rawReasoning, groupID: "reasoning_1", @@ -5329,7 +5326,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .command, @@ -5344,7 +5341,7 @@ struct ReviewUITests { commandStatus: "inProgress" ) )) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .rawReasoning, @@ -5370,7 +5367,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "**bo") ] ) @@ -5385,7 +5382,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) + appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) let snapshot = try await awaitTransportRender(transport) #expect(snapshot.log == "bold") @@ -5404,7 +5401,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) @@ -5416,7 +5413,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .plan, @@ -5428,7 +5425,7 @@ struct ReviewUITests { let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) + appendTimelineEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Command output") } @@ -5477,7 +5474,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -5490,7 +5487,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) transport.setLogReduceMotionForTesting(false) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) _ = try await awaitTransportRender(transport) @@ -5499,13 +5496,13 @@ struct ReviewUITests { transport.completeLogWordGlowAnimationsForTesting() #expect(transport.logWordGlowCountForTesting == 0) - appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) + appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 1) transport.setLogReduceMotionForTesting(true) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) _ = try await awaitTransportRender(transport) @@ -5522,7 +5519,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -5535,7 +5532,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 201), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Other job") ] ) @@ -5551,14 +5548,14 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) _ = try await awaitTransportRender(transport) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting == 0) - appendTimelineLogEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) + appendTimelineEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting > 0) } @@ -5573,7 +5570,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -5589,7 +5586,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) _ = try await awaitTransportRender(transport) @@ -5615,7 +5612,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - logEntries: [ + timelineEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -5630,7 +5627,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) transport.setLogReduceMotionForTesting(false) - appendTimelineLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) _ = try await awaitTransportRender(transport) #expect(transport.logWordGlowCountForTesting > 0) @@ -5667,7 +5664,7 @@ struct ReviewUITests { transport.scrollLogToTopForTesting() #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == unpinnedAutoFollow) #expect(transport.isLogPinnedToBottomForTesting == false) @@ -5675,7 +5672,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) let pinnedAutoFollow = transport.logAutoFollowCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) @@ -5706,7 +5703,7 @@ struct ReviewUITests { let wrappedLine = (0..<140) .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) _ = try await awaitTransportRender(transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) @@ -5745,7 +5742,7 @@ struct ReviewUITests { let offsetBeforeAppend = transport.logVerticalScrollOffsetForTesting let autoFollowBeforeAppend = transport.logAutoFollowCountForTesting let programmaticScrollsBeforeAppend = transport.logProgrammaticScrollCountForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init( kind: .progress, @@ -5785,7 +5782,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.isLogPinnedToBottomForTesting) @@ -5818,7 +5815,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5848,7 +5845,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "short update")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "short update")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5922,7 +5919,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -5954,7 +5951,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) _ = try await awaitTransportRender(transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) @@ -6044,7 +6041,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) _ = try await awaitTransportRender(transport) let appendCount = transport.logAppendCountForTesting - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) _ = try await awaitTransportRender(transport) #expect(transport.logAppendCountForTesting == appendCount + 1) @@ -6216,7 +6213,7 @@ struct ReviewUITests { } #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) let appendedLength = (reviewMonitorLogText(for: job) as NSString).length @@ -6242,7 +6239,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting == false) let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .progress, text: "needle appended while the log is not following bottom")) _ = try await awaitTransportRender(transport) @@ -6346,7 +6343,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineLogEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) + appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6398,7 +6395,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineLogEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) + appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) _ = try await awaitTransportRender(transport) #expect( transport.displayedLogForTesting.trimmingCharacters(in: .newlines) @@ -6489,7 +6486,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) _ = try await awaitTransportRender(transport) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6526,7 +6523,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6536,7 +6533,7 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logSelectedTextForTesting == nil) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6570,7 +6567,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6581,7 +6578,7 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logHasActiveFindQueryForTesting == false) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6617,7 +6614,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6654,7 +6651,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("core")) #expect(transport.logVisibleFindBarSearchStringForTesting == "core") #expect(transport.logHasActiveFindQueryForTesting) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -6693,7 +6690,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("alpha")) #expect(transport.logFindStringLengthForTesting == initialLength) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) _ = try await awaitTransportRender(transport) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindStringLengthForTesting == initialLength) @@ -6737,7 +6734,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(normalSelectionRange) #expect(transport.logSelectedTextForTesting == "copyable") #expect(transport.logHasActiveFindQueryForTesting == false) - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .progress, text: "needle appended after normal selection")) _ = try await awaitTransportRender(transport) @@ -6777,7 +6774,7 @@ struct ReviewUITests { #expect(transport.logHasActiveFindQueryForTesting) let initialLength = (reviewMonitorLogText(for: job) as NSString).length - appendTimelineLogEntryForTesting( + appendTimelineEntryForTesting( job, .init(kind: .progress, text: "active query appears after no-result search")) _ = try await awaitTransportRender(transport) @@ -6835,7 +6832,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) - appendTimelineLogEntryForTesting(job, .init(kind: .progress, text: "needle first content")) + appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle first content")) _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) @@ -7339,7 +7336,7 @@ func makeJob( summary: summary ?? status.displayText, reviewResult: reviewResult, lastAgentMessage: "", - logEntries: [], + timelineEntries: [], errorMessage: status == .failed ? summary ?? status.displayText : nil ) seedTimelineForTesting(job, logText: logText, rawLogText: rawLogText) @@ -7361,9 +7358,6 @@ func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] { @MainActor func makeSidebarContent(from jobs: [CodexReviewJob]) -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) { - for job in jobs { - seedTimelineForTesting(job, logEntries: job.logEntries) - } return (makeWorkspaces(from: jobs), Array(jobs.reversed())) } From e05ce25f38cec3852c655272a381cbb07479ec30 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:22:21 +0900 Subject: [PATCH 116/359] refactor(review-core): hide legacy log buffer surface --- Docs/architecture.md | 9 ++- .../CodexReviewKit/Model/CodexReviewJob.swift | 12 +-- .../Model/CodexReviewJobTesting.swift | 6 +- .../CodexReviewKit/Model/ReviewLogEntry.swift | 80 +++++++++---------- .../Store/CodexReviewStoreReviews.swift | 3 +- 5 files changed, 54 insertions(+), 56 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index e37e6132..124ab20a 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -76,10 +76,11 @@ the store backend. `ReviewTimeline` and `CodexDataKit` model state are the observable sources of truth after conversion. ReviewMonitor UI should render selected chats through the `CodexChat` snapshot/change stream and `ReviewTimelineDocument` projection. -`ReviewLogEntry` is not a ReviewMonitor rendering contract and must not rebuild -or author semantic timeline state. Rendering helpers and MCP server projections -project from semantic state; they do not parse raw app-server events and do not -make string logs authoritative again. +Legacy log buffering is package-internal compatibility plumbing only. It is not +a ReviewMonitor rendering contract and must not rebuild or author semantic +timeline state. Rendering helpers and MCP server projections project from +semantic state; they do not parse raw app-server events and do not make string +logs authoritative again. Collection/list style UI observes source-of-truth models through ObservationBridge. Large transcript/log surfaces consume stream APIs that emit diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 93ad7a48..59eba950 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -43,12 +43,12 @@ public final class CodexReviewJob: Identifiable, Hashable { private var pendingDirectTimelineTextItemIDsForLogRetention: [ReviewTimelineItem.ID] @ObservationIgnored private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID] - 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 logEntries: [ReviewLogEntry] + package private(set) var logText: String + package private(set) var rawLogText: String + package private(set) var reviewOutputText: String + package private(set) var activityLogText: String + package private(set) var diagnosticText: String package private(set) var cappedLogBytes: Int package private(set) var logRevision: UInt64 @ObservationIgnored diff --git a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift b/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift index e3cd508a..374c084e 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift @@ -1,8 +1,7 @@ import Foundation extension CodexReviewJob { - @_spi(Testing) - public static func makeForTesting( + package static func makeForTesting( id: String = UUID().uuidString, sessionID: String = "session-1", cwd: String = "/tmp/repo", @@ -55,8 +54,7 @@ extension CodexReviewJob { ) } - @_spi(Testing) - public func updateStateForTesting( + package func updateStateForTesting( targetSummary: String? = nil, status: ReviewJobState? = nil, endedAt: Date? = nil, diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntry.swift b/Sources/CodexReviewKit/Model/ReviewLogEntry.swift index aed76a6e..62c21916 100644 --- a/Sources/CodexReviewKit/Model/ReviewLogEntry.swift +++ b/Sources/CodexReviewKit/Model/ReviewLogEntry.swift @@ -1,22 +1,22 @@ 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 { +package struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable { + package struct Metadata: Codable, Sendable, Hashable { + package struct CommandAction: Codable, Sendable, Hashable { + package 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? + package let kind: Kind + package let command: String? + package let name: String? + package let path: String? + package let query: String? - public init( + package init( kind: Kind, command: String? = nil, name: String? = nil, @@ -31,28 +31,28 @@ public struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable { } } - 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? + package let sourceType: String + package let title: String? + package let status: String? + package let detail: String? + package let itemID: String? + package let command: String? + package let cwd: String? + package let exitCode: Int? + package let startedAt: Date? + package let completedAt: Date? + package let durationMs: Int? + package let commandActions: [CommandAction]? + package let commandStatus: String? + package let namespace: String? + package let server: String? + package let tool: String? + package let query: String? + package let path: String? + package let resultText: String? + package let errorText: String? - public init( + package init( sourceType: String, title: String? = nil, status: String? = nil, @@ -97,7 +97,7 @@ public struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable { } } - public enum Kind: String, Codable, Sendable, Hashable { + package enum Kind: String, Codable, Sendable, Hashable { case agentMessage case command case commandOutput @@ -114,15 +114,15 @@ public struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable { 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 + package let id: UUID + package let kind: Kind + package let groupID: String? + package let replacesGroup: Bool + package let text: String + package let metadata: Metadata? + package let timestamp: Date - public init( + package init( id: UUID = UUID(), kind: Kind, groupID: String? = nil, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index d2843704..c85c278f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -73,8 +73,7 @@ extension CodexReviewStore { core: .init( lifecycle: .init(status: .queued), output: .init(summary: "Queued.") - ), - logEntries: [] + ) ) insertReviewJob(job) markReviewRunning(job, startedAt: createdAt) From 2bea2f1aff5f9a6bdf50111a698bf1cb72d98f8a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:33:06 +0900 Subject: [PATCH 117/359] refactor(review-core): derive diagnostics from timeline --- .../Domain/ReviewTimelineTextSnapshot.swift | 123 ++++++++++++++++++ .../Store/CodexReviewStore.swift | 11 +- .../Store/CodexReviewStoreDiagnostics.swift | 4 +- 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift diff --git a/Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift new file mode 100644 index 00000000..1f9ecc8d --- /dev/null +++ b/Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift @@ -0,0 +1,123 @@ +import Foundation + +package struct ReviewTimelineTextSnapshot: Sendable, Equatable { + package var timelineText: String + package var rawTimelineText: String +} + +package extension ReviewTimeline { + @MainActor + var textSnapshot: ReviewTimelineTextSnapshot { + var timelineSegments: [String] = [] + var rawTimelineSegments: [String] = [] + + for item in items { + if let text = item.timelineTextSegment?.nilIfEmpty { + timelineSegments.append(text) + } + if let text = item.rawTimelineTextSegment?.nilIfEmpty { + rawTimelineSegments.append(text) + } + } + + if let terminalSummary = terminalSummary?.nilIfEmpty { + timelineSegments.append(terminalSummary) + rawTimelineSegments.append(terminalSummary) + } + if let terminalResult = terminalResult?.nilIfEmpty, + terminalResult != terminalSummary + { + timelineSegments.append(terminalResult) + rawTimelineSegments.append(terminalResult) + } + + return .init( + timelineText: timelineSegments.joined(separator: "\n\n"), + rawTimelineText: rawTimelineSegments.joined(separator: "\n\n") + ) + } +} + +private extension ReviewTimelineItem { + var timelineTextSegment: String? { + switch content { + case .approval(let approval): + return [approval.title, approval.detail].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") + case .command(let command): + return command.command.nilIfEmpty.map { command.phaseTitle(command: $0, phase: phase) } + 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.result?.nilIfEmpty ?? search.query + case .toolCall(let toolCall): + return toolCall.error?.nilIfEmpty + ?? toolCall.result?.nilIfEmpty + ?? toolCall.progress?.nilIfEmpty + ?? [toolCall.namespace, toolCall.server, toolCall.tool] + .compactMap { $0?.nilIfEmpty } + .joined(separator: ".") + .nilIfEmpty + case .unknown(let unknown): + return [unknown.title, unknown.detail].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") + } + } + + var rawTimelineTextSegment: String? { + switch content { + 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 .fileChange(let fileChange): + return fileChange.output.isEmpty ? fileChange.title : "\(fileChange.title)\n\(fileChange.output)" + case .search(let search): + return [search.query, search.result].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") + case .toolCall(let toolCall): + return [ + [toolCall.namespace, toolCall.server, toolCall.tool] + .compactMap { $0?.nilIfEmpty } + .joined(separator: ".") + .nilIfEmpty, + toolCall.progress?.nilIfEmpty, + toolCall.result?.nilIfEmpty, + toolCall.error?.nilIfEmpty, + ] + .compactMap { $0 } + .joined(separator: "\n") + case .approval, + .contextCompaction, + .diagnostic, + .message, + .plan, + .reasoning, + .unknown: + return timelineTextSegment + } + } +} + +private extension ReviewTimelineItem.Command { + func phaseTitle(command: String, phase: ReviewItemPhase) -> String { + switch status { + case .some(.completed): + return "Ran \(command)" + case .some(.failed): + return "Command failed: \(command)" + case .some(.cancelled): + return "Command cancelled: \(command)" + default: + return phase.isTerminal ? "Ran \(command)" : "Running \(command)" + } + } +} diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 0c63c452..ae50fe3c 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -363,12 +363,13 @@ public final class CodexReviewStore { guard let diagnosticsURL else { return } - let jobs = orderedJobs.map { job in - CodexReviewStoreDiagnosticsSnapshot.Job( + let jobs: [CodexReviewStoreDiagnosticsSnapshot.Job] = orderedJobs.map { job in + let timelineTextSnapshot = job.timeline.textSnapshot + return CodexReviewStoreDiagnosticsSnapshot.Job( status: job.core.lifecycle.status.rawValue, summary: job.core.output.summary, - logText: job.logText, - rawLogText: job.rawLogText + timelineText: timelineTextSnapshot.timelineText, + rawTimelineText: timelineTextSnapshot.rawTimelineText ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( @@ -386,7 +387,7 @@ 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 {} } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index fcca7a23..671f8cc5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -17,8 +17,8 @@ struct CodexReviewStoreDiagnosticsSnapshot: Encodable { struct Job: Encodable { var status: String var summary: String - var logText: String - var rawLogText: String + var timelineText: String + var rawTimelineText: String } var serverState: String From 636376f69e098924cf37d6a216389dd1333d22b1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:26:35 +0900 Subject: [PATCH 118/359] refactor(review-core): remove legacy review log entries --- .../AppServerCodexReviewBackend.swift | 272 +-- .../ReviewBackendEventSession.swift | 352 +-- .../CodexReviewKit/CodexReviewBackend.swift | 5 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 11 +- .../Domain/ReviewTimeline.swift | 51 +- .../Domain/ReviewTimelineItem.swift | 81 + .../CodexReviewKit/Model/CodexReviewJob.swift | 400 +--- .../Model/CodexReviewJobTesting.swift | 4 +- .../Model/ReviewLogBuffer.swift | 987 -------- .../CodexReviewKit/Model/ReviewLogEntry.swift | 143 -- .../Model/ReviewTimelineTextRetention.swift | 268 --- .../Store/CodexReviewStoreCancellation.swift | 23 +- .../Store/CodexReviewStoreReviews.swift | 219 +- Sources/CodexReviewTesting/TestSupport.swift | 68 +- .../AppServerClientTests.swift | 71 +- .../ReviewBackendEventSessionTests.swift | 68 +- .../CodexReviewJobRenderingTests.swift | 205 -- .../CodexReviewJobTests.swift | 59 + .../CodexReviewStoreCommandTests.swift | 2038 +---------------- .../CodexReviewMCPHTTPServerTests.swift | 37 +- .../ReviewMonitorTimelineTesting.swift | 123 +- 21 files changed, 626 insertions(+), 4859 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/ReviewLogBuffer.swift delete mode 100644 Sources/CodexReviewKit/Model/ReviewLogEntry.swift delete mode 100644 Sources/CodexReviewKit/Model/ReviewTimelineTextRetention.swift delete mode 100644 Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift create mode 100644 Tests/CodexReviewKitTests/CodexReviewJobTests.swift diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index ecd22bac..d517e66d 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -503,14 +503,9 @@ private enum AppServerTypedReviewEventAdapter { .init(events: [.failed("Review thread is no longer loaded.")], controlThreadID: controlThreadID) case .statusChanged(.unknown(let status)): .init( - events: [ - .logEntry( - kind: .diagnostic, - text: "Review thread status changed: \(status).", - groupID: review.turnID.rawValue, - replacesGroup: false - ) - ], controlThreadID: controlThreadID) + events: unknownStatusEvents(status, turnID: review.turnID.rawValue), + controlThreadID: controlThreadID + ) case .closed: .init(events: [.failed("Review thread closed.")], controlThreadID: controlThreadID) case .unknown(let raw): @@ -540,6 +535,20 @@ private enum AppServerTypedReviewEventAdapter { ] } + private static func unknownStatusEvents( + _ status: String, + turnID: String + ) -> [CodexReviewBackendModel.Review.Event] { + let seed = ReviewTimelineItemSeed( + id: .init(rawValue: "\(turnID):status"), + kind: .init(rawValue: "reviewThreadStatus"), + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: "Review thread status changed: \(status).")) + ) + return [.domainEvents([.itemCompleted(seed)])] + } + private static func terminalFailureEvents( status: CodexTurnStatus?, message: String @@ -581,13 +590,9 @@ private enum AppServerTypedReviewEventAdapter { case .completed: .itemCompleted(seed) } - let logEvents = logEntryEvents(for: item, phase: phase) return [ - .domainEvents( - [domainEvent], - retainedLogEntryCount: logEvents.immediateTimelineTextRetentionCount - ) - ] + logEvents.addingTerminalFailureTimelineTextRetentionIfNeeded + .domainEvents([domainEvent]) + ] } private static func messageEvents( @@ -621,8 +626,7 @@ private enum AppServerTypedReviewEventAdapter { delta: delta.text ) return [ - .domainEvents([domainEvent], retainedLogEntryCount: 1), - .messageDelta(delta.text, itemID: itemID), + .domainEvents([domainEvent]) ] } @@ -643,7 +647,7 @@ private enum AppServerTypedReviewEventAdapter { phase: .running, content: .reasoning(.init(text: "", style: style)) ) - return [.domainEvents([.itemStarted(seed)], retainedLogEntryCount: 0)] + return [.domainEvents([.itemStarted(seed)])] } private static func reasoningDeltaEvents( @@ -652,16 +656,13 @@ private enum AppServerTypedReviewEventAdapter { guard delta.delta.isEmpty == false else { return [] } - let style: ReviewTimelineItem.Reasoning.Style - let kind: ReviewLogEntry.Kind - switch delta.part.kind { - case .summary: - style = .summary - kind = .reasoningSummary - case .text: - style = .raw - kind = .rawReasoning - } + let style: ReviewTimelineItem.Reasoning.Style = + switch delta.part.kind { + case .summary: + .summary + case .text: + .raw + } let domainEvent = ReviewDomainEvent.textDelta( itemID: .init(rawValue: delta.id), kind: .reasoning, @@ -669,10 +670,7 @@ private enum AppServerTypedReviewEventAdapter { content: .reasoning(.init(text: "", style: style)), delta: delta.delta ) - return [ - .domainEvents([domainEvent], retainedLogEntryCount: 1), - .logEntry(kind: kind, text: delta.delta, groupID: delta.id, replacesGroup: false), - ] + return [.domainEvents([domainEvent])] } private static func unknownEvents( @@ -695,7 +693,7 @@ private enum AppServerTypedReviewEventAdapter { rawKind: .init(rawValue: raw.method) )) ) - return [.domainEvents([.itemUpdated(seed)], retainedLogEntryCount: 0)] + return [.domainEvents([.itemUpdated(seed)])] } private static func timelineSeed( @@ -711,137 +709,6 @@ private enum AppServerTypedReviewEventAdapter { ) } - private static func logEntryEvents( - for item: CodexThreadItem, - phase: AppServerTypedItemPhase - ) -> [CodexReviewBackendModel.Review.Event] { - switch item.content { - case .message(let message): - guard message.role != .user, - message.text.isEmpty == false - else { - return [] - } - return [ - item.logEntry( - kind: .agentMessage, - text: message.text, - phase: phase, - title: nil - ) - ] - case .plan(let text): - return text.nilIfEmpty.map { - [item.logEntry(kind: .plan, text: $0, phase: phase, title: nil)] - } ?? [] - case .reasoning(let reasoning): - let summaryEvents = reasoning.summary.enumerated().compactMap { index, text in - text.nilIfEmpty.map { - CodexReviewBackendModel.Review.Event.logEntry( - kind: .reasoningSummary, - text: $0, - groupID: reasoningSummaryGroupID(itemID: item.id, summaryIndex: index), - replacesGroup: phase != .started - ) - } - } - let rawEvents = reasoning.content.enumerated().compactMap { index, text in - text.nilIfEmpty.map { - CodexReviewBackendModel.Review.Event.logEntry( - kind: .rawReasoning, - text: $0, - groupID: rawReasoningGroupID(itemID: item.id, contentIndex: index), - replacesGroup: phase != .started - ) - } - } - return summaryEvents + rawEvents - case .command(let command): - return commandLogEvents(item: item, command: command, phase: phase) - case .fileChange(let fileChange): - let text = - fileChange.output?.nilIfEmpty - ?? fileChange.path?.nilIfEmpty - ?? "File changes \(item.logStatus(phase: phase) ?? "updated")." - let kind: ReviewLogEntry.Kind = fileChange.output?.nilIfEmpty == nil ? .toolCall : .commandOutput - return [ - item.logEntry( - kind: kind, - text: text, - phase: phase, - title: "File changes" - ) - ] - case .toolCall(let toolCall): - let label = item.toolLabel - let text = - toolCall.error?.nilIfEmpty - ?? toolCall.result?.nilIfEmpty - ?? "\(label) \(item.logStatus(phase: phase) ?? "updated")." - return [ - item.logEntry( - kind: .toolCall, - text: text, - phase: phase, - title: label - ) - ] - case .contextCompaction(let text): - return [ - item.logEntry( - kind: .contextCompaction, - text: text?.nilIfEmpty ?? contextCompactionText(phase: phase, status: item.logStatus(phase: phase)), - phase: phase, - title: nil - ) - ] - case .diagnostic(let text): - return text.nilIfEmpty.map { - [item.logEntry(kind: .diagnostic, text: $0, phase: phase, title: nil)] - } ?? [] - case .log(let text): - return text.nilIfEmpty.map { - [item.logEntry(kind: .event, text: $0, phase: phase, title: item.kind.rawValue)] - } ?? [] - case .unknown(let raw): - return raw.text?.nilIfEmpty.map { - [item.logEntry(kind: .event, text: $0, phase: phase, title: raw.rawType)] - } ?? [] - } - } - - private static func commandLogEvents( - item: CodexThreadItem, - command: CodexCommand, - phase: AppServerTypedItemPhase - ) -> [CodexReviewBackendModel.Review.Event] { - var events: [CodexReviewBackendModel.Review.Event] = [] - if command.command.isEmpty == false { - events.append( - item.logEntry( - kind: .command, - text: "$ \(command.command)", - phase: phase, - title: nil - )) - } - let output = command.output?.nilIfEmpty - if let output { - if command.command.isEmpty { - events.append(item.commandOutputDeltaLogEntry(text: output)) - } else { - events.append( - item.logEntry( - kind: .commandOutput, - text: output, - phase: phase, - title: nil - )) - } - } - return events - } - private static func contextCompactionText( phase: AppServerTypedItemPhase, status: String? @@ -985,16 +852,6 @@ private extension CodexThreadItem { } } - var toolLabel: String { - guard case .toolCall(let toolCall) = content else { - return kind.rawValue - } - return [toolCall.namespace, toolCall.server, toolCall.name] - .compactMap { $0?.nilIfEmpty } - .joined(separator: ".") - .nilIfEmpty ?? kind.rawValue - } - func logStatus(phase: AppServerTypedItemPhase) -> String? { if let status = statusRaw?.nilIfEmpty { return status @@ -1008,75 +865,6 @@ private extension CodexThreadItem { "completed" } } - - func logEntry( - kind logKind: ReviewLogEntry.Kind, - text: String, - phase: AppServerTypedItemPhase, - title: String? - ) -> CodexReviewBackendModel.Review.Event { - .logEntry( - kind: logKind, - text: text, - groupID: id, - replacesGroup: phase != .started, - metadata: reviewLogMetadata(title: title, phase: phase) - ) - } - - func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { - .logEntry( - kind: .commandOutput, - text: text, - groupID: id, - replacesGroup: false, - metadata: reviewLogMetadata(title: "Command output", phase: .updated) - ) - } - - private func reviewLogMetadata( - title: String?, - phase: AppServerTypedItemPhase - ) -> ReviewLogEntry.Metadata { - let status = logStatus(phase: phase) - let command: CodexCommand? - let fileChange: CodexFileChange? - let toolCall: CodexToolCall? - switch content { - case .command(let value): - command = value - fileChange = nil - toolCall = nil - case .fileChange(let value): - command = nil - fileChange = value - toolCall = nil - case .toolCall(let value): - command = nil - fileChange = nil - toolCall = value - case .contextCompaction, .diagnostic, .log, .message, .plan, .reasoning, .unknown: - command = nil - fileChange = nil - toolCall = nil - } - return .init( - sourceType: kind.rawValue, - title: title?.nilIfEmpty, - status: status, - itemID: kind == .commandExecution || kind == .contextCompaction ? id : nil, - command: command?.command, - cwd: command?.cwd, - exitCode: command?.exitCode, - commandStatus: kind == .commandExecution ? status : nil, - namespace: toolCall?.namespace, - server: toolCall?.server, - tool: toolCall?.name, - path: fileChange?.path, - resultText: toolCall?.result?.nilIfEmpty, - errorText: toolCall?.error?.nilIfEmpty - ) - } } private actor AppServerReviewEventSession { diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index c465cf84..7b6214de 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -35,17 +35,10 @@ package struct ReviewBackendEventSessionCallbacks: Sendable { } package actor ReviewBackendEventSession { - 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 mailbox: BackendReviewEventMailbox private let callbacks: ReviewBackendEventSessionCallbacks private var reviewThreadIDsForCleanup: [String] = [] - private var pendingStreamedLogEntries: [PendingStreamedLogEntry] = [] - private var pendingStreamedLogIndexByKey: [PendingStreamedLogEntry.Key: Int] = [:] - private var streamedLogFlushTask: Task? private var cancellationRequestedMessage: String? private let completionCoordinator = ReviewCompletionCoordinator() private let createdAt = Date() @@ -97,22 +90,14 @@ package actor ReviewBackendEventSession { } package func finish(cancellationMessage: String?) async { - let precedingEvents = drainPendingStreamedLogEvents() - if cancellationMessage == nil { - cancelPendingStreamedLogFlush() - } else { - cancellationRequestedMessage = cancellationMessage - } - await finish(precedingEvents: precedingEvents, cancellationMessage: cancellationMessage) + cancellationRequestedMessage = cancellationMessage + await finishSession(cancellationMessage: cancellationMessage) } package func finish(throwing error: (any Error)?) async { guard finished == false else { return } - let precedingEvents = drainPendingStreamedLogEvents() - cancelPendingStreamedLogFlush() - await emitPrecedingEvents(precedingEvents) if let error { finished = true completionCoordinator.cancelPendingCompletion() @@ -133,9 +118,6 @@ package actor ReviewBackendEventSession { } finished = true completionCoordinator.cancelPendingCompletion() - cancelPendingStreamedLogFlush() - pendingStreamedLogEntries.removeAll(keepingCapacity: true) - pendingStreamedLogIndexByKey.removeAll(keepingCapacity: true) await mailbox.abandon() } @@ -157,30 +139,7 @@ package actor ReviewBackendEventSession { return } metrics.decoded += 1 - let events = eventsWithTypedResultFallback(events) - for index in events.indices { - let event = events[index] - if case .domainEvents = event { - let followingEvents = events[events.index(after: index)...] - if shouldFlushPendingStreamedLogBeforeDomainEvent( - followingEvents: followingEvents, - retainTimelineText: false - ), - await flushPendingStreamedLog(controlThreadID: controlThreadID) - { - return - } - if await emit(event, controlThreadID: controlThreadID) { - return - } - continue - } - if bufferStreamedLog(event) { - continue - } - if await flushPendingStreamedLog(controlThreadID: controlThreadID) { - return - } + for event in eventsWithTypedResultFallback(events) { if event.shouldDeferReviewBackendCompletion { completionCoordinator.deferCompletion(event) continue @@ -208,19 +167,13 @@ package actor ReviewBackendEventSession { typedReviewResultText = text } return event - case .logEntry(.agentMessage, let text, _, _, _): - if let text = text.nilIfEmpty { - typedReviewResultText = text - } + case .domainEvents(let events): + recordTypedMessageText(from: events) return event case .completed(let summary, nil): return .completed(summary: summary, result: typedReviewResultText) - case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, - .started, + case .started, .log, - .logEntry, .completed, .failed, .cancelled: @@ -229,16 +182,46 @@ package actor ReviewBackendEventSession { } } - private func finish( - precedingEvents: [CodexReviewBackendModel.Review.Event], - cancellationMessage: String? - ) async { + private func recordTypedMessageText(from events: [ReviewDomainEvent]) { + for event in events { + switch event { + case .itemStarted(let seed), + .itemUpdated(let seed), + .itemCompleted(let seed): + recordTypedMessageText(from: seed) + case .textDelta(let itemID, _, let family, _, let delta) where family == .message: + let text = (typedMessageTextByItemID[itemID.rawValue] ?? "") + delta + typedMessageTextByItemID[itemID.rawValue] = text + if let text = text.nilIfEmpty { + typedReviewResultText = text + } + case .runStarted, + .textDelta, + .reviewCompleted, + .reviewFailed, + .reviewCancelled: + break + } + } + } + + private func recordTypedMessageText(from seed: ReviewTimelineItemSeed) { + guard seed.family == .message, + case .message(let message) = seed.content + else { + return + } + typedMessageTextByItemID[seed.id.rawValue] = message.text + if let text = message.text.nilIfEmpty { + typedReviewResultText = text + } + } + + private func finishSession(cancellationMessage: String?) async { guard finished == false else { return } completionCoordinator.cancelPendingCompletion() - cancelPendingStreamedLogFlush() - await emitPrecedingEvents(precedingEvents) if let cancellationMessage { _ = await emit(.cancelled(cancellationMessage)) } else { @@ -268,108 +251,6 @@ package actor ReviewBackendEventSession { return didFinish } - private func shouldFlushPendingStreamedLogBeforeDomainEvent( - followingEvents: ArraySlice, - retainTimelineText: Bool - ) -> Bool { - guard pendingStreamedLogEntries.isEmpty == false else { - return false - } - return followingEvents.contains { - canCoalescePendingStreamedLog( - with: $0, - retainTimelineText: retainTimelineText - ) - } == false - } - - private func canCoalescePendingStreamedLog( - with event: CodexReviewBackendModel.Review.Event, - retainTimelineText: Bool - ) -> Bool { - guard - let entry = PendingStreamedLogEntry( - event, - retainsTimelineText: retainTimelineText - ) - else { - return false - } - return pendingStreamedLogIndexByKey[entry.key] != nil - } - - private func bufferStreamedLog( - _ event: CodexReviewBackendModel.Review.Event, - retainTimelineText: Bool = false - ) -> Bool { - guard - let entry = PendingStreamedLogEntry( - event, - retainsTimelineText: retainTimelineText - ) - else { - return false - } - if let index = pendingStreamedLogIndexByKey[entry.key] { - pendingStreamedLogEntries[index].append(entry.text) - if retainTimelineText { - pendingStreamedLogEntries[index].retainTimelineText() - } - } 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 { @@ -396,30 +277,13 @@ package actor ReviewBackendEventSession { case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, .message, .messageDelta, - .log, - .logEntry: + .log: 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) - await recordReviewEvent(event) - } - } - private func noteEmission(_ event: CodexReviewBackendModel.Review.Event) { metrics.emitted += 1 if metrics.firstEventLatencyMs == nil { @@ -428,21 +292,6 @@ package actor ReviewBackendEventSession { if event.isReviewBackendTerminal { 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 { @@ -454,109 +303,16 @@ package actor ReviewBackendEventSession { } } -private struct PendingStreamedLogEntry: Sendable { - struct Key: Hashable, Sendable { - var kind: ReviewLogEntry.Kind - var groupID: String - var sourceType: String? - var itemID: String? - } - - var kind: ReviewLogEntry.Kind - var text: String - var groupID: String - var metadata: ReviewLogEntry.Metadata? - var retainsTimelineText: Bool - - var key: Key { - .init( - kind: kind, - groupID: groupID, - sourceType: metadata?.sourceType, - itemID: metadata?.itemID - ) - } - - var events: [CodexReviewBackendModel.Review.Event] { - let logEntry = CodexReviewBackendModel.Review.Event.logEntry( - kind: kind, - text: text, - groupID: groupID, - replacesGroup: false, - metadata: metadata - ) - return retainsTimelineText - ? [.retainNextTimelineTextFromLogEntry, logEntry] - : [logEntry] - } - - init?(_ event: CodexReviewBackendModel.Review.Event, retainsTimelineText: 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 - } - self.kind = kind - self.text = text - self.groupID = groupID - self.metadata = metadata - self.retainsTimelineText = retainsTimelineText - } - - mutating func append(_ suffix: String) { - text += suffix - } - - mutating func retainTimelineText() { - retainsTimelineText = true - } -} - -package extension Array where Element == CodexReviewBackendModel.Review.Event { - var immediateTimelineTextRetentionCount: Int { - reduce(0) { count, event in - count + (event.needsImmediateTimelineTextRetention ? 1 : 0) - } - } - - var addingTerminalFailureTimelineTextRetentionIfNeeded: [Element] { - flatMap { event -> [Element] in - if case .failed = event { - return [.retainNextTerminalFailureTimelineTextFromLogEntry, event] - } - return [event] - } - } -} - private extension CodexReviewBackendModel.Review.Event { var isReviewBackendTerminal: Bool { - return switch self { + switch self { case .completed, .failed, .cancelled: true case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, .started, .message, .messageDelta, - .log, - .logEntry: + .log: false } } @@ -567,24 +323,6 @@ private extension CodexReviewBackendModel.Review.Event { } return result?.nilIfEmpty == nil } - - var needsImmediateTimelineTextRetention: Bool { - guard PendingStreamedLogEntry(self) == nil else { - return false - } - return switch self { - case .message, .messageDelta, .log, .logEntry: - true - case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, - .started, - .completed, - .failed, - .cancelled: - false - } - } } private final class ReviewCompletionCoordinator { diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 77b60255..74f5a81f 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -179,13 +179,10 @@ package actor BackendReviewEventMailbox { case .completed, .failed, .cancelled: return true case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, .started, .message, .messageDelta, - .log, - .logEntry: + .log: return false } } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index b095c378..62787887 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -277,20 +277,11 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { - case domainEvents([ReviewDomainEvent], retainedLogEntryCount: Int) - case retainNextTimelineTextFromLogEntry - case retainNextTerminalFailureTimelineTextFromLogEntry + case domainEvents([ReviewDomainEvent]) 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) diff --git a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift index e57fb126..f9519d5d 100644 --- a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift +++ b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift @@ -64,14 +64,16 @@ public final class ReviewTimeline { 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 - ) + 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 @@ -101,6 +103,37 @@ public final class ReviewTimeline { itemsByID[id] } + @discardableResult + package func closeActiveItems( + family: ReviewItemFamily? = nil, + phase: ReviewItemPhase, + timestamp: Date + ) -> Bool { + let ids = activeItemIDs.filter { id in + guard let family else { + return true + } + return itemsByID[id]?.family == family + } + guard ids.isEmpty == false else { + return false + } + for id in ids { + guard let item = itemsByID[id] else { + continue + } + item.update( + phase: phase, + content: item.content.closingActiveContent(phase: phase), + updatedAt: timestamp, + completedAt: timestamp + ) + activeItemIDs.remove(id) + } + bumpRevision() + return true + } + @discardableResult package func updateItemContent( _ content: ReviewTimelineItem.Content, @@ -108,7 +141,7 @@ public final class ReviewTimeline { updatedAt: Date? = nil ) -> Bool { guard let item = itemsByID[id], - item.content != content + item.content != content else { return false } diff --git a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift index 7ad2907e..c2d0c153 100644 --- a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift +++ b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift @@ -446,6 +446,87 @@ extension ReviewTimelineItem.Content { return incoming } } + + func closingActiveContent(phase: ReviewItemPhase) -> Self { + switch self { + case .command(var command): + command.status = phase.commandStatus + return .command(command) + case .fileChange(var fileChange): + fileChange.status = phase.fileChangeStatus + return .fileChange(fileChange) + case .toolCall(var toolCall): + toolCall.status = phase.toolCallStatus + return .toolCall(toolCall) + case .approval(var approval): + approval.status = phase == .cancelled ? .cancelled : approval.status + return .approval(approval) + case .contextCompaction(var contextCompaction): + contextCompaction.status = phase.contextCompactionStatus + return .contextCompaction(contextCompaction) + case .diagnostic, + .message, + .plan, + .reasoning, + .search, + .unknown: + return self + } + } +} + +private extension ReviewItemPhase { + var commandStatus: ReviewCommandStatus? { + switch self { + case .completed: + .completed + case .failed, .incomplete: + .failed + case .cancelled, .skipped: + .cancelled + case .awaitingApproval, .queued, .running, .waitingForInput: + nil + } + } + + var fileChangeStatus: ReviewFileChangeStatus? { + switch self { + case .completed: + .completed + case .failed, .incomplete: + .failed + case .cancelled, .skipped: + nil + case .awaitingApproval, .queued, .running, .waitingForInput: + nil + } + } + + var toolCallStatus: ReviewToolCallStatus? { + switch self { + case .completed: + .completed + case .failed, .incomplete: + .failed + case .cancelled, .skipped: + .cancelled + case .awaitingApproval, .queued, .running, .waitingForInput: + nil + } + } + + var contextCompactionStatus: ReviewContextCompactionStatus? { + switch self { + case .completed: + .completed + case .failed, .incomplete: + .failed + case .cancelled, .skipped: + nil + case .awaitingApproval, .queued, .running, .waitingForInput: + nil + } + } } private extension ReviewTimelineItem.Approval { diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 59eba950..7370e822 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -4,19 +4,6 @@ import Observation @MainActor @Observable public final class CodexReviewJob: Identifiable, Hashable { - package enum TruncationDirection { - case prefix - case suffix - } - - package enum LogMutation: Sendable, Equatable { - case reload - case append - } - - @ObservationIgnored - private var logBuffer: ReviewLogBuffer - public nonisolated let id: String public let timeline: ReviewTimeline public let sessionID: String @@ -25,34 +12,13 @@ public final class CodexReviewJob: Identifiable, Hashable { 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 - private var pendingTimelineTextRetentionEntries: Int - @ObservationIgnored - private var pendingTerminalFailureTimelineTextRetentionEntries: Int - @ObservationIgnored - package var directTimelineTextItemIDs: Set - @ObservationIgnored - package var directTimelineTextItemIDsWithRetainedLogText: Set - @ObservationIgnored - package var retainedTimelineTextItemIDsByLogEntryID: [ReviewLogEntry.ID: Set] - @ObservationIgnored - private var pendingDirectTimelineTextItemIDsForLogRetention: [ReviewTimelineItem.ID] - @ObservationIgnored - private var latestDirectTimelineTextItemIDs: [ReviewTimelineItem.ID] - package private(set) var logEntries: [ReviewLogEntry] - package private(set) var logText: String - package private(set) var rawLogText: String - package private(set) var reviewOutputText: String - package private(set) var activityLogText: String - package private(set) var diagnosticText: String - package private(set) var cappedLogBytes: Int - package private(set) var logRevision: UInt64 - @ObservationIgnored - package private(set) var lastLogMutation: LogMutation + private var syntheticTimelineItemCounter: UInt64 public var isTerminal: Bool { core.isTerminal @@ -73,11 +39,8 @@ public final class CodexReviewJob: Identifiable, Hashable { sortOrder: Double = 0, targetSummary: String, core: ReviewJobCore, - cancellationRequested: Bool = false, - logEntries: [ReviewLogEntry] = [] + cancellationRequested: Bool = false ) { - let initialLogBuffer = ReviewLogBuffer(entries: logEntries) - let initialLogSnapshot = initialLogBuffer.snapshot self.id = id self.timeline = ReviewTimeline() self.sessionID = sessionID @@ -88,23 +51,7 @@ public final class CodexReviewJob: Identifiable, Hashable { self.cancellationRequested = cancellationRequested self.agentMessagesByItemID = [:] self.completedAgentMessageItemIDs = [] - self.pendingTimelineTextRetentionEntries = 0 - self.pendingTerminalFailureTimelineTextRetentionEntries = 0 - self.directTimelineTextItemIDs = [] - self.directTimelineTextItemIDsWithRetainedLogText = [] - self.retainedTimelineTextItemIDsByLogEntryID = [:] - self.pendingDirectTimelineTextItemIDsForLogRetention = [] - self.latestDirectTimelineTextItemIDs = [] - self.logBuffer = initialLogBuffer - self.logEntries = initialLogSnapshot.entries - self.logText = initialLogSnapshot.logText - self.rawLogText = initialLogSnapshot.rawLogText - self.reviewOutputText = initialLogSnapshot.reviewOutputText - self.activityLogText = initialLogSnapshot.activityLogText - self.diagnosticText = initialLogSnapshot.diagnosticText - self.cappedLogBytes = initialLogSnapshot.cappedBytes - self.logRevision = 0 - self.lastLogMutation = .reload + self.syntheticTimelineItemCounter = 0 syncTimelineTerminalStateFromCore() } @@ -116,323 +63,38 @@ public final class CodexReviewJob: Identifiable, Hashable { hasher.combine(id) } - package func replaceLogEntries(_ entries: [ReviewLogEntry], resetDirectTimeline: Bool = false) { - logBuffer.replace(with: entries) - syncLogEntriesFromBuffer() - if resetDirectTimeline { - pendingTimelineTextRetentionEntries = 0 - pendingTerminalFailureTimelineTextRetentionEntries = 0 - directTimelineTextItemIDs.removeAll(keepingCapacity: true) - directTimelineTextItemIDsWithRetainedLogText.removeAll(keepingCapacity: true) - retainedTimelineTextItemIDsByLogEntryID.removeAll(keepingCapacity: true) - pendingDirectTimelineTextItemIDsForLogRetention.removeAll(keepingCapacity: true) - latestDirectTimelineTextItemIDs.removeAll(keepingCapacity: true) - timeline.reset(keepingTerminal: core.lifecycle.status.isTerminal) - syncTimelineTerminalStateFromCore() - } - trimTimelineTextContentToLogEntries() - syncLogSnapshot(mutation: .reload) - } - - package func appendLogEntry(_ entry: ReviewLogEntry, retainTimelineText: Bool = false) { - let appendResult = logBuffer.append(entry) - let entry = appendResult.entry - syncLogEntriesFromBuffer() - if entry.canProvideDirectTimelineText { - let allowsPendingRetainedText = retainTimelineText || pendingTimelineTextRetentionEntries > 0 - if entry.retainedTimelineText != nil { - recordDirectTimelineTextRetention( - for: entry, - allowsPendingRetainedText: allowsPendingRetainedText - ) - } else { - discardDirectTimelineTextRetention( - for: entry, - allowsPendingRetainedText: allowsPendingRetainedText - ) - } - } - if retainTimelineText == false { - _ = consumeTimelineTextRetention() - } - let didTrim = core.lifecycle.status.isTerminal ? trimReviewLogToLimit() : false - syncLogSnapshot(mutation: didTrim ? .reload : LogMutation(appendResult.mutation)) - } - - package func closeActiveCommandLogEntries(status: String, completedAt: Date) { - let replacements = ReviewLogBuffer.activeCommandClosingEntries( - entries: logEntries, - status: status, - completedAt: completedAt - ) - for replacement in replacements { - appendLogEntry(replacement) - } - } - - package func applyDirectTimelineEvents( - _ events: [ReviewDomainEvent], - retainedLogEntryCount: Int, - at timestamp: Date - ) { - let directEvents = events.filter { - $0.appliesFromDirectTimelineSource && shouldApplyDirectTimelineEvent($0) - } - guard directEvents.isEmpty == false else { - return - } - pendingTimelineTextRetentionEntries += max(0, retainedLogEntryCount) - 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 retainedLogEntryCount > 0 { - appendPendingDirectTimelineTextItemIDsForLogRetention(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 recordDirectTimelineTextRetention( - for entry: ReviewLogEntry, - allowsPendingRetainedText: Bool - ) { - var itemIDs: Set = [] - for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) { - itemIDs.insert(itemID) - } - if itemIDs.isEmpty, - allowsPendingRetainedText, - let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() - { - itemIDs.insert(pendingItemID) - } - guard itemIDs.isEmpty == false else { - return - } - directTimelineTextItemIDsWithRetainedLogText.formUnion(itemIDs) - retainedTimelineTextItemIDsByLogEntryID[entry.id, default: []].formUnion(itemIDs) - removePendingDirectTimelineTextItemIDsForLogRetention(itemIDs) - } - - private func discardDirectTimelineTextRetention( - for entry: ReviewLogEntry, - allowsPendingRetainedText: Bool - ) { - var itemIDs: Set = [] - for itemID in entry.directTimelineTextCandidateIDs where directTimelineTextItemIDs.contains(itemID) { - itemIDs.insert(itemID) - } - if itemIDs.isEmpty, - allowsPendingRetainedText, - let pendingItemID = popPendingDirectTimelineTextItemIDForLogRetention() - { - itemIDs.insert(pendingItemID) - } - removePendingDirectTimelineTextItemIDsForLogRetention(itemIDs) - } - - private func appendPendingDirectTimelineTextItemIDsForLogRetention(_ itemIDs: [ReviewTimelineItem.ID]) { - for itemID in itemIDs where pendingDirectTimelineTextItemIDsForLogRetention.contains(itemID) == false { - pendingDirectTimelineTextItemIDsForLogRetention.append(itemID) - } - } - - private func popPendingDirectTimelineTextItemIDForLogRetention() -> ReviewTimelineItem.ID? { - guard pendingDirectTimelineTextItemIDsForLogRetention.isEmpty == false else { - return nil - } - return pendingDirectTimelineTextItemIDsForLogRetention.removeFirst() + package func nextSyntheticTimelineItemID(prefix: String) -> ReviewTimelineItem.ID { + syntheticTimelineItemCounter &+= 1 + return .init(rawValue: "\(id):\(prefix):\(syntheticTimelineItemCounter)") } - private func removePendingDirectTimelineTextItemIDsForLogRetention(_ itemIDs: Set) { - guard itemIDs.isEmpty == false else { + package func syncTimelineTerminalStateFromCore() { + guard core.lifecycle.status.isTerminal else { return } - pendingDirectTimelineTextItemIDsForLogRetention.removeAll { itemIDs.contains($0) } - } - - package func retainNextTimelineTextFromLogEntry() { - pendingTimelineTextRetentionEntries += 1 - appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) - } - - package func retainNextTerminalFailureTimelineTextFromLogEntry() { - pendingTerminalFailureTimelineTextRetentionEntries += 1 - appendPendingDirectTimelineTextItemIDsForLogRetention(latestDirectTimelineTextItemIDs) - } - - package func discardPendingTimelineTextRetention() { - _ = consumeTimelineTextRetention() - } - - package func consumeTerminalFailureTimelineTextRetention() -> Bool { - guard pendingTerminalFailureTimelineTextRetentionEntries > 0 else { - return false - } - pendingTerminalFailureTimelineTextRetentionEntries -= 1 - return true - } - - private func consumeTimelineTextRetention() -> Bool { - guard pendingTimelineTextRetentionEntries > 0 else { - return false - } - pendingTimelineTextRetentionEntries -= 1 - return true - } - - @discardableResult - package func applyReviewLogLimit() -> Bool { - guard trimReviewLogToLimit() else { - return false - } - syncLogSnapshot(mutation: .reload) - return true - } - - @discardableResult - private func trimReviewLogToLimit() -> Bool { - guard logBuffer.applyLimit() else { - return false - } - syncLogEntriesFromBuffer() - trimTimelineTextContentToLogEntries() - return true - } - - package func truncateOrRemoveEntry( - at index: Int, - keeping direction: TruncationDirection, - overflowBytes: Int - ) { - guard - logBuffer.truncateOrRemoveEntry( - at: index, - keeping: ReviewLogBuffer.TruncationDirection(direction), - overflowBytes: overflowBytes + let timestamp = core.lifecycle.endedAt ?? 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 ) - else { - return - } - syncLogEntriesFromBuffer() - trimTimelineTextContentToLogEntries() - syncLogSnapshot(mutation: .reload) - } - - private func syncLogEntriesFromBuffer() { - logEntries = logBuffer.entries - } - - private func syncLogSnapshot(mutation: LogMutation) { - let snapshot = logBuffer.snapshot - logEntries = snapshot.entries - logText = snapshot.logText - rawLogText = snapshot.rawLogText - reviewOutputText = snapshot.reviewOutputText - activityLogText = snapshot.activityLogText - diagnosticText = snapshot.diagnosticText - cappedLogBytes = snapshot.cappedBytes - lastLogMutation = mutation - logRevision &+= 1 - } - -} - -private extension CodexReviewJob.LogMutation { - init(_ mutation: ReviewLogBuffer.Mutation) { - switch mutation { - case .reload: - self = .reload - case .append: - self = .append - } - } -} - -private extension ReviewLogBuffer.TruncationDirection { - init(_ direction: CodexReviewJob.TruncationDirection) { - switch direction { - case .prefix: - self = .prefix - case .suffix: - self = .suffix - } - } -} - -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 + 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 } } } diff --git a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift b/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift index 374c084e..f28b8373 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift @@ -18,7 +18,6 @@ extension CodexReviewJob { hasFinalReview: Bool = false, reviewResult: ParsedReviewResult? = nil, lastAgentMessage: String? = "", - logEntries: [ReviewLogEntry] = [], errorMessage: String? = nil, exitCode: Int? = nil ) -> CodexReviewJob { @@ -49,8 +48,7 @@ extension CodexReviewJob { reviewResult: reviewResult ) ), - cancellationRequested: cancellationRequested, - logEntries: logEntries + cancellationRequested: cancellationRequested ) } diff --git a/Sources/CodexReviewKit/Model/ReviewLogBuffer.swift b/Sources/CodexReviewKit/Model/ReviewLogBuffer.swift deleted file mode 100644 index a4c5ed19..00000000 --- a/Sources/CodexReviewKit/Model/ReviewLogBuffer.swift +++ /dev/null @@ -1,987 +0,0 @@ -import Foundation - -package struct ReviewLogBuffer { - package enum TruncationDirection { - case prefix - case suffix - } - - package enum Mutation { - case reload - case append - } - - package struct AppendResult { - package var entry: ReviewLogEntry - package var mutation: Mutation - } - - package struct Snapshot { - package var entries: [ReviewLogEntry] - package var logText: String - package var rawLogText: String - package var reviewOutputText: String - package var activityLogText: String - package var diagnosticText: String - package var cappedBytes: Int - } - - package private(set) var entries: [ReviewLogEntry] - private var projectionState: ReviewLogProjectionState - - package init(entries: [ReviewLogEntry]) { - let trimmed = Self.trimmedEntriesAndProjection(entries: entries) - self.entries = trimmed.entries - self.projectionState = trimmed.projectionState - } - - package var cappedBytes: Int { - projectionState.cappedBytes - } - - package var snapshot: Snapshot { - Snapshot( - entries: entries, - logText: projectionState.logText, - rawLogText: projectionState.rawLogText, - reviewOutputText: projectionState.reviewOutputText, - activityLogText: projectionState.activityLogText, - diagnosticText: projectionState.diagnosticText, - cappedBytes: projectionState.cappedBytes - ) - } - - package mutating func replace(with entries: [ReviewLogEntry]) { - let trimmed = Self.trimmedEntriesAndProjection(entries: entries) - self.entries = trimmed.entries - self.projectionState = trimmed.projectionState - } - - package mutating func append(_ entry: ReviewLogEntry) -> AppendResult { - let entry = entry.clampingUnownedRetainedMetadata(maxBytes: Self.limitBytes) - let supportsIncrementalAppend = projectionState.supportsIncrementalAppend(entry) - if supportsIncrementalAppend { - projectionState.append(entry) - } - entries.append(entry) - if supportsIncrementalAppend == false { - projectionState = ReviewLogProjectionState(entries: entries) - } - return AppendResult( - entry: entry, - mutation: supportsIncrementalAppend ? .append : .reload - ) - } - - @discardableResult - package mutating func applyLimit() -> Bool { - guard projectionState.cappedBytes > Self.limitBytes else { - return false - } - - let trimmed = Self.trimmedEntriesAndProjection(entries: entries) - guard trimmed.entries != entries else { - return false - } - entries = trimmed.entries - projectionState = trimmed.projectionState - return true - } - - @discardableResult - package mutating func truncateOrRemoveEntry( - at index: Int, - keeping direction: TruncationDirection, - overflowBytes: Int - ) -> Bool { - guard entries.indices.contains(index) else { - return false - } - - var updatedEntries = entries - let entry = updatedEntries[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 { - updatedEntries.remove(at: index) - } else { - updatedEntries[index] = ReviewLogEntry( - id: entry.id, - kind: entry.kind, - groupID: entry.groupID, - replacesGroup: entry.replacesGroup, - text: truncatedText, - metadata: entry.metadata, - timestamp: entry.timestamp - ) - } - replace(with: updatedEntries) - return true - } - - package 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 struct TrimmedEntriesAndProjection { - var entries: [ReviewLogEntry] - var projectionState: ReviewLogProjectionState - } - - private static func trimmedEntriesAndProjection( - entries initialEntries: [ReviewLogEntry] - ) -> TrimmedEntriesAndProjection { - var entries = initialEntries.map { - $0.clampingUnownedRetainedMetadata(maxBytes: limitBytes) - } - var projectionState = ReviewLogProjectionState(entries: entries) - - while projectionState.cappedBytes > limitBytes { - let overflowBytes = projectionState.cappedBytes - limitBytes - guard let trimmedEntries = trimOnce(entries: entries, overflowBytes: overflowBytes) else { - break - } - entries = trimmedEntries - projectionState = ReviewLogProjectionState(entries: entries) - } - - return .init(entries: entries, projectionState: projectionState) - } - - private 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: { - ReviewLogProjectionState.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 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 && ReviewLogProjectionState.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 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] = ReviewLogEntry( - 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 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 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()) - } - - package static let limitBytes = 256 * 1024 -} - -package struct ReviewLogProjectionState { - private struct GroupKey: Hashable { - var kind: ReviewLogEntry.Kind - var groupID: String - } - - private struct RenderedBlock { - var kind: ReviewLogEntry.Kind - var groupID: String? - var text: String - } - - 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 var blocks: [RenderedBlock] - private var indexByGroup: [GroupKey: Int] - private var logProjection: ProjectionAccumulator - private var reviewOutputProjection: ProjectionAccumulator - private var activityProjection: ProjectionAccumulator - private var errorProjection: ProjectionAccumulator - private var rawProjection: ProjectionAccumulator - private var cappedProjection: ProjectionAccumulator - - package init(entries: [ReviewLogEntry]) { - self = Self.rebuild(entries: entries) - } - - package var logText: String { - logProjection.text - } - - package var rawLogText: String { - rawProjection.text - } - - package var reviewOutputText: String { - reviewOutputProjection.text - } - - package var activityLogText: String { - activityProjection.text - } - - package var diagnosticText: String { - Self.combinedText( - sections: [ - errorProjection.text, - rawProjection.text, - ] - ) - } - - package var cappedBytes: Int { - cappedProjection.text.utf8.count - } - - package func supportsIncrementalAppend(_ entry: ReviewLogEntry) -> Bool { - guard entry.replacesGroup == false, - let key = Self.mergeKey(for: entry), - let blockIndex = indexByGroup[key] - else { - return entry.replacesGroup == false - } - return blockIndex == blocks.indices.last - } - - package mutating func append(_ entry: ReviewLogEntry) { - if let key = Self.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 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 - } - - private static func rebuild(entries: [ReviewLogEntry]) -> ReviewLogProjectionState { - var state = ReviewLogProjectionState( - 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 = Self.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 - } - - private mutating func ingestBlock(_ block: RenderedBlock, at index: Int) { - _ = Self.updateRenderedProjection( - &logProjection, - block: block, - blockIndex: index, - visibleKinds: Self.displayedLogKinds, - includeEmptyDiagnostic: true - ) - _ = Self.updateRenderedProjection( - &reviewOutputProjection, - block: block, - blockIndex: index, - visibleKinds: Self.reviewOutputKinds, - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &activityProjection, - block: block, - blockIndex: index, - visibleKinds: Self.activityKinds, - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &errorProjection, - block: block, - blockIndex: index, - visibleKinds: [.error], - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &cappedProjection, - block: block, - blockIndex: index, - visibleKinds: Self.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: Self.displayedLogKinds, - includeEmptyDiagnostic: true - ) - _ = Self.updateRenderedProjection( - &reviewOutputProjection, - block: block, - blockIndex: blockIndex, - visibleKinds: Self.reviewOutputKinds, - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &activityProjection, - block: block, - blockIndex: blockIndex, - visibleKinds: Self.activityKinds, - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &errorProjection, - block: block, - blockIndex: blockIndex, - visibleKinds: [.error], - includeEmptyDiagnostic: false - ) - _ = Self.updateRenderedProjection( - &cappedProjection, - block: block, - blockIndex: blockIndex, - visibleKinds: Self.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: Self.displayedLogKinds, - includeEmptyDiagnostic: true - ) - _ = Self.updateTailProjection( - &reviewOutputProjection, - kind: block.kind, - oldText: oldText, - newText: newText, - blockIndex: blockIndex, - delta: delta, - visibleKinds: Self.reviewOutputKinds, - includeEmptyDiagnostic: false - ) - _ = Self.updateTailProjection( - &activityProjection, - kind: block.kind, - oldText: oldText, - newText: newText, - blockIndex: blockIndex, - delta: delta, - visibleKinds: Self.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: Self.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 = Self.isVisibleInRenderedProjection( - kind: kind, - text: oldText, - visibleKinds: visibleKinds, - includeEmptyDiagnostic: includeEmptyDiagnostic - ) - let isVisible = Self.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 Self.isVisibleInRenderedProjection( - kind: block.kind, - text: block.text, - visibleKinds: visibleKinds, - includeEmptyDiagnostic: includeEmptyDiagnostic - ) else { - return nil - } - return projection.appendSection(block.text, at: blockIndex) - } - - private static func combinedText(sections: [String]) -> String { - joinSectionsPreservingWhitespace( - sections.filter { $0.isEmpty == false } - ) - } - - 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 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 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 static let displayedLogKinds: Set = [ - .agentMessage, - .command, - .commandOutput, - .plan, - .todoList, - .reasoning, - .reasoningSummary, - .rawReasoning, - .toolCall, - .diagnostic, - .error, - .progress, - .event, - .contextCompaction, - ] - - private static let reviewOutputKinds: Set = [ - .agentMessage, - .plan, - .reasoningSummary, - .reasoning, - .rawReasoning, - ] - - private static let activityKinds: Set = [ - .command, - .commandOutput, - .toolCall, - .progress, - .event, - .contextCompaction, - ] - - package static let cappedLogKinds: Set = [ - .agentMessage, - .commandOutput, - .toolCall, - .plan, - .todoList, - .reasoningSummary, - .rawReasoning, - .diagnostic, - .error, - .progress, - .event, - ] - - package static let prefixTrimmableCappedKinds = cappedLogKinds.subtracting([.rawReasoning, .diagnostic, .error]) -} - -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 - } -} diff --git a/Sources/CodexReviewKit/Model/ReviewLogEntry.swift b/Sources/CodexReviewKit/Model/ReviewLogEntry.swift deleted file mode 100644 index 62c21916..00000000 --- a/Sources/CodexReviewKit/Model/ReviewLogEntry.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation - -package struct ReviewLogEntry: Codable, Identifiable, Sendable, Hashable { - package struct Metadata: Codable, Sendable, Hashable { - package struct CommandAction: Codable, Sendable, Hashable { - package enum Kind: String, Codable, Sendable, Hashable { - case read - case listFiles - case search - case unknown - } - - package let kind: Kind - package let command: String? - package let name: String? - package let path: String? - package let query: String? - - package 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 - } - } - - package let sourceType: String - package let title: String? - package let status: String? - package let detail: String? - package let itemID: String? - package let command: String? - package let cwd: String? - package let exitCode: Int? - package let startedAt: Date? - package let completedAt: Date? - package let durationMs: Int? - package let commandActions: [CommandAction]? - package let commandStatus: String? - package let namespace: String? - package let server: String? - package let tool: String? - package let query: String? - package let path: String? - package let resultText: String? - package let errorText: String? - - package 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 - } - } - - package 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 - } - - package let id: UUID - package let kind: Kind - package let groupID: String? - package let replacesGroup: Bool - package let text: String - package let metadata: Metadata? - package let timestamp: Date - - package 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/CodexReviewKit/Model/ReviewTimelineTextRetention.swift b/Sources/CodexReviewKit/Model/ReviewTimelineTextRetention.swift deleted file mode 100644 index 74012d65..00000000 --- a/Sources/CodexReviewKit/Model/ReviewTimelineTextRetention.swift +++ /dev/null @@ -1,268 +0,0 @@ -import Foundation - -@MainActor -extension CodexReviewJob { - package func trimTimelineTextContentToLogEntries() { - guard directTimelineTextItemIDs.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 directTimelineTextItemIDsWithRetainedLogText.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) - } - } - - 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 retainedTextItemIDs = retainedTimelineTextItemIDsByLogEntryID[entry.id] { - ids.append(contentsOf: retainedTextItemIDs) - } - var seen: Set = [] - return ids.filter { seen.insert($0).inserted } - } -} - -package extension ReviewLogEntry { - var retainedTimelineText: String? { - guard canProvideDirectTimelineText else { - return nil - } - switch directTimelineFamily { - 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] { - guard let rawID = metadata?.itemID?.nilIfEmpty ?? groupID?.nilIfEmpty else { - return [] - } - - var ids: [ReviewTimelineItem.ID] = [.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 isMCPToolProgressSummaryLog { - ids.append(.init(rawValue: "\(rawID):progress")) - } - var seen: Set = [] - return ids.filter { seen.insert($0).inserted } - } - - var isMCPToolProgressSummaryLog: Bool { - kind == .toolCall - && metadata?.sourceType == "mcpToolCall" - && metadata?.title?.trimmingCharacters(in: .whitespacesAndNewlines) == "Tool progress" - } - - var shouldAppendRetainedTimelineText: Bool { - guard replacesGroup == false else { - return false - } - switch kind { - case .agentMessage, - .commandOutput, - .plan, - .rawReasoning, - .reasoning, - .reasoningSummary, - .todoList: - return true - case .command, - .contextCompaction, - .diagnostic, - .error, - .event, - .progress, - .toolCall: - return false - } - } - - private var directTimelineFamily: 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 - } - } -} - -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[.. [String] { - let activeJobIDs = orderedJobs + let activeJobIDs = + orderedJobs .filter { $0.isTerminal == false } .map(\.id) for jobID in activeJobIDs { @@ -136,9 +137,10 @@ extension CodexReviewStore { package func cleanupActiveReviewsForRuntimeStop( reason: ReviewCancellation = .system(message: "Review runtime stopped."), workerDrainTimeout: Duration, - cleanupBackendReviews: @escaping @Sendable ( - CodexReviewRuntimeStopReviewCleanupRequest - ) async -> Bool + cleanupBackendReviews: + @escaping @Sendable ( + CodexReviewRuntimeStopReviewCleanupRequest + ) async -> Bool ) async -> CodexReviewRuntimeStopReviewCleanupResult { let request = runtimeStopReviewCleanupRequest(reason: reason) let didCompleteBackendCleanup = await cleanupBackendReviews(request) @@ -170,7 +172,8 @@ extension CodexReviewStore { reason: ReviewCancellation = .system(message: "Review runtime stopped."), cancelWorkers: Bool = true ) -> [String] { - let activeJobIDs = orderedJobs + let activeJobIDs = + orderedJobs .filter { $0.isTerminal == false } .map(\.id) guard activeJobIDs.isEmpty == false else { @@ -257,12 +260,12 @@ extension CodexReviewStore { job.core.output.summary = "Failed to cancel review." } job.core.output.hasFinalReview = false - job.core.lifecycle.errorMessage = resolvedError + 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/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index c85c278f..95e46f24 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -235,19 +235,150 @@ extension CodexReviewStore { } private func appendRecoveryProgress(_ message: String, to job: CodexReviewJob) { + let now = clock.now() job.core.output.summary = message - job.appendLogEntry(.init(kind: .progress, text: message, timestamp: clock.now())) - job.applyReviewLogLimit() + appendDiagnostic(message, to: job, at: now) writeDiagnosticsIfNeeded() } private func markReviewWaitingForNetworkRecovery(_ job: CodexReviewJob) { - let now = clock.now() - job.closeActiveCommandLogEntries(status: "canceled", completedAt: now) job.resetReviewAttemptOutputForRecovery() appendRecoveryProgress(networkRecoveryUnavailableMessage, to: job) } + private func applyDomainEvents( + _ events: [ReviewDomainEvent], + to job: CodexReviewJob, + at timestamp: Date + ) { + for event in events { + guard shouldApplyDomainEvent(event, to: job) else { + continue + } + updateCoreOutput(from: event, for: job) + job.timeline.apply(event, at: timestamp) + } + } + + private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to job: CodexReviewJob) -> Bool { + switch event { + case .textDelta(let itemID, _, let family, _, _) + where family == .message && job.completedAgentMessageItemIDs.contains(itemID.rawValue): + false + default: + true + } + } + + private func updateCoreOutput(from event: ReviewDomainEvent, for job: CodexReviewJob) { + switch event { + case .itemStarted(let seed), + .itemUpdated(let seed): + updateCoreOutput(from: seed, isCompleted: false, for: job) + case .itemCompleted(let seed): + updateCoreOutput(from: seed, isCompleted: true, for: job) + case .textDelta(let itemID, _, let family, _, let delta) where family == .message: + guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID.rawValue, delta: delta) else { + return + } + job.core.output.lastAgentMessage = updatedMessage + job.core.output.summary = updatedMessage + case .runStarted, + .textDelta, + .reviewCompleted, + .reviewFailed, + .reviewCancelled: + break + } + } + + private func updateCoreOutput( + from seed: ReviewTimelineItemSeed, + isCompleted: Bool, + for job: CodexReviewJob + ) { + guard seed.family == .message, + case .message(let message) = seed.content + else { + return + } + job.noteAgentMessageSnapshot( + itemID: seed.id.rawValue, + text: message.text, + isCompleted: isCompleted + ) + guard let text = message.text.nilIfEmpty else { + return + } + job.core.output.lastAgentMessage = text + job.core.output.summary = text + } + + private func applyMessageSnapshot( + _ text: String, + itemID: String?, + isCompleted: Bool, + to job: CodexReviewJob, + at timestamp: Date + ) { + let resolvedItemID = + itemID?.nilIfEmpty.map(ReviewTimelineItem.ID.init(rawValue:)) + ?? job.nextSyntheticTimelineItemID(prefix: "message") + let seed = ReviewTimelineItemSeed( + id: resolvedItemID, + kind: .agentMessage, + family: .message, + phase: isCompleted ? .completed : .running, + content: .message(.init(text: text)) + ) + job.noteAgentMessageSnapshot( + itemID: resolvedItemID.rawValue, + text: text, + isCompleted: isCompleted + ) + job.timeline.apply(isCompleted ? .itemCompleted(seed) : .itemUpdated(seed), at: timestamp) + } + + private func applyMessageDelta( + _ text: String, + itemID: String, + to job: CodexReviewJob, + at timestamp: Date + ) { + guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID, delta: text) else { + return + } + job.core.output.lastAgentMessage = updatedMessage + job.core.output.summary = updatedMessage + job.timeline.apply( + .textDelta( + itemID: .init(rawValue: itemID), + kind: .agentMessage, + family: .message, + content: .message(.init(text: "")), + delta: text + ), at: timestamp) + } + + private func appendDiagnostic( + _ message: String, + to job: CodexReviewJob, + at timestamp: Date, + severity: ReviewDiagnosticSeverity? = nil + ) { + guard let message = message.nilIfEmpty else { + return + } + let seed = ReviewTimelineItemSeed( + id: job.nextSyntheticTimelineItemID(prefix: "diagnostic"), + kind: .init(rawValue: "reviewDiagnostic"), + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: message, severity: severity)) + ) + job.timeline.apply(.itemCompleted(seed), at: timestamp) + } + private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { let networkMonitor = self.networkMonitor let policy = self.networkRecoveryPolicy @@ -551,18 +682,12 @@ extension CodexReviewStore { return } let endedAt = clock.now() - job.closeActiveCommandLogEntries(status: "failed", completedAt: endedAt) + job.timeline.closeActiveItems(family: .command, phase: .failed, timestamp: endedAt) job.core.lifecycle.status = .failed job.core.lifecycle.endedAt = endedAt job.core.lifecycle.errorMessage = message job.core.output.summary = message - let retainErrorTimelineText = job.consumeTerminalFailureTimelineTextRetention() - job.appendLogEntry( - .init(kind: .error, text: message, timestamp: endedAt), - retainTimelineText: retainErrorTimelineText - ) job.timeline.apply(.reviewFailed(message), at: endedAt) - job.applyReviewLogLimit() writeDiagnosticsIfNeeded() } @@ -827,16 +952,8 @@ extension CodexReviewStore { } var updatedRun = currentRun switch event { - case .domainEvents(let events, let retainedLogEntryCount): - job.applyDirectTimelineEvents( - events, - retainedLogEntryCount: retainedLogEntryCount, - at: clock.now() - ) - case .retainNextTimelineTextFromLogEntry: - job.retainNextTimelineTextFromLogEntry() - case .retainNextTerminalFailureTimelineTextFromLogEntry: - job.retainNextTerminalFailureTimelineTextFromLogEntry() + case .domainEvents(let events): + applyDomainEvents(events, to: job, at: clock.now()) case .started(let turnID, let reviewThreadID, let model): job.core.run.turnID = turnID job.core.run.reviewThreadID = reviewThreadID ?? job.core.run.reviewThreadID @@ -854,46 +971,14 @@ extension CodexReviewStore { model: model ?? job.core.run.model ), at: clock.now()) case .message(let text): + let now = clock.now() job.core.output.lastAgentMessage = text job.core.output.summary = text - job.appendLogEntry(.init(kind: .agentMessage, text: text, timestamp: clock.now())) + applyMessageSnapshot(text, itemID: nil, isCompleted: true, to: job, at: now) case .messageDelta(let text, let itemID): - guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID, delta: text) else { - job.discardPendingTimelineTextRetention() - return updatedRun - } - job.core.output.lastAgentMessage = updatedMessage - job.core.output.summary = updatedMessage - job.appendLogEntry( - .init( - kind: .agentMessage, - groupID: itemID, - text: text, - timestamp: clock.now() - )) + applyMessageDelta(text, itemID: itemID, to: job, at: 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() - )) + appendDiagnostic(text, to: job, at: clock.now()) case .completed(let summary, let result): completeReview(job, summary: summary, result: result) case .failed(let message): @@ -935,7 +1020,7 @@ extension CodexReviewStore { let previousAgentMessage = job.core.output.lastAgentMessage?.nilIfEmpty let resultText = result?.nilIfEmpty let finalReviewText = resultText ?? previousAgentMessage - job.closeActiveCommandLogEntries(status: "completed", completedAt: endedAt) + job.timeline.closeActiveItems(family: .command, phase: .completed, timestamp: endedAt) job.core.lifecycle.status = .succeeded job.core.lifecycle.endedAt = endedAt job.core.output.summary = summary @@ -945,10 +1030,9 @@ extension CodexReviewStore { if let result = resultText, result != previousAgentMessage { - job.appendLogEntry(.init(kind: .agentMessage, text: result, timestamp: endedAt)) + applyMessageSnapshot(result, itemID: nil, isCompleted: true, to: job, at: endedAt) } job.timeline.apply(.reviewCompleted(summary: summary, result: finalReviewText), at: endedAt) - job.applyReviewLogLimit() writeDiagnosticsIfNeeded() } @@ -973,26 +1057,16 @@ extension CodexReviewStore { } } -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, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry, .started, .message, .messageDelta, - .log, - .logEntry: + .log: false } } @@ -1036,7 +1110,8 @@ private extension CodexReviewJob { core.output.reviewResult = nil agentMessagesByItemID.removeAll(keepingCapacity: true) completedAgentMessageItemIDs.removeAll(keepingCapacity: true) - replaceLogEntries(logEntries.filter { $0.kind != .agentMessage }, resetDirectTimeline: true) + timeline.reset(keepingTerminal: core.lifecycle.status.isTerminal) + syncTimelineTerminalStateFromCore() } } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index ac490f26..a29aaa0a 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -160,7 +160,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 @@ -352,7 +353,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, @@ -369,7 +372,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") } @@ -378,7 +383,9 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { commands.append(.cancelLogin(challenge)) } - 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 @@ -397,7 +404,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) @@ -447,13 +456,15 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { 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)) } @@ -461,7 +472,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) } @@ -536,7 +549,6 @@ package final class StoreSnapshotProbe { status: job.core.lifecycle.status, summary: job.core.output.summary, lastAgentMessage: job.core.output.lastAgentMessage, - logs: job.logEntries, run: job.core.run, activeRun: runtimeState.activeRun, cancellationRequested: job.cancellationRequested @@ -555,19 +567,6 @@ package final class StoreSnapshotProbe { } } - 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) - } - } - package func waitUntilRunAttempt( _ attemptID: String, jobID: String? = nil, @@ -613,7 +612,6 @@ package struct StoreJobSnapshot: Sendable { package var status: ReviewJobState package var summary: String package var lastAgentMessage: String? - package var logs: [ReviewLogEntry] package var run: ReviewJobCore.Run package var activeRun: CodexReviewBackendModel.Review.Run? package var cancellationRequested: Bool @@ -678,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)) } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 462e4bdd..2c6990fe 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -14,24 +14,18 @@ private struct BackendReviewEventSequence: AsyncSequence { while let event = try await mailbox.next() { if includesDomainEvents == false { switch event { - case .domainEvents, - .retainNextTimelineTextFromLogEntry, - .retainNextTerminalFailureTimelineTextFromLogEntry: + case .domainEvents: continue case .started, .message, .messageDelta, .log, - .logEntry, .completed, .failed, .cancelled: break } } - if case .retainNextTerminalFailureTimelineTextFromLogEntry = event { - continue - } return event } return nil @@ -124,12 +118,11 @@ struct AppServerClientTests { #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let commandDomainEvents, let commandSuppressionCount) = try await iterator.next() + guard case .domainEvents(let commandDomainEvents) = try await iterator.next() else { Issue.record("expected typed command domain event") return } - #expect(commandSuppressionCount == 2) guard case .itemCompleted(let commandSeed) = try #require(commandDomainEvents.first) else { Issue.record("expected completed command seed") return @@ -141,50 +134,21 @@ struct AppServerClientTests { } #expect(command.command == "swift test") #expect(command.output == "passed") - #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: "passed", - groupID: "cmd-1", - replacesGroup: true, - metadata: .init( - sourceType: "commandExecution", - status: "completed", - itemID: "cmd-1", - command: "swift test", - commandStatus: "completed" - ) - )) - guard case .domainEvents(let messageDomainEvents, let messageSuppressionCount) = try await iterator.next() + guard case .domainEvents(let messageDomainEvents) = try await iterator.next() else { Issue.record("expected typed message delta domain event") return } - #expect(messageSuppressionCount == 1) - guard case .textDelta(let itemID, _, let family, _, let delta) = try #require(messageDomainEvents.first) else { + guard + case .textDelta(let itemID, kind: _, family: let family, content: _, delta: let delta) = + try #require(messageDomainEvents.first) + else { Issue.record("expected message text delta") return } #expect(itemID.rawValue == "msg-1") #expect(family == .message) #expect(delta == "Looks good.") - #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } @@ -223,7 +187,7 @@ struct AppServerClientTests { #expect( try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) - guard case .domainEvents(let firstDomainEvents, _) = try await firstIterator.next(), + guard case .domainEvents(let firstDomainEvents) = try await firstIterator.next(), case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), case .command(let firstCommand) = firstSeed.content else { @@ -236,7 +200,7 @@ struct AppServerClientTests { #expect( try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) - guard case .domainEvents(let secondDomainEvents, _) = try await secondIterator.next(), + guard case .domainEvents(let secondDomainEvents) = try await secondIterator.next(), case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), case .command(let secondCommand) = secondSeed.content else { @@ -246,7 +210,7 @@ struct AppServerClientTests { #expect(secondCommand.output == "second") } - @Test func backendCoalescesTypedCommandOutputDeltas() async throws { + @Test func backendEmitsTypedCommandOutputDeltasAsDomainEvents() 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") @@ -284,22 +248,9 @@ struct AppServerClientTests { guard case .domainEvents = try await nextEvent(from: attempt.events), case .domainEvents = try await nextEvent(from: attempt.events) else { - Issue.record("expected direct timeline updates before coalesced log") - return - } - guard - case .logEntry(.commandOutput, let text, let groupID, let replacesGroup, let metadata) = - try await nextEvent(from: attempt.events) - else { - Issue.record("expected coalesced command output log") + Issue.record("expected direct timeline updates") return } - #expect(text == "firstsecond") - #expect(groupID == "cmd-1") - #expect(replacesGroup == false) - #expect(metadata?.sourceType == "commandExecution") - #expect(metadata?.title == "Command output") - #expect(metadata?.itemID == "cmd-1") } @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index c19a6d51..9b8d7bc7 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -51,23 +51,15 @@ struct ReviewBackendEventSessionTests { #expect(await recorder.finishedMetrics() == metrics) } - @Test func coalescesStreamedLogEntriesWithoutDelayingDomainEvents() async throws { + @Test func emitsDomainEventsWithoutDelayingLaterUpdates() async throws { let session = ReviewBackendEventSession(run: makeRun()) let attempt = await session.attempt() - await session.receive( - [ - commandDomainEvent(output: "first"), - commandOutputDeltaLogEntry(text: "first"), - ], controlThreadID: "review-thread") - await session.receive( - [ - commandDomainEvent(output: "firstsecond"), - commandOutputDeltaLogEntry(text: "second"), - ], controlThreadID: "review-thread") + await session.receive([commandDomainEvent(output: "first")], controlThreadID: "review-thread") + await session.receive([commandDomainEvent(output: "firstsecond")], controlThreadID: "review-thread") await session.finish(throwing: nil) - guard case .domainEvents(let firstDomainEvents, _) = try await nextEvent(from: attempt.events), + guard case .domainEvents(let firstDomainEvents) = try await nextEvent(from: attempt.events), case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), case .command(let firstCommand) = firstSeed.content else { @@ -76,7 +68,7 @@ struct ReviewBackendEventSessionTests { } #expect(firstCommand.output == "first") - guard case .domainEvents(let secondDomainEvents, _) = try await nextEvent(from: attempt.events), + guard case .domainEvents(let secondDomainEvents) = try await nextEvent(from: attempt.events), case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), case .command(let secondCommand) = secondSeed.content else { @@ -84,16 +76,6 @@ struct ReviewBackendEventSessionTests { return } #expect(secondCommand.output == "firstsecond") - - #expect( - try await nextEvent(from: attempt.events) - == .logEntry( - kind: .commandOutput, - text: "firstsecond", - groupID: "cmd-1", - replacesGroup: false, - metadata: commandOutputMetadata() - )) #expect(try await nextEvent(from: attempt.events) == nil) } @@ -101,12 +83,12 @@ struct ReviewBackendEventSessionTests { let session = ReviewBackendEventSession(run: makeRun()) let attempt = await session.attempt() - await session.receive([.messageDelta("Looks ", itemID: "msg-1")]) - await session.receive([.messageDelta("good.", itemID: "msg-1")]) + await session.receive([messageTextDelta("Looks ", itemID: "msg-1")]) + await session.receive([messageTextDelta("good.", itemID: "msg-1")]) await session.receive([.completed(summary: "Succeeded.", result: nil)]) - #expect(try await nextEvent(from: attempt.events) == .messageDelta("Looks ", itemID: "msg-1")) - #expect(try await nextEvent(from: attempt.events) == .messageDelta("good.", itemID: "msg-1")) + #expect(try await nextEvent(from: attempt.events) == messageTextDelta("Looks ", itemID: "msg-1")) + #expect(try await nextEvent(from: attempt.events) == messageTextDelta("good.", itemID: "msg-1")) #expect( try await nextEvent(from: attempt.events) == .completed( @@ -189,23 +171,21 @@ private func commandDomainEvent(output: String) -> CodexReviewBackendModel.Revie phase: .running, content: .command(.init(command: "swift test", output: output)) )) - ], retainedLogEntryCount: 1) + ]) } -private func commandOutputDeltaLogEntry(text: String) -> CodexReviewBackendModel.Review.Event { - .logEntry( - kind: .commandOutput, - text: text, - groupID: "cmd-1", - replacesGroup: false, - metadata: commandOutputMetadata() - ) -} - -private func commandOutputMetadata() -> ReviewLogEntry.Metadata { - .init( - sourceType: "commandExecution", - title: "Command output", - itemID: "cmd-1" - ) +private func messageTextDelta( + _ delta: String, + itemID: String +) -> CodexReviewBackendModel.Review.Event { + .domainEvents( + [ + .textDelta( + itemID: .init(rawValue: itemID), + kind: .agentMessage, + family: .message, + content: .message(.init(text: "")), + delta: delta + ) + ]) } diff --git a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift b/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift deleted file mode 100644 index 6c3ad8cc..00000000 --- a/Tests/CodexReviewKitTests/CodexReviewJobRenderingTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Foundation -import Testing -@_spi(Testing) @testable import CodexReviewKit - -@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 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/CodexReviewKitTests/CodexReviewJobTests.swift b/Tests/CodexReviewKitTests/CodexReviewJobTests.swift new file mode 100644 index 00000000..ec845beb --- /dev/null +++ b/Tests/CodexReviewKitTests/CodexReviewJobTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import CodexReviewKit + +@Suite("Codex review job") +@MainActor +struct CodexReviewJobTests { + @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) + } +} diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 4cb85a31..df4cc82f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -69,1425 +69,6 @@ struct CodexReviewStoreCommandTests { } } - @Test func domainEventsMutateTimelineAndLogEntriesDoNotProject() 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: "")) - )) - ], retainedLogEntryCount: 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" - ) - ], retainedLogEntryCount: 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: " log text", - groupID: "msg-1", - replacesGroup: false - )) - #expect( - await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == " log 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: "log-only diagnostic", - groupID: nil, - replacesGroup: false - )) - #expect( - await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == "log-only diagnostic" } == true - }) - #expect(store.job(id: "job-1")?.timeline.items.count == 1) - #expect(store.job(id: "job-1")?.timeline.item(for: itemID) === originalItem) - } - } - - @Test func terminalCommandRetainedLogTextUpdatesDirectTimelineItem() 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")) - )) - ], retainedLogEntryCount: 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( - .domainEvents( - [ - .itemCompleted( - .init( - id: itemID, - kind: .commandExecution, - family: .command, - phase: .completed, - content: .command(.init(command: "swift test")) - )) - ], retainedLogEntryCount: 1)) - 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 skippedLogDeltaConsumesTimelineTextRetention() 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")) - )) - ], retainedLogEntryCount: 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" - ) - ], retainedLogEntryCount: 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: "log-only diagnostic", - groupID: nil, - replacesGroup: false - )) - #expect( - await waitUntil { - store.job(id: "job-1")?.logEntries.contains { $0.text == "log-only diagnostic" } == true - }) - #expect(store.job(id: "job-1")?.timeline.items.count == timelineCount) - } - } - - @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")) - )) - ], retainedLogEntryCount: 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" - ) - ], retainedLogEntryCount: 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 directTerminalErrorRetainsTimelineTextFromErrorLogEntry() 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)) - )) - ], retainedLogEntryCount: 0)) - await backend.yield(.retainNextTerminalFailureTimelineTextFromLogEntry) - 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 - ), - ], retainedLogEntryCount: 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)) - )) - ], retainedLogEntryCount: 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 - ), - ], retainedLogEntryCount: 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 syntheticDirectTimelineTextIsTrimmedThroughSuppressedRetainedLogText() 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)) - )) - ], retainedLogEntryCount: 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 eventRetainedLogTextTrimsDirectDiagnosticTimelineText() 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)) - )) - ], retainedLogEntryCount: 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 diffRetainedLogTextTrimsDirectFileChangeTimelineText() 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)) - )) - ], retainedLogEntryCount: 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 fileChangeOutputRetainedLogTextTrimsDirectFileChangeTimelineText() 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)) - )) - ], retainedLogEntryCount: 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 fileChangeStatusRetainedLogTextDoesNotTrimDirectPatchText() 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)) - )) - ], retainedLogEntryCount: 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 searchRetainedLogTextTrimsDirectSearchResult() 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)) - )) - ], retainedLogEntryCount: 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 searchCompletionSummaryRetainedLogTextDoesNotClearDirectResult() 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")) - )) - ], retainedLogEntryCount: 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 unownedLargeResultMetadataIsDroppedBeforeLogStorage() 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) - } - } - - @Test func directRawReasoningTimelineTextTrimUsesLogGroupIDAndBumpsRevision() 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 - ) - ], retainedLogEntryCount: 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)) - )) - ], retainedLogEntryCount: 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" - )) - )) - ], retainedLogEntryCount: 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") - } - } - - @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 - )) - )) - ], retainedLogEntryCount: 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 - )) - )) - ], retainedLogEntryCount: 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 mappedDirectTimelineTextClearsWhenRetainedLogTextIsRemovedByLimit() 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)) - )) - ], retainedLogEntryCount: 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)) - )) - ], retainedLogEntryCount: 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 awaitReviewReturnsWhenRunningJobCompletes() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( @@ -1641,37 +222,6 @@ struct CodexReviewStoreCommandTests { } } - @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(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( @@ -1690,71 +240,14 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "second") #expect(read.core.reviewText == "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") - } - } - - @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) + let messages = job.timeline.items.compactMap { item -> String? in + guard case .message(let message) = item.content else { + return nil + } + return message.text + } + #expect(messages == ["first", "second"]) } } @@ -2016,53 +509,6 @@ struct CodexReviewStoreCommandTests { } } - @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() @@ -2127,145 +573,17 @@ struct CodexReviewStoreCommandTests { 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(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.waitForPrepareReviewRestart(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.waitForRestartPreparedReview(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 .restartPreparedReview = command { - true - } else { - false - } - }) - let logText = try #require(store.job(id: "job-1")).logText - #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.waitForPrepareReviewRestart(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.waitForRestartPreparedReview(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 + networkMonitor.yield(.init(status: .unsatisfied)) + try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - #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 .restartPreparedReview = command { - true - } else { - false - } - }) - let logText = try #require(store.job(id: "job-1")).logText - #expect(logText.contains("completed during settle") == false) + 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 } } @@ -2326,72 +644,6 @@ struct CodexReviewStoreCommandTests { } } - @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.waitForPrepareReviewRestart(timeout: .seconds(2)) - _ = try await running - networkMonitor.yield(.satisfied()) - try await backend.waitForRestartPreparedReview(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", @@ -2463,121 +715,6 @@ struct CodexReviewStoreCommandTests { } } - @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.waitForPrepareReviewRestart(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.waitForRestartPreparedReview(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 #require(store.job(id: "job-1")).logText - #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.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(.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 #require(store.job(id: "job-1")).logText - #expect(logText.contains("stale output") == false) - #expect(logText.contains("fresh review")) - } - } - @Test func networkRecoveryIgnoresStaleCompletionAfterRecoveredSubscriptionStarts() async throws { let initialRun = CodexReviewBackendModel.Review.Run( threadID: "thread-1", @@ -3158,54 +1295,6 @@ struct CodexReviewStoreCommandTests { } } - @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.waitForPrepareReviewRestart(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.waitForRestartPreparedReview(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 #require(store.job(id: "job-1")).logText - #expect(logText.contains("stale old attempt output") == false) - } - } - @Test func userCancellationWinsOverPendingNetworkRecovery() async throws { let backend = FakeCodexReviewBackend() let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -3250,101 +1339,6 @@ struct CodexReviewStoreCommandTests { } } - @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.waitForPrepareReviewRestart(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( - try #require(store.job(id: "job-1")).logEntries.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") - 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( diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 866a5b49..ba83aaba 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -410,23 +410,32 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) + let includedJob = CodexReviewJob.makeForTesting( + id: "job-in-session", + sessionID: sessionID, + cwd: "/tmp/project", + targetSummary: "Included", + status: .succeeded, + summary: "Done", + hasFinalReview: true, + lastAgentMessage: "No correctness issues found." + ) + includedJob.timeline.apply( + .itemCompleted( + .init( + id: "cmd-1", + kind: .commandExecution, + family: .command, + phase: .completed, + content: .command(.init(command: "swift test", output: "Tests passed", status: .completed)) + )), + at: Date() + ) 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."), - ] - ), + includedJob, CodexReviewJob.makeForTesting( id: "job-other-session", sessionID: "other-session", @@ -469,7 +478,7 @@ struct CodexReviewMCPHTTPServerTests { #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 == "No correctness issues found.") #expect(readText?.contains("rawLogText") == false) #expect(denied.value(for: ["result", "isError"]) as? Bool == true) #expect( diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 7d723b6b..9612feb2 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -125,7 +125,6 @@ extension CodexReviewJob { hasFinalReview: hasFinalReview, reviewResult: reviewResult, lastAgentMessage: lastAgentMessage, - logEntries: [], errorMessage: errorMessage, exitCode: exitCode ) @@ -142,23 +141,27 @@ func seedTimelineForTesting( ) { let trimmedLogText = logText.trimmingCharacters(in: .newlines) if trimmedLogText.isEmpty == false { - job.timeline.apply(.itemCompleted(.init( - id: .init(rawValue: "fixture-log-\(job.id)"), - kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: trimmedLogText)) - ))) + job.timeline.apply( + .itemCompleted( + .init( + id: .init(rawValue: "fixture-log-\(job.id)"), + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: trimmedLogText)) + ))) } for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { - job.timeline.apply(.itemCompleted(.init( - id: .init(rawValue: "fixture-diagnostic-\(job.id)-\(index)"), - kind: .dynamicToolCall, - family: .diagnostic, - phase: .completed, - content: .diagnostic(.init(message: String(line))) - ))) + job.timeline.apply( + .itemCompleted( + .init( + id: .init(rawValue: "fixture-diagnostic-\(job.id)-\(index)"), + kind: .dynamicToolCall, + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: String(line))) + ))) } } @@ -177,27 +180,31 @@ func appendTimelineEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewTimelin let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content let phase = timelinePhase(for: entry) - job.timeline.apply(.itemUpdated(.init( - id: itemID, - kind: timelineKind(for: entry), - family: timelineFamily(for: entry), - phase: phase, - content: timelineContent(for: entry, existing: existingContent), - startedAt: entry.metadata?.startedAt, - completedAt: entry.metadata?.completedAt, - durationMs: entry.metadata?.durationMs - ))) + job.timeline.apply( + .itemUpdated( + .init( + id: itemID, + kind: timelineKind(for: entry), + family: timelineFamily(for: entry), + phase: phase, + content: timelineContent(for: entry, existing: existingContent), + startedAt: entry.metadata?.startedAt, + completedAt: entry.metadata?.completedAt, + durationMs: entry.metadata?.durationMs + ))) } @MainActor func replaceTimelineLogTextForTesting(_ job: CodexReviewJob, _ text: String) { - job.timeline.apply(.itemUpdated(.init( - id: .init(rawValue: "fixture-log-\(job.id)"), - kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: text.trimmingCharacters(in: .newlines))) - ))) + job.timeline.apply( + .itemUpdated( + .init( + id: .init(rawValue: "fixture-log-\(job.id)"), + kind: .agentMessage, + family: .message, + phase: .completed, + content: .message(.init(text: text.trimmingCharacters(in: .newlines))) + ))) } @MainActor @@ -251,14 +258,15 @@ private func timelineContent( ) -> ReviewTimelineItem.Content { switch entry.kind { case .command: - return .command(.init( - command: commandText(for: entry), - cwd: entry.metadata?.cwd, - output: "", - exitCode: entry.metadata?.exitCode, - status: commandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) + return .command( + .init( + command: commandText(for: entry), + cwd: entry.metadata?.cwd, + output: "", + exitCode: entry.metadata?.exitCode, + status: commandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) case .commandOutput: let existingOutput: String let existingCommand: String? @@ -269,14 +277,15 @@ private func timelineContent( existingOutput = "" existingCommand = nil } - return .command(.init( - command: entry.metadata?.command ?? existingCommand ?? "Command", - cwd: entry.metadata?.cwd, - output: existingOutput + entry.text, - exitCode: entry.metadata?.exitCode, - status: commandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) + return .command( + .init( + command: entry.metadata?.command ?? existingCommand ?? "Command", + cwd: entry.metadata?.cwd, + output: existingOutput + entry.text, + exitCode: entry.metadata?.exitCode, + status: commandStatus(for: entry), + durationMs: entry.metadata?.durationMs + )) case .agentMessage: return .message(.init(text: existingText(existing, message: "") + entry.text)) case .plan, .todoList: @@ -288,15 +297,17 @@ private func timelineContent( case .rawReasoning: return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .raw)) case .contextCompaction: - return .contextCompaction(.init( - title: entry.text, - status: contextCompactionStatus(for: entry) - )) + return .contextCompaction( + .init( + title: entry.text, + status: contextCompactionStatus(for: entry) + )) case .toolCall: - return .toolCall(.init( - result: entry.text, - status: toolCallStatus(for: entry) - )) + return .toolCall( + .init( + result: entry.text, + status: toolCallStatus(for: entry) + )) case .diagnostic, .error, .progress, .event: return .diagnostic(.init(message: existingText(existing, diagnostic: "") + entry.text)) } From 21b1f933f975677275eab877975002027f53ff7f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:48:36 +0900 Subject: [PATCH 119/359] refactor(review-ui): use Codex chat turn projection --- ...rSelectedCodexChatDocumentProjection.swift | 224 +++++------------- 1 file changed, 55 insertions(+), 169 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift index cbcb3938..4a1f3300 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift @@ -4,33 +4,12 @@ import ReviewMonitorRendering @MainActor struct ReviewMonitorSelectedCodexChatDocumentProjection { - private struct ItemKey: Hashable { - var id: String - var turnID: CodexTurnID? - - init(id: String, turnID: CodexTurnID?) { - self.id = id - self.turnID = turnID - } - - init(_ item: CodexChatItemSnapshot) { - self.id = item.id - self.turnID = item.turnID - } - } - - private var turnsByID: [CodexTurnID: CodexChatTurnStateSnapshot] = [:] - private var orderedTurnIDs: [CodexTurnID] = [] - private var itemsByKey: [ItemKey: CodexChatItemSnapshot] = [:] - private var orderedItemKeys: [ItemKey] = [] + private var turnProjection = CodexChatTurnProjection() private var document: ReviewTimelineDocument? private var revision: UInt64 = 0 mutating func reset() { - turnsByID.removeAll(keepingCapacity: true) - orderedTurnIDs.removeAll(keepingCapacity: true) - itemsByKey.removeAll(keepingCapacity: true) - orderedItemKeys.removeAll(keepingCapacity: true) + turnProjection = CodexChatTurnProjection() document = nil revision = 0 } @@ -41,186 +20,100 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewTimelineDocument? { - switch change { - case .snapshot(let snapshot): - apply(snapshot) - guard let effectiveTurnID = effectiveTurnID(activeTurnID: activeTurnID) else { - document = nil - return nil - } - return rebuildDocument( - activeTurnID: effectiveTurnID, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) + turnProjection.selection = activeTurnID.map(CodexChatTurnSelection.turn) ?? .latest + let update = turnProjection.apply(change) + guard update.affectsSelectedTurn else { + return document + } + guard let snapshot = update.snapshot else { + document = nil + return nil + } - case .turnInserted(let turn), .turnUpdated(let turn): - upsert(turn) - guard let effectiveTurnID = effectiveTurnID(activeTurnID: activeTurnID), - turn.id == effectiveTurnID - else { - return document - } + switch update.kind { + case .snapshot, + .turnUpdated, + .phaseChanged: return rebuildDocument( - activeTurnID: effectiveTurnID, + from: snapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) - - case .itemInserted(let item), .itemUpdated(let item): - upsert(item) - guard let effectiveTurnID = effectiveTurnID( - activeTurnID: activeTurnID, - preferredTurnID: item.turnID - ), - item.turnID == effectiveTurnID - else { - return document - } + case .itemUpserted(let item): return rebuildOrReplaceBlock( for: item, - activeTurnID: effectiveTurnID, + turnSnapshot: snapshot.turn, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) - case .itemTextAppended(_, _, _, let item): - upsert(item) - guard let effectiveTurnID = effectiveTurnID( - activeTurnID: activeTurnID, - preferredTurnID: item.turnID - ), - item.turnID == effectiveTurnID - else { - return document - } return replaceBlock( for: item, - activeTurnID: effectiveTurnID, + turnSnapshot: snapshot.turn, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) - case .itemRemoved(let id, let turnID): - removeItem(id: id, turnID: turnID) - guard let effectiveTurnID = effectiveTurnID( - activeTurnID: activeTurnID, - preferredTurnID: turnID - ), - turnID == effectiveTurnID - else { - return document - } return removeBlock( - id: .init(rawValue: "\(effectiveTurnID.rawValue):\(id)"), - activeTurnID: effectiveTurnID + id: .init(rawValue: "\(turnID?.rawValue ?? snapshot.turn.id.rawValue):\(id)"), + turnSnapshot: snapshot.turn ) - - case .phaseChanged: + case .ignored: return document } } - private mutating func apply(_ snapshot: CodexChatSnapshot) { - turnsByID = Dictionary(uniqueKeysWithValues: snapshot.turns.map { ($0.id, $0) }) - orderedTurnIDs = snapshot.turns.map(\.id) - itemsByKey.removeAll(keepingCapacity: true) - orderedItemKeys.removeAll(keepingCapacity: true) - for item in snapshot.items { - upsert(item) - } - } - - private mutating func upsert(_ turn: CodexChatTurnStateSnapshot) { - if turnsByID[turn.id] == nil { - orderedTurnIDs.append(turn.id) - } - turnsByID[turn.id] = turn - } - - private mutating func upsert(_ item: CodexChatItemSnapshot) { - let key = ItemKey(item) - if itemsByKey[key] == nil { - orderedItemKeys.append(key) - } - itemsByKey[key] = item - } - - private mutating func removeItem(id: String, turnID: CodexTurnID?) { - let key = ItemKey(id: id, turnID: turnID) - itemsByKey.removeValue(forKey: key) - orderedItemKeys.removeAll { $0 == key } - } - - private func effectiveTurnID( - activeTurnID: CodexTurnID?, - preferredTurnID: CodexTurnID? = nil - ) -> CodexTurnID? { - activeTurnID ?? orderedTurnIDs.last ?? preferredTurnID + private mutating func rebuildDocument( + from snapshot: CodexChatProjectedTurnSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewTimelineDocument? { + document = ReviewMonitorCodexChatTimelineProjection().document( + from: snapshot.turn, + items: snapshot.items, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + revision: nextRevision() + ) + return document } private mutating func rebuildOrReplaceBlock( for item: CodexChatItemSnapshot, - activeTurnID: CodexTurnID, + turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewTimelineDocument? { guard document != nil else { + guard let snapshot = turnProjection.snapshot else { + return nil + } return rebuildDocument( - activeTurnID: activeTurnID, + from: snapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) } return replaceBlock( for: item, - activeTurnID: activeTurnID, + turnSnapshot: turnSnapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) } - private mutating func rebuildDocument( - activeTurnID: CodexTurnID, - chatCreatedAt: Date?, - chatUpdatedAt: Date? - ) -> ReviewTimelineDocument? { - guard let turn = turnsByID[activeTurnID] else { - document = nil - return nil - } - let items = orderedItemKeys.compactMap { key -> CodexChatItemSnapshot? in - guard key.turnID == activeTurnID else { - return nil - } - return itemsByKey[key] - } - document = ReviewMonitorCodexChatTimelineProjection().document( - from: turn, - items: items, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - revision: nextRevision() - ) - return document - } - private mutating func replaceBlock( for item: CodexChatItemSnapshot, - activeTurnID: CodexTurnID, + turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewTimelineDocument? { - guard let turn = turnsByID[activeTurnID] else { - return rebuildDocument( - activeTurnID: activeTurnID, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) - } guard var document else { + guard let snapshot = turnProjection.snapshot else { + return nil + } return rebuildDocument( - activeTurnID: activeTurnID, + from: snapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) @@ -229,7 +122,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { let projection = ReviewMonitorCodexChatTimelineProjection() let block = projection.block( from: item, - turnSnapshot: turn, + turnSnapshot: turnSnapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) @@ -241,7 +134,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { } refreshMetadata( in: &document, - turn: turn, + turn: turnSnapshot, latestActivityBlockID: block.id, revision: nextRevision() ) @@ -251,7 +144,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { private mutating func removeBlock( id: ReviewTimelineDocument.Block.ID, - activeTurnID: CodexTurnID + turnSnapshot: CodexChatTurnStateSnapshot ) -> ReviewTimelineDocument? { guard var document else { return nil @@ -259,19 +152,12 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { document.blocks.removeAll { $0.id == id } document.orderedBlockIDs.removeAll { $0 == id } let latestActivityBlockID = document.blocks.last?.id - if let turn = turnsByID[activeTurnID] { - refreshMetadata( - in: &document, - turn: turn, - latestActivityBlockID: latestActivityBlockID, - revision: nextRevision() - ) - } else { - document.timelineRevision = .init(rawValue: nextRevision()) - document.activeBlockIDs = document.blocks.filter(\.isActive).map(\.id) - document.activeBlockCount = document.activeBlockIDs.count - document.latestActivityBlockID = latestActivityBlockID - } + refreshMetadata( + in: &document, + turn: turnSnapshot, + latestActivityBlockID: latestActivityBlockID, + revision: nextRevision() + ) self.document = document.blocks.isEmpty ? nil : document return self.document } From 94529975c6ddaa630f0e9e1de5fabb07479810a4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:01:57 +0900 Subject: [PATCH 120/359] refactor(review-ui): stream chat timeline document changes --- ...ReviewMonitorTransportViewController.swift | 36 ++++++++--- .../ReviewMonitorSelectedCodexChat.swift | 26 ++++---- ...rSelectedCodexChatDocumentProjection.swift | 63 +++++++++++++++---- 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 3e1774de..cfdb07b8 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -483,29 +483,51 @@ final class ReviewMonitorTransportViewController: NSViewController { ) { selectedCodexChatDocumentTask?.cancel() selectedCodexChatDocumentHasRendered = false - let stream = selectedCodexChat.timelineDocumentStream() + let stream = selectedCodexChat.timelineDocumentChangeStream() selectedCodexChatDocumentTask = Task { @MainActor [weak self] in var didRenderInitialDocument = false - for await timelineDocument in stream { + for await change in stream { guard let self, self.isCurrentLogRenderTarget(target) else { return } - self.selectedCodexChatDocumentHasRendered = true - self.renderBoundLog( - timelineDocument: timelineDocument, + self.applySelectedCodexChatDocumentChange( + change, target: target, restorationTarget: didRenderInitialDocument ? self.logScrollView.currentScrollRestorationTarget : initialRestorationTarget, - allowIncrementalUpdate: didRenderInitialDocument + allowIncrementalUpdate: didRenderInitialDocument && change.allowsIncrementalRender ) - didRenderInitialDocument = true + if change.document != nil { + didRenderInitialDocument = true + } } } } + private func applySelectedCodexChatDocumentChange( + _ change: ReviewTimelineDocumentChange, + target: LogRenderTarget, + restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, + allowIncrementalUpdate: Bool + ) { + guard let document = change.document else { + selectedCodexChatDocumentHasRendered = false + resetLogRenderer() + logScrollView.clear() + return + } + selectedCodexChatDocumentHasRendered = true + renderBoundLog( + timelineDocument: document, + target: target, + restorationTarget: restorationTarget, + allowIncrementalUpdate: allowIncrementalUpdate + ) + } + private func isCurrentLogRenderTarget(_ target: LogRenderTarget) -> Bool { switch target { case .job(let id): diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 6e1305a0..106448c2 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -63,7 +63,7 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? @ObservationIgnored - private var documentContinuations: [UUID: AsyncStream.Continuation] = [:] + private var documentChangeContinuations: [UUID: AsyncStream.Continuation] = [:] init(modelSource: ReviewMonitorCodexModelSource?) { self.modelSource = modelSource @@ -90,16 +90,16 @@ final class ReviewMonitorSelectedCodexChat { refreshBinding() } - func timelineDocumentStream() -> AsyncStream { + func timelineDocumentChangeStream() -> AsyncStream { let id = UUID() - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - documentContinuations[id] = pair.continuation + let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) + documentChangeContinuations[id] = pair.continuation if let timelineDocument { - pair.continuation.yield(timelineDocument) + pair.continuation.yield(.replaceAll(timelineDocument)) } pair.continuation.onTermination = { [weak self] _ in Task { @MainActor [weak self] in - self?.documentContinuations.removeValue(forKey: id) + self?.documentChangeContinuations.removeValue(forKey: id) } } return pair.stream @@ -126,7 +126,7 @@ final class ReviewMonitorSelectedCodexChat { identity = nextTarget?.reviewIdentity chatID = nextTarget?.chatID chat = nil - publishTimelineDocument(nil) + publishTimelineDocumentChange(.clear) documentProjection.reset() boundModelContext = nextModelContext @@ -156,7 +156,7 @@ final class ReviewMonitorSelectedCodexChat { else { break } - self.publishTimelineDocument( + self.publishTimelineDocumentChange( self.documentProjection.apply( change, activeTurnID: nextTarget.activeTurnID, @@ -177,13 +177,13 @@ final class ReviewMonitorSelectedCodexChat { observation = nil } - private func publishTimelineDocument(_ document: ReviewTimelineDocument?) { - timelineDocument = document - guard let document else { + private func publishTimelineDocumentChange(_ change: ReviewTimelineDocumentChange?) { + guard let change else { return } - for continuation in documentContinuations.values { - continuation.yield(document) + timelineDocument = change.document + for continuation in documentChangeContinuations.values { + continuation.yield(change) } } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift index 4a1f3300..ee8fc40e 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift @@ -2,6 +2,37 @@ import CodexKit import Foundation import ReviewMonitorRendering +@MainActor +enum ReviewTimelineDocumentChange: Equatable { + case replaceAll(ReviewTimelineDocument) + case upsertBlock(ReviewTimelineDocument, ReviewTimelineDocument.Block.ID) + case removeBlock(ReviewTimelineDocument?, ReviewTimelineDocument.Block.ID) + case clear + + var document: ReviewTimelineDocument? { + switch self { + case .replaceAll(let document), + .upsertBlock(let document, _): + return document + case .removeBlock(let document, _): + return document + case .clear: + return nil + } + } + + var allowsIncrementalRender: Bool { + switch self { + case .upsertBlock, + .removeBlock: + return true + case .replaceAll, + .clear: + return false + } + } +} + @MainActor struct ReviewMonitorSelectedCodexChatDocumentProjection { private var turnProjection = CodexChatTurnProjection() @@ -19,15 +50,16 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { activeTurnID: CodexTurnID?, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocument? { + ) -> ReviewTimelineDocumentChange? { turnProjection.selection = activeTurnID.map(CodexChatTurnSelection.turn) ?? .latest let update = turnProjection.apply(change) guard update.affectsSelectedTurn else { - return document + return nil } guard let snapshot = update.snapshot else { + let hadDocument = document != nil document = nil - return nil + return hadDocument ? .clear : nil } switch update.kind { @@ -59,7 +91,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { turnSnapshot: snapshot.turn ) case .ignored: - return document + return nil } } @@ -67,15 +99,20 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { from snapshot: CodexChatProjectedTurnSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocument? { - document = ReviewMonitorCodexChatTimelineProjection().document( + ) -> ReviewTimelineDocumentChange? { + let hadDocument = document != nil + guard let nextDocument = ReviewMonitorCodexChatTimelineProjection().document( from: snapshot.turn, items: snapshot.items, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt, revision: nextRevision() - ) - return document + ) else { + document = nil + return hadDocument ? .clear : nil + } + document = nextDocument + return .replaceAll(nextDocument) } private mutating func rebuildOrReplaceBlock( @@ -83,7 +120,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocument? { + ) -> ReviewTimelineDocumentChange? { guard document != nil else { guard let snapshot = turnProjection.snapshot else { return nil @@ -107,7 +144,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocument? { + ) -> ReviewTimelineDocumentChange? { guard var document else { guard let snapshot = turnProjection.snapshot else { return nil @@ -139,13 +176,13 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { revision: nextRevision() ) self.document = document - return document + return .upsertBlock(document, block.id) } private mutating func removeBlock( id: ReviewTimelineDocument.Block.ID, turnSnapshot: CodexChatTurnStateSnapshot - ) -> ReviewTimelineDocument? { + ) -> ReviewTimelineDocumentChange? { guard var document else { return nil } @@ -159,7 +196,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { revision: nextRevision() ) self.document = document.blocks.isEmpty ? nil : document - return self.document + return .removeBlock(self.document, id) } private mutating func nextRevision() -> UInt64 { From 2c5cef07af627fe555a4fa454979cb97107255f1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:01:56 +0900 Subject: [PATCH 121/359] fix(review-ui): preserve command output panel scroll --- ...viewMonitorLogCommandOutputPanelView.swift | 59 +++++++++++++++++-- .../Detail/ReviewMonitorLogDocumentView.swift | 27 ++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift index a3485b80..aab4f7e9 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift +++ b/Sources/ReviewUI/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/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift index 099d4d96..c6670826 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift @@ -44,6 +44,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, 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? @@ -391,6 +392,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, return } + cacheCommandOutputPanelOutputScrollOffsets() let commandOutputPanels = displayPanelsWithCurrentExpansionState(document.commandOutputPanels) let changedPanelRanges = changedCommandOutputPanelRanges( from: currentCommandOutputPanels, @@ -398,6 +400,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, ) currentDecorations = document.decorations currentCommandOutputPanels = commandOutputPanels + pruneCommandOutputPanelOutputScrollOffsets(for: commandOutputPanels) invalidateFinderStringMapping() if let append, let invalidationRange = styleInvalidationRange(for: append, in: document) @@ -428,6 +431,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, func resetCommandOutputPanelState() { expandedCommandOutputBlockIDs.removeAll() + commandOutputPanelOutputScrollOffsetsByBlockID.removeAll() currentCommandOutputPanels = displayPanelsWithCurrentExpansionState(currentCommandOutputPanels) invalidateFinderStringMapping() } @@ -435,6 +439,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, func pruneCommandOutputPanelExpansionState(for panels: [ReviewMonitorLog.CommandOutputPanel]) { let blockIDs = Set(panels.map(\.blockID)) expandedCommandOutputBlockIDs.formIntersection(blockIDs) + commandOutputPanelOutputScrollOffsetsByBlockID = commandOutputPanelOutputScrollOffsetsByBlockID + .filter { blockIDs.contains($0.key) } } @discardableResult @@ -643,7 +649,8 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, .attachment, value: ReviewMonitorCommandOutputPanelAttachment( panel: commandOutputPanelAttachmentPayload(for: panel), - outputLineHeight: commandOutputPanelLineHeight + outputLineHeight: commandOutputPanelLineHeight, + restoredOutputScrollOffset: commandOutputPanelOutputScrollOffsetsByBlockID[panel.blockID] ), range: panelAttachmentRange ) @@ -2455,6 +2462,24 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, } } + 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 From 54d61b84ad6431586452aa629c66aba171d74d1c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:02:08 +0900 Subject: [PATCH 122/359] refactor(review-ui): remove timeline detail fallback --- ...ReviewMonitorTransportViewController.swift | 81 +- .../ReviewMonitorTimelineTesting.swift | 76 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 94 +-- Tests/ReviewUITests/ReviewUITests.swift | 717 ++++++++++++------ 4 files changed, 652 insertions(+), 316 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index cfdb07b8..14a7013e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -215,34 +215,13 @@ final class ReviewMonitorTransportViewController: NSViewController { initialRestorationTarget: restorationTarget(selectedJob) ) - selectedJobObservation = withPortableContinuousObservation { [weak self] event in - let eventKind = event.kind + selectedJobObservation = withPortableContinuousObservation { [weak self] _ in guard let self, self.boundJob === selectedJob else { return } self.selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) - let timeline = selectedJob.timeline - #if DEBUG - _ = timeline.revision - #endif - guard self.selectedCodexChatDocumentHasRendered == false else { - return - } - if let timelineDocument = self.timelineDocumentForBoundJob(timeline: timeline) { - self.renderBoundLog( - timelineDocument: timelineDocument, - target: .job(selectedJob.id), - restorationTarget: eventKind == .initial - ? self.restorationTarget(selectedJob) - : self.logScrollView.currentScrollRestorationTarget, - allowIncrementalUpdate: eventKind != .initial - ) - return - } - - self.logScrollView.clear() } } @@ -537,15 +516,6 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func timelineDocumentForBoundJob(timeline: ReviewTimeline) -> ReviewTimelineDocument? { - #if DEBUG - let document = ReviewTimelineDocumentRenderer().document(from: timeline) - return document.blocks.isEmpty ? nil : document - #else - return nil - #endif - } - private func cacheBoundJobScrollTarget() { guard let boundJob else { return @@ -1027,19 +997,11 @@ final class ReviewMonitorTransportViewController: NSViewController { var expectedRenderSnapshotForTesting: RenderSnapshotForTesting { switch uiState.selection { - case .job(let job): + case .job: .init( title: nil, summary: nil, - log: { - let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - if timelineDocument.blocks.isEmpty == false { - var projection = ReviewMonitorTimelineLogProjection() - let document = projection.render(timelineDocument: timelineDocument) - return logScrollView.displayTextForTesting(sourceDocument: document) - } - return "" - }(), + log: displayedLogForTesting, isShowingEmptyState: false ) case .workspaceSection: @@ -1060,7 +1022,7 @@ final class ReviewMonitorTransportViewController: NSViewController { .init( title: nil, summary: nil, - log: "", + log: displayedLogForTesting, isShowingEmptyState: false ) case nil: @@ -1329,6 +1291,41 @@ final class ReviewMonitorTransportViewController: NSViewController { logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate) } + @discardableResult + func renderTimelineDocumentForTesting( + _ timelineDocument: ReviewTimelineDocument, + target: DisplayedSelectionForTesting? = nil, + restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, + allowIncrementalUpdate: Bool + ) -> Bool { + let resolvedTarget: LogRenderTarget + switch target ?? displayedSelectionForTesting { + case .job(let id): + resolvedTarget = .job(id) + case .chat(let id): + resolvedTarget = .chat(CodexThreadID(rawValue: id)) + case .workspaceSection, .workspace, nil: + return false + } + let resolvedRestorationTarget = restorationTarget ?? { + guard hasAppliedBoundLog == false else { + return logScrollView.currentScrollRestorationTarget + } + switch resolvedTarget { + case .job(let id): + return logScrollTargetsByJobID[id] ?? .bottom + case .chat: + return .bottom + } + }() + return renderBoundLog( + timelineDocument: timelineDocument, + target: resolvedTarget, + restorationTarget: resolvedRestorationTarget, + allowIncrementalUpdate: allowIncrementalUpdate + ) + } + func copyLogSelectionForTesting() { logScrollView.copySelectionForTesting() } diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 9612feb2..c4d35fa1 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -180,18 +180,17 @@ func appendTimelineEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewTimelin let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content let phase = timelinePhase(for: entry) - job.timeline.apply( - .itemUpdated( - .init( - id: itemID, - kind: timelineKind(for: entry), - family: timelineFamily(for: entry), - phase: phase, - content: timelineContent(for: entry, existing: existingContent), - startedAt: entry.metadata?.startedAt, - completedAt: entry.metadata?.completedAt, - durationMs: entry.metadata?.durationMs - ))) + let seed = ReviewTimelineItemSeed( + id: itemID, + kind: timelineKind(for: entry), + family: timelineFamily(for: entry), + phase: phase, + content: timelineContent(for: entry, existing: existingContent), + startedAt: entry.metadata?.startedAt, + completedAt: entry.metadata?.completedAt, + durationMs: entry.metadata?.durationMs + ) + job.timeline.apply(phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed)) } @MainActor @@ -211,7 +210,54 @@ func replaceTimelineLogTextForTesting(_ job: CodexReviewJob, _ text: String) { func reviewMonitorLogText(for job: CodexReviewJob) -> String { var projection = ReviewMonitorTimelineLogProjection() let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - return projection.render(timelineDocument: timelineDocument).text + let sourceDocument = projection.render(timelineDocument: timelineDocument) + let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceDocument) + return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) +} + +@MainActor +@discardableResult +func renderTimelineForTesting( + _ job: CodexReviewJob, + in transport: ReviewMonitorTransportViewController, + restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, + allowIncrementalUpdate: Bool +) -> Bool { + let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) + return transport.renderTimelineDocumentForTesting( + timelineDocument, + target: .job(job.id), + restoring: restorationTarget, + allowIncrementalUpdate: allowIncrementalUpdate + ) +} + +@MainActor +func awaitTimelineRenderForTesting( + _ job: CodexReviewJob, + in transport: ReviewMonitorTransportViewController, + restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, + allowIncrementalUpdate: Bool = true, + timeout: Duration = .seconds(2), + matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil +) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { + let expectedLog = reviewMonitorLogText(for: job) + try await waitForCondition(timeout: timeout) { + transport.renderedStateForTesting.selection == .job(job.id) + && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false + } + _ = renderTimelineForTesting( + job, + in: transport, + restoring: restorationTarget, + allowIncrementalUpdate: allowIncrementalUpdate + ) + return try await awaitTransportRender(transport, timeout: timeout) { snapshot in + if let predicate { + return predicate(snapshot) + } + return snapshot.log == expectedLog + } } private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { @@ -353,6 +399,10 @@ private func commandText(for entry: ReviewTimelineEntryForTesting) -> String { private func timelinePhase(for entry: ReviewTimelineEntryForTesting) -> ReviewItemPhase { let status = entry.metadata?.commandStatus ?? entry.metadata?.status + let normalizedStatus = ReviewItemPhase.normalized(status) + if normalizedStatus.isTerminal { + return normalizedStatus + } switch status { case "inProgress", "running", "started": return .running diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index f366e45b..9d9211bf 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1079,11 +1079,11 @@ extension ReviewUITests { } @Test func detailLogViewExtendsBehindTitlebarWithoutOverlappingSidebar() async throws { + let logText = "Safe area log\n" let job = makeJob( id: "job-safe-area", status: .running, - targetSummary: "Uncommitted changes", - logText: "Safe area log\n" + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1095,9 +1095,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, job: job) let logFrame = transport.logFrameForTesting let viewBounds = transport.viewBoundsForTesting @@ -1120,11 +1118,11 @@ extension ReviewUITests { } @Test func shortDetailLogKeepsTextContentWithinDocumentBounds() async throws { + let logText = "Short log\n" let job = makeJob( id: "job-short-log-layout", status: .running, - targetSummary: "Uncommitted changes", - logText: "Short log\n" + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1136,9 +1134,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, job: job) let textContentFrame = transport.logTextContentFrameForTesting let documentViewFrame = transport.logDocumentViewFrameForTesting @@ -1150,12 +1146,12 @@ extension ReviewUITests { } @Test func detailLogExpandsAfterSidebarReopensFromCompactWidth() async throws { + let logText = Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40) + .joined() 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() + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1169,8 +1165,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, job: job) window.setContentSize(NSSize(width: 360, height: 420)) sidebarItem.isCollapsed = true @@ -1197,12 +1192,12 @@ extension ReviewUITests { } @Test func detailLogShrinksAfterSidebarReopensIntoNarrowWidth() async throws { + let logText = Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40) + .joined() 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() + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1216,10 +1211,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, job: job) let expandedDocumentWidth = transport.logDocumentViewFrameForTesting.width expectLogTextContainerWidthTracksContentView(transport) @@ -1240,11 +1232,11 @@ extension ReviewUITests { } @Test func detailLogTracksSimpleWindowResizeInBothDirections() async throws { + let logText = Array(repeating: "Long line that should reflow as the window resizes.\n", count: 40).joined() 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() + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1256,10 +1248,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, job: job) let wideWidth = transport.logDocumentViewFrameForTesting.width expectLogTextContainerWidthTracksContentView(transport) @@ -1290,12 +1279,12 @@ extension ReviewUITests { } @Test func detailLogRewrapsVisibleTextDuringLiveWindowResize() async throws { + let logText = String(repeating: "wrap-sensitive text ", count: 600) 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) + summary: "Running review." ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1313,10 +1302,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, job: job) let wideWidth = transport.logDocumentViewFrameForTesting.width let wideDocumentHeight = transport.logDocumentViewFrameForTesting.height let wideFragmentHeight = transport.logVisibleFragmentBoundsForTesting.height @@ -1354,8 +1340,7 @@ extension ReviewUITests { id: "job-window-live-resize-stream-log", status: .running, targetSummary: "Uncommitted changes", - summary: "Running review.", - logText: streamLog + summary: "Running review." ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1373,10 +1358,7 @@ 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, job: job) #expect(transport.isLogPinnedToBottomForTesting) transport.beginLogLiveResizeForTesting() @@ -1396,13 +1378,13 @@ extension ReviewUITests { } @Test func detailLogTextContainerExpandsAfterToolbarSidebarToggleAtCompactWidth() async throws { + let logText = Array( + repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40 + ).joined() 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() + targetSummary: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) @@ -1420,8 +1402,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, job: job) viewController.toggleSidebar(nil) await awaitNativeLayoutTurn() @@ -1652,6 +1633,27 @@ private func contextCompactionStatus(_ item: ReviewTimelineItem) -> ReviewContex return nil } +@MainActor +private func renderDetailLogForShellLayoutTesting( + _ text: String, + in transport: ReviewMonitorTransportViewController, + viewController: ReviewMonitorSplitViewController, + job: CodexReviewJob +) async throws { + viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + try await waitForCondition { + transport.renderedStateForTesting.selection == .job(job.id) + && 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)" let defaults = try #require(UserDefaults(suiteName: suiteName)) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index ef24bba7..a92d4943 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1777,7 +1777,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting sidebar.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) sidebar.collapseWorkspaceInOutlineForTesting(storedWorkspace) try await waitForCondition { @@ -2343,7 +2347,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) - let selectedSnapshot = try await awaitTransportRender(transport) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) #expect( selectedSnapshot == .init( @@ -2375,7 +2383,7 @@ struct ReviewUITests { appendTimelineEntryForTesting( recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) - let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: transport) { snapshot in snapshot.log.contains("Current selection log after stale mutation") } #expect(updatedSnapshot.log.contains("Old selection log") == false) @@ -3324,7 +3332,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let selectedSnapshot = try await awaitTransportRender(transport) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(selectedSnapshot.title == nil) #expect(selectedSnapshot.summary == nil) #expect(window.title == job.targetSummary) @@ -3369,7 +3381,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) job.timeline.apply( .itemCompleted( @@ -3381,7 +3397,7 @@ struct ReviewUITests { content: .message(.init(text: "Timeline-only detail update")) ))) - var snapshot = try await awaitTransportRender(transport) + var snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log == "Timeline-only detail update") let startedAt = Date(timeIntervalSince1970: 250) @@ -3405,7 +3421,7 @@ struct ReviewUITests { durationMs: 2_000 ))) - snapshot = try await awaitTransportRender(transport) { + snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { $0.log.contains("Ran swift test for 2s") } #expect(snapshot.log.contains("Timeline-only detail update")) @@ -3451,7 +3467,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let startedAt = Date(timeIntervalSince1970: 300) job.timeline.apply( @@ -3471,7 +3491,7 @@ struct ReviewUITests { durationMs: 4_000 ))) - let snapshot = try await awaitTransportRender(transport) { + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { $0.log.contains("Ran swift test for 4s") } #expect(snapshot.log.contains("Tests failed") == false) @@ -3512,7 +3532,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) job.timeline.apply( .itemStarted( @@ -3529,7 +3553,7 @@ struct ReviewUITests { startedAt: Date(timeIntervalSince1970: 300) ))) - let snapshot = try await awaitTransportRender(transport) { + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { $0.log.contains("Running swift test") } #expect(snapshot.log.contains("Running swift test")) @@ -3572,7 +3596,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) job.timeline.apply( .itemCompleted( @@ -3589,7 +3617,7 @@ struct ReviewUITests { )) ))) - let snapshot = try await awaitTransportRender(transport) { + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { $0.log.contains("Ran swift test") } #expect(snapshot.log.contains("Running swift test") == false) @@ -3914,7 +3942,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) job.timeline.apply( .itemCompleted( @@ -3932,7 +3964,7 @@ struct ReviewUITests { )) ))) - let snapshot = try await awaitTransportRender(transport) { + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { $0.log.contains("Updated Sources/App.swift") } #expect(snapshot.log.contains("Updated Sources/App.swift")) @@ -3989,7 +4021,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.displayedLogForTesting == "Automatically compacting context") #expect(transport.logFindStringForTesting.contains("Automatically compacting context")) #expect(transport.logCommandOutputPanelCountForTesting == 0) @@ -4007,7 +4043,7 @@ struct ReviewUITests { itemID: "compact_1" ) )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.displayedLogForTesting == "Context automatically compacted") #expect(transport.displayedLogForTesting.contains("Automatically compacting context") == false) @@ -4062,7 +4098,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + 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) @@ -4128,7 +4168,7 @@ struct ReviewUITests { text: "\noutput line 10", metadata: commandMetadata )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logReloadCountForTesting == expandedOutputAppendReloadCount) let offsetAfterOutputAppend = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) @@ -4165,7 +4205,7 @@ struct ReviewUITests { metadata: commandMetadata )) appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 11") == true) #expect(transport.logFindStringForTesting.contains("output line 11") == false) @@ -4220,7 +4260,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let firstBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_1") let secondBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_2") @@ -4271,7 +4315,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logCommandOutputPanelCountForTesting == 1) #expect(transport.displayedLogForTesting.contains("Running swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) @@ -4291,7 +4339,7 @@ struct ReviewUITests { commandStatus: "completed" ) )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logCommandOutputPanelCountForTesting == 1) #expect(transport.displayedLogForTesting.contains("Ran swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) @@ -4330,7 +4378,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) @@ -4353,7 +4405,7 @@ struct ReviewUITests { appendTimelineEntryForTesting( job, .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logFindClientUsesSnapshotForTesting) @@ -4390,14 +4442,22 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - let activeSnapshot = try await awaitTransportRender(transport) + let activeSnapshot = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(activeSnapshot.title == nil) #expect(activeSnapshot.summary == nil) #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) - let recentSnapshot = try await awaitTransportRender(transport) + let recentSnapshot = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) #expect( recentSnapshot == .init( @@ -4430,7 +4490,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.view.layoutSubtreeIfNeeded() #expect(transport.isLogPinnedToBottomForTesting) @@ -4467,18 +4531,30 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + activeJob, + 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) + _ = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.isLogPinnedToBottomForTesting) viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logVerticalScrollOffsetForTesting == activeOffset) #expect(transport.isLogPinnedToBottomForTesting == false) @@ -4515,18 +4591,30 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.isLogPinnedToBottomForTesting) appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(snapshot.log.contains("Newest active line")) #expect(transport.isLogPinnedToBottomForTesting) @@ -4552,7 +4640,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.scrollLogToOffsetForTesting(120) let preservedOffset = transport.logVerticalScrollOffsetForTesting @@ -4598,12 +4690,20 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.isLogPinnedToBottomForTesting) } @@ -4635,10 +4735,18 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + shortJob, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.selectJobForTesting(longJob) - let longSnapshot = try await awaitTransportRender(transport) + let longSnapshot = try await awaitTimelineRenderForTesting( + longJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(longSnapshot.log == reviewMonitorLogText(for: longJob)) #expect(transport.isLogPinnedToBottomForTesting) @@ -4673,14 +4781,26 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + shortJob, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) expectLogVisibleFragmentsWithoutForcingLayout(transport) replaceTimelineLogTextForTesting(shortJob, longLog) viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + shortJob, + in: transport, + allowIncrementalUpdate: false + ) #expect( abs( @@ -4717,13 +4837,21 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) appendTimelineEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) - let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: transport) { snapshot in snapshot.log.contains("fresh update") } #expect(updatedSnapshot.log.contains("stale update") == false) @@ -4753,7 +4881,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let selectedSnapshot = try await awaitTransportRender(transport) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == job.id) @@ -4786,7 +4918,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(workspace) _ = try await awaitTransportRender(transport) @@ -4854,7 +4990,11 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) - let activeSnapshot = try await awaitTransportRender(transport) + let activeSnapshot = try await awaitTimelineRenderForTesting( + activeJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(activeSnapshot.title == nil) #expect(activeSnapshot.summary == nil) store.loadForTesting( @@ -4896,7 +5036,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let selectedSnapshot = try await awaitTransportRender(transport) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(selectedSnapshot.title == nil) #expect(window.title == job.targetSummary) #expect(window.subtitle == job.cwd) @@ -4935,7 +5079,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let selectedSnapshot = try await awaitTransportRender(transport) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(selectedSnapshot.title == nil) #expect(selectedSnapshot.summary == nil) job.updateStateForTesting( @@ -4944,7 +5092,7 @@ struct ReviewUITests { ) replaceTimelineLogTextForTesting(job, "Updated log") - let updatedSnapshot = try await awaitTransportRender(transport) + let updatedSnapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == "job-1") #expect(updatedSnapshot.summary == nil) #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) @@ -4975,13 +5123,17 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log == "Initial log") #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logReloadCountForTesting == reloadCount) @@ -5009,11 +5161,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let wordGlowCount = transport.logWordGlowCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log.hasSuffix("stream.tick 001")) #expect(transport.logWordGlowCountForTesting == wordGlowCount) } @@ -5041,7 +5197,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting @@ -5073,11 +5233,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log == "Initial one two") } @@ -5102,11 +5266,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log.hasSuffix("stream.tick 002")) } @@ -5131,7 +5299,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogReduceMotionForTesting(false) let wordGlowCount = transport.logWordGlowCountForTesting appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) @@ -5143,7 +5315,7 @@ struct ReviewUITests { text: String(repeating: "progress ", count: 20) )) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log.contains("progress progress")) #expect(transport.logAppendCountForTesting > 0) #expect(transport.logWordGlowCountForTesting == wordGlowCount + 1) @@ -5165,7 +5337,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.view.layoutSubtreeIfNeeded() let initialDocumentFrame = transport.logDocumentViewFrameForTesting @@ -5180,7 +5356,7 @@ struct ReviewUITests { text: "stream.tick 001 delta/layout +2 -0 while the short log remains below the scrollable viewport height" )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) transport.view.layoutSubtreeIfNeeded() let appendedDocumentFrame = transport.logDocumentViewFrameForTesting @@ -5211,90 +5387,24 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting appendTimelineEntryForTesting( job, .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log == "- updated") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) #expect(transport.logReloadCountForTesting == reloadCount) } - @Test func commandCompletionAndReasoningAppendDoNotReloadFullLog() async throws { - let startedAt = Date(timeIntervalSince1970: 200) - let completedAt = Date(timeIntervalSince1970: 203) - let job = CodexReviewJob.makeForTesting( - id: "job-command-close-reasoning-append", - cwd: "/tmp/workspace-alpha", - targetSummary: "Uncommitted changes", - threadID: UUID().uuidString, - turnID: UUID().uuidString, - status: .running, - startedAt: startedAt, - summary: "Running review.", - timelineEntries: [ - .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 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) - - appendTimelineEntryForTesting( - job, - .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" - ) - )) - appendTimelineEntryForTesting( - job, - .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.")) - } - @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws { let startedAt = Date(timeIntervalSince1970: 200) let job = CodexReviewJob.makeForTesting( @@ -5322,7 +5432,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting @@ -5349,7 +5463,7 @@ struct ReviewUITests { text: "Inspecting details after the command starts." )) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log.contains("Need to inspect files.")) #expect(snapshot.log.contains("Running git diff")) #expect(snapshot.log.contains("Inspecting details after the command starts.")) @@ -5378,13 +5492,17 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) #expect(snapshot.log == "bold") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) @@ -5412,7 +5530,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) appendTimelineEntryForTesting( job, .init( @@ -5421,12 +5543,12 @@ struct ReviewUITests { replacesGroup: true, text: "- updated with longer replacement text" )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting appendTimelineEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) - _ = try await awaitTransportRender(transport) { snapshot in + _ = try await awaitTimelineRenderForTesting(job, in: transport) { snapshot in snapshot.log.contains("Command output") } @@ -5454,7 +5576,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting job.updateStateForTesting(summary: "Updated summary.") @@ -5485,11 +5611,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogReduceMotionForTesting(false) appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 2) @@ -5497,14 +5627,14 @@ struct ReviewUITests { #expect(transport.logWordGlowCountForTesting == 0) appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 1) transport.setLogReduceMotionForTesting(true) appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 0) } @@ -5545,18 +5675,30 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) appendTimelineEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logWordGlowCountForTesting == 0) appendTimelineEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) #expect(transport.logWordGlowCountForTesting > 0) } @@ -5582,13 +5724,17 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogReduceMotionForTesting(false) let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting appendTimelineEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting > 0) #expect(transport.logWordFadeRenderingAttributeRangeCountForTesting > 0) @@ -5624,11 +5770,15 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogReduceMotionForTesting(false) appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting > 0) transport.advanceLogWordGlowAnimationsAfterInitialDelayForTesting(5) @@ -5655,7 +5805,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.isLogPinnedToBottomForTesting) transport.scrollLogToBottomForTesting() @@ -5665,7 +5819,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == unpinnedAutoFollow) #expect(transport.isLogPinnedToBottomForTesting == false) @@ -5673,7 +5827,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) let pinnedAutoFollow = transport.logAutoFollowCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) } @@ -5695,7 +5849,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) @@ -5704,7 +5862,7 @@ struct ReviewUITests { .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") appendTimelineEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) @@ -5734,7 +5892,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let nearBottomOffset = transport.logMaximumVerticalScrollOffsetForTesting - 12 transport.scrollLogToOffsetForTesting(nearBottomOffset) @@ -5748,7 +5910,7 @@ struct ReviewUITests { kind: .progress, text: "Near-bottom append should not snap inertial or manual scrolling to the document end" )) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == autoFollowBeforeAppend) #expect(transport.logProgrammaticScrollCountForTesting == programmaticScrollsBeforeAppend) @@ -5776,14 +5938,18 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.isLogPinnedToBottomForTesting) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend + 1) @@ -5809,14 +5975,18 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.legacy) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -5840,13 +6010,17 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "short update")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -5878,17 +6052,30 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + restoring: .top, + allowIncrementalUpdate: false + ) #expect(transport.logOverlayScrollerHideRequestCountForTesting > hideCountBeforeRestore) } @@ -5913,14 +6100,18 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -5945,14 +6136,18 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -5972,7 +6167,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logUsesCustomTextKit2SurfaceForTesting) #expect(transport.logUsesTextViewForTesting == false) @@ -6000,7 +6199,11 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.view.layoutSubtreeIfNeeded() let bottomFragmentCount = transport.logVisibleFragmentViewCountForTesting @@ -6039,10 +6242,14 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let appendCount = transport.logAppendCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logVisibleFragmentViewCountForTesting > 0) @@ -6064,7 +6271,11 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.showFindInterface))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.nextMatch))) @@ -6145,7 +6356,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) transport.scrollLogToTopForTesting() #expect(transport.logVisibleFragmentViewCountForTesting > 0) @@ -6190,7 +6405,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let renderedInitialLog = reviewMonitorLogText(for: job) let renderedInitialLength = (renderedInitialLog as NSString).length @@ -6214,7 +6433,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) let appendedLength = (reviewMonitorLogText(for: job) as NSString).length let appendedVisibleRanges = transport.logFindVisibleCharacterRangesForTesting @@ -6241,7 +6460,7 @@ struct ReviewUITests { let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting appendTimelineEntryForTesting( job, .init(kind: .progress, text: "needle appended while the log is not following bottom")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(abs(transport.logVerticalScrollOffsetForTesting - offsetBeforeMiddleAppend) < 0.5) #expect(transport.logSelectedTextForTesting == "needle") @@ -6336,7 +6555,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6344,7 +6567,7 @@ struct ReviewUITests { viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) viewController.sidebarViewControllerForTesting.clearSelectionForTesting() @@ -6352,7 +6575,11 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6388,7 +6615,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6396,7 +6627,7 @@ struct ReviewUITests { viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) #expect( transport.displayedLogForTesting.trimmingCharacters(in: .newlines) == reviewMonitorLogText(for: secondJob).trimmingCharacters(in: .newlines) @@ -6405,7 +6636,11 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6439,7 +6674,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + firstJob, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6452,7 +6691,11 @@ struct ReviewUITests { let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + secondJob, + in: transport, + allowIncrementalUpdate: false + ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logTextFinderIdentifierForTesting == finderIdentifierBeforeSwitch) @@ -6479,7 +6722,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6487,7 +6734,7 @@ struct ReviewUITests { viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindClientUsesSnapshotForTesting) viewController.performTextFinderAction(textFinderMenuItemForTesting(.hideFindInterface)) @@ -6516,7 +6763,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6524,7 +6775,7 @@ struct ReviewUITests { viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6534,7 +6785,7 @@ struct ReviewUITests { #expect(transport.logSelectedTextForTesting == nil) #expect(transport.logFindClientUsesSnapshotForTesting == false) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6560,7 +6811,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) @@ -6568,7 +6823,7 @@ struct ReviewUITests { viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6579,7 +6834,7 @@ struct ReviewUITests { #expect(transport.logHasActiveFindQueryForTesting == false) #expect(transport.logFindClientUsesSnapshotForTesting == false) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6606,7 +6861,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) @@ -6615,7 +6874,7 @@ struct ReviewUITests { #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6642,7 +6901,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let initialLength = (reviewMonitorLogText(for: job) as NSString).length try await withFindPasteboardString(nil) { @@ -6652,7 +6915,7 @@ struct ReviewUITests { #expect(transport.logVisibleFindBarSearchStringForTesting == "core") #expect(transport.logHasActiveFindQueryForTesting) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logVisibleFindBarSearchStringForTesting == "core") @@ -6681,7 +6944,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) let initialLength = (reviewMonitorLogText(for: job) as NSString).length try await withFindPasteboardString(nil) { @@ -6691,7 +6958,7 @@ struct ReviewUITests { #expect(transport.logFindStringLengthForTesting == initialLength) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindStringLengthForTesting == initialLength) @@ -6722,7 +6989,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) @@ -6736,7 +7007,7 @@ struct ReviewUITests { #expect(transport.logHasActiveFindQueryForTesting == false) appendTimelineEntryForTesting( job, .init(kind: .progress, text: "needle appended after normal selection")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6763,7 +7034,11 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) #expect(transport.logFindBarVisibleForTesting) @@ -6776,7 +7051,7 @@ struct ReviewUITests { let initialLength = (reviewMonitorLogText(for: job) as NSString).length appendTimelineEntryForTesting( job, .init(kind: .progress, text: "active query appears after no-result search")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -6826,14 +7101,18 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle first content")) - _ = try await awaitTransportRender(transport) + _ = try await awaitTimelineRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) @@ -6860,7 +7139,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(snapshot.summary == nil) #expect(snapshot.log == reviewMonitorLogText(for: job)) } @@ -6885,7 +7168,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let snapshot = try await awaitTransportRender(transport) + let snapshot = try await awaitTimelineRenderForTesting( + job, + in: transport, + allowIncrementalUpdate: false + ) #expect(snapshot.summary == nil) #expect(snapshot.log == reviewMonitorLogText(for: job)) } From 72ec6f7dd9aa7c5641bcbfbe5e618acf505e6c48 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:13:29 +0900 Subject: [PATCH 123/359] refactor(review-ui): clarify selected chat log stream --- ...ReviewMonitorTransportViewController.swift | 55 +++++++------------ .../ReviewMonitorSelectedCodexChat.swift | 26 ++++----- ...rSelectedCodexChatDocumentProjection.swift | 14 ++--- 3 files changed, 39 insertions(+), 56 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 14a7013e..5748e28c 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -21,9 +21,8 @@ final class ReviewMonitorTransportViewController: NSViewController { private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? private var selectedJobObservation: PortableObservationTracking.Token? - private var selectedChatObservation: PortableObservationTracking.Token? private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? - private var selectedCodexChatDocumentTask: Task? + private var selectedCodexChatLogTask: Task? private var boundJob: CodexReviewJob? private var boundChatID: CodexThreadID? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? @@ -33,7 +32,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 private var hasAppliedBoundLog = false - private var selectedCodexChatDocumentHasRendered = false convenience init( store: CodexReviewStore, @@ -66,9 +64,8 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() selectedJobObservation?.cancel() - selectedChatObservation?.cancel() selectedWorkspaceFindingsObservation?.cancel() - selectedCodexChatDocumentTask?.cancel() + selectedCodexChatLogTask?.cancel() logRenderTask?.cancel() } @@ -202,15 +199,13 @@ final class ReviewMonitorTransportViewController: NSViewController { } selectedJobObservation?.cancel() selectedJobObservation = nil - selectedChatObservation?.cancel() - selectedChatObservation = nil - selectedCodexChatDocumentTask?.cancel() - selectedCodexChatDocumentTask = nil + selectedCodexChatLogTask?.cancel() + selectedCodexChatLogTask = nil boundChatID = nil resetLogRenderer() boundJob = selectedJob selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) - startSelectedCodexChatDocumentStream( + startSelectedCodexChatLogStream( target: .job(selectedJob.id), initialRestorationTarget: restorationTarget(selectedJob) ) @@ -229,25 +224,20 @@ final class ReviewMonitorTransportViewController: NSViewController { if boundChatID != selectedChat.id { logScrollView.resetFindStateForContentReuse() } - selectedChatObservation?.cancel() - selectedChatObservation = nil - selectedCodexChatDocumentTask?.cancel() - selectedCodexChatDocumentTask = nil + selectedCodexChatLogTask?.cancel() + selectedCodexChatLogTask = nil resetLogRenderer() boundChatID = selectedChat.id selectedCodexChat.bind(toChatID: selectedChat.id) - startSelectedCodexChatDocumentStream(target: .chat(selectedChat.id), initialRestorationTarget: .bottom) + startSelectedCodexChatLogStream(target: .chat(selectedChat.id), initialRestorationTarget: .bottom) } private func clearDisplayedLogSelection() { cacheBoundJobScrollTarget() selectedJobObservation?.cancel() selectedJobObservation = nil - selectedChatObservation?.cancel() - selectedChatObservation = nil - selectedCodexChatDocumentTask?.cancel() - selectedCodexChatDocumentTask = nil - selectedCodexChatDocumentHasRendered = false + selectedCodexChatLogTask?.cancel() + selectedCodexChatLogTask = nil boundJob = nil boundChatID = nil selectedCodexChat.unbind() @@ -456,14 +446,13 @@ final class ReviewMonitorTransportViewController: NSViewController { return true } - private func startSelectedCodexChatDocumentStream( + private func startSelectedCodexChatLogStream( target: LogRenderTarget, initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget ) { - selectedCodexChatDocumentTask?.cancel() - selectedCodexChatDocumentHasRendered = false - let stream = selectedCodexChat.timelineDocumentChangeStream() - selectedCodexChatDocumentTask = Task { @MainActor [weak self] in + selectedCodexChatLogTask?.cancel() + let stream = selectedCodexChat.logSourceChangeStream() + selectedCodexChatLogTask = Task { @MainActor [weak self] in var didRenderInitialDocument = false for await change in stream { guard let self, @@ -471,7 +460,7 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - self.applySelectedCodexChatDocumentChange( + self.applySelectedCodexChatLogChange( change, target: target, restorationTarget: didRenderInitialDocument @@ -479,26 +468,24 @@ final class ReviewMonitorTransportViewController: NSViewController { : initialRestorationTarget, allowIncrementalUpdate: didRenderInitialDocument && change.allowsIncrementalRender ) - if change.document != nil { + if change.sourceDocument != nil { didRenderInitialDocument = true } } } } - private func applySelectedCodexChatDocumentChange( - _ change: ReviewTimelineDocumentChange, + private func applySelectedCodexChatLogChange( + _ change: ReviewMonitorLogSourceChange, target: LogRenderTarget, restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, allowIncrementalUpdate: Bool ) { - guard let document = change.document else { - selectedCodexChatDocumentHasRendered = false + guard let document = change.sourceDocument else { resetLogRenderer() logScrollView.clear() return } - selectedCodexChatDocumentHasRendered = true renderBoundLog( timelineDocument: document, target: target, @@ -603,10 +590,6 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation } - var selectedChatObservationForTesting: PortableObservationTracking.Token? { - selectedChatObservation - } - var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { selectedWorkspaceFindingsObservation } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 106448c2..1a22d8b4 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -38,7 +38,7 @@ final class ReviewMonitorSelectedCodexChat { private(set) var identity: CodexReviewIdentity? private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? - private(set) var timelineDocument: ReviewTimelineDocument? + private(set) var logSourceDocument: ReviewTimelineDocument? var phase: CodexDataPhase { chat?.phase ?? .idle } @@ -63,7 +63,7 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? @ObservationIgnored - private var documentChangeContinuations: [UUID: AsyncStream.Continuation] = [:] + private var logSourceChangeContinuations: [UUID: AsyncStream.Continuation] = [:] init(modelSource: ReviewMonitorCodexModelSource?) { self.modelSource = modelSource @@ -90,16 +90,16 @@ final class ReviewMonitorSelectedCodexChat { refreshBinding() } - func timelineDocumentChangeStream() -> AsyncStream { + func logSourceChangeStream() -> AsyncStream { let id = UUID() - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - documentChangeContinuations[id] = pair.continuation - if let timelineDocument { - pair.continuation.yield(.replaceAll(timelineDocument)) + let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) + logSourceChangeContinuations[id] = pair.continuation + if let logSourceDocument { + pair.continuation.yield(.replaceAll(logSourceDocument)) } pair.continuation.onTermination = { [weak self] _ in Task { @MainActor [weak self] in - self?.documentChangeContinuations.removeValue(forKey: id) + self?.logSourceChangeContinuations.removeValue(forKey: id) } } return pair.stream @@ -126,7 +126,7 @@ final class ReviewMonitorSelectedCodexChat { identity = nextTarget?.reviewIdentity chatID = nextTarget?.chatID chat = nil - publishTimelineDocumentChange(.clear) + publishLogSourceChange(.clear) documentProjection.reset() boundModelContext = nextModelContext @@ -156,7 +156,7 @@ final class ReviewMonitorSelectedCodexChat { else { break } - self.publishTimelineDocumentChange( + self.publishLogSourceChange( self.documentProjection.apply( change, activeTurnID: nextTarget.activeTurnID, @@ -177,12 +177,12 @@ final class ReviewMonitorSelectedCodexChat { observation = nil } - private func publishTimelineDocumentChange(_ change: ReviewTimelineDocumentChange?) { + private func publishLogSourceChange(_ change: ReviewMonitorLogSourceChange?) { guard let change else { return } - timelineDocument = change.document - for continuation in documentChangeContinuations.values { + logSourceDocument = change.sourceDocument + for continuation in logSourceChangeContinuations.values { continuation.yield(change) } } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift index ee8fc40e..3984b831 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift @@ -3,13 +3,13 @@ import Foundation import ReviewMonitorRendering @MainActor -enum ReviewTimelineDocumentChange: Equatable { +enum ReviewMonitorLogSourceChange: Equatable { case replaceAll(ReviewTimelineDocument) case upsertBlock(ReviewTimelineDocument, ReviewTimelineDocument.Block.ID) case removeBlock(ReviewTimelineDocument?, ReviewTimelineDocument.Block.ID) case clear - var document: ReviewTimelineDocument? { + var sourceDocument: ReviewTimelineDocument? { switch self { case .replaceAll(let document), .upsertBlock(let document, _): @@ -50,7 +50,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { activeTurnID: CodexTurnID?, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocumentChange? { + ) -> ReviewMonitorLogSourceChange? { turnProjection.selection = activeTurnID.map(CodexChatTurnSelection.turn) ?? .latest let update = turnProjection.apply(change) guard update.affectsSelectedTurn else { @@ -99,7 +99,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { from snapshot: CodexChatProjectedTurnSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocumentChange? { + ) -> ReviewMonitorLogSourceChange? { let hadDocument = document != nil guard let nextDocument = ReviewMonitorCodexChatTimelineProjection().document( from: snapshot.turn, @@ -120,7 +120,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocumentChange? { + ) -> ReviewMonitorLogSourceChange? { guard document != nil else { guard let snapshot = turnProjection.snapshot else { return nil @@ -144,7 +144,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, chatUpdatedAt: Date? - ) -> ReviewTimelineDocumentChange? { + ) -> ReviewMonitorLogSourceChange? { guard var document else { guard let snapshot = turnProjection.snapshot else { return nil @@ -182,7 +182,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { private mutating func removeBlock( id: ReviewTimelineDocument.Block.ID, turnSnapshot: CodexChatTurnStateSnapshot - ) -> ReviewTimelineDocumentChange? { + ) -> ReviewMonitorLogSourceChange? { guard var document else { return nil } From c7797a449a6143c021dfe104c4bcae8f449daba0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:27:26 +0900 Subject: [PATCH 124/359] refactor(review-ui): stream selected chat log documents --- .../Detail/ReviewMonitorLogRenderer.swift | 4 +++ ...ReviewMonitorTransportViewController.swift | 17 ++++++++--- .../ReviewMonitorSelectedCodexChat.swift | 8 ++--- ...nitorSelectedCodexChatLogProjection.swift} | 30 +++++++++++-------- 4 files changed, 38 insertions(+), 21 deletions(-) rename Sources/ReviewUI/{ReviewMonitorSelectedCodexChatDocumentProjection.swift => ReviewMonitorSelectedCodexChatLogProjection.swift} (88%) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift index 8f21d02f..98bf2fef 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift @@ -18,6 +18,10 @@ actor ReviewMonitorLogRenderer { renderedDocument(from: timelineProjection.render(timelineDocument: timelineDocument)) } + func render(sourceDocument: ReviewMonitorLog.Document) -> ReviewMonitorRenderedLogDocument { + renderedDocument(from: sourceDocument) + } + private func renderedDocument( from source: ReviewMonitorLog.Document ) -> ReviewMonitorRenderedLogDocument { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 5748e28c..a6e2bcba 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -16,6 +16,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() + private var timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] @@ -416,7 +417,7 @@ final class ReviewMonitorTransportViewController: NSViewController { @discardableResult private func renderBoundLog( - timelineDocument: ReviewTimelineDocument, + sourceDocument: ReviewMonitorLog.Document, target: LogRenderTarget, restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, allowIncrementalUpdate: Bool @@ -426,7 +427,7 @@ final class ReviewMonitorTransportViewController: NSViewController { let renderer = logRenderer logRenderTask?.cancel() logRenderTask = Task { @MainActor [weak self] in - let renderedDocument = await renderer.render(timelineDocument: timelineDocument) + let renderedDocument = await renderer.render(sourceDocument: sourceDocument) guard Task.isCancelled == false, let self, self.logRenderGeneration == generation, @@ -487,7 +488,7 @@ final class ReviewMonitorTransportViewController: NSViewController { return } renderBoundLog( - timelineDocument: document, + sourceDocument: document, target: target, restorationTarget: restorationTarget, allowIncrementalUpdate: allowIncrementalUpdate @@ -517,6 +518,7 @@ final class ReviewMonitorTransportViewController: NSViewController { appliedLogRenderGeneration = logRenderGeneration hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() + timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() } private func restorationTarget( @@ -1301,14 +1303,21 @@ final class ReviewMonitorTransportViewController: NSViewController { return .bottom } }() + let renderedDocument = logDocumentForTesting(from: timelineDocument) return renderBoundLog( - timelineDocument: timelineDocument, + sourceDocument: renderedDocument, target: resolvedTarget, restorationTarget: resolvedRestorationTarget, allowIncrementalUpdate: allowIncrementalUpdate ) } + private func logDocumentForTesting( + from timelineDocument: ReviewTimelineDocument + ) -> ReviewMonitorLog.Document { + timelineLogProjectionForTesting.render(timelineDocument: timelineDocument) + } + func copyLogSelectionForTesting() { logScrollView.copySelectionForTesting() } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 1a22d8b4..df1558e1 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -38,7 +38,7 @@ final class ReviewMonitorSelectedCodexChat { private(set) var identity: CodexReviewIdentity? private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? - private(set) var logSourceDocument: ReviewTimelineDocument? + private(set) var logSourceDocument: ReviewMonitorLog.Document? var phase: CodexDataPhase { chat?.phase ?? .idle } @@ -59,7 +59,7 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private var observationTask: Task? @ObservationIgnored - private var documentProjection = ReviewMonitorSelectedCodexChatDocumentProjection() + private var logProjection = ReviewMonitorSelectedCodexChatLogProjection() @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? @ObservationIgnored @@ -127,7 +127,7 @@ final class ReviewMonitorSelectedCodexChat { chatID = nextTarget?.chatID chat = nil publishLogSourceChange(.clear) - documentProjection.reset() + logProjection.reset() boundModelContext = nextModelContext guard let nextTarget, let modelContext = nextModelContext else { @@ -157,7 +157,7 @@ final class ReviewMonitorSelectedCodexChat { break } self.publishLogSourceChange( - self.documentProjection.apply( + self.logProjection.apply( change, activeTurnID: nextTarget.activeTurnID, chatCreatedAt: nextChat.createdAt, diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift similarity index 88% rename from Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift rename to Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift index 3984b831..1349c771 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatDocumentProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift @@ -4,17 +4,14 @@ import ReviewMonitorRendering @MainActor enum ReviewMonitorLogSourceChange: Equatable { - case replaceAll(ReviewTimelineDocument) - case upsertBlock(ReviewTimelineDocument, ReviewTimelineDocument.Block.ID) - case removeBlock(ReviewTimelineDocument?, ReviewTimelineDocument.Block.ID) + case replaceAll(ReviewMonitorLog.Document) + case update(ReviewMonitorLog.Document) case clear - var sourceDocument: ReviewTimelineDocument? { + var sourceDocument: ReviewMonitorLog.Document? { switch self { case .replaceAll(let document), - .upsertBlock(let document, _): - return document - case .removeBlock(let document, _): + .update(let document): return document case .clear: return nil @@ -23,8 +20,7 @@ enum ReviewMonitorLogSourceChange: Equatable { var allowsIncrementalRender: Bool { switch self { - case .upsertBlock, - .removeBlock: + case .update: return true case .replaceAll, .clear: @@ -34,14 +30,16 @@ enum ReviewMonitorLogSourceChange: Equatable { } @MainActor -struct ReviewMonitorSelectedCodexChatDocumentProjection { +struct ReviewMonitorSelectedCodexChatLogProjection { private var turnProjection = CodexChatTurnProjection() private var document: ReviewTimelineDocument? + private var logProjection = ReviewMonitorTimelineLogProjection() private var revision: UInt64 = 0 mutating func reset() { turnProjection = CodexChatTurnProjection() document = nil + logProjection = ReviewMonitorTimelineLogProjection() revision = 0 } @@ -59,6 +57,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { guard let snapshot = update.snapshot else { let hadDocument = document != nil document = nil + logProjection = ReviewMonitorTimelineLogProjection() return hadDocument ? .clear : nil } @@ -109,10 +108,11 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { revision: nextRevision() ) else { document = nil + logProjection = ReviewMonitorTimelineLogProjection() return hadDocument ? .clear : nil } document = nextDocument - return .replaceAll(nextDocument) + return .replaceAll(logProjection.render(timelineDocument: nextDocument)) } private mutating func rebuildOrReplaceBlock( @@ -176,7 +176,7 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { revision: nextRevision() ) self.document = document - return .upsertBlock(document, block.id) + return .update(logProjection.render(timelineDocument: document)) } private mutating func removeBlock( @@ -196,7 +196,11 @@ struct ReviewMonitorSelectedCodexChatDocumentProjection { revision: nextRevision() ) self.document = document.blocks.isEmpty ? nil : document - return .removeBlock(self.document, id) + guard let document = self.document else { + logProjection = ReviewMonitorTimelineLogProjection() + return .clear + } + return .update(logProjection.render(timelineDocument: document)) } private mutating func nextRevision() -> UInt64 { From d7908e0fa818093ac5dcb99865c72109eeafc429 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:02:49 +0900 Subject: [PATCH 125/359] refactor(review-ui): render codex chats as log documents --- ...ewMonitorCodexChatTimelineProjection.swift | 421 ---------------- .../ReviewMonitorLogDocumentProjection.swift | 266 ++++++++++ .../ReviewMonitorTimelineLogProjection.swift | 271 +---------- ...ReviewMonitorTransportViewController.swift | 78 ++- .../ReviewMonitorCodexChatLogProjection.swift | 456 ++++++++++++++++++ ...onitorSelectedCodexChatLogProjection.swift | 175 ++----- .../ReviewMonitorSelectedCodexChatTests.swift | 73 +++ Tests/ReviewUITests/ReviewUIShellTests.swift | 27 +- 8 files changed, 939 insertions(+), 828 deletions(-) delete mode 100644 Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift create mode 100644 Sources/ReviewUI/Detail/ReviewMonitorLogDocumentProjection.swift create mode 100644 Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift deleted file mode 100644 index 8f259a15..00000000 --- a/Sources/ReviewUI/Detail/ReviewMonitorCodexChatTimelineProjection.swift +++ /dev/null @@ -1,421 +0,0 @@ -import CodexKit -import CodexReviewKit -import Foundation -import ReviewMonitorRendering - -@MainActor -struct ReviewMonitorCodexChatTimelineProjection { - func document( - from snapshot: CodexChatSnapshot, - activeTurnID: CodexTurnID, - chatCreatedAt: Date?, - chatUpdatedAt: Date?, - revision: UInt64 - ) -> ReviewTimelineDocument? { - guard let turn = snapshot.turns.first(where: { $0.id == activeTurnID }) else { - return nil - } - return document( - from: turn, - items: snapshot.items.filter { $0.turnID == activeTurnID }, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - revision: revision - ) - } - - func document( - from turnSnapshot: CodexChatTurnStateSnapshot, - items: [CodexChatItemSnapshot], - chatCreatedAt: Date?, - chatUpdatedAt: Date?, - revision: UInt64 - ) -> ReviewTimelineDocument? { - guard items.isEmpty == false else { - return nil - } - - let blocks = items.map { - block( - from: $0, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) - } - let activeBlockIDs = blocks.filter(\.isActive).map(\.id) - return ReviewTimelineDocument( - timelineRevision: .init(rawValue: revision), - orderedBlockIDs: blocks.map(\.id), - activeBlockIDs: activeBlockIDs, - activeBlockCount: activeBlockIDs.count, - latestActivityBlockID: blocks.last?.id, - terminalStatus: terminalStatus(for: turnSnapshot), - terminalSummary: turnSnapshot.errorDescription, - terminalResult: nil, - blocks: blocks - ) - } - - func block( - from item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? - ) -> ReviewTimelineDocument.Block { - let content = content(from: item, turnSnapshot: turnSnapshot) - let phase = phase(for: item, turnSnapshot: turnSnapshot) - let blockID = blockID(for: item) - let sourceItemID = ReviewTimelineItem.ID(rawValue: blockID.rawValue) - let timestamp = chatUpdatedAt ?? chatCreatedAt ?? Date(timeIntervalSince1970: 0) - return ReviewTimelineDocument.Block( - id: blockID, - sourceItemID: sourceItemID, - kind: kind(for: item.kind), - family: family(for: item), - phase: phase, - isActive: phase.isTerminal == false, - primaryText: primaryText(for: content), - rawTranscriptText: rawTranscriptText(for: content), - content: content, - createdAt: chatCreatedAt ?? timestamp, - updatedAt: timestamp - ) - } - - func blockID(for item: CodexChatItemSnapshot) -> ReviewTimelineDocument.Block.ID { - let rawTurnID = item.turnID?.rawValue ?? "unknown-turn" - return .init(rawValue: "\(rawTurnID):\(item.id)") - } - - private func kind(for kind: CodexThreadItem.Kind) -> ReviewItemKind { - switch kind { - case .agentMessage: - return .agentMessage - case .userMessage: - return ReviewItemKind(rawValue: kind.rawValue) - case .plan: - return .plan - case .reasoning: - return .reasoning - case .commandExecution: - return .commandExecution - case .fileChange: - return .fileChange - case .mcpToolCall: - return .mcpToolCall - case .dynamicToolCall: - return .dynamicToolCall - case .webSearch: - return .webSearch - case .imageGeneration: - return .imageGeneration - case .imageView: - return .imageView - case .contextCompaction: - return .contextCompaction - case .collabAgentToolCall, .subAgentActivity, .sleep, .diagnostic, .error, .unknown: - return ReviewItemKind(rawValue: kind.rawValue) - } - } - - private func family(for item: CodexChatItemSnapshot) -> ReviewItemFamily { - switch item.content { - case .message: - return .message - case .plan: - return .plan - case .reasoning: - return .reasoning - case .command: - return .command - case .fileChange: - return .fileChange - case .toolCall: - return item.kind == .webSearch ? .search : .tool - case .contextCompaction: - return .contextCompaction - case .diagnostic, .log: - return .diagnostic - case .unknown: - return .unknown - } - } - - private func content( - from item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot - ) -> ReviewTimelineDocument.Content { - switch item.content { - case .message(let message): - return .message(.init(text: message.text)) - case .plan(let markdown): - return .plan(.init(markdown: markdown)) - case .reasoning(let reasoning): - return .reasoning( - .init( - text: reasoning.text, - style: reasoning.summary.isEmpty ? .raw : .summary - )) - case .command(let command): - return .command( - .init( - title: command.command, - command: command.command, - cwd: command.cwd, - output: command.output ?? "", - exitCode: command.exitCode, - status: commandStatus(for: command.status) - )) - case .fileChange(let fileChange): - let title = fileChange.path ?? "File change" - return .fileChange( - .init( - title: title, - output: fileChange.output ?? "", - paths: fileChange.path.map { [$0] } ?? [], - status: fileChangeStatus(for: fileChange.status) - )) - case .toolCall(let toolCall): - if item.kind == .webSearch { - return .search( - .init( - query: toolCall.arguments ?? toolCall.name ?? "Web search", - result: toolCall.result ?? toolCall.error, - status: searchStatus(for: toolCall.status) - )) - } - return .toolCall( - .init( - namespace: toolCall.namespace, - server: toolCall.server, - name: toolCall.name, - arguments: toolCall.arguments, - result: toolCall.result, - error: toolCall.error, - status: toolCallStatus(for: toolCall.status) - )) - case .contextCompaction(let text): - return .contextCompaction( - .init( - title: text ?? "Context compaction", - status: contextCompactionStatus(for: turnSnapshot.status) - )) - case .diagnostic(let message): - return .diagnostic( - .init( - message: message, - severity: item.kind == .error ? .error : nil - )) - case .log(let message): - return .diagnostic(.init(message: message)) - case .unknown(let raw): - return .unknown( - .init( - title: raw.text ?? raw.rawType, - detail: raw.text, - rawKind: ReviewItemKind(rawValue: item.kind.rawValue), - rawStatus: item.itemStatus?.rawValue - )) - } - } - - private func phase( - for item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot - ) -> ReviewItemPhase { - statusPhase(item.itemStatus ?? turnSnapshot.status) - } - - func terminalStatus(for turnSnapshot: CodexChatTurnStateSnapshot) -> ReviewLifecycleStatus? { - switch turnSnapshot.status { - case .completed: - return .succeeded - case .failed, .interrupted: - return .failed - case .cancelled: - return .cancelled - case .running, .unknown, nil: - return nil - } - } - - private func statusPhase(_ status: CodexTurnStatus?) -> ReviewItemPhase { - switch status { - case .running: - return .running - case .completed: - return .completed - case .failed: - return .failed - case .interrupted: - return .incomplete - case .cancelled: - return .cancelled - case .unknown(let rawValue): - return .normalized(rawValue) - case nil: - return .running - } - } - - private func commandStatus(for status: CodexTurnStatus?) -> ReviewCommandStatus? { - switch status { - case .running: - return .inProgress - case .completed: - return .completed - case .failed: - return .failed - case .interrupted, .cancelled: - return .cancelled - case .unknown(let rawValue): - return .init(rawValue: rawValue) - case nil: - return nil - } - } - - private func toolCallStatus(for status: CodexTurnStatus?) -> ReviewToolCallStatus? { - switch status { - case .running: - return .inProgress - case .completed: - return .completed - case .failed: - return .failed - case .interrupted, .cancelled: - return .cancelled - case .unknown(let rawValue): - return .init(rawValue: rawValue) - case nil: - return nil - } - } - - private func fileChangeStatus(for status: CodexTurnStatus?) -> ReviewFileChangeStatus? { - switch status { - case .running: - return .updated - case .completed: - return .completed - case .failed, .interrupted, .cancelled: - return .failed - case .unknown(let rawValue): - return .init(rawValue: rawValue) - case nil: - return nil - } - } - - private func searchStatus(for status: CodexTurnStatus?) -> ReviewSearchStatus? { - switch status { - case .running: - return .started - case .completed: - return .completed - case .failed, .interrupted, .cancelled: - return .failed - case .unknown(let rawValue): - return .init(rawValue: rawValue) - case nil: - return nil - } - } - - private func contextCompactionStatus(for status: CodexTurnStatus?) -> ReviewContextCompactionStatus? { - switch status { - case .running: - return .inProgress - case .completed: - return .completed - case .failed, .interrupted, .cancelled: - return .failed - case .unknown(let rawValue): - return .init(rawValue: rawValue) - case nil: - return nil - } - } - - private 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 [toolCall.namespace, toolCall.server, toolCall.name] - .compactMap { $0?.nilIfEmpty } - .joined(separator: ".") - case .unknown(let unknown): - return unknown.title - } - } - - private 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 [ - primaryText(for: content).nilIfEmpty, - toolCall.progress?.nilIfEmpty, - toolCall.result?.nilIfEmpty, - toolCall.error?.nilIfEmpty, - ] - .compactMap { $0 } - .joined(separator: "\n") - case .unknown(let unknown): - return [unknown.title, unknown.detail].compactMap { $0 }.joined(separator: "\n") - } - } -} - -private extension CodexChatItemSnapshot { - var itemStatus: CodexTurnStatus? { - switch content { - 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 - } - } -} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentProjection.swift new file mode 100644 index 00000000..4f64cd3d --- /dev/null +++ b/Sources/ReviewUI/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/ReviewMonitorTimelineLogProjection.swift b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift index 34978774..a0053f27 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift @@ -3,49 +3,33 @@ import CodexReviewKit import ReviewMonitorRendering struct ReviewMonitorTimelineLogProjection: Sendable { - private struct ProjectedBlock: Equatable, Sendable { - var id: ReviewMonitorLog.BlockID - var kind: ReviewMonitorLog.Kind - var groupID: String? - var text: String - var metadata: ReviewMonitorLog.Metadata? - } - - private var document = ReviewMonitorLog.Document() + private var documentProjection = ReviewMonitorLogDocumentProjection() var currentDocument: ReviewMonitorLog.Document { - document + documentProjection.currentDocument } 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 + documentProjection.render(projectedBlocks: Self.projectedBlocks(from: timelineDocument)) } - private static func makeDocument(from timelineDocument: ReviewTimelineDocument) -> ReviewMonitorLog.Document { - var builder = DocumentBuilder() + private static func projectedBlocks( + from timelineDocument: ReviewTimelineDocument + ) -> [ReviewMonitorLogProjectedBlock] { + var blocks: [ReviewMonitorLogProjectedBlock] = [] 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) - } + blocks.append(contentsOf: projectedBlocks(for: block)) } - return builder.document + return blocks } - private static func projectedBlocks(for block: ReviewTimelineDocument.Block) -> [ProjectedBlock] { + private static func projectedBlocks( + for block: ReviewTimelineDocument.Block + ) -> [ReviewMonitorLogProjectedBlock] { switch block.content { case .approval(let approval): return [ @@ -68,7 +52,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { let commandLine = command.command.nilIfEmpty if (commandLine == nil || command.command == "Command"), command.output.isEmpty == false { return [ - ProjectedBlock( + ReviewMonitorLogProjectedBlock( id: derivedBlockID(prefix: "commandOutput", from: block), kind: .commandOutput, groupID: groupID, @@ -81,7 +65,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { return [] } var blocks = [ - ProjectedBlock( + ReviewMonitorLogProjectedBlock( id: derivedBlockID(prefix: "command", from: block), kind: .command, groupID: groupID, @@ -91,7 +75,7 @@ struct ReviewMonitorTimelineLogProjection: Sendable { ] if command.output.isEmpty == false { blocks.append( - ProjectedBlock( + ReviewMonitorLogProjectedBlock( id: derivedBlockID(prefix: "commandOutput", from: block), kind: .commandOutput, groupID: groupID, @@ -224,8 +208,8 @@ struct ReviewMonitorTimelineLogProjection: Sendable { groupID: String? = nil, text: String, metadata: ReviewMonitorLog.Metadata? = nil - ) -> ProjectedBlock { - ProjectedBlock( + ) -> ReviewMonitorLogProjectedBlock { + ReviewMonitorLogProjectedBlock( id: derivedBlockID(prefix: kind.rawValue, from: block), kind: kind, groupID: groupID, @@ -420,227 +404,6 @@ struct ReviewMonitorTimelineLogProjection: Sendable { 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: ReviewMonitorLog.Kind, text: String) -> Bool { - if kind == .diagnostic { - return true - } - return text.isEmpty == false - } - } } private extension String { diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index a6e2bcba..bcddb70c 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -11,12 +11,13 @@ final class ReviewMonitorTransportViewController: NSViewController { case chat(CodexThreadID) } + private let codexModelSource: ReviewMonitorCodexModelSource? private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() - private var timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() + private var previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] @@ -51,6 +52,7 @@ final class ReviewMonitorTransportViewController: NSViewController { uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil ) { + self.codexModelSource = codexModelSource self.store = store self.uiState = uiState self.selectedCodexChat = ReviewMonitorSelectedCodexChat(modelSource: codexModelSource) @@ -206,6 +208,34 @@ final class ReviewMonitorTransportViewController: NSViewController { resetLogRenderer() boundJob = selectedJob selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) + #if DEBUG + guard codexModelSource?.modelContext != nil else { + renderPreviewJobTimeline( + selectedJob, + restoring: restorationTarget(selectedJob), + allowIncrementalUpdate: false + ) + selectedJobObservation = withPortableContinuousObservation { [weak self] _ in + guard let self, + self.boundJob === selectedJob + else { + return + } + _ = self.codexModelSource?.generation + if self.codexModelSource?.modelContext != nil { + self.displayJob(selectedJob) + return + } + _ = selectedJob.timeline.revision + self.renderPreviewJobTimeline( + selectedJob, + restoring: self.logScrollView.currentScrollRestorationTarget, + allowIncrementalUpdate: true + ) + } + return + } + #endif startSelectedCodexChatLogStream( target: .job(selectedJob.id), initialRestorationTarget: restorationTarget(selectedJob) @@ -221,6 +251,24 @@ final class ReviewMonitorTransportViewController: NSViewController { } } + #if DEBUG + @discardableResult + private func renderPreviewJobTimeline( + _ selectedJob: CodexReviewJob, + restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, + allowIncrementalUpdate: Bool + ) -> Bool { + let timelineDocument = ReviewTimelineDocumentRenderer().document(from: selectedJob.timeline) + let sourceDocument = previewTimelineLogProjection.render(timelineDocument: timelineDocument) + return renderBoundLog( + sourceDocument: sourceDocument, + target: .job(selectedJob.id), + restorationTarget: restorationTarget, + allowIncrementalUpdate: allowIncrementalUpdate && hasAppliedBoundLog + ) + } + #endif + private func displayChat(_ selectedChat: ReviewMonitorCodexSidebarSnapshot.Chat) { if boundChatID != selectedChat.id { logScrollView.resetFindStateForContentReuse() @@ -518,7 +566,7 @@ final class ReviewMonitorTransportViewController: NSViewController { appliedLogRenderGeneration = logRenderGeneration hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() - timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() + previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() } private func restorationTarget( @@ -1292,17 +1340,19 @@ final class ReviewMonitorTransportViewController: NSViewController { case .workspaceSection, .workspace, nil: return false } - let resolvedRestorationTarget = restorationTarget ?? { - guard hasAppliedBoundLog == false else { - return logScrollView.currentScrollRestorationTarget - } - switch resolvedTarget { - case .job(let id): - return logScrollTargetsByJobID[id] ?? .bottom - case .chat: - return .bottom - } - }() + let resolvedRestorationTarget = + restorationTarget + ?? { + guard hasAppliedBoundLog == false else { + return logScrollView.currentScrollRestorationTarget + } + switch resolvedTarget { + case .job(let id): + return logScrollTargetsByJobID[id] ?? .bottom + case .chat: + return .bottom + } + }() let renderedDocument = logDocumentForTesting(from: timelineDocument) return renderBoundLog( sourceDocument: renderedDocument, @@ -1315,7 +1365,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func logDocumentForTesting( from timelineDocument: ReviewTimelineDocument ) -> ReviewMonitorLog.Document { - timelineLogProjectionForTesting.render(timelineDocument: timelineDocument) + previewTimelineLogProjection.render(timelineDocument: timelineDocument) } func copyLogSelectionForTesting() { diff --git a/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift new file mode 100644 index 00000000..9f1041b1 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift @@ -0,0 +1,456 @@ +import CodexKit +import Foundation + +struct ReviewMonitorCodexChatLogProjection: Sendable { + private var documentProjection = ReviewMonitorLogDocumentProjection() + + var currentDocument: ReviewMonitorLog.Document { + documentProjection.currentDocument + } + + mutating func reset() { + documentProjection.reset() + } + + mutating func render( + from snapshot: CodexChatProjectedTurnSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewMonitorLog.Document? { + guard snapshot.items.isEmpty == false else { + documentProjection.reset() + return nil + } + let blocks = snapshot.items.flatMap { + projectedBlocks( + from: $0, + turnSnapshot: snapshot.turn, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + guard blocks.isEmpty == false else { + documentProjection.reset() + return nil + } + return documentProjection.render(projectedBlocks: blocks) + } + + private func projectedBlocks( + from item: CodexChatItemSnapshot, + turnSnapshot: CodexChatTurnStateSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> [ReviewMonitorLogProjectedBlock] { + switch item.content { + case .message(let message): + 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, + turnSnapshot: turnSnapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + 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, + turnSnapshot: turnSnapshot + ), + 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: turnSnapshot.status?.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, turnSnapshot: turnSnapshot).rawValue + ) + ) + ] + case .log(let message): + return [ + projectedBlock( + item, + kind: .diagnostic, + text: message, + metadata: metadata( + for: item, + sourceType: "log", + title: message, + status: itemStatus(for: item, turnSnapshot: turnSnapshot).rawValue + ) + ) + ] + case .unknown(let raw): + let title = raw.text ?? raw.rawType + return [ + projectedBlock( + item, + kind: logKind(for: item, fallback: .event), + text: [title, raw.text].compactMap { $0 }.joined(separator: "\n"), + metadata: metadata( + for: item, + sourceType: item.kind.rawValue, + title: title, + status: item.itemStatus?.rawValue, + detail: raw.text + ) + ) + ] + } + } + + private func commandBlocks( + item: CodexChatItemSnapshot, + command: CodexCommand, + turnSnapshot: CodexChatTurnStateSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> [ReviewMonitorLogProjectedBlock] { + let groupID = sourceBlockID(for: item) + let metadata = commandMetadata( + item: item, + command: command, + turnSnapshot: turnSnapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + 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, + turnSnapshot: turnSnapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + ) + ] + } + 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( + .init( + id: derivedBlockID(prefix: "commandOutput", item: item), + kind: .commandOutput, + groupID: groupID, + text: output, + metadata: metadata + )) + } + return blocks + } + + private func webSearchBlocks( + item: CodexChatItemSnapshot, + 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: CodexChatItemSnapshot, + 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: CodexChatItemSnapshot, + 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: CodexChatItemSnapshot + ) -> ReviewMonitorLog.BlockID { + ReviewMonitorLog.BlockID("\(prefix):\(sourceBlockID(for: item))") + } + + private func sourceBlockID(for item: CodexChatItemSnapshot) -> String { + let rawTurnID = item.turnID?.rawValue ?? "unknown-turn" + return "\(rawTurnID):\(item.id)" + } + + private func logKind( + for item: CodexChatItemSnapshot, + fallback: ReviewMonitorLog.Kind + ) -> ReviewMonitorLog.Kind { + ReviewMonitorLog.Kind(rawValue: item.kind.rawValue) ?? fallback + } + + private func metadata( + for item: CodexChatItemSnapshot, + 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: CodexChatItemSnapshot, + command: CodexCommand, + turnSnapshot: CodexChatTurnStateSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewMonitorLog.Metadata { + let status = commandStatus(item: item, command: command, turnSnapshot: turnSnapshot) + return .init( + sourceType: "commandExecution", + status: status, + itemID: sourceBlockID(for: item), + command: command.command, + cwd: command.cwd, + exitCode: command.exitCode, + startedAt: chatCreatedAt, + completedAt: terminalStatus(itemStatus(for: item, turnSnapshot: turnSnapshot)) ? chatUpdatedAt : nil, + commandStatus: status + ) + } + + private func outputOnlyCommandMetadata( + item: CodexChatItemSnapshot, + command: CodexCommand, + turnSnapshot: CodexChatTurnStateSnapshot, + chatCreatedAt: Date?, + chatUpdatedAt: Date? + ) -> ReviewMonitorLog.Metadata { + guard + command.cwd != nil || command.exitCode != nil || command.status != nil || chatCreatedAt != nil + || chatUpdatedAt != nil + else { + let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + return .init( + sourceType: "command", + title: "Command output", + status: terminalStatus(status) ? status.rawValue : "completed" + ) + } + return commandMetadata( + item: item, + command: command, + turnSnapshot: turnSnapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt + ) + } + + private func commandStatus( + item: CodexChatItemSnapshot, + command: CodexCommand, + turnSnapshot: CodexChatTurnStateSnapshot + ) -> String { + let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + if terminalStatus(status) { + return status.rawValue + } + if let commandStatus = command.status, + terminalStatus(commandStatus) + { + return commandStatus.rawValue + } + if let exitCode = command.exitCode { + return exitCode == 0 ? CodexTurnStatus.completed.rawValue : CodexTurnStatus.failed.rawValue + } + return command.status?.rawValue ?? status.rawValue + } + + private func fileChangeStatus( + item: CodexChatItemSnapshot, + fileChange: CodexFileChange, + turnSnapshot: CodexChatTurnStateSnapshot + ) -> String { + let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + if terminalStatus(status) { + return status.rawValue + } + return fileChange.status?.rawValue ?? status.rawValue + } + + private func itemStatus( + for item: CodexChatItemSnapshot, + turnSnapshot: CodexChatTurnStateSnapshot + ) -> CodexTurnStatus { + item.itemStatus ?? turnSnapshot.status ?? .running + } + + private func terminalStatus(_ status: CodexTurnStatus) -> Bool { + switch status { + case .running, .unknown: + return false + case .completed, .failed, .interrupted, .cancelled: + return true + } + } +} + +private extension CodexChatItemSnapshot { + var itemStatus: CodexTurnStatus? { + switch content { + 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/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift index 1349c771..1ba2e87e 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift @@ -1,6 +1,5 @@ import CodexKit import Foundation -import ReviewMonitorRendering @MainActor enum ReviewMonitorLogSourceChange: Equatable { @@ -32,15 +31,13 @@ enum ReviewMonitorLogSourceChange: Equatable { @MainActor struct ReviewMonitorSelectedCodexChatLogProjection { private var turnProjection = CodexChatTurnProjection() - private var document: ReviewTimelineDocument? - private var logProjection = ReviewMonitorTimelineLogProjection() - private var revision: UInt64 = 0 + private var logProjection = ReviewMonitorCodexChatLogProjection() + private var hasLogDocument = false mutating func reset() { turnProjection = CodexChatTurnProjection() - document = nil - logProjection = ReviewMonitorTimelineLogProjection() - revision = 0 + logProjection.reset() + hasLogDocument = false } mutating func apply( @@ -55,170 +52,72 @@ struct ReviewMonitorSelectedCodexChatLogProjection { return nil } guard let snapshot = update.snapshot else { - let hadDocument = document != nil - document = nil - logProjection = ReviewMonitorTimelineLogProjection() - return hadDocument ? .clear : nil + return clearIfNeeded() } switch update.kind { case .snapshot, .turnUpdated, .phaseChanged: - return rebuildDocument( + return renderSnapshot( from: snapshot, chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: false ) case .itemUpserted(let item): - return rebuildOrReplaceBlock( - for: item, - turnSnapshot: snapshot.turn, + return renderSnapshot( + from: snapshot, chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: hasLogDocument && item.turnID == snapshot.turn.id ) case .itemTextAppended(_, _, _, let item): - return replaceBlock( - for: item, - turnSnapshot: snapshot.turn, + return renderSnapshot( + from: snapshot, chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: hasLogDocument && item.turnID == snapshot.turn.id ) - case .itemRemoved(let id, let turnID): - return removeBlock( - id: .init(rawValue: "\(turnID?.rawValue ?? snapshot.turn.id.rawValue):\(id)"), - turnSnapshot: snapshot.turn + case .itemRemoved: + return renderSnapshot( + from: snapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: hasLogDocument ) case .ignored: return nil } } - private mutating func rebuildDocument( + private mutating func renderSnapshot( from snapshot: CodexChatProjectedTurnSnapshot, chatCreatedAt: Date?, - chatUpdatedAt: Date? - ) -> ReviewMonitorLogSourceChange? { - let hadDocument = document != nil - guard let nextDocument = ReviewMonitorCodexChatTimelineProjection().document( - from: snapshot.turn, - items: snapshot.items, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - revision: nextRevision() - ) else { - document = nil - logProjection = ReviewMonitorTimelineLogProjection() - return hadDocument ? .clear : nil - } - document = nextDocument - return .replaceAll(logProjection.render(timelineDocument: nextDocument)) - } - - private mutating func rebuildOrReplaceBlock( - for item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? + chatUpdatedAt: Date?, + allowIncrementalUpdate: Bool ) -> ReviewMonitorLogSourceChange? { - guard document != nil else { - guard let snapshot = turnProjection.snapshot else { - return nil - } - return rebuildDocument( + guard + let document = logProjection.render( from: snapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) + else { + return clearIfNeeded() } - return replaceBlock( - for: item, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) - } - - private mutating func replaceBlock( - for item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? - ) -> ReviewMonitorLogSourceChange? { - guard var document else { - guard let snapshot = turnProjection.snapshot else { - return nil - } - return rebuildDocument( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) + defer { + hasLogDocument = true } - - let projection = ReviewMonitorCodexChatTimelineProjection() - let block = projection.block( - from: item, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) - if let index = document.blocks.firstIndex(where: { $0.id == block.id }) { - document.blocks[index] = block - } else { - document.blocks.append(block) - document.orderedBlockIDs.append(block.id) - } - refreshMetadata( - in: &document, - turn: turnSnapshot, - latestActivityBlockID: block.id, - revision: nextRevision() - ) - self.document = document - return .update(logProjection.render(timelineDocument: document)) + return allowIncrementalUpdate ? .update(document) : .replaceAll(document) } - private mutating func removeBlock( - id: ReviewTimelineDocument.Block.ID, - turnSnapshot: CodexChatTurnStateSnapshot - ) -> ReviewMonitorLogSourceChange? { - guard var document else { + private mutating func clearIfNeeded() -> ReviewMonitorLogSourceChange? { + guard hasLogDocument else { return nil } - document.blocks.removeAll { $0.id == id } - document.orderedBlockIDs.removeAll { $0 == id } - let latestActivityBlockID = document.blocks.last?.id - refreshMetadata( - in: &document, - turn: turnSnapshot, - latestActivityBlockID: latestActivityBlockID, - revision: nextRevision() - ) - self.document = document.blocks.isEmpty ? nil : document - guard let document = self.document else { - logProjection = ReviewMonitorTimelineLogProjection() - return .clear - } - return .update(logProjection.render(timelineDocument: document)) - } - - private mutating func nextRevision() -> UInt64 { - revision &+= 1 - return revision - } - - private func refreshMetadata( - in document: inout ReviewTimelineDocument, - turn: CodexChatTurnStateSnapshot, - latestActivityBlockID: ReviewTimelineDocument.Block.ID?, - revision: UInt64 - ) { - document.timelineRevision = .init(rawValue: revision) - document.activeBlockIDs = document.blocks.filter(\.isActive).map(\.id) - document.activeBlockCount = document.activeBlockIDs.count - document.latestActivityBlockID = latestActivityBlockID - document.terminalStatus = ReviewMonitorCodexChatTimelineProjection().terminalStatus(for: turn) - document.terminalSummary = turn.errorDescription + logProjection.reset() + hasLogDocument = false + return .clear } } diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index f693e678..4fa01f51 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -280,6 +280,79 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(updatedSnapshot.log.contains("Log fallback") == false) } + @Test func selectedCodexChatTextAppendUsesAppendPath() 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( + store: store, + uiState: uiState, + modelContext: modelContext + ) + transport.loadViewIfNeeded() + + let job = makeRunningReviewJob( + sourceThreadID: "source-thread", + reviewThreadID: "review-thread", + turnID: "turn-1" + ) + uiState.selection = .job(job) + + _ = try await awaitTransportRender(transport) { 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) { snapshot in + snapshot.log == "Initial log" + } + #expect(updatedSnapshot.log == "Initial log") + #expect(transport.logAppendCountForTesting == appendCount + 1) + #expect(transport.logReloadCountForTesting == reloadCount) + } + @Test func selectedCodexChatRendersThreadAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 9d9211bf..9185c86a 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -716,6 +716,30 @@ extension ReviewUITests { #expect(window.isMovableByWindowBackground == false) } + @Test func previewContentViewControllerRendersSelectedJobLog() async throws { + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let selectedJob = try #require( + store.orderedJobs.first { $0.core.lifecycle.status == .running } + ?? store.orderedJobs.first + ) + let expectedLog = reviewMonitorLogText(for: selectedJob) + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + 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) { snapshot in + snapshot.log == expectedLog && snapshot.isShowingEmptyState == false + } + + #expect(snapshot.log.isEmpty == false) + } + @Test func windowControllerUsesSeededAuthenticatedStateOnFirstPresentation() { let backend = AuthActionBackend( initialAuthState: .signedIn(accountID: "review@example.com") @@ -1358,7 +1382,8 @@ extension ReviewUITests { } window.close() } - try await renderDetailLogForShellLayoutTesting(streamLog, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting( + streamLog, in: transport, viewController: viewController, job: job) #expect(transport.isLogPinnedToBottomForTesting) transport.beginLogLiveResizeForTesting() From f04a477b2360547011b3e53c5b4c3d1597e56160 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:06:13 +0900 Subject: [PATCH 126/359] perf(review-ui): keep selected chat log cache out of observation --- Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index df1558e1..5bfa8784 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -2,7 +2,6 @@ import CodexKit import Foundation import Observation import ObservationBridge -import ReviewMonitorRendering @MainActor @Observable @@ -38,7 +37,6 @@ final class ReviewMonitorSelectedCodexChat { private(set) var identity: CodexReviewIdentity? private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? - private(set) var logSourceDocument: ReviewMonitorLog.Document? var phase: CodexDataPhase { chat?.phase ?? .idle } @@ -63,6 +61,8 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? @ObservationIgnored + private var currentLogSourceDocument: ReviewMonitorLog.Document? + @ObservationIgnored private var logSourceChangeContinuations: [UUID: AsyncStream.Continuation] = [:] init(modelSource: ReviewMonitorCodexModelSource?) { @@ -94,8 +94,8 @@ final class ReviewMonitorSelectedCodexChat { let id = UUID() let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) logSourceChangeContinuations[id] = pair.continuation - if let logSourceDocument { - pair.continuation.yield(.replaceAll(logSourceDocument)) + if let currentLogSourceDocument { + pair.continuation.yield(.replaceAll(currentLogSourceDocument)) } pair.continuation.onTermination = { [weak self] _ in Task { @MainActor [weak self] in @@ -181,7 +181,7 @@ final class ReviewMonitorSelectedCodexChat { guard let change else { return } - logSourceDocument = change.sourceDocument + currentLogSourceDocument = change.sourceDocument for continuation in logSourceChangeContinuations.values { continuation.yield(change) } From 73a27b8d2c6410328627aca5ea1b0ad93a53e946 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:11:41 +0900 Subject: [PATCH 127/359] refactor(review-ui): remove timeline input from log renderer --- Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift | 8 -------- .../Detail/ReviewMonitorTransportViewController.swift | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift index 98bf2fef..b580ca36 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift @@ -1,23 +1,15 @@ -import ReviewMonitorRendering - struct ReviewMonitorRenderedLogDocument: Equatable, Sendable { var source: ReviewMonitorLog.Document var display: ReviewMonitorLog.Document } actor ReviewMonitorLogRenderer { - private var timelineProjection = ReviewMonitorTimelineLogProjection() private var displayDocument = ReviewMonitorLog.Document() func reset() { - timelineProjection = ReviewMonitorTimelineLogProjection() displayDocument = ReviewMonitorLog.Document() } - func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorRenderedLogDocument { - renderedDocument(from: timelineProjection.render(timelineDocument: timelineDocument)) - } - func render(sourceDocument: ReviewMonitorLog.Document) -> ReviewMonitorRenderedLogDocument { renderedDocument(from: sourceDocument) } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index bcddb70c..3e48a6eb 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -17,7 +17,9 @@ final class ReviewMonitorTransportViewController: NSViewController { private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() - private var previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() + #if DEBUG + private var previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() + #endif private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] @@ -566,7 +568,9 @@ final class ReviewMonitorTransportViewController: NSViewController { appliedLogRenderGeneration = logRenderGeneration hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() - previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() + #if DEBUG + previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() + #endif } private func restorationTarget( From 1a078795838952a7e3819091ce22f94a10dbe74c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:16:06 +0900 Subject: [PATCH 128/359] refactor(review-kit): await review job lifecycle --- .../ReviewObservationAwaiter.swift | 14 +++--- .../Store/CodexReviewStoreReviews.swift | 2 +- .../ReviewObservationAwaiterTests.swift | 50 +++++++------------ 3 files changed, 27 insertions(+), 39 deletions(-) diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index 11bb11dd..b348ef03 100644 --- a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -4,10 +4,10 @@ import ObservationBridge @MainActor public enum ReviewObservationAwaiter { public static func waitUntilTerminal( - timeline: ReviewTimeline, + job: CodexReviewJob, timeout: Duration? = nil ) async -> Bool { - if timeline.activeItemIDs.isEmpty, timeline.isTerminal { + if job.isTerminal { return true } @@ -15,7 +15,7 @@ public enum ReviewObservationAwaiter { return await withTaskCancellationHandler { await withCheckedContinuation { continuation in waiter.begin( - timeline: timeline, + job: job, timeout: timeout, continuation: continuation ) @@ -36,14 +36,14 @@ private final class ReviewTerminalObservationWaiter { private var isResolved = false func begin( - timeline: ReviewTimeline, + job: CodexReviewJob, 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, job] event in + _ = job.core.lifecycle.status + guard job.isTerminal else { return } event.cancel() diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 95e46f24..d5809b1c 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -1043,7 +1043,7 @@ extension CodexReviewStore { return } _ = await ReviewObservationAwaiter.waitUntilTerminal( - timeline: job.timeline, + job: job, timeout: timeout ) } diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index 76dba7bf..05de141b 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -5,69 +5,57 @@ import Testing @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")) - ))) + @Test func resumesWhenJobReachesTerminalState() async throws { + let job = makeRunningJob() let task = Task { @MainActor in await ReviewObservationAwaiter.waitUntilTerminal( - timeline: timeline, + job: job, timeout: .seconds(1) ) } await Task.yield() - timeline.apply(.reviewCompleted(summary: "Done", result: nil)) + job.updateStateForTesting(status: .succeeded, summary: "Done") 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")) - ))) + @Test func resumesWhenJobCancellationReachesTerminalState() async throws { + let job = makeRunningJob() let task = Task { @MainActor in await ReviewObservationAwaiter.waitUntilTerminal( - timeline: timeline, + job: job, timeout: .seconds(1) ) } await Task.yield() - timeline.apply(.reviewCancelled("Stop")) + job.updateStateForTesting(status: .cancelled, summary: "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 job = makeRunningJob() let result = await ReviewObservationAwaiter.waitUntilTerminal( - timeline: timeline, + job: job, timeout: .milliseconds(10) ) #expect(result == false) } + + private func makeRunningJob() -> CodexReviewJob { + CodexReviewJob.makeForTesting( + id: "job-awaiter", + targetSummary: "Uncommitted changes", + status: .running, + summary: "Running review." + ) + } } From 50b417bbfafb7b79c29ef0e6a9457e02fabe2bce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:36:47 +0900 Subject: [PATCH 129/359] refactor(review-ui): preview reviews through chat selection --- ...ReviewMonitorTransportViewController.swift | 45 +++++++++- .../ReviewUI/ReviewMonitorContentView.swift | 26 ++++-- .../ReviewMonitorPreviewChatLogSource.swift | 82 +++++++++++++++++++ .../ReviewMonitorSplitViewController.swift | 11 ++- .../ReviewMonitorSidebarViewController.swift | 16 +++- Tests/ReviewUITests/ReviewUIShellTests.swift | 4 +- 6 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 3e48a6eb..a4e0d183 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -12,6 +12,7 @@ final class ReviewMonitorTransportViewController: NSViewController { } private let codexModelSource: ReviewMonitorCodexModelSource? + private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let selectedCodexChat: ReviewMonitorSelectedCodexChat @@ -40,21 +41,25 @@ final class ReviewMonitorTransportViewController: NSViewController { convenience init( store: CodexReviewStore, uiState: ReviewMonitorUIState, - modelContext: CodexModelContext + modelContext: CodexModelContext, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil ) { self.init( store: store, uiState: uiState, - codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext) + codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), + previewChatLogSource: previewChatLogSource ) } init( store: CodexReviewStore, uiState: ReviewMonitorUIState, - codexModelSource: ReviewMonitorCodexModelSource? = nil + codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil ) { self.codexModelSource = codexModelSource + self.previewChatLogSource = previewChatLogSource self.store = store self.uiState = uiState self.selectedCodexChat = ReviewMonitorSelectedCodexChat(modelSource: codexModelSource) @@ -214,6 +219,7 @@ final class ReviewMonitorTransportViewController: NSViewController { guard codexModelSource?.modelContext != nil else { renderPreviewJobTimeline( selectedJob, + target: .job(selectedJob.id), restoring: restorationTarget(selectedJob), allowIncrementalUpdate: false ) @@ -231,6 +237,7 @@ final class ReviewMonitorTransportViewController: NSViewController { _ = selectedJob.timeline.revision self.renderPreviewJobTimeline( selectedJob, + target: .job(selectedJob.id), restoring: self.logScrollView.currentScrollRestorationTarget, allowIncrementalUpdate: true ) @@ -257,6 +264,7 @@ final class ReviewMonitorTransportViewController: NSViewController { @discardableResult private func renderPreviewJobTimeline( _ selectedJob: CodexReviewJob, + target: LogRenderTarget, restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, allowIncrementalUpdate: Bool ) -> Bool { @@ -264,7 +272,7 @@ final class ReviewMonitorTransportViewController: NSViewController { let sourceDocument = previewTimelineLogProjection.render(timelineDocument: timelineDocument) return renderBoundLog( sourceDocument: sourceDocument, - target: .job(selectedJob.id), + target: target, restorationTarget: restorationTarget, allowIncrementalUpdate: allowIncrementalUpdate && hasAppliedBoundLog ) @@ -277,9 +285,38 @@ final class ReviewMonitorTransportViewController: NSViewController { } selectedCodexChatLogTask?.cancel() selectedCodexChatLogTask = nil + selectedJobObservation?.cancel() + selectedJobObservation = nil resetLogRenderer() boundChatID = selectedChat.id selectedCodexChat.bind(toChatID: selectedChat.id) + #if DEBUG + if codexModelSource?.modelContext == nil, + let previewJob = previewChatLogSource?.job(for: selectedChat.id) + { + renderPreviewJobTimeline( + previewJob, + target: .chat(selectedChat.id), + restoring: .bottom, + allowIncrementalUpdate: false + ) + selectedJobObservation = withPortableContinuousObservation { [weak self] _ in + guard let self, + self.boundChatID == selectedChat.id + else { + return + } + _ = previewJob.timeline.revision + self.renderPreviewJobTimeline( + previewJob, + target: .chat(selectedChat.id), + restoring: self.logScrollView.currentScrollRestorationTarget, + allowIncrementalUpdate: true + ) + } + return + } + #endif startSelectedCodexChatLogStream(target: .chat(selectedChat.id), initialRestorationTarget: .bottom) } diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 4d5c7352..732df9c5 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -15,6 +15,7 @@ final class ReviewMonitorRootViewController: NSViewController { private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let codexModelSource: ReviewMonitorCodexModelSource? + private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let contentTransitionAnimator: ReviewMonitorContentTransitionAnimator private let showSettings: (@MainActor () -> Void)? private var observation: PortableObservationTracking.Token? @@ -25,6 +26,7 @@ final class ReviewMonitorRootViewController: NSViewController { store: store, uiState: uiState, codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource, showSettings: showSettings ) @@ -34,6 +36,7 @@ final class ReviewMonitorRootViewController: NSViewController { store: CodexReviewStore, uiState: ReviewMonitorUIState, modelContext: CodexModelContext, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil ) { @@ -41,6 +44,7 @@ final class ReviewMonitorRootViewController: NSViewController { store: store, uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), + previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings ) @@ -50,12 +54,14 @@ final class ReviewMonitorRootViewController: NSViewController { store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil ) { self.store = store self.uiState = uiState self.codexModelSource = codexModelSource + self.previewChatLogSource = previewChatLogSource self.contentTransitionAnimator = contentTransitionAnimator self.showSettings = showSettings super.init(nibName: nil, bundle: nil) @@ -302,13 +308,19 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( 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) + let previewChatLogSource: ReviewMonitorPreviewChatLogSource? = + if case .running = serverState { + ReviewMonitorPreviewChatLogSource(jobs: store.orderedJobs) + } else { + nil + } + if let initialChat = previewChatLogSource?.initialChat { + uiState.selection = .chat(initialChat) } - return ReviewMonitorRootViewController(store: store, uiState: uiState) + return ReviewMonitorRootViewController( + store: store, + uiState: uiState, + previewChatLogSource: previewChatLogSource + ) } #endif diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift new file mode 100644 index 00000000..91484edd --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -0,0 +1,82 @@ +import CodexKit +import CodexReviewKit +import Foundation + +@MainActor +final class ReviewMonitorPreviewChatLogSource { + let snapshot: ReviewMonitorCodexSidebarSnapshot + let initialChat: ReviewMonitorCodexSidebarSnapshot.Chat? + + private let jobsByChatID: [CodexThreadID: CodexReviewJob] + + init(jobs: [CodexReviewJob]) { + var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] + var sectionIndexesByCWD: [String: Int] = [:] + var jobsByChatID: [CodexThreadID: CodexReviewJob] = [:] + var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? + var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? + + for job in jobs { + guard let chatID = Self.chatID(for: job) else { + continue + } + + let chat = Self.chat(from: job, chatID: chatID) + jobsByChatID[chatID] = job + firstChat = firstChat ?? chat + if initialRunningChat == nil, job.core.lifecycle.status == .running { + initialRunningChat = chat + } + + if let sectionIndex = sectionIndexesByCWD[job.cwd] { + sections[sectionIndex].uncategorizedChats.append(chat) + } else { + sectionIndexesByCWD[job.cwd] = sections.count + sections.append( + ReviewMonitorCodexSidebarSnapshot.Section( + rowID: .section(job.cwd), + id: job.cwd, + title: URL(fileURLWithPath: job.cwd).lastPathComponent, + workspaces: [], + uncategorizedChats: [chat] + )) + } + } + + self.snapshot = ReviewMonitorCodexSidebarSnapshot(sections: sections) + self.initialChat = initialRunningChat ?? firstChat + self.jobsByChatID = jobsByChatID + } + + func job(for chatID: CodexThreadID) -> CodexReviewJob? { + jobsByChatID[chatID] + } + + private static func chat(from job: CodexReviewJob, chatID: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat { + ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: job.displayTitle, + preview: nonEmpty(job.core.output.lastAgentMessage) ?? nonEmpty(job.core.output.summary), + workspaceCWD: job.cwd, + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt + ) + } + + private static func chatID(for job: CodexReviewJob) -> CodexThreadID? { + if let reviewThreadID = nonEmpty(job.core.run.reviewThreadID) { + return CodexThreadID(rawValue: reviewThreadID) + } + if let threadID = nonEmpty(job.core.run.threadID) { + return CodexThreadID(rawValue: threadID) + } + return nil + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } +} diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index cf26d61d..200ec638 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -21,6 +21,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe private let store: CodexReviewStore private let uiState: ReviewMonitorUIState private let codexModelSource: ReviewMonitorCodexModelSource? + private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let showSettings: (@MainActor () -> Void)? private var sidebarViewController: ReviewMonitorSidebarViewController? private var transportViewController: ReviewMonitorTransportViewController? @@ -41,12 +42,14 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe store: CodexReviewStore, uiState: ReviewMonitorUIState, modelContext: CodexModelContext, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { self.init( store: store, uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), + previewChatLogSource: previewChatLogSource, showSettings: showSettings ) } @@ -55,11 +58,13 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { self.store = store self.uiState = uiState self.codexModelSource = codexModelSource + self.previewChatLogSource = previewChatLogSource self.showSettings = showSettings super.init(nibName: nil, bundle: nil) } @@ -79,12 +84,14 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe let sidebarViewController = ReviewMonitorSidebarViewController( store: store, uiState: uiState, - codexModelSource: codexModelSource + codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource ) let transportViewController = ReviewMonitorTransportViewController( store: store, uiState: uiState, - codexModelSource: codexModelSource + codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource ) let statusAccessoryViewController = ReviewMonitorServerStatusAccessoryViewController( store: store, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index df96e400..4cd537dd 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -191,6 +191,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let store: CodexReviewStore private let uiState: ReviewMonitorUIState private let codexModelSource: ReviewMonitorCodexModelSource? + private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let scrollView = NSScrollView() private let outlineView = ReviewMonitorSidebarOutlineView() private let accountsViewController: ReviewMonitorAccountsViewController @@ -219,11 +220,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD init( store: CodexReviewStore, uiState: ReviewMonitorUIState, - codexModelSource: ReviewMonitorCodexModelSource? = nil + codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil ) { self.store = store self.uiState = uiState self.codexModelSource = codexModelSource + self.previewChatLogSource = previewChatLogSource self.accountsViewController = ReviewMonitorAccountsViewController(store: store) self.unavailableView = NSHostingView(rootView: MCPServerUnavailableView(store: store)) self.rowHeights = SidebarRowHeights.measure() @@ -252,6 +255,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD configureHierarchy() configureOutlineView() bindObservation() + applyPreviewCodexSidebarSnapshotIfNeeded() bindCodexSidebarLibrary() } @@ -398,7 +402,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarFetchTask = nil guard let modelContext else { codexSidebarLibrary = nil - applyCodexSidebarSnapshot(ReviewMonitorCodexSidebarSnapshot(sections: [])) + applyPreviewCodexSidebarSnapshotIfNeeded() return } @@ -418,6 +422,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } + private func applyPreviewCodexSidebarSnapshotIfNeeded() { + if let previewChatLogSource { + applyCodexSidebarSnapshot(previewChatLogSource.snapshot) + } else { + applyCodexSidebarSnapshot(ReviewMonitorCodexSidebarSnapshot(sections: [])) + } + } + private func applyCodexSidebarSnapshot(_ snapshot: ReviewMonitorCodexSidebarSnapshot) { let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline codexSidebarOutlineTree.apply(snapshot: snapshot) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 9185c86a..6c8c8bc5 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -716,12 +716,13 @@ extension ReviewUITests { #expect(window.isMovableByWindowBackground == false) } - @Test func previewContentViewControllerRendersSelectedJobLog() async throws { + @Test func previewContentViewControllerRendersSelectedChatLog() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) let selectedJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first ) + let selectedChatID = try #require(selectedJob.core.run.reviewThreadID) let expectedLog = reviewMonitorLogText(for: selectedJob) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store @@ -737,6 +738,7 @@ extension ReviewUITests { snapshot.log == expectedLog && snapshot.isShowingEmptyState == false } + #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) #expect(snapshot.log.isEmpty == false) } From 60ac15959d8179799e4e04d2ebe381a7af3d5ba8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:21:59 +0900 Subject: [PATCH 130/359] refactor(review-ui): select review chats from job rows --- .../CodexReviewJob+ReviewChatIdentity.swift | 27 +++++++ .../Detail/ReviewMonitorLogScrollView.swift | 1 + ...ReviewMonitorTransportViewController.swift | 70 +++++++++++++++---- .../ReviewMonitorPreviewChatLogSource.swift | 33 +-------- .../ReviewMonitorSplitViewController.swift | 33 ++++++--- .../ReviewMonitorCodexSidebarLibrary.swift | 19 +++++ .../ReviewMonitorSidebarViewController.swift | 62 ++++++++++++++-- .../ReviewMonitorSelectedCodexChatTests.swift | 21 ++++-- .../ReviewMonitorTimelineTesting.swift | 13 +++- Tests/ReviewUITests/ReviewUIShellTests.swift | 8 ++- 10 files changed, 216 insertions(+), 71 deletions(-) diff --git a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift index fd79f8ee..c2bf729d 100644 --- a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift +++ b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift @@ -16,4 +16,31 @@ extension CodexReviewJob { model: core.run.model?.nilIfEmpty ) } + + @MainActor + var reviewChatID: CodexThreadID? { + if let reviewThreadID = core.run.reviewThreadID?.nilIfEmpty { + return CodexThreadID(rawValue: reviewThreadID) + } + if let threadID = core.run.threadID?.nilIfEmpty { + return CodexThreadID(rawValue: threadID) + } + return nil + } + + @MainActor + var reviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let chatID = reviewChatID else { + return nil + } + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: displayTitle, + preview: core.output.lastAgentMessage?.nilIfEmpty ?? core.output.summary.nilIfEmpty, + workspaceCWD: cwd, + updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, + reviewIdentity: reviewChatIdentity + ) + } } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift index 42c9ee47..efcaeafd 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift @@ -130,6 +130,7 @@ final class ReviewMonitorLogScrollView: NSScrollView { func resetFindStateForContentReuse() { endFindSession() logDocumentView.resetCommandOutputPanelState() + displayedPresentationSignature = nil displayedFinderSupplementSignature = nil sourceDocument = nil } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index a4e0d183..e44ba8cd 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -33,6 +33,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? private var displayedSelection: ReviewMonitorSelectionID? private var logScrollTargetsByJobID: [String: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] + private var logScrollTargetsByChatID: [CodexThreadID: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] private var logRenderTask: Task? private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 @@ -149,8 +150,12 @@ final class ReviewMonitorTransportViewController: NSViewController { return boundJob !== selectedJob || displayedSelection != selection?.id case .workspaceSection(let selectedSection): return boundWorkspaceSection != selectedSection || displayedSelection != selection?.id - case .workspace, .chat: + case .workspace: return displayedSelection != selection?.id + case .chat(let selectedChat): + return boundChatID != selectedChat.id + || selectedCodexChat.identity != selectedChat.reviewIdentity + || displayedSelection != selection?.id case nil: return displayedSelection != nil } @@ -181,7 +186,6 @@ final class ReviewMonitorTransportViewController: NSViewController { displayedSelection = selection?.id case .chat: - clearDisplayedLogSelection() clearDisplayedWorkspace() if case .chat(let selectedChat) = selection { displayChat(selectedChat) @@ -203,7 +207,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func displayJob(_ selectedJob: CodexReviewJob) { let isSwitchingRenderedJob = boundJob != nil && boundJob !== selectedJob - cacheBoundJobScrollTarget() + cacheBoundLogScrollTarget() if isSwitchingRenderedJob { logScrollView.resetFindStateForContentReuse() } @@ -280,7 +284,9 @@ final class ReviewMonitorTransportViewController: NSViewController { #endif private func displayChat(_ selectedChat: ReviewMonitorCodexSidebarSnapshot.Chat) { - if boundChatID != selectedChat.id { + let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChat.id + cacheBoundLogScrollTarget() + if isSwitchingRenderedChat { logScrollView.resetFindStateForContentReuse() } selectedCodexChatLogTask?.cancel() @@ -288,16 +294,22 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedJobObservation?.cancel() selectedJobObservation = nil resetLogRenderer() + boundJob = nil boundChatID = selectedChat.id - selectedCodexChat.bind(toChatID: selectedChat.id) + let restorationTarget = restorationTarget(chatID: selectedChat.id) + if let reviewIdentity = selectedChat.reviewIdentity { + selectedCodexChat.bind(to: reviewIdentity) + } else { + selectedCodexChat.bind(toChatID: selectedChat.id) + } #if DEBUG if codexModelSource?.modelContext == nil, - let previewJob = previewChatLogSource?.job(for: selectedChat.id) + let previewJob = previewJob(for: selectedChat.id) { renderPreviewJobTimeline( previewJob, target: .chat(selectedChat.id), - restoring: .bottom, + restoring: restorationTarget, allowIncrementalUpdate: false ) selectedJobObservation = withPortableContinuousObservation { [weak self] _ in @@ -317,11 +329,25 @@ final class ReviewMonitorTransportViewController: NSViewController { return } #endif - startSelectedCodexChatLogStream(target: .chat(selectedChat.id), initialRestorationTarget: .bottom) + startSelectedCodexChatLogStream( + target: .chat(selectedChat.id), + initialRestorationTarget: restorationTarget + ) } + #if DEBUG + private func previewJob(for chatID: CodexThreadID) -> CodexReviewJob? { + if let previewJob = previewChatLogSource?.job(for: chatID) { + return previewJob + } + return store.orderedJobs.first { job in + job.reviewChatID == chatID + } + } + #endif + private func clearDisplayedLogSelection() { - cacheBoundJobScrollTarget() + cacheBoundLogScrollTarget() selectedJobObservation?.cancel() selectedJobObservation = nil selectedCodexChatLogTask?.cancel() @@ -591,11 +617,14 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func cacheBoundJobScrollTarget() { - guard let boundJob else { - return + private func cacheBoundLogScrollTarget() { + let restorationTarget = logScrollView.currentScrollRestorationTarget + if let boundJob { + logScrollTargetsByJobID[boundJob.id] = restorationTarget + } + if let boundChatID { + logScrollTargetsByChatID[boundChatID] = restorationTarget } - logScrollTargetsByJobID[boundJob.id] = logScrollView.currentScrollRestorationTarget } private func resetLogRenderer() { @@ -613,9 +642,20 @@ final class ReviewMonitorTransportViewController: NSViewController { private func restorationTarget( _ job: CodexReviewJob ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget { + if let chatID = job.reviewChatID, + let chatTarget = logScrollTargetsByChatID[chatID] + { + return chatTarget + } return logScrollTargetsByJobID[job.id] ?? .bottom } + private func restorationTarget( + chatID: CodexThreadID + ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget { + logScrollTargetsByChatID[chatID] ?? .bottom + } + @discardableResult func performDisplayedTextFinderAction(_ sender: Any?) -> Bool { switch displayedSelection { @@ -1390,8 +1430,8 @@ final class ReviewMonitorTransportViewController: NSViewController { switch resolvedTarget { case .job(let id): return logScrollTargetsByJobID[id] ?? .bottom - case .chat: - return .bottom + case .chat(let id): + return logScrollTargetsByChatID[id] ?? .bottom } }() let renderedDocument = logDocumentForTesting(from: timelineDocument) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 91484edd..7b25e728 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -17,12 +17,11 @@ final class ReviewMonitorPreviewChatLogSource { var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? for job in jobs { - guard let chatID = Self.chatID(for: job) else { + guard let chat = job.reviewChatSelection else { continue } - let chat = Self.chat(from: job, chatID: chatID) - jobsByChatID[chatID] = job + jobsByChatID[chat.id] = job firstChat = firstChat ?? chat if initialRunningChat == nil, job.core.lifecycle.status == .running { initialRunningChat = chat @@ -51,32 +50,4 @@ final class ReviewMonitorPreviewChatLogSource { func job(for chatID: CodexThreadID) -> CodexReviewJob? { jobsByChatID[chatID] } - - private static func chat(from job: CodexReviewJob, chatID: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat { - ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: job.displayTitle, - preview: nonEmpty(job.core.output.lastAgentMessage) ?? nonEmpty(job.core.output.summary), - workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt - ) - } - - private static func chatID(for job: CodexReviewJob) -> CodexThreadID? { - if let reviewThreadID = nonEmpty(job.core.run.reviewThreadID) { - return CodexThreadID(rawValue: reviewThreadID) - } - if let threadID = nonEmpty(job.core.run.threadID) { - return CodexThreadID(rawValue: threadID) - } - return nil - } - - private static func nonEmpty(_ value: String?) -> String? { - guard let value, value.isEmpty == false else { - return nil - } - return value - } } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 200ec638..f42db1cd 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -176,7 +176,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe } window.layoutIfNeeded() synchronizeSidebarToolbarState() - applyWindowTitle(Self.windowTitlePresentation(for: uiState.selection)) + applyWindowTitle(windowTitlePresentation(for: uiState.selection)) } func detachFromWindow() { @@ -207,8 +207,11 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe } windowTitleObservation = withPortableContinuousObservation { [weak self, uiState] _ in - let presentation = Self.windowTitlePresentation(for: uiState.selection) - self?.applyWindowTitle(presentation) + guard let self else { + return + } + let presentation = self.windowTitlePresentation(for: uiState.selection) + self.applyWindowTitle(presentation) } } @@ -239,32 +242,44 @@ 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( + return WindowTitlePresentation( title: section.title, subtitle: section.subtitle ) case .workspace(let workspace): - WindowTitlePresentation( + return WindowTitlePresentation( title: workspace.title, subtitle: workspace.cwd ) case .chat(let chat): - WindowTitlePresentation( + if let job = reviewJob(for: chat.id) { + return WindowTitlePresentation( + title: job.targetSummary, + subtitle: job.cwd + ) + } + return WindowTitlePresentation( title: chat.title, subtitle: chat.workspaceCWD ?? "" ) case .job(let job): - WindowTitlePresentation( + return WindowTitlePresentation( title: job.targetSummary, subtitle: job.cwd ) case nil: - WindowTitlePresentation(title: "", subtitle: "") + return WindowTitlePresentation(title: "", subtitle: "") + } + } + + private func reviewJob(for chatID: CodexThreadID) -> CodexReviewJob? { + store.orderedJobs.first { job in + job.reviewChatID == chatID } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index f10b9213..178ecdad 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -65,6 +65,25 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { package var preview: String? package var workspaceCWD: String? package var updatedAt: Date? + package var reviewIdentity: CodexReviewIdentity? + + package init( + rowID: ReviewMonitorCodexSidebarRowID, + id: CodexThreadID, + title: String, + preview: String?, + workspaceCWD: String?, + updatedAt: Date?, + reviewIdentity: CodexReviewIdentity? = nil + ) { + self.rowID = rowID + self.id = id + self.title = title + self.preview = preview + self.workspaceCWD = workspaceCWD + self.updatedAt = updatedAt + self.reviewIdentity = reviewIdentity + } } package var sections: [Section] diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 4cd537dd..4972f06e 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -807,8 +807,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) case .chat(let selectedChat): - guard let currentChat = codexChatSelection(id: selectedChat.id), - let row = row(forCodexSidebarSelectionID: .chat(selectedChat.id)) + guard let currentChat = codexChatSelection(id: selectedChat.id) + ?? reviewChatSelection(id: selectedChat.id) else { uiState.selection = nil outlineView.deselectAll(nil) @@ -819,6 +819,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = .chat(currentChat) } + guard let row = row(forCodexSidebarSelectionID: .chat(selectedChat.id)) + ?? row(forReviewChatID: selectedChat.id) + else { + return + } + guard outlineView.selectedRow != row else { return } @@ -870,7 +876,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } else if let section = workspaceSection(from: item) { uiState.selection = .workspaceSection(section.selection) } else if let job = job(from: item) { - uiState.selection = .job(job) + uiState.selection = job.reviewChatSelection.map(ReviewMonitorSelection.chat) } else { uiState.selection = nil } @@ -893,17 +899,18 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedJobRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard let selectedJobID = uiState.selectedJobID, - let selectedJob = job(withID: selectedJobID), + guard let selectedJob = selectedJobForCurrentSelection(), 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, + let currentSelectedJob = self.selectedJobForCurrentSelection(), section.isExpanded, - self.uiState.selectedJobID == selectedJobID, + currentSelectedJob.id == selectedJobID, let row = self.row(forJobID: selectedJobID), self.outlineView.selectedRow != row else { @@ -915,6 +922,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } + private func selectedJobForCurrentSelection() -> CodexReviewJob? { + switch uiState.selection { + case .job(let selectedJob): + return job(withID: selectedJob.id) + case .chat(let chat): + return job(withReviewChatID: chat.id) + case .workspaceSection, .workspace, nil: + return nil + } + } + private func makeContextMenu(at point: NSPoint) -> NSMenu? { let row = outlineView.row(at: point) guard row != -1, @@ -1315,6 +1333,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return row == -1 ? nil : row } + private func row(forReviewChatID chatID: CodexThreadID) -> Int? { + guard let job = job(withReviewChatID: chatID) else { + return nil + } + let row = outlineView.row(forItem: job) + return row == -1 ? nil : row + } + private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { let rowID: ReviewMonitorCodexSidebarRowID switch selectionID { @@ -1363,6 +1389,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return codexWorkspaceSelection(id: workspace.id) != nil case .chat(let chat): return codexChatSelection(id: chat.id) != nil + || reviewChatSelection(id: chat.id, in: workspaces) != nil case .job: guard case .job(let id) = selection.id else { return false @@ -1384,6 +1411,27 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } + private func job(withReviewChatID chatID: CodexThreadID) -> CodexReviewJob? { + job(withReviewChatID: chatID, in: workspaces()) + } + + private func job(withReviewChatID chatID: CodexThreadID, in workspaces: [CodexReviewWorkspace]) -> CodexReviewJob? { + store.orderedJobs.first { job in + job.reviewChatID == chatID && workspaces.contains(where: { $0.cwd == job.cwd }) + } + } + + private func reviewChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + reviewChatSelection(id: id, in: workspaces()) + } + + private func reviewChatSelection( + id: CodexThreadID, + in workspaces: [CodexReviewWorkspace] + ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + job(withReviewChatID: id, in: workspaces)?.reviewChatSelection + } + private func codexWorkspaceSelection( id: CodexWorkspaceID ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { @@ -2187,7 +2235,7 @@ extension ReviewMonitorSidebarViewController { } var selectedJobForTesting: CodexReviewJob? { - uiState.selectedJobEntry + selectedJobForCurrentSelection() } var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 4fa01f51..ddab2be4 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -7,7 +7,7 @@ import Testing @Suite("ReviewMonitor selected Codex chat", .serialized) @MainActor struct ReviewMonitorSelectedCodexChatTests { - @Test func selectedReviewJobObservesActiveCodexChat() async throws { + @Test func selectedReviewChatObservesReviewIdentity() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -44,12 +44,21 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - let job = makeRunningReviewJob( - sourceThreadID: "source-thread", - reviewThreadID: "review-thread", - turnID: "turn-1" + let reviewIdentity = CodexReviewIdentity( + threadID: CodexThreadID(rawValue: "source-thread"), + turnID: CodexTurnID(rawValue: "turn-1"), + reviewThreadID: CodexThreadID(rawValue: "review-thread") ) - uiState.selection = .job(job) + uiState.selection = .chat( + .init( + rowID: .chat(reviewIdentity.activeTurnThreadID), + id: reviewIdentity.activeTurnThreadID, + title: "Review", + preview: nil, + workspaceCWD: "/tmp/project", + updatedAt: nil, + reviewIdentity: reviewIdentity + )) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index c4d35fa1..7f6fbf6a 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -226,7 +226,7 @@ func renderTimelineForTesting( let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) return transport.renderTimelineDocumentForTesting( timelineDocument, - target: .job(job.id), + target: timelineRenderTarget(for: job), restoring: restorationTarget, allowIncrementalUpdate: allowIncrementalUpdate ) @@ -242,8 +242,9 @@ func awaitTimelineRenderForTesting( matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil ) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { let expectedLog = reviewMonitorLogText(for: job) + let expectedTarget = timelineRenderTarget(for: job) try await waitForCondition(timeout: timeout) { - transport.renderedStateForTesting.selection == .job(job.id) + transport.renderedStateForTesting.selection == expectedTarget && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false } _ = renderTimelineForTesting( @@ -260,6 +261,14 @@ func awaitTimelineRenderForTesting( } } +@MainActor +private func timelineRenderTarget(for job: CodexReviewJob) -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { + if let chatID = job.reviewChatID { + return .chat(chatID.rawValue) + } + return .job(job.id) +} + private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { switch entry.kind { case .command, .commandOutput: diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 6c8c8bc5..6e86b84d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1668,8 +1668,14 @@ private func renderDetailLogForShellLayoutTesting( job: CodexReviewJob ) async throws { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = + if let chatID = job.reviewChatID { + .chat(chatID.rawValue) + } else { + .job(job.id) + } try await waitForCondition { - transport.renderedStateForTesting.selection == .job(job.id) + transport.renderedStateForTesting.selection == expectedSelection && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false } #expect(transport.renderLogForTesting(text: text, allowIncrementalUpdate: false)) From 40547807d61061abc142baf580c5cb379f6ef228 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:33:56 +0900 Subject: [PATCH 131/359] refactor(review-ui): remove job selection mode --- ...ReviewMonitorTransportViewController.swift | 128 ++---------------- Sources/ReviewUI/ReviewMonitorSelection.swift | 3 - .../ReviewMonitorSplitViewController.swift | 5 - Sources/ReviewUI/ReviewMonitorUIState.swift | 19 --- .../ReviewMonitorSidebarViewController.swift | 26 ---- .../ReviewMonitorSelectedCodexChatTests.swift | 35 +++-- .../ReviewMonitorTimelineTesting.swift | 17 ++- Tests/ReviewUITests/ReviewUIShellTests.swift | 8 +- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 9 files changed, 44 insertions(+), 199 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index e44ba8cd..8d327046 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -7,7 +7,6 @@ import ReviewMonitorRendering @MainActor final class ReviewMonitorTransportViewController: NSViewController { private enum LogRenderTarget: Equatable { - case job(String) case chat(CodexThreadID) } @@ -25,14 +24,12 @@ final class ReviewMonitorTransportViewController: NSViewController { private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? - private var selectedJobObservation: PortableObservationTracking.Token? + private var previewChatJobObservation: PortableObservationTracking.Token? private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? private var selectedCodexChatLogTask: Task? - private var boundJob: CodexReviewJob? private var boundChatID: CodexThreadID? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? private var displayedSelection: ReviewMonitorSelectionID? - private var logScrollTargetsByJobID: [String: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] private var logScrollTargetsByChatID: [CodexThreadID: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] private var logRenderTask: Task? private var logRenderGeneration: UInt64 = 0 @@ -74,7 +71,7 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() - selectedJobObservation?.cancel() + previewChatJobObservation?.cancel() selectedWorkspaceFindingsObservation?.cancel() selectedCodexChatLogTask?.cancel() logRenderTask?.cancel() @@ -146,8 +143,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private func selectionRequiresPresentationUpdate(_ selection: ReviewMonitorSelection?) -> Bool { switch selection { - case .job(let selectedJob): - return boundJob !== selectedJob || displayedSelection != selection?.id case .workspaceSection(let selectedSection): return boundWorkspaceSection != selectedSection || displayedSelection != selection?.id case .workspace: @@ -163,14 +158,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private func updatePresentation(selection: ReviewMonitorSelection?) { switch selection { - case .job(let selectedJob): - clearDisplayedWorkspace() - displayJob(selectedJob) - hidePlaceholder() - logScrollView.isHidden = false - workspaceFindingsView.isHidden = true - displayedSelection = selection?.id - case .workspaceSection(let selectedSection): clearDisplayedLogSelection() displayWorkspaceSection(selectedSection) @@ -205,65 +192,6 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func displayJob(_ selectedJob: CodexReviewJob) { - let isSwitchingRenderedJob = boundJob != nil && boundJob !== selectedJob - cacheBoundLogScrollTarget() - if isSwitchingRenderedJob { - logScrollView.resetFindStateForContentReuse() - } - selectedJobObservation?.cancel() - selectedJobObservation = nil - selectedCodexChatLogTask?.cancel() - selectedCodexChatLogTask = nil - boundChatID = nil - resetLogRenderer() - boundJob = selectedJob - selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) - #if DEBUG - guard codexModelSource?.modelContext != nil else { - renderPreviewJobTimeline( - selectedJob, - target: .job(selectedJob.id), - restoring: restorationTarget(selectedJob), - allowIncrementalUpdate: false - ) - selectedJobObservation = withPortableContinuousObservation { [weak self] _ in - guard let self, - self.boundJob === selectedJob - else { - return - } - _ = self.codexModelSource?.generation - if self.codexModelSource?.modelContext != nil { - self.displayJob(selectedJob) - return - } - _ = selectedJob.timeline.revision - self.renderPreviewJobTimeline( - selectedJob, - target: .job(selectedJob.id), - restoring: self.logScrollView.currentScrollRestorationTarget, - allowIncrementalUpdate: true - ) - } - return - } - #endif - startSelectedCodexChatLogStream( - target: .job(selectedJob.id), - initialRestorationTarget: restorationTarget(selectedJob) - ) - - selectedJobObservation = withPortableContinuousObservation { [weak self] _ in - guard let self, - self.boundJob === selectedJob - else { - return - } - self.selectedCodexChat.bind(to: selectedJob.reviewChatIdentity) - } - } - #if DEBUG @discardableResult private func renderPreviewJobTimeline( @@ -291,10 +219,9 @@ final class ReviewMonitorTransportViewController: NSViewController { } selectedCodexChatLogTask?.cancel() selectedCodexChatLogTask = nil - selectedJobObservation?.cancel() - selectedJobObservation = nil + previewChatJobObservation?.cancel() + previewChatJobObservation = nil resetLogRenderer() - boundJob = nil boundChatID = selectedChat.id let restorationTarget = restorationTarget(chatID: selectedChat.id) if let reviewIdentity = selectedChat.reviewIdentity { @@ -312,7 +239,7 @@ final class ReviewMonitorTransportViewController: NSViewController { restoring: restorationTarget, allowIncrementalUpdate: false ) - selectedJobObservation = withPortableContinuousObservation { [weak self] _ in + previewChatJobObservation = withPortableContinuousObservation { [weak self] _ in guard let self, self.boundChatID == selectedChat.id else { @@ -348,11 +275,10 @@ final class ReviewMonitorTransportViewController: NSViewController { private func clearDisplayedLogSelection() { cacheBoundLogScrollTarget() - selectedJobObservation?.cancel() - selectedJobObservation = nil + previewChatJobObservation?.cancel() + previewChatJobObservation = nil selectedCodexChatLogTask?.cancel() selectedCodexChatLogTask = nil - boundJob = nil boundChatID = nil selectedCodexChat.unbind() resetLogRenderer() @@ -610,8 +536,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private func isCurrentLogRenderTarget(_ target: LogRenderTarget) -> Bool { switch target { - case .job(let id): - boundJob?.id == id case .chat(let id): boundChatID == id } @@ -619,9 +543,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private func cacheBoundLogScrollTarget() { let restorationTarget = logScrollView.currentScrollRestorationTarget - if let boundJob { - logScrollTargetsByJobID[boundJob.id] = restorationTarget - } if let boundChatID { logScrollTargetsByChatID[boundChatID] = restorationTarget } @@ -639,17 +560,6 @@ final class ReviewMonitorTransportViewController: NSViewController { #endif } - private func restorationTarget( - _ job: CodexReviewJob - ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget { - if let chatID = job.reviewChatID, - let chatTarget = logScrollTargetsByChatID[chatID] - { - return chatTarget - } - return logScrollTargetsByJobID[job.id] ?? .bottom - } - private func restorationTarget( chatID: CodexThreadID ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget { @@ -702,7 +612,6 @@ final class ReviewMonitorTransportViewController: NSViewController { } enum DisplayedSelectionForTesting: Sendable, Equatable { - case job(String) case workspaceSection(String) case workspace(String) case chat(String) @@ -717,8 +626,8 @@ final class ReviewMonitorTransportViewController: NSViewController { selectionObservation } - var selectedJobObservationForTesting: PortableObservationTracking.Token? { - selectedJobObservation + var previewChatJobObservationForTesting: PortableObservationTracking.Token? { + previewChatJobObservation } var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { @@ -747,8 +656,6 @@ final class ReviewMonitorTransportViewController: NSViewController { return selectionObservation } switch expectedSelection { - case .job: - return selectedJobObservation ?? selectionObservation case .workspaceSection: return selectedWorkspaceFindingsObservation ?? selectionObservation case .workspace, .chat: @@ -1111,13 +1018,6 @@ final class ReviewMonitorTransportViewController: NSViewController { var expectedRenderSnapshotForTesting: RenderSnapshotForTesting { switch uiState.selection { - case .job: - .init( - title: nil, - summary: nil, - log: displayedLogForTesting, - isShowingEmptyState: false - ) case .workspaceSection: .init( title: nil, @@ -1158,23 +1058,19 @@ final class ReviewMonitorTransportViewController: NSViewController { private var displayedSelectionForTesting: DisplayedSelectionForTesting? { switch displayedSelection { - case .job(let id): - .job(id) case .workspaceSection(let id): .workspaceSection(id) case .workspace(let id): .workspace(id.rawValue) case .chat(let id): .chat(id.rawValue) - case nil: + case .job, nil: nil } } private var expectedDisplayedSelectionForTesting: DisplayedSelectionForTesting? { switch uiState.selection { - case .job(let job): - .job(job.id) case .workspaceSection(let section): .workspaceSection(section.id) case .workspace(let workspace): @@ -1414,8 +1310,6 @@ final class ReviewMonitorTransportViewController: NSViewController { ) -> Bool { let resolvedTarget: LogRenderTarget switch target ?? displayedSelectionForTesting { - case .job(let id): - resolvedTarget = .job(id) case .chat(let id): resolvedTarget = .chat(CodexThreadID(rawValue: id)) case .workspaceSection, .workspace, nil: @@ -1428,8 +1322,6 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollView.currentScrollRestorationTarget } switch resolvedTarget { - case .job(let id): - return logScrollTargetsByJobID[id] ?? .bottom case .chat(let id): return logScrollTargetsByChatID[id] ?? .bottom } diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 8e866d10..2e646786 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -13,7 +13,6 @@ enum ReviewMonitorSelection { case workspaceSection(ReviewMonitorWorkspaceSectionSelection) case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) case chat(ReviewMonitorCodexSidebarSnapshot.Chat) - case job(CodexReviewJob) var id: ReviewMonitorSelectionID { switch self { @@ -23,8 +22,6 @@ enum ReviewMonitorSelection { return .workspace(workspace.id) case .chat(let chat): return .chat(chat.id) - case .job(let job): - return .job(job.id) } } } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index f42db1cd..5e32cf95 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -267,11 +267,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe title: chat.title, subtitle: chat.workspaceCWD ?? "" ) - case .job(let job): - return WindowTitlePresentation( - title: job.targetSummary, - subtitle: job.cwd - ) case nil: return WindowTitlePresentation(title: "", subtitle: "") } diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index 02444cba..0b246f3a 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -27,25 +27,6 @@ final class ReviewMonitorUIState { self.persistSidebarJobFilter = persistSidebarJobFilter } - var selectedJobEntry: CodexReviewJob? { - get { - guard case .job(let job) = selection else { - return nil - } - return job - } - set { - selection = newValue.map(ReviewMonitorSelection.job) - } - } - - var selectedJobID: String? { - guard case .job(let id) = selectionID else { - return nil - } - return id - } - var selectedWorkspaceSectionEntry: ReviewMonitorWorkspaceSectionSelection? { get { guard case .workspaceSection(let section) = selection else { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 4972f06e..21da3097 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -830,25 +830,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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) } } @@ -924,8 +905,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func selectedJobForCurrentSelection() -> CodexReviewJob? { switch uiState.selection { - case .job(let selectedJob): - return job(withID: selectedJob.id) case .chat(let chat): return job(withReviewChatID: chat.id) case .workspaceSection, .workspace, nil: @@ -1390,11 +1369,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .chat(let chat): return codexChatSelection(id: chat.id) != nil || reviewChatSelection(id: chat.id, in: workspaces) != nil - case .job: - guard case .job(let id) = selection.id else { - return false - } - return containsJob(id: id, in: workspaces) } } diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index ddab2be4..ba7fd0ea 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -86,12 +86,14 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - uiState.selection = .job( + try selectReviewChat( makeRunningReviewJob( sourceThreadID: "source-thread", reviewThreadID: "review-thread", turnID: "turn-1" - )) + ), + in: uiState + ) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" && transport.selectedCodexChatPhaseForTesting == .loaded @@ -104,7 +106,7 @@ struct ReviewMonitorSelectedCodexChatTests { } } - @Test func selectedReviewJobConnectsWhenModelSourceInstallsLater() async throws { + @Test func selectedReviewChatConnectsWhenModelSourceInstallsLater() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelSource = ReviewMonitorCodexModelSource() try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -140,12 +142,14 @@ struct ReviewMonitorSelectedCodexChatTests { codexModelSource: modelSource ) transport.loadViewIfNeeded() - uiState.selection = .job( + try selectReviewChat( makeRunningReviewJob( sourceThreadID: "source-thread", reviewThreadID: "review-thread", turnID: "turn-1" - )) + ), + in: uiState + ) try await waitForCondition { transport.selectedCodexChatReviewIdentityForTesting?.activeTurnThreadID == "review-thread" @@ -161,7 +165,7 @@ struct ReviewMonitorSelectedCodexChatTests { } } - @Test func selectedReviewJobConnectsWhenRunIdentifiersArriveAfterSelection() async throws { + @Test func reviewChatSelectionCanStartAfterRunIdentifiersArrive() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -202,14 +206,13 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: nil, turnID: nil ) - uiState.selection = .job(job) - - #expect(transport.selectedCodexChatReviewIdentityForTesting == nil) + #expect(job.reviewChatSelection == nil) job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" job.core.run.turnID = "turn-1" appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline trigger")) + try selectReviewChat(job, in: uiState) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" @@ -218,7 +221,7 @@ struct ReviewMonitorSelectedCodexChatTests { } } - @Test func selectedReviewJobRendersCodexChatTurnAndLiveUpdates() async throws { + @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")) @@ -261,7 +264,7 @@ struct ReviewMonitorSelectedCodexChatTests { turnID: "turn-1" ) appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline fallback")) - uiState.selection = .job(job) + try selectReviewChat(job, in: uiState) let initialSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Chat snapshot") @@ -331,7 +334,7 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: "review-thread", turnID: "turn-1" ) - uiState.selection = .job(job) + try selectReviewChat(job, in: uiState) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log == "Initial" @@ -456,6 +459,14 @@ struct ReviewMonitorSelectedCodexChatTests { job.core.run.reviewThreadID = reviewThreadID return job } + + private func selectReviewChat( + _ job: CodexReviewJob, + in uiState: ReviewMonitorUIState + ) throws { + let chat = try #require(job.reviewChatSelection) + uiState.selection = .chat(chat) + } } private struct ThreadItemParams: Encodable, Sendable { diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 7f6fbf6a..04c2015a 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -1,4 +1,5 @@ import Foundation +import Testing @_spi(Testing) @testable import CodexReviewKit import ReviewMonitorRendering @_spi(PreviewSupport) @testable import ReviewUI @@ -222,11 +223,11 @@ func renderTimelineForTesting( in transport: ReviewMonitorTransportViewController, restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, allowIncrementalUpdate: Bool -) -> Bool { +) throws -> Bool { let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) return transport.renderTimelineDocumentForTesting( timelineDocument, - target: timelineRenderTarget(for: job), + target: try timelineRenderTarget(for: job), restoring: restorationTarget, allowIncrementalUpdate: allowIncrementalUpdate ) @@ -242,12 +243,12 @@ func awaitTimelineRenderForTesting( matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil ) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { let expectedLog = reviewMonitorLogText(for: job) - let expectedTarget = timelineRenderTarget(for: job) + let expectedTarget = try timelineRenderTarget(for: job) try await waitForCondition(timeout: timeout) { transport.renderedStateForTesting.selection == expectedTarget && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false } - _ = renderTimelineForTesting( + _ = try renderTimelineForTesting( job, in: transport, restoring: restorationTarget, @@ -262,11 +263,9 @@ func awaitTimelineRenderForTesting( } @MainActor -private func timelineRenderTarget(for job: CodexReviewJob) -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { - if let chatID = job.reviewChatID { - return .chat(chatID.rawValue) - } - return .job(job.id) +private func timelineRenderTarget(for job: CodexReviewJob) throws -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { + let chatID = try #require(job.reviewChatID) + return .chat(chatID.rawValue) } private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 6e86b84d..1ab4ac1c 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1668,12 +1668,8 @@ private func renderDetailLogForShellLayoutTesting( job: CodexReviewJob ) async throws { viewController.sidebarViewControllerForTesting.selectJobForTesting(job) - let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = - if let chatID = job.reviewChatID { - .chat(chatID.rawValue) - } else { - .job(job.id) - } + let chatID = try #require(job.reviewChatID) + let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { transport.renderedStateForTesting.selection == expectedSelection && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index a92d4943..56ef6e84 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5056,7 +5056,7 @@ struct ReviewUITests { job.updateStateForTesting(summary: "Deselected summary") replaceTimelineLogTextForTesting(job, "Deselected log") - #expect(contentPane.selectedJobObservationForTesting == nil) + #expect(contentPane.previewChatJobObservationForTesting == nil) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) } From 5d9ab3b7dd66a0dc3274623acb055db2f6328caf Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:37:38 +0900 Subject: [PATCH 132/359] refactor(review-ui): drop stale job selection id --- .../Detail/ReviewMonitorTransportViewController.swift | 6 +----- Sources/ReviewUI/ReviewMonitorSelection.swift | 1 - .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 2 -- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 8d327046..ef041ec7 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -569,8 +569,6 @@ final class ReviewMonitorTransportViewController: NSViewController { @discardableResult func performDisplayedTextFinderAction(_ sender: Any?) -> Bool { switch displayedSelection { - case .job: - return logScrollView.performDisplayedTextFinderAction(sender) case .workspaceSection: return workspaceFindingsView.performDisplayedTextFinderAction(sender) case .chat: @@ -582,8 +580,6 @@ final class ReviewMonitorTransportViewController: NSViewController { func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool { switch displayedSelection { - case .job: - return logScrollView.validateDisplayedTextFinderAction(item) case .workspaceSection: return workspaceFindingsView.validateDisplayedTextFinderAction(item) case .chat: @@ -1064,7 +1060,7 @@ final class ReviewMonitorTransportViewController: NSViewController { .workspace(id.rawValue) case .chat(let id): .chat(id.rawValue) - case .job, nil: + case nil: nil } } diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 2e646786..434d8fe8 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -5,7 +5,6 @@ enum ReviewMonitorSelectionID: Hashable, Sendable { case workspaceSection(String) case workspace(CodexWorkspaceID) case chat(CodexThreadID) - case job(String) } @MainActor diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 21da3097..140d6b83 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1329,8 +1329,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD rowID = .chat(id) case .workspaceSection(let id): rowID = .section(id) - case .job: - return nil } guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return nil From c845ff8f9c9d3775fe1eb54ee728031ffb8f415e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:43:30 +0900 Subject: [PATCH 133/359] refactor(review-ui): centralize review chat job lookup --- .../CodexReviewJob+ReviewChatIdentity.swift | 13 ++++++++ ...ReviewMonitorTransportViewController.swift | 4 +-- .../ReviewMonitorSplitViewController.swift | 4 +-- .../ReviewMonitorSidebarViewController.swift | 32 +++++++------------ 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift index c2bf729d..71f3b9e0 100644 --- a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift +++ b/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift @@ -44,3 +44,16 @@ extension CodexReviewJob { ) } } + +extension CodexReviewStore { + @MainActor + func reviewJob( + forChatID chatID: CodexThreadID, + in workspaces: [CodexReviewWorkspace]? = nil + ) -> CodexReviewJob? { + orderedJobs.first { job in + job.reviewChatID == chatID + && (workspaces?.contains { $0.cwd == job.cwd } ?? true) + } + } +} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index ef041ec7..db2dfb61 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -267,9 +267,7 @@ final class ReviewMonitorTransportViewController: NSViewController { if let previewJob = previewChatLogSource?.job(for: chatID) { return previewJob } - return store.orderedJobs.first { job in - job.reviewChatID == chatID - } + return store.reviewJob(forChatID: chatID) } #endif diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 5e32cf95..dee552ee 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -273,9 +273,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe } private func reviewJob(for chatID: CodexThreadID) -> CodexReviewJob? { - store.orderedJobs.first { job in - job.reviewChatID == chatID - } + store.reviewJob(forChatID: chatID) } private func applyWindowTitle(_ presentation: WindowTitlePresentation) { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 140d6b83..b66be2cc 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -880,19 +880,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedJobRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard let selectedJob = selectedJobForCurrentSelection(), - section.workspaces.contains(where: { $0.cwd == selectedJob.cwd }) + guard let selectedReviewJob = reviewJobForCurrentChatSelection(), + section.workspaces.contains(where: { $0.cwd == selectedReviewJob.cwd }) else { return } - let selectedJobID = selectedJob.id + let selectedReviewJobID = selectedReviewJob.id DispatchQueue.main.async { [weak self, weak section] in guard let self, let section, - let currentSelectedJob = self.selectedJobForCurrentSelection(), + let currentSelectedReviewJob = self.reviewJobForCurrentChatSelection(), section.isExpanded, - currentSelectedJob.id == selectedJobID, - let row = self.row(forJobID: selectedJobID), + currentSelectedReviewJob.id == selectedReviewJobID, + let row = self.row(forJobID: selectedReviewJobID), self.outlineView.selectedRow != row else { return @@ -903,10 +903,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func selectedJobForCurrentSelection() -> CodexReviewJob? { + private func reviewJobForCurrentChatSelection() -> CodexReviewJob? { switch uiState.selection { case .chat(let chat): - return job(withReviewChatID: chat.id) + return store.reviewJob(forChatID: chat.id, in: workspaces()) case .workspaceSection, .workspace, nil: return nil } @@ -1313,7 +1313,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forReviewChatID chatID: CodexThreadID) -> Int? { - guard let job = job(withReviewChatID: chatID) else { + guard let job = store.reviewJob(forChatID: chatID, in: workspaces()) else { return nil } let row = outlineView.row(forItem: job) @@ -1383,16 +1383,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } - private func job(withReviewChatID chatID: CodexThreadID) -> CodexReviewJob? { - job(withReviewChatID: chatID, in: workspaces()) - } - - private func job(withReviewChatID chatID: CodexThreadID, in workspaces: [CodexReviewWorkspace]) -> CodexReviewJob? { - store.orderedJobs.first { job in - job.reviewChatID == chatID && workspaces.contains(where: { $0.cwd == job.cwd }) - } - } - private func reviewChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { reviewChatSelection(id: id, in: workspaces()) } @@ -1401,7 +1391,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD id: CodexThreadID, in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - job(withReviewChatID: id, in: workspaces)?.reviewChatSelection + store.reviewJob(forChatID: id, in: workspaces)?.reviewChatSelection } private func codexWorkspaceSelection( @@ -2207,7 +2197,7 @@ extension ReviewMonitorSidebarViewController { } var selectedJobForTesting: CodexReviewJob? { - selectedJobForCurrentSelection() + reviewJobForCurrentChatSelection() } var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { From 4278d44e0dd087444b11a549c57b8e0f0e28234e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:59:55 +0900 Subject: [PATCH 134/359] refactor(review-ui): stream preview chat logs by chat id --- ...ReviewMonitorTransportViewController.swift | 102 ++++++------------ .../ReviewMonitorPreviewChatLogSource.swift | 61 ++++++++++- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 3 files changed, 91 insertions(+), 74 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index db2dfb61..0203bfc1 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -17,16 +17,12 @@ final class ReviewMonitorTransportViewController: NSViewController { private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() - #if DEBUG - private var previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() - #endif private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? - private var previewChatJobObservation: PortableObservationTracking.Token? private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? - private var selectedCodexChatLogTask: Task? + private var selectedChatLogTask: Task? private var boundChatID: CodexThreadID? private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? private var displayedSelection: ReviewMonitorSelectionID? @@ -35,6 +31,9 @@ final class ReviewMonitorTransportViewController: NSViewController { private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 private var hasAppliedBoundLog = false + #if DEBUG + private var timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() + #endif convenience init( store: CodexReviewStore, @@ -71,9 +70,8 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() - previewChatJobObservation?.cancel() selectedWorkspaceFindingsObservation?.cancel() - selectedCodexChatLogTask?.cancel() + selectedChatLogTask?.cancel() logRenderTask?.cancel() } @@ -192,35 +190,14 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - #if DEBUG - @discardableResult - private func renderPreviewJobTimeline( - _ selectedJob: CodexReviewJob, - target: LogRenderTarget, - restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, - allowIncrementalUpdate: Bool - ) -> Bool { - let timelineDocument = ReviewTimelineDocumentRenderer().document(from: selectedJob.timeline) - let sourceDocument = previewTimelineLogProjection.render(timelineDocument: timelineDocument) - return renderBoundLog( - sourceDocument: sourceDocument, - target: target, - restorationTarget: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate && hasAppliedBoundLog - ) - } - #endif - private func displayChat(_ selectedChat: ReviewMonitorCodexSidebarSnapshot.Chat) { let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChat.id cacheBoundLogScrollTarget() if isSwitchingRenderedChat { logScrollView.resetFindStateForContentReuse() } - selectedCodexChatLogTask?.cancel() - selectedCodexChatLogTask = nil - previewChatJobObservation?.cancel() - previewChatJobObservation = nil + selectedChatLogTask?.cancel() + selectedChatLogTask = nil resetLogRenderer() boundChatID = selectedChat.id let restorationTarget = restorationTarget(chatID: selectedChat.id) @@ -230,29 +207,12 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedCodexChat.bind(toChatID: selectedChat.id) } #if DEBUG - if codexModelSource?.modelContext == nil, - let previewJob = previewJob(for: selectedChat.id) - { - renderPreviewJobTimeline( - previewJob, + if let previewStream = previewChatLogSource?.logSourceChangeStream(for: selectedChat.id) { + startLogSourceChangeStream( + previewStream, target: .chat(selectedChat.id), - restoring: restorationTarget, - allowIncrementalUpdate: false + initialRestorationTarget: restorationTarget ) - previewChatJobObservation = withPortableContinuousObservation { [weak self] _ in - guard let self, - self.boundChatID == selectedChat.id - else { - return - } - _ = previewJob.timeline.revision - self.renderPreviewJobTimeline( - previewJob, - target: .chat(selectedChat.id), - restoring: self.logScrollView.currentScrollRestorationTarget, - allowIncrementalUpdate: true - ) - } return } #endif @@ -262,21 +222,10 @@ final class ReviewMonitorTransportViewController: NSViewController { ) } - #if DEBUG - private func previewJob(for chatID: CodexThreadID) -> CodexReviewJob? { - if let previewJob = previewChatLogSource?.job(for: chatID) { - return previewJob - } - return store.reviewJob(forChatID: chatID) - } - #endif - private func clearDisplayedLogSelection() { cacheBoundLogScrollTarget() - previewChatJobObservation?.cancel() - previewChatJobObservation = nil - selectedCodexChatLogTask?.cancel() - selectedCodexChatLogTask = nil + selectedChatLogTask?.cancel() + selectedChatLogTask = nil boundChatID = nil selectedCodexChat.unbind() resetLogRenderer() @@ -488,9 +437,20 @@ final class ReviewMonitorTransportViewController: NSViewController { target: LogRenderTarget, initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget ) { - selectedCodexChatLogTask?.cancel() - let stream = selectedCodexChat.logSourceChangeStream() - selectedCodexChatLogTask = Task { @MainActor [weak self] in + startLogSourceChangeStream( + selectedCodexChat.logSourceChangeStream(), + target: target, + initialRestorationTarget: initialRestorationTarget + ) + } + + private func startLogSourceChangeStream( + _ stream: AsyncStream, + target: LogRenderTarget, + initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget + ) { + selectedChatLogTask?.cancel() + selectedChatLogTask = Task { @MainActor [weak self] in var didRenderInitialDocument = false for await change in stream { guard let self, @@ -554,7 +514,7 @@ final class ReviewMonitorTransportViewController: NSViewController { hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() #if DEBUG - previewTimelineLogProjection = ReviewMonitorTimelineLogProjection() + timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() #endif } @@ -620,8 +580,8 @@ final class ReviewMonitorTransportViewController: NSViewController { selectionObservation } - var previewChatJobObservationForTesting: PortableObservationTracking.Token? { - previewChatJobObservation + var selectedChatLogTaskForTesting: Task? { + selectedChatLogTask } var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { @@ -1332,7 +1292,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func logDocumentForTesting( from timelineDocument: ReviewTimelineDocument ) -> ReviewMonitorLog.Document { - previewTimelineLogProjection.render(timelineDocument: timelineDocument) + timelineLogProjectionForTesting.render(timelineDocument: timelineDocument) } func copyLogSelectionForTesting() { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 7b25e728..405acd34 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -1,6 +1,8 @@ import CodexKit import CodexReviewKit import Foundation +import ObservationBridge +import ReviewMonitorRendering @MainActor final class ReviewMonitorPreviewChatLogSource { @@ -47,7 +49,62 @@ final class ReviewMonitorPreviewChatLogSource { self.jobsByChatID = jobsByChatID } - func job(for chatID: CodexThreadID) -> CodexReviewJob? { - jobsByChatID[chatID] + func logSourceChangeStream(for chatID: CodexThreadID) -> AsyncStream? { + guard let job = jobsByChatID[chatID] else { + return nil + } + let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) + let subscription = PreviewChatLogSubscription(job: job, continuation: pair.continuation) + subscription.start() + pair.continuation.onTermination = { _ in + Task { @MainActor in + subscription.cancel() + } + } + return pair.stream + } +} + +@MainActor +private final class PreviewChatLogSubscription { + private let job: CodexReviewJob + private let continuation: AsyncStream.Continuation + private var observation: PortableObservationTracking.Token? + private var projection = ReviewMonitorTimelineLogProjection() + private var hasRenderedDocument = false + + init( + job: CodexReviewJob, + continuation: AsyncStream.Continuation + ) { + self.job = job + self.continuation = continuation + } + + func start() { + publish(allowIncrementalUpdate: false) + observation = withPortableContinuousObservation { [weak self] _ in + guard let self else { + return + } + _ = job.timeline.revision + publish(allowIncrementalUpdate: hasRenderedDocument) + } + } + + func cancel() { + observation?.cancel() + observation = nil + } + + private func publish(allowIncrementalUpdate: Bool) { + let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) + let sourceDocument = projection.render(timelineDocument: timelineDocument) + continuation.yield( + allowIncrementalUpdate + ? .update(sourceDocument) + : .replaceAll(sourceDocument) + ) + hasRenderedDocument = true } } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 56ef6e84..92ea037a 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5056,7 +5056,7 @@ struct ReviewUITests { job.updateStateForTesting(summary: "Deselected summary") replaceTimelineLogTextForTesting(job, "Deselected log") - #expect(contentPane.previewChatJobObservationForTesting == nil) + #expect(contentPane.selectedChatLogTaskForTesting == nil) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) } From c9a5939650e106e25b2d776f0def7ffc28e5eeeb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:10:09 +0900 Subject: [PATCH 135/359] refactor(review-ui): refresh selected chat snapshots in sidebar --- .../ReviewMonitorSplitViewController.swift | 10 ---------- .../ReviewMonitorSidebarViewController.swift | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index dee552ee..54522858 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -257,12 +257,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe subtitle: workspace.cwd ) case .chat(let chat): - if let job = reviewJob(for: chat.id) { - return WindowTitlePresentation( - title: job.targetSummary, - subtitle: job.cwd - ) - } return WindowTitlePresentation( title: chat.title, subtitle: chat.workspaceCWD ?? "" @@ -272,10 +266,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe } } - private func reviewJob(for chatID: CodexThreadID) -> CodexReviewJob? { - store.reviewJob(forChatID: chatID) - } - private func applyWindowTitle(_ presentation: WindowTitlePresentation) { guard let attachedWindow else { return diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index b66be2cc..dddb32f0 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -203,6 +203,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarTopologyObservation: PortableObservationTracking.Token? private var sidebarFilterObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? + private var selectedChatSnapshotObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() @@ -243,6 +244,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() codexSidebarObservation?.cancel() + selectedChatSnapshotObservation?.cancel() codexSidebarFetchTask?.cancel() } @@ -348,6 +350,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarKindObservation?.cancel() sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() + selectedChatSnapshotObservation?.cancel() sidebarKindObservation = withPortableContinuousObservation { [weak self, uiState, store] _ in let sidebarSelection = uiState.sidebarSelection @@ -384,6 +387,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD filter: initialFilter, animatedInitialDelivery: false ) + + selectedChatSnapshotObservation = withPortableContinuousObservation { [weak self, uiState] _ in + guard let self, + case .chat(let selectedChat) = uiState.selection, + let currentChat = self.currentChatSelection(id: selectedChat.id), + currentChat != selectedChat + else { + return + } + uiState.selection = .chat(currentChat) + } } private func bindCodexSidebarLibrary() { @@ -1394,6 +1408,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD store.reviewJob(forChatID: id, in: workspaces)?.reviewChatSelection } + private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + codexChatSelection(id: id) ?? reviewChatSelection(id: id) + } + private func codexWorkspaceSelection( id: CodexWorkspaceID ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { From af0fee3843f190ece459b37c65a65f632762e8cf Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:34:14 +0900 Subject: [PATCH 136/359] refactor(review-ui): stream preview logs through chat changes --- .../ReviewMonitorPreviewChatLogSource.swift | 290 +++++++++++++++++- Tests/ReviewUITests/ReviewUIShellTests.swift | 39 ++- 2 files changed, 314 insertions(+), 15 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 405acd34..83683b0e 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -2,7 +2,6 @@ import CodexKit import CodexReviewKit import Foundation import ObservationBridge -import ReviewMonitorRendering @MainActor final class ReviewMonitorPreviewChatLogSource { @@ -70,8 +69,8 @@ private final class PreviewChatLogSubscription { private let job: CodexReviewJob private let continuation: AsyncStream.Continuation private var observation: PortableObservationTracking.Token? - private var projection = ReviewMonitorTimelineLogProjection() - private var hasRenderedDocument = false + private var previousSnapshot: CodexChatSnapshot? + private var logProjection = ReviewMonitorSelectedCodexChatLogProjection() init( job: CodexReviewJob, @@ -82,13 +81,13 @@ private final class PreviewChatLogSubscription { } func start() { - publish(allowIncrementalUpdate: false) + publish() observation = withPortableContinuousObservation { [weak self] _ in guard let self else { return } _ = job.timeline.revision - publish(allowIncrementalUpdate: hasRenderedDocument) + publish() } } @@ -97,14 +96,279 @@ private final class PreviewChatLogSubscription { observation = nil } - private func publish(allowIncrementalUpdate: Bool) { - let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - let sourceDocument = projection.render(timelineDocument: timelineDocument) - continuation.yield( - allowIncrementalUpdate - ? .update(sourceDocument) - : .replaceAll(sourceDocument) + private func publish() { + let snapshot = CodexChatSnapshot.previewSnapshot(from: job) + let changes = CodexChatChange.previewChanges(from: previousSnapshot, to: snapshot) + previousSnapshot = snapshot + for change in changes { + guard + let logChange = logProjection.apply( + change, + activeTurnID: job.previewTurnID, + chatCreatedAt: job.core.lifecycle.startedAt, + chatUpdatedAt: job.core.lifecycle.endedAt ?? job.timeline.latestUpdatedAt + ) + else { + continue + } + continuation.yield(logChange) + } + } +} + +private extension CodexChatSnapshot { + @MainActor + static func previewSnapshot(from job: CodexReviewJob) -> Self { + let turn = CodexChatTurnStateSnapshot( + id: job.previewTurnID, + status: CodexTurnStatus(job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ) + return .init( + chatID: job.previewChatID, + phase: CodexDataPhase( + job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ), + turns: [turn], + items: job.timeline.items.map { CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) } + ) + } +} + +private extension CodexChatChange { + static func previewChanges(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { + guard let previous else { + return [.snapshot(current)] + } + + var changes: [Self] = [] + let previousTurnsByID = Dictionary(uniqueKeysWithValues: previous.turns.map { ($0.id, $0) }) + for turn in current.turns where previousTurnsByID[turn.id] != turn { + changes.append(.turnUpdated(turn)) + } + + let previousItemsByID = Dictionary(uniqueKeysWithValues: previous.items.map { ($0.id, $0) }) + let currentItemIDs = Set(current.items.map(\.id)) + for removedItem in previous.items where currentItemIDs.contains(removedItem.id) == false { + changes.append(.itemRemoved(id: removedItem.id, turnID: removedItem.turnID)) + } + for item in current.items { + guard let previousItem = previousItemsByID[item.id] else { + changes.append(.itemInserted(item)) + continue + } + guard previousItem != item else { + continue + } + if let delta = previousItem.textDelta(to: item) { + changes.append(.itemTextAppended(id: item.id, turnID: item.turnID, delta: delta, item: item)) + } else { + changes.append(.itemUpdated(item)) + } + } + + if previous.phase != current.phase { + changes.append(.phaseChanged(current.phase)) + } + return changes + } +} + +private extension CodexChatItemSnapshot { + @MainActor + init(previewItem item: ReviewTimelineItem, turnID: CodexTurnID) { + self.init( + id: item.id.rawValue, + turnID: turnID, + kind: CodexThreadItem.Kind(item.kind), + content: CodexThreadItem.Content(previewItem: item), + rawPayload: nil ) - hasRenderedDocument = true + } + + func textDelta(to item: CodexChatItemSnapshot) -> String? { + guard + turnID == item.turnID, + kind == item.kind, + sameContentShape(as: 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 sameContentShape(as item: CodexChatItemSnapshot) -> 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 + } + } +} + +private extension CodexThreadItem.Kind { + init(_ kind: ReviewItemKind) { + self.init(rawValue: kind.rawValue) + } +} + +private extension CodexThreadItem.Content { + @MainActor + init(previewItem item: ReviewTimelineItem) { + switch item.content { + case .approval(let approval): + self = .diagnostic( + [ + approval.title.nilIfEmpty, + approval.detail?.nilIfEmpty, + ] + .compactMap { $0 } + .joined(separator: "\n") + ) + case .command(let command): + self = .command( + .init( + command: command.command, + cwd: command.cwd, + output: command.output.nilIfEmpty, + exitCode: command.exitCode, + status: command.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + )) + case .contextCompaction(let compaction): + self = .contextCompaction(compaction.title) + case .diagnostic(let diagnostic): + self = .diagnostic(diagnostic.message) + case .fileChange(let fileChange): + self = .fileChange( + .init( + path: fileChange.paths.first ?? fileChange.title.nilIfEmpty, + output: fileChange.output.nilIfEmpty ?? fileChange.patch?.nilIfEmpty, + status: fileChange.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + )) + case .message(let message): + self = .message(.init(id: item.id.rawValue, role: .assistant, text: message.text)) + case .plan(let plan): + self = .plan(plan.markdown) + case .reasoning(let reasoning): + switch reasoning.style { + case .raw: + self = .reasoning(.init(content: reasoning.text)) + case .summary: + self = .reasoning(.init(summary: reasoning.text)) + } + case .search(let search): + self = .toolCall( + .init( + name: "web_search", + arguments: search.query, + result: search.result, + status: search.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + )) + case .toolCall(let toolCall): + self = .toolCall( + .init( + namespace: toolCall.namespace, + server: toolCall.server, + name: toolCall.tool, + arguments: toolCall.arguments, + result: toolCall.result, + error: toolCall.error, + status: toolCall.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + )) + case .unknown(let unknown): + let text = [ + unknown.title.nilIfEmpty, + unknown.detail?.nilIfEmpty, + ] + .compactMap { $0 } + .joined(separator: "\n") + .nilIfEmpty + self = .unknown( + .init( + rawType: unknown.rawKind?.rawValue ?? item.kind.rawValue, + text: text + )) + } + } +} + +private extension CodexTurnStatus { + init(_ jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .running + case .succeeded: + self = .completed + case .failed: + self = .failed + case .cancelled: + self = .cancelled + } + } + + init(_ phase: ReviewItemPhase) { + switch phase { + case .awaitingApproval, .queued, .running, .waitingForInput: + self = .running + case .cancelled: + self = .cancelled + case .completed, .skipped: + self = .completed + case .failed, .incomplete: + self = .failed + } + } + + init(_ status: some ReviewOpenStringValue) { + self.init(rawValue: status.rawValue) + } +} + +private extension CodexDataPhase { + init(_ jobState: ReviewJobState, errorMessage: String?) { + switch jobState { + case .queued, .running, .succeeded, .cancelled: + self = .loaded + case .failed: + self = .failed(errorMessage ?? "Review failed") + } + } +} + +private extension CodexReviewJob { + var previewChatID: CodexThreadID { + core.run.reviewThreadID.map(CodexThreadID.init(rawValue:)) ?? CodexThreadID(rawValue: id) + } + + var previewTurnID: CodexTurnID { + core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") + } +} + +private extension ReviewTimeline { + var latestUpdatedAt: Date? { + items.map(\.updatedAt).max() + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 1ab4ac1c..9f5c8db0 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -723,7 +723,7 @@ extension ReviewUITests { ?? store.orderedJobs.first ) let selectedChatID = try #require(selectedJob.core.run.reviewThreadID) - let expectedLog = reviewMonitorLogText(for: selectedJob) + let selectedTargetSummary = selectedJob.targetSummary let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -735,13 +735,48 @@ extension ReviewUITests { let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting let snapshot = try await awaitTransportRender(transport) { snapshot in - snapshot.log == expectedLog && snapshot.isShowingEmptyState == false + snapshot.log.contains(selectedTargetSummary) && snapshot.isShowingEmptyState == false } #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) #expect(snapshot.log.isEmpty == false) } + @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws { + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let selectedJob = try #require( + store.orderedJobs.first { $0.core.lifecycle.status == .running } + ?? store.orderedJobs.first + ) + let existingItemIDs = Set(selectedJob.timeline.items.map(\.id)) + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + 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) { snapshot in + snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false + } + + ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) + let appendedItem = try #require( + selectedJob.timeline.items.first { existingItemIDs.contains($0.id) == false } + ) + let appendedText = try #require(diagnosticMessage(appendedItem).nilIfEmpty) + let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + snapshot.log.count > initialSnapshot.log.count + && snapshot.log.contains(appendedText) + && snapshot.isShowingEmptyState == false + } + + #expect(updatedSnapshot.log.contains(appendedText)) + } + @Test func windowControllerUsesSeededAuthenticatedStateOnFirstPresentation() { let backend = AuthActionBackend( initialAuthState: .signedIn(accountID: "review@example.com") From fcba165ff61bc5e048f5f599c24f5d32722255f8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:41:25 +0900 Subject: [PATCH 137/359] refactor(review-ui): feed preview transport with chat changes --- ...ReviewMonitorTransportViewController.swift | 48 ++++++++++++++++++- .../ReviewMonitorPreviewChatLogSource.swift | 27 ++--------- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 0203bfc1..14177e37 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -207,9 +207,11 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedCodexChat.bind(toChatID: selectedChat.id) } #if DEBUG - if let previewStream = previewChatLogSource?.logSourceChangeStream(for: selectedChat.id) { - startLogSourceChangeStream( + if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChat.id) { + startPreviewChatChangeStream( previewStream, + activeTurnID: selectedChat.reviewIdentity?.turnID, + chatUpdatedAt: selectedChat.updatedAt, target: .chat(selectedChat.id), initialRestorationTarget: restorationTarget ) @@ -444,6 +446,48 @@ final class ReviewMonitorTransportViewController: NSViewController { ) } + private func startPreviewChatChangeStream( + _ stream: AsyncStream, + activeTurnID: CodexTurnID?, + chatUpdatedAt: Date?, + target: LogRenderTarget, + initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget + ) { + selectedChatLogTask?.cancel() + selectedChatLogTask = Task { @MainActor [weak self] in + var didRenderInitialDocument = false + var projection = ReviewMonitorSelectedCodexChatLogProjection() + for await change in stream { + guard let self, + self.isCurrentLogRenderTarget(target) + else { + return + } + guard + let logChange = projection.apply( + change, + activeTurnID: activeTurnID, + chatCreatedAt: nil, + chatUpdatedAt: chatUpdatedAt + ) + else { + continue + } + self.applySelectedCodexChatLogChange( + logChange, + target: target, + restorationTarget: didRenderInitialDocument + ? self.logScrollView.currentScrollRestorationTarget + : initialRestorationTarget, + allowIncrementalUpdate: didRenderInitialDocument && logChange.allowsIncrementalRender + ) + if logChange.sourceDocument != nil { + didRenderInitialDocument = true + } + } + } + } + private func startLogSourceChangeStream( _ stream: AsyncStream, target: LogRenderTarget, diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 83683b0e..e322ca27 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -48,11 +48,11 @@ final class ReviewMonitorPreviewChatLogSource { self.jobsByChatID = jobsByChatID } - func logSourceChangeStream(for chatID: CodexThreadID) -> AsyncStream? { + func chatChangeStream(for chatID: CodexThreadID) -> AsyncStream? { guard let job = jobsByChatID[chatID] else { return nil } - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) + let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) let subscription = PreviewChatLogSubscription(job: job, continuation: pair.continuation) subscription.start() pair.continuation.onTermination = { _ in @@ -67,14 +67,13 @@ final class ReviewMonitorPreviewChatLogSource { @MainActor private final class PreviewChatLogSubscription { private let job: CodexReviewJob - private let continuation: AsyncStream.Continuation + private let continuation: AsyncStream.Continuation private var observation: PortableObservationTracking.Token? private var previousSnapshot: CodexChatSnapshot? - private var logProjection = ReviewMonitorSelectedCodexChatLogProjection() init( job: CodexReviewJob, - continuation: AsyncStream.Continuation + continuation: AsyncStream.Continuation ) { self.job = job self.continuation = continuation @@ -101,17 +100,7 @@ private final class PreviewChatLogSubscription { let changes = CodexChatChange.previewChanges(from: previousSnapshot, to: snapshot) previousSnapshot = snapshot for change in changes { - guard - let logChange = logProjection.apply( - change, - activeTurnID: job.previewTurnID, - chatCreatedAt: job.core.lifecycle.startedAt, - chatUpdatedAt: job.core.lifecycle.endedAt ?? job.timeline.latestUpdatedAt - ) - else { - continue - } - continuation.yield(logChange) + continuation.yield(change) } } } @@ -361,12 +350,6 @@ private extension CodexReviewJob { } } -private extension ReviewTimeline { - var latestUpdatedAt: Date? { - items.map(\.updatedAt).max() - } -} - private extension String { var nilIfEmpty: String? { isEmpty ? nil : self From e15b3b4a9d26beea18f0c58d040f84be3da597fb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:52:18 +0900 Subject: [PATCH 138/359] refactor(review-ui): render sidebar jobs as chat rows --- ...w.swift => ReviewMonitorChatRowView.swift} | 128 +++++++++--------- .../ReviewMonitorSidebarViewController.swift | 9 +- 2 files changed, 67 insertions(+), 70 deletions(-) rename Sources/ReviewUI/Sidebar/Jobs/{ReviewMonitorJobRowView.swift => ReviewMonitorChatRowView.swift} (63%) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift similarity index 63% rename from Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift rename to Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 70e8f3cb..496d3f15 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorJobRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -2,26 +2,73 @@ import Foundation import SwiftUI import CodexReviewKit -struct ReviewMonitorJobRowView: View { - var job: CodexReviewJob +struct ReviewMonitorSidebarChatRow: Equatable { + var id: String + var title: String + var model: String? + var subtitle: String? + var startedAt: Date? + var endedAt: Date? + var isRunning: Bool + + @MainActor + init(job: CodexReviewJob) { + self.id = job.id + self.title = job.displayTitle + self.model = job.core.run.model + self.subtitle = Self.subtitleText(for: job) + self.startedAt = job.core.lifecycle.startedAt + self.endedAt = job.core.lifecycle.endedAt + self.isRunning = job.core.lifecycle.status == .running + } + + @MainActor + private static func subtitleText(for job: CodexReviewJob) -> 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 ReviewMonitorChatRowView: View { + var row: ReviewMonitorSidebarChatRow var body: some View { Label { VStack { HStack { - Text(job.displayTitle) + Text(row.title) .truncationMode(.tail) Spacer(minLength: 0) - TimerLabelView(job: job) + ReviewMonitorChatRowTimerLabel(row: row) .foregroundStyle(.secondary) .layoutPriority(1) } .lineLimit(1) HStack { - if let model = job.core.run.model { + if let model = row.model { Text(model) } - if let subtitle = subtitleText { + if let subtitle = row.subtitle { Text(subtitle) } Spacer(minLength: 0) @@ -34,85 +81,34 @@ struct ReviewMonitorJobRowView: View { ZStack { Image(systemName: "circle.fill") .foregroundStyle(.clear) - if job.core.lifecycle.status == .running { + if row.isRunning { ProgressView() .controlSize(.mini) } } - .animation(.default, value: job.core.lifecycle.status) + .animation(.default, value: row.isRunning) .padding(.leading, SidebarLayout.disclosureGutterWidth) } - .transaction(value: job.id) { transaction in + .transaction(value: row.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 +struct ReviewMonitorChatRowTimerLabel: View { + var row: ReviewMonitorSidebarChatRow var body: some View { - if let startedAt = job.core.lifecycle.startedAt { + if let startedAt = row.startedAt { Text( - timerInterval: startedAt...(job.core.lifecycle.endedAt ?? .distantFuture), - pauseTime: job.core.lifecycle.endedAt, + timerInterval: startedAt...(row.endedAt ?? .distantFuture), + pauseTime: row.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 @@ -126,7 +122,7 @@ extension ReviewJobState { NavigationLink{ }label:{ - ReviewMonitorJobRowView(job: job) + ReviewMonitorChatRowView(row: ReviewMonitorSidebarChatRow(job: job)) } } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index dddb32f0..28557e6d 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -3006,7 +3006,7 @@ private final class ReviewMonitorJobTableRowView: NSTableRowView { @MainActor private final class ReviewMonitorJobCellView: NSTableCellView { - private var hostingView: NSHostingView? + private var hostingView: NSHostingView? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -3021,11 +3021,12 @@ private final class ReviewMonitorJobCellView: NSTableCellView { func configure(with job: CodexReviewJob) { objectValue = job toolTip = job.cwd + let row = ReviewMonitorSidebarChatRow(job: job) if let hostingView { - hostingView.rootView.job = job + hostingView.rootView.row = row } else { let hostingView = NSHostingView( - rootView: ReviewMonitorJobRowView(job: job) + rootView: ReviewMonitorChatRowView(row: row) ) hostingView.translatesAutoresizingMaskIntoConstraints = false hostingView.setAccessibilityIdentifier("review-monitor.job-row") @@ -3050,7 +3051,7 @@ private final class ReviewMonitorJobCellView: NSTableCellView { } var hostedJobIDForTesting: String? { - hostingView?.rootView.job.id + hostingView?.rootView.row.id } var hostingViewIdentityForTesting: ObjectIdentifier? { From 37bd111d84717bce5b1b9293bc3cd7dbec2758db Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:03:03 +0900 Subject: [PATCH 139/359] refactor(review-ui): back legacy sidebar with review chat rows --- .../Jobs/ReviewMonitorChatRowView.swift | 30 ++ .../ReviewMonitorSidebarViewController.swift | 310 ++++++++++-------- 2 files changed, 202 insertions(+), 138 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 496d3f15..9583db50 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -49,6 +49,36 @@ struct ReviewMonitorSidebarChatRow: Equatable { } } +@MainActor +final class ReviewMonitorSidebarReviewChatRow { + let jobID: String + private(set) var sessionID: String + private(set) var cwd: String + private(set) var chat: ReviewMonitorCodexSidebarSnapshot.Chat? + private(set) var presentation: ReviewMonitorSidebarChatRow + private(set) var isTerminal: Bool + private(set) var cancellationRequested: Bool + + init(job: CodexReviewJob) { + self.jobID = job.id + self.sessionID = job.sessionID + self.cwd = job.cwd + self.chat = job.reviewChatSelection + self.presentation = ReviewMonitorSidebarChatRow(job: job) + self.isTerminal = job.isTerminal + self.cancellationRequested = job.cancellationRequested + } + + func update(from job: CodexReviewJob) { + sessionID = job.sessionID + cwd = job.cwd + chat = job.reviewChatSelection + presentation = ReviewMonitorSidebarChatRow(job: job) + isTerminal = job.isTerminal + cancellationRequested = job.cancellationRequested + } +} + struct ReviewMonitorChatRowView: View { var row: ReviewMonitorSidebarChatRow diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 28557e6d..023d2305 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -90,7 +90,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private struct SidebarWorkspaceTopology { let workspace: CodexReviewWorkspace - let jobs: [CodexReviewJob] + let reviewRows: [ReviewMonitorSidebarReviewChatRow] } private struct SidebarWorkspaceDropDestination { @@ -143,19 +143,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let id: String var title: String var workspaces: [CodexReviewWorkspace] - var jobs: [CodexReviewJob] + var reviewRows: [ReviewMonitorSidebarReviewChatRow] var isExpanded: Bool init( id: String, title: String, workspaces: [CodexReviewWorkspace], - jobs: [CodexReviewJob] + reviewRows: [ReviewMonitorSidebarReviewChatRow] ) { self.id = id self.title = title self.workspaces = workspaces - self.jobs = jobs + self.reviewRows = reviewRows self.isExpanded = true } @@ -179,7 +179,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private struct SidebarRootTopology { let item: AnyObject let workspaces: [CodexReviewWorkspace] - let jobs: [CodexReviewJob] + let reviewRows: [ReviewMonitorSidebarReviewChatRow] } private enum SidebarMutationAnimation { @@ -209,6 +209,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] + private var reviewRowsByJobID: [String: ReviewMonitorSidebarReviewChatRow] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] private var isReconcilingSelection = false #if DEBUG @@ -533,12 +534,30 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] { - store.orderedWorkspaces.map { workspace in + let workspaces = store.orderedWorkspaces + let activeJobIDs = Set(workspaces.flatMap { workspace in + store.orderedJobs(in: workspace).map(\.id) + }) + let topologies = workspaces.map { workspace in SidebarWorkspaceTopology( workspace: workspace, - jobs: store.orderedJobs(in: workspace) + reviewRows: reviewRows(for: store.orderedJobs(in: workspace)) ) } + reviewRowsByJobID = reviewRowsByJobID.filter { activeJobIDs.contains($0.key) } + return topologies + } + + private func reviewRows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { + jobs.map { job in + if let row = reviewRowsByJobID[job.id] { + row.update(from: job) + return row + } + let row = ReviewMonitorSidebarReviewChatRow(job: job) + reviewRowsByJobID[job.id] = row + return row + } } private func sidebarRootTopologies( @@ -567,19 +586,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } renderedSectionIDs.insert(identity.id) - let sectionJobs = Self.visibleJobs( - in: topologies.flatMap(\.jobs), + let sectionRows = Self.visibleReviewRows( + in: topologies.flatMap(\.reviewRows), filter: filter ) let section = workspaceSection( identity: identity, workspaces: topologies.map(\.workspace), - jobs: sectionJobs + reviewRows: sectionRows ) return SidebarRootTopology( item: section, workspaces: section.workspaces, - jobs: section.jobs + reviewRows: section.reviewRows ) } workspaceSectionsByID = workspaceSectionsByID.filter { renderedSectionIDs.contains($0.key) } @@ -598,12 +617,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func workspaceSection( identity: ReviewMonitorWorkspaceSectionIdentity, workspaces: [CodexReviewWorkspace], - jobs: [CodexReviewJob] + reviewRows: [ReviewMonitorSidebarReviewChatRow] ) -> SidebarWorkspaceSection { if let section = workspaceSectionsByID[identity.id] { section.title = identity.title section.workspaces = workspaces - section.jobs = jobs + section.reviewRows = reviewRows return section } @@ -611,7 +630,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD id: identity.id, title: identity.title, workspaces: workspaces, - jobs: jobs + reviewRows: reviewRows ) workspaceSectionsByID[identity.id] = section return section @@ -627,7 +646,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD rootTopologies.map(\.item), animated: shouldAnimate ) - applyJobMembershipChange(rootTopologies, animated: shouldAnimate) + applyReviewRowMembershipChange(rootTopologies, animated: shouldAnimate) scheduleOutlineSelectionReconciliation() } @@ -649,7 +668,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD applyStoredExpansionState(for: insertedRootItems) } - private func applyJobMembershipChange( + private func applyReviewRowMembershipChange( _ rootTopologies: [SidebarRootTopology], animated: Bool ) { @@ -658,15 +677,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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 { + let displayedRows = displayedReviewRows(inRootItem: rootItem) + let targetRows = topology.reviewRows + guard hasSameIdentityOrder(displayedRows, targetRows) == false else { continue } if outlineView.isItemExpanded(rootItem) { applyMembershipChange( - currentItems: displayedJobs, - targetItems: targetJobs, + currentItems: displayedRows, + targetItems: targetRows, parent: rootItem, animated: animated ) @@ -870,19 +889,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } else if let section = workspaceSection(from: item) { uiState.selection = .workspaceSection(section.selection) - } else if let job = job(from: item) { - uiState.selection = job.reviewChatSelection.map(ReviewMonitorSelection.chat) + } else if let row = reviewRow(from: item) { + uiState.selection = row.chat.map(ReviewMonitorSelection.chat) } else { uiState.selection = nil } } - private func triggerCancellation(for job: CodexReviewJob) { + private func triggerCancellation(for row: ReviewMonitorSidebarReviewChatRow) { Task { @MainActor [weak self] in guard let self else { return } - await performCancellation(for: job) + await performCancellation(for: row) } } @@ -929,7 +948,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func makeContextMenu(at point: NSPoint) -> NSMenu? { let row = outlineView.row(at: point) guard row != -1, - let job = job(atRow: row) + let reviewRow = reviewRow(atRow: row) else { return nil } @@ -943,47 +962,47 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD keyEquivalent: "" ) cancelItem.target = self - cancelItem.representedObject = job - cancelItem.isEnabled = job.isTerminal == false && job.cancellationRequested == false + cancelItem.representedObject = reviewRow + cancelItem.isEnabled = reviewRow.isTerminal == false && reviewRow.cancellationRequested == false menu.addItem(cancelItem) return menu } @objc private func handleCancelMenuItem(_ sender: NSMenuItem) { - guard let job = sender.representedObject as? CodexReviewJob else { + guard let row = sender.representedObject as? ReviewMonitorSidebarReviewChatRow else { return } - triggerCancellation(for: job) + triggerCancellation(for: row) } - private func requestCancellation(for job: CodexReviewJob) async throws { - guard job.isTerminal == false, - job.cancellationRequested == false + private func requestCancellation(for row: ReviewMonitorSidebarReviewChatRow) async throws { + guard row.isTerminal == false, + row.cancellationRequested == false else { return } _ = try await store.cancelReview( - jobID: job.id, - sessionID: job.sessionID, + jobID: row.jobID, + sessionID: row.sessionID, cancellation: .userInterface() ) } - private func performCancellation(for job: CodexReviewJob) async { + private func performCancellation(for row: ReviewMonitorSidebarReviewChatRow) async { do { - try await requestCancellation(for: job) + try await requestCancellation(for: row) } catch { - handleCancellationFailure(error, for: job) + handleCancellationFailure(error, for: row) } } - private func handleCancellationFailure(_ error: Error, for job: CodexReviewJob) { + private func handleCancellationFailure(_ error: Error, for row: ReviewMonitorSidebarReviewChatRow) { 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, + jobID: row.jobID, + sessionID: row.sessionID, message: message ) } @@ -1103,15 +1122,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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 currentRows = displayedReviewRows(in: workspace) + guard let sourceIndex = currentRows.firstIndex(where: { $0.jobID == 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 }) + let rootRows = displayedReviewRows(inRootItem: parentItem) + guard let sourceRootIndex = rootRows.firstIndex(where: { $0.jobID == id }), + let workspaceRootStartIndex = rootRows.firstIndex(where: { $0.cwd == workspace.cwd }) else { return } @@ -1155,83 +1174,85 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func displayedJobs(in workspace: CodexReviewWorkspace) -> [CodexReviewJob] { + private func displayedReviewRows(in workspace: CodexReviewWorkspace) -> [ReviewMonitorSidebarReviewChatRow] { (0.. [CodexReviewJob] { + private func displayedReviewRows(inRootItem rootItem: AnyObject) -> [ReviewMonitorSidebarReviewChatRow] { (0.. [CodexReviewJob] { + private func visibleReviewRows(in workspace: CodexReviewWorkspace) -> [ReviewMonitorSidebarReviewChatRow] { guard let section = workspaceSection(containing: workspace) else { return [] } - return section.jobs.filter { $0.cwd == workspace.cwd } + return section.reviewRows.filter { $0.cwd == workspace.cwd } } - private static func visibleJobs( - in jobs: [CodexReviewJob], + private static func visibleReviewRows( + in rows: [ReviewMonitorSidebarReviewChatRow], filter: SidebarJobFilter - ) -> [CodexReviewJob] { + ) -> [ReviewMonitorSidebarReviewChatRow] { guard filter.isActive else { - return jobs + return rows } - let latestFinishedJob = filter.contains(.latestFinished) - ? latestFinishedJob(in: jobs) + let latestFinishedRow = filter.contains(.latestFinished) + ? latestFinishedRow(in: rows) : nil - return jobs.filter { job in + return rows.filter { row in if filter.contains(.running), - job.core.lifecycle.status.isTerminal == false + row.isTerminal == false { return true } - return latestFinishedJob.map { $0 === job } ?? false + return latestFinishedRow.map { $0 === row } ?? false } } - private static func latestFinishedJob(in orderedJobs: [CodexReviewJob]) -> CodexReviewJob? { - var latestJob: CodexReviewJob? + private static func latestFinishedRow( + in orderedRows: [ReviewMonitorSidebarReviewChatRow] + ) -> ReviewMonitorSidebarReviewChatRow? { + var latestRow: ReviewMonitorSidebarReviewChatRow? var latestDate = Date.distantPast - for job in orderedJobs { - guard job.core.lifecycle.status.isTerminal else { + for row in orderedRows { + guard row.isTerminal else { continue } - let finishedAt = job.core.lifecycle.endedAt - ?? job.core.lifecycle.startedAt + let finishedAt = row.presentation.endedAt + ?? row.presentation.startedAt ?? .distantPast - if latestJob == nil || finishedAt > latestDate { - latestJob = job + if latestRow == nil || finishedAt > latestDate { + latestRow = row latestDate = finishedAt } } - return latestJob + return latestRow } private func workspaceSection(from item: Any?) -> SidebarWorkspaceSection? { item as? SidebarWorkspaceSection } - private func job(from item: Any?) -> CodexReviewJob? { - item as? CodexReviewJob + private func reviewRow(from item: Any?) -> ReviewMonitorSidebarReviewChatRow? { + item as? ReviewMonitorSidebarReviewChatRow } private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -1242,7 +1263,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if codexSidebarNode(from: item) != nil { return true } - return workspaceSection(from: item) != nil || job(from: item) != nil + return workspaceSection(from: item) != nil || reviewRow(from: item) != nil } private func configureCodexSidebarCell( @@ -1268,16 +1289,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func filteredJobCount(in workspace: CodexReviewWorkspace) -> Int { - visibleJobs(in: workspace).count + visibleReviewRows(in: workspace).count } - private func job(atRow row: Int) -> CodexReviewJob? { + private func reviewRow(atRow row: Int) -> ReviewMonitorSidebarReviewChatRow? { guard row >= 0, let item = outlineView.item(atRow: row) else { return nil } - return job(from: item) + return reviewRow(from: item) } private func row(for workspace: CodexReviewWorkspace) -> Int? { @@ -1311,26 +1332,26 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func rootItem(containing job: CodexReviewJob) -> AnyObject? { - guard let workspace = workspace(containing: job) else { + private func rootItem(containing row: ReviewMonitorSidebarReviewChatRow) -> AnyObject? { + guard let workspace = workspace(cwd: row.cwd) else { return nil } return rootItem(containing: workspace) } private func row(forJobID jobID: String) -> Int? { - guard let job = job(withID: jobID) else { + guard let rowItem = reviewRowsByJobID[jobID] else { return nil } - let row = outlineView.row(forItem: job) + let row = outlineView.row(forItem: rowItem) return row == -1 ? nil : row } private func row(forReviewChatID chatID: CodexThreadID) -> Int? { - guard let job = store.reviewJob(forChatID: chatID, in: workspaces()) else { + guard let rowItem = reviewRowsByJobID.values.first(where: { $0.chat?.id == chatID }) else { return nil } - let row = outlineView.row(forItem: job) + let row = outlineView.row(forItem: rowItem) return row == -1 ? nil : row } @@ -1432,10 +1453,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return chat } - 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, @@ -1471,8 +1488,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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) + if let row = reviewRow(from: item) { + return .job(id: row.jobID, cwd: row.cwd) } return nil } @@ -1595,8 +1612,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } - if let targetJob = job(from: proposedItem), - let targetWorkspace = workspace(containing: targetJob), + if let targetRow = reviewRow(from: proposedItem), + let targetWorkspace = workspace(cwd: targetRow.cwd), let targetSection = workspaceSection(containing: targetWorkspace), let targetRootIndex = rootIndex(forRootItem: targetSection) { @@ -1694,8 +1711,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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) + let lastRow = displayedReviewRows(inRootItem: rootItem).last, + let lastJobRow = row(forJobID: lastRow.jobID) else { return sectionRect } @@ -1726,23 +1743,23 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let orderedJobs = store.orderedJobs(in: destination.workspace) - let destinationVisibleJobs = visibleJobs(in: destination.workspace) - guard let visibleSourceIndex = destinationVisibleJobs.firstIndex(where: { $0.id == id }) else { + let destinationVisibleRows = visibleReviewRows(in: destination.workspace) + guard let visibleSourceIndex = destinationVisibleRows.firstIndex(where: { $0.jobID == id }) else { return nil } - let visibleInsertionIndex = max(0, min(destination.childIndex, destinationVisibleJobs.count)) + let visibleInsertionIndex = max(0, min(destination.childIndex, destinationVisibleRows.count)) let beforeJobID = jobBeforeID( movingJobID: id, visibleInsertionIndex: visibleInsertionIndex, - visibleJobs: destinationVisibleJobs, + visibleRows: destinationVisibleRows, orderedJobs: orderedJobs, movesToFullOrderEnd: destination.movesToFullOrderEnd ) let displayDestinationIndex = visibleInsertionIndex > visibleSourceIndex ? visibleInsertionIndex - 1 : visibleInsertionIndex - let clampedDisplayDestinationIndex = max(0, min(displayDestinationIndex, destinationVisibleJobs.count - 1)) + let clampedDisplayDestinationIndex = max(0, min(displayDestinationIndex, destinationVisibleRows.count - 1)) let displayOrderChanges = clampedDisplayDestinationIndex != visibleSourceIndex guard destination.movesToFullOrderEnd || displayOrderChanges else { return nil @@ -1770,19 +1787,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func jobBeforeID( movingJobID: String, visibleInsertionIndex: Int, - visibleJobs: [CodexReviewJob], + visibleRows: [ReviewMonitorSidebarReviewChatRow], orderedJobs: [CodexReviewJob], movesToFullOrderEnd: Bool ) -> String? { if movesToFullOrderEnd { return nil } - if visibleInsertionIndex < visibleJobs.count { - let targetJobID = visibleJobs[visibleInsertionIndex].id + if visibleInsertionIndex < visibleRows.count { + let targetJobID = visibleRows[visibleInsertionIndex].jobID return targetJobID == movingJobID ? movingJobID : targetJobID } - guard let lastVisibleJob = visibleJobs.last, - let lastVisibleIndex = orderedJobs.firstIndex(where: { $0 === lastVisibleJob }) + guard let lastVisibleRow = visibleRows.last, + let lastVisibleIndex = orderedJobs.firstIndex(where: { $0.id == lastVisibleRow.jobID }) else { return nil } @@ -1846,34 +1863,34 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } - guard let targetJob = job(from: proposedItem), + guard let targetRow = reviewRow(from: proposedItem), index == NSOutlineViewDropOnItemIndex else { return nil } return resolvedJobDropDestination( - around: targetJob, + around: targetRow, draggingLocation: draggingLocation, sourceCWD: sourceCWD ) } private func resolvedJobDropDestination( - around targetJob: CodexReviewJob, + around targetRow: ReviewMonitorSidebarReviewChatRow, draggingLocation: NSPoint?, sourceCWD: String ) -> SidebarJobDropDestination? { guard let draggingLocation, - let targetWorkspace = workspace(containing: targetJob), + let targetWorkspace = workspace(cwd: targetRow.cwd), 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) + let targetWorkspaceRootStartIndex = targetSection.reviewRows.firstIndex(where: { $0.cwd == targetWorkspace.cwd }), + let targetJobIndex = visibleReviewRows(in: targetWorkspace).firstIndex(where: { $0.jobID == targetRow.jobID }), + let outlineRow = row(forJobID: targetRow.jobID) else { return nil } - let targetRowRect = outlineView.rect(ofRow: targetRow) + let targetRowRect = outlineView.rect(ofRow: outlineRow) let rootInsertionIndex = targetWorkspaceRootStartIndex + targetJobIndex + (draggingLocation.y < targetRowRect.midY ? 0 : 1) @@ -1899,14 +1916,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let workspace = workspace(cwd: sourceCWD), let sourceSection = workspaceSection(containing: workspace), currentRootTopologies.last?.item === sourceSection, - sourceSection.jobs.last?.cwd == sourceCWD + sourceSection.reviewRows.last?.cwd == sourceCWD else { return nil } return SidebarJobDropDestination( workspace: workspace, - childIndex: visibleJobs(in: workspace).count, + childIndex: visibleReviewRows(in: workspace).count, movesToFullOrderEnd: true ) } @@ -1917,14 +1934,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedRootChildIndex index: Int ) -> SidebarJobDropDestination? { guard let workspace = section.workspaces.first(where: { $0.cwd == sourceCWD }), - let workspaceRootStartIndex = section.jobs.firstIndex(where: { $0.cwd == sourceCWD }) + let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.cwd == sourceCWD }) else { return nil } - let workspaceJobCount = visibleJobs(in: workspace).count + let workspaceJobCount = visibleReviewRows(in: workspace).count let workspaceRootEndIndex = workspaceRootStartIndex + workspaceJobCount - let rootInsertionIndex = max(0, min(index, section.jobs.count)) + let rootInsertionIndex = max(0, min(index, section.reviewRows.count)) guard rootInsertionIndex >= workspaceRootStartIndex, rootInsertionIndex <= workspaceRootEndIndex else { @@ -1944,13 +1961,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> (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 }) + let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.cwd == workspace.cwd }) else { return (workspace, childIndex) } let rootChildIndex = workspaceRootStartIndex + childIndex - return (section, max(0, min(rootChildIndex, section.jobs.count))) + return (section, max(0, min(rootChildIndex, section.reviewRows.count))) } @discardableResult @@ -1990,7 +2007,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return currentRootTopologies.count } if let section = workspaceSection(from: item) { - return section.jobs.count + return section.reviewRows.count } return 0 } @@ -2009,7 +2026,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return currentRootTopologies[index].item } if let section = workspaceSection(from: item) { - return section.jobs[index] + return section.reviewRows[index] } fatalError("Unsupported sidebar item.") } @@ -2032,7 +2049,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if workspaceSection(from: item) != nil { return rowHeights.workspace } - if job(from: item) != nil { + if reviewRow(from: item) != nil { return rowHeights.job } return rowHeights.job @@ -2137,7 +2154,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if workspaceSection(from: item) != nil { return ReviewMonitorWorkspaceRowView() } - if job(from: item) != nil { + if reviewRow(from: item) != nil { return ReviewMonitorJobTableRowView() } return nil @@ -2166,11 +2183,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return view } - if let job = job(from: item) { + if let row = reviewRow(from: item) { let view = (outlineView.makeView(withIdentifier: Identifier.jobCell, owner: self) as? ReviewMonitorJobCellView) ?? ReviewMonitorJobCellView() view.identifier = Identifier.jobCell - view.configure(with: job) + view.configure(with: row) return view } return nil @@ -2306,12 +2323,12 @@ extension ReviewMonitorSidebarViewController { func displayedJobIDsForTesting(in workspace: CodexReviewWorkspace) -> [String] { var jobIDs: [String] = [] for row in 0.. Bool { - guard let section = workspaceSection(containing: workspace) else { + guard let section = workspaceSection(containing: workspace), + let targetRow = reviewRowsByJobID[targetJob.id] + else { return true } return resolvedDrop( for: .workspaceSection(id: section.id), - proposedItem: targetJob, + proposedItem: targetRow, proposedChildIndex: NSOutlineViewDropOnItemIndex ) == nil } @@ -2620,6 +2642,7 @@ extension ReviewMonitorSidebarViewController { hoveringBelowMidpoint: Bool ) -> Bool { guard let section = workspaceSection(containing: workspace), + let targetRowItem = reviewRowsByJobID[targetJob.id], let targetRow = row(forJobID: targetJob.id) else { return false @@ -2632,7 +2655,7 @@ extension ReviewMonitorSidebarViewController { guard let resolvedDrop = resolvedDrop( for: .workspaceSection(id: section.id), draggingLocation: draggingLocation, - proposedItem: targetJob, + proposedItem: targetRowItem, proposedChildIndex: NSOutlineViewDropOnItemIndex ) else { return false @@ -2721,7 +2744,9 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool ) -> Bool { - guard let targetRow = row(forJobID: targetJob.id) else { + guard let targetRowItem = reviewRowsByJobID[targetJob.id], + let targetRow = row(forJobID: targetJob.id) + else { return false } let targetRowRect = outlineView.rect(ofRow: targetRow) @@ -2732,7 +2757,7 @@ extension ReviewMonitorSidebarViewController { guard let resolvedDrop = resolvedDrop( for: .job(id: job.id, cwd: job.cwd), draggingLocation: draggingLocation, - proposedItem: targetJob, + proposedItem: targetRowItem, proposedChildIndex: NSOutlineViewDropOnItemIndex ) else { return false @@ -2780,7 +2805,7 @@ extension ReviewMonitorSidebarViewController { for: .job(id: job.id, cwd: job.cwd), draggingLocation: draggingLocation, proposedItem: section, - proposedChildIndex: section.jobs.count + proposedChildIndex: section.reviewRows.count ) else { return false } @@ -3021,7 +3046,16 @@ private final class ReviewMonitorJobCellView: NSTableCellView { func configure(with job: CodexReviewJob) { objectValue = job toolTip = job.cwd - let row = ReviewMonitorSidebarChatRow(job: job) + configureRow(ReviewMonitorSidebarChatRow(job: job)) + } + + func configure(with row: ReviewMonitorSidebarReviewChatRow) { + objectValue = row + toolTip = row.cwd + configureRow(row.presentation) + } + + private func configureRow(_ row: ReviewMonitorSidebarChatRow) { if let hostingView { hostingView.rootView.row = row } else { From eb2fcb31bd86fe392e89435d7c4681dccb98a216 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:05:45 +0900 Subject: [PATCH 140/359] refactor(review-ui): name sidebar presentation as chat list --- .../ReviewMonitorSplitViewController.swift | 6 +++--- .../ReviewMonitorSidebarViewController.swift | 6 +++--- Tests/ReviewUITests/ReviewUIShellTests.swift | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 54522858..ce28c409 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -522,7 +522,7 @@ extension ReviewMonitorSplitViewController { } enum SidebarPresentationForTesting: Sendable, Equatable { - case jobList + case chatList case accountList case unavailable } @@ -537,8 +537,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: diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 023d2305..8dd3ffef 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -13,7 +13,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD enum SidebarKind: Equatable { case unavailable case empty - case jobList + case chatList case accountList } @@ -522,7 +522,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD break } let hasSidebarContent = hasReviewJobs || hasWorkspaces || hasCodexSidebarContent - return hasSidebarContent ? .jobList : .empty + return hasSidebarContent ? .chatList : .empty } private var hasCodexSidebarContent: Bool { @@ -780,7 +780,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD scrollView.isHidden = true emptyStateViewController.view.isHidden = false accountsViewController.view.isHidden = true - case .jobList: + case .chatList: unavailableView.isHidden = true scrollView.isHidden = false emptyStateViewController.view.isHidden = true diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 9f5c8db0..6e2bd820 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -105,7 +105,7 @@ extension ReviewUITests { 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) } @@ -121,7 +121,7 @@ extension ReviewUITests { let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() - #expect(viewController.sidebarPresentationForTesting == .jobList) + #expect(viewController.sidebarPresentationForTesting == .chatList) #expect(viewController.sidebarBottomAccessoryIsHiddenForTesting == false) uiState.sidebarSelection = .account @@ -130,7 +130,7 @@ extension ReviewUITests { uiState.sidebarSelection = .workspace try await waitForSidebarBottomAccessoryHidden(viewController, false) - #expect(viewController.sidebarPresentationForTesting == .jobList) + #expect(viewController.sidebarPresentationForTesting == .chatList) } @Test func statusAccessoryViewControllerVisibilityTracksOnlySidebarSelection() async throws { @@ -241,7 +241,7 @@ extension ReviewUITests { 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."), @@ -261,7 +261,7 @@ extension ReviewUITests { ) try await waitForSidebarPresentation( viewController, - .jobList, + .chatList, observation: viewController.sidebarViewControllerForTesting.sidebarKindObservationForTesting ) #expect(viewController.sidebarAccessoryCountForTesting == 1) @@ -494,7 +494,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) @@ -622,7 +622,7 @@ extension ReviewUITests { uiState.sidebarSelection = .workspace try await waitForSidebarPresentation( viewController, - .jobList, + .chatList, observation: sidebar.sidebarKindObservationForTesting ) @@ -643,7 +643,7 @@ extension ReviewUITests { ) try await waitForSidebarPresentation( viewController, - .jobList, + .chatList, observation: sidebar.sidebarKindObservationForTesting ) } From bc94af1556dbf4145efad49c5a5c5194f5512b34 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:12:11 +0900 Subject: [PATCH 141/359] refactor(review-ui): rename sidebar filter for review chats --- .../ReviewMonitorSplitViewController.swift | 94 +++++++-------- Sources/ReviewUI/ReviewMonitorUIState.swift | 16 +-- .../ReviewMonitorWindowController.swift | 28 ++--- .../ReviewMonitorSidebarViewController.swift | 12 +- ... SidebarReviewChatFilterToolbarItem.swift} | 38 +++--- ...er.swift => SidebarReviewChatFilter.swift} | 14 +-- Tests/ReviewUITests/ReviewUIShellTests.swift | 114 +++++++++--------- Tests/ReviewUITests/ReviewUITests.swift | 26 ++-- 8 files changed, 171 insertions(+), 171 deletions(-) rename Sources/ReviewUI/Sidebar/{SidebarJobFilterToolbarItem.swift => SidebarReviewChatFilterToolbarItem.swift} (77%) rename Sources/ReviewUI/{SidebarJobFilter.swift => SidebarReviewChatFilter.swift} (78%) diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index ce28c409..986520a7 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -14,8 +14,8 @@ 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 @@ -29,7 +29,7 @@ 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? @@ -198,12 +198,12 @@ 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 @@ -280,7 +280,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe [ Self.sidebarPickerToolbarItemIdentifier, .flexibleSpace, - Self.sidebarJobFilterToolbarItemIdentifier, + Self.sidebarReviewChatFilterToolbarItemIdentifier, .sidebarTrackingSeparator, .flexibleSpace, ] @@ -290,7 +290,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe [ Self.sidebarPickerToolbarItemIdentifier, Self.addAccountToolbarItemIdentifier, - Self.sidebarJobFilterToolbarItemIdentifier, + Self.sidebarReviewChatFilterToolbarItemIdentifier, .sidebarTrackingSeparator, .space, .flexibleSpace, @@ -310,8 +310,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( @@ -340,16 +340,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 } @@ -388,9 +388,9 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe ) } - private func updateSidebarJobFilterToolbarVisibility() { - setShowingSidebarJobFilter( - Self.isShowingSidebarJobFilterToolbarItem( + private func updateSidebarReviewChatFilterToolbarVisibility() { + setShowingSidebarReviewChatFilter( + Self.isShowingSidebarReviewChatFilterToolbarItem( sidebarSelection: uiState.sidebarSelection, isSidebarCollapsed: isSidebarCollapsed ) @@ -408,7 +408,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe return sidebarSelection == .account && isSidebarCollapsed == false } - private static func isShowingSidebarJobFilterToolbarItem( + private static func isShowingSidebarReviewChatFilterToolbarItem( sidebarSelection: SidebarPickerSelection, isSidebarCollapsed: Bool ) -> Bool { @@ -421,13 +421,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) { @@ -456,17 +456,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() { @@ -483,14 +483,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 } @@ -499,14 +499,14 @@ 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 } @@ -586,8 +586,8 @@ extension ReviewMonitorSplitViewController { Self.sidebarPickerToolbarItemIdentifier } - var sidebarJobFilterToolbarItemIdentifierForTesting: NSToolbarItem.Identifier { - Self.sidebarJobFilterToolbarItemIdentifier + var sidebarReviewChatFilterToolbarItemIdentifierForTesting: NSToolbarItem.Identifier { + Self.sidebarReviewChatFilterToolbarItemIdentifier } var sidebarPickerToolbarSegmentAccessibilityDescriptionsForTesting: [String] { @@ -624,41 +624,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 0b246f3a..061f2ae0 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -5,26 +5,26 @@ import CodexReviewKit @Observable 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( 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 selectedWorkspaceSectionEntry: ReviewMonitorWorkspaceSectionSelection? { diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index cd36a39d..80389bf9 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -71,7 +71,7 @@ public final class ReviewMonitorWindowController: NSWindowController { store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, - sidebarJobFilterDefaults: UserDefaults? = .standard, + sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil ) { self.init( @@ -79,7 +79,7 @@ public final class ReviewMonitorWindowController: NSWindowController { codexModelSource: codexModelSource, contentTransitionAnimator: contentTransitionAnimator, frameAutosaveName: Self.frameAutosaveName, - sidebarJobFilterDefaults: sidebarJobFilterDefaults, + sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults, showSettings: showSettings ) } @@ -89,12 +89,12 @@ public final class ReviewMonitorWindowController: NSWindowController { codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, frameAutosaveName: NSWindow.FrameAutosaveName, - sidebarJobFilterDefaults: UserDefaults? = .standard, + sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil ) { let uiState = Self.makeUIState( auth: store.auth, - sidebarJobFilterDefaults: sidebarJobFilterDefaults + sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) let rootViewController = ReviewMonitorRootViewController( store: store, @@ -127,16 +127,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) } ) } @@ -145,19 +145,19 @@ public final class ReviewMonitorWindowController: NSWindowController { enum ReviewMonitorSidebar {} extension ReviewMonitorSidebar { - enum JobFilterPersistence { - static let defaultsKey = "CodexReviewKit.ReviewMonitor.sidebarJobFilter" + enum ReviewChatFilterPersistence { + static let defaultsKey = "CodexReviewKit.ReviewMonitor.sidebarReviewChatFilter" - static func load(from defaults: UserDefaults) -> SidebarJobFilter { + static func load(from defaults: UserDefaults) -> SidebarReviewChatFilter { guard let rawValue = defaults.string(forKey: defaultsKey), - let filter = SidebarJobFilter(persistedValue: rawValue) + let filter = SidebarReviewChatFilter(persistedValue: rawValue) else { return .all } return filter } - static func save(_ filter: SidebarJobFilter, to defaults: UserDefaults) { + static func save(_ filter: SidebarReviewChatFilter, to defaults: UserDefaults) { defaults.set(filter.persistedValue, forKey: defaultsKey) } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 8dd3ffef..50065345 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -370,9 +370,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD )) } - let initialFilter = uiState.sidebarJobFilter + let initialFilter = uiState.sidebarReviewChatFilter sidebarFilterObservation = withPortableContinuousObservation { [weak self, uiState] event in - let filter = uiState.sidebarJobFilter + let filter = uiState.sidebarReviewChatFilter guard event.kind != .initial else { return } @@ -474,7 +474,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func bindSidebarStoreTopologyObservation( - filter: SidebarJobFilter, + filter: SidebarReviewChatFilter, animatedInitialDelivery: Bool ) { sidebarTopologyObservation?.cancel() @@ -562,7 +562,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func sidebarRootTopologies( from workspaceTopologies: [SidebarWorkspaceTopology], - filter: SidebarJobFilter + filter: SidebarReviewChatFilter ) -> [SidebarRootTopology] { var topologiesBySectionID: [String: [SidebarWorkspaceTopology]] = [:] var sectionIdentityByID: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] @@ -1208,7 +1208,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private static func visibleReviewRows( in rows: [ReviewMonitorSidebarReviewChatRow], - filter: SidebarJobFilter + filter: SidebarReviewChatFilter ) -> [ReviewMonitorSidebarReviewChatRow] { guard filter.isActive else { return rows @@ -1728,7 +1728,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedItem: Any?, proposedChildIndex index: Int ) -> SidebarResolvedDrop? { - guard uiState.sidebarJobFilter.allowsJobReordering else { + guard uiState.sidebarReviewChatFilter.allowsReviewChatReordering else { return nil } guard let destination = resolvedJobDropDestination( 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/SidebarJobFilter.swift b/Sources/ReviewUI/SidebarReviewChatFilter.swift similarity index 78% rename from Sources/ReviewUI/SidebarJobFilter.swift rename to Sources/ReviewUI/SidebarReviewChatFilter.swift index fbdb6abe..bf560260 100644 --- a/Sources/ReviewUI/SidebarJobFilter.swift +++ b/Sources/ReviewUI/SidebarReviewChatFilter.swift @@ -1,12 +1,12 @@ import Foundation -struct SidebarJobFilter: OptionSet, Hashable, Sendable { +struct SidebarReviewChatFilter: 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] + static let all: SidebarReviewChatFilter = [] + static let running = SidebarReviewChatFilter(rawValue: 1 << 0) + static let latestFinished = SidebarReviewChatFilter(rawValue: 1 << 1) + static let menuFilters: [SidebarReviewChatFilter] = [.running, .latestFinished] init(rawValue: Int) { self.rawValue = rawValue @@ -28,7 +28,7 @@ struct SidebarJobFilter: OptionSet, Hashable, Sendable { isEmpty == false } - var allowsJobReordering: Bool { + var allowsReviewChatReordering: Bool { self == .all || contains(.running) } @@ -47,7 +47,7 @@ struct SidebarJobFilter: OptionSet, Hashable, Sendable { return } - var filters = SidebarJobFilter(rawValue: 0) + var filters = SidebarReviewChatFilter(rawValue: 0) for component in persistedValue.split(separator: ",") { switch component.trimmingCharacters(in: .whitespacesAndNewlines) { case "running": diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 6e2bd820..d87d32ef 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -276,7 +276,7 @@ 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) @@ -286,7 +286,7 @@ extension ReviewUITests { viewController.sidebarPickerToolbarItemIdentifierForTesting)) #expect( viewController.toolbarIdentifiersForTesting.contains( - viewController.sidebarJobFilterToolbarItemIdentifierForTesting)) + viewController.sidebarReviewChatFilterToolbarItemIdentifierForTesting)) #expect(viewController.toolbarIdentifiersForTesting.contains(.toggleSidebar) == false) #expect(viewController.toolbarIdentifiersForTesting.contains(.sidebarTrackingSeparator)) #expect( @@ -303,7 +303,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 @@ -312,83 +312,83 @@ extension ReviewUITests { let sidebarItem = try #require(viewController.splitViewItems.first) sidebarItem.isCollapsed = false - #expect(viewController.sidebarJobFilterToolbarItemIsHiddenForTesting == false) + #expect(viewController.sidebarReviewChatFilterToolbarItemIsHiddenForTesting == false) #expect( - viewController.sidebarJobFilterToolbarMenuItemTitlesForTesting == [ + viewController.sidebarReviewChatFilterToolbarMenuItemTitlesForTesting == [ "All Items", "-", "Running", "Latest Finished", ]) - #expect(viewController.sidebarJobFilterToolbarShowsActiveBackgroundForTesting == false) + #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.sidebarReviewChatFilterToolbarShowsActiveBackgroundForTesting) #expect( - viewController.sidebarJobFilterToolbarSelectedMenuItemTitlesForTesting == ["Running", "Latest Finished"]) + 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() @@ -398,22 +398,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() } @@ -422,7 +422,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) @@ -430,23 +430,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) @@ -454,11 +454,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 @@ -467,16 +467,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 } } @@ -785,7 +785,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.") @@ -810,7 +810,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.") @@ -839,7 +839,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.") @@ -908,7 +908,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.") @@ -934,7 +934,7 @@ extension ReviewUITests { let windowController = ReviewMonitorWindowController( store: store, contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, - sidebarJobFilterDefaults: nil + sidebarReviewChatFilterDefaults: nil ) defer { windowController.window?.close() } await Task.yield() @@ -1718,8 +1718,8 @@ private func renderDetailLogForShellLayoutTesting( 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 92ea037a..b78f4f41 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -473,7 +473,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - uiState.sidebarJobFilter = .running + uiState.sidebarReviewChatFilter = .running try await waitForObservedValueFromCurrentObservation( from: { sidebar.sidebarTopologyObservationForTesting }, @@ -505,7 +505,7 @@ struct ReviewUITests { jobs: [runningJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = .running + uiState.sidebarReviewChatFilter = .running let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -560,7 +560,7 @@ struct ReviewUITests { sidebar.selectJobForTesting(completedJob) #expect(sidebar.selectedJobForTesting?.id == "job-filter-completed") - uiState.sidebarJobFilter = .running + uiState.sidebarReviewChatFilter = .running try await waitForObservedValueFromCurrentObservation( from: { sidebar.sidebarTopologyObservationForTesting }, ["job-filter-running"] @@ -646,7 +646,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting sidebar.selectJobForTesting(alphaRunningJob) - uiState.sidebarJobFilter = .latestFinished + uiState.sidebarReviewChatFilter = .latestFinished try await waitForObservedValueFromCurrentObservation( from: { sidebar.sidebarTopologyObservationForTesting }, @@ -696,7 +696,7 @@ struct ReviewUITests { jobs: [firstJob, secondJob, runningJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = .latestFinished + uiState.sidebarReviewChatFilter = .latestFinished let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -760,7 +760,7 @@ struct ReviewUITests { jobs: [runningJob, olderFinishedJob, queuedJob, latestFinishedJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = [.running, .latestFinished] + uiState.sidebarReviewChatFilter = [.running, .latestFinished] let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -812,7 +812,7 @@ struct ReviewUITests { jobs: [hiddenPrefix, runningA, hiddenMiddle, runningB, hiddenSuffix] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = .running + uiState.sidebarReviewChatFilter = .running let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -899,7 +899,7 @@ struct ReviewUITests { jobs: [runningJob, olderJob, latestJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = .latestFinished + uiState.sidebarReviewChatFilter = .latestFinished let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -938,7 +938,7 @@ struct ReviewUITests { jobs: [runningJob, olderJob, latestJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = [.running, .latestFinished] + uiState.sidebarReviewChatFilter = [.running, .latestFinished] let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -2602,7 +2602,7 @@ struct ReviewUITests { jobs: [firstJob, secondJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = .latestFinished + uiState.sidebarReviewChatFilter = .latestFinished let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -2678,7 +2678,7 @@ struct ReviewUITests { ] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarJobFilter = [.running, .latestFinished] + uiState.sidebarReviewChatFilter = [.running, .latestFinished] let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() @@ -7206,7 +7206,7 @@ func makeWindowHarness( store: CodexReviewStore, authState: TestAuthState = .signedIn(accountID: "review@example.com"), contentSize: NSSize? = nil, - sidebarJobFilterDefaults: UserDefaults? = nil, + sidebarReviewChatFilterDefaults: UserDefaults? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController .defaultContentTransitionAnimator ) -> ReviewMonitorWindowHarness { @@ -7214,7 +7214,7 @@ func makeWindowHarness( let windowController = ReviewMonitorWindowController( store: store, contentTransitionAnimator: contentTransitionAnimator, - sidebarJobFilterDefaults: sidebarJobFilterDefaults + sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) guard let window = windowController.window else { fatalError("ReviewMonitorWindowController did not create a window.") From 6f3e1e6ed97a89e7363c7ab41845f44af5b49270 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:17:10 +0900 Subject: [PATCH 142/359] refactor(review-ui): name sidebar drops as review chats --- .../ReviewMonitorSidebarViewController.swift | 84 +++++++++---------- Tests/ReviewUITests/ReviewUITests.swift | 38 ++++----- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 50065345..384b038b 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -73,14 +73,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private enum SidebarDragPayload: Codable, Equatable { case workspaceSection(id: String) - case job(id: String, cwd: String) + case reviewChat(jobID: 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) + case reorderReviewChat(jobID: String, cwd: String, beforeJobID: String?, displayIndex: Int) } let operation: Operation @@ -97,7 +97,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let rootInsertionIndex: Int } - private struct SidebarJobDropDestination { + private struct SidebarReviewChatDropDestination { let workspace: CodexReviewWorkspace let childIndex: Int let movesToFullOrderEnd: Bool @@ -1121,7 +1121,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD moveOutlineItem(from: sourceIndex, to: destinationIndex, parent: nil) } - private func moveJobInOutline(id: String, in workspace: CodexReviewWorkspace, toIndex destinationIndex: Int) { + private func moveReviewChatInOutline(id: String, in workspace: CodexReviewWorkspace, toIndex destinationIndex: Int) { let currentRows = displayedReviewRows(in: workspace) guard let sourceIndex = currentRows.firstIndex(where: { $0.jobID == id }), let parentItem = rootItem(containing: workspace) @@ -1489,7 +1489,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return .workspaceSection(id: section.id) } if let row = reviewRow(from: item) { - return .job(id: row.jobID, cwd: row.cwd) + return .reviewChat(jobID: row.jobID, cwd: row.cwd) } return nil } @@ -1531,8 +1531,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedItem: proposedItem, proposedChildIndex: index ) - case .job(let id, let cwd): - resolvedJobDrop( + case .reviewChat(let id, let cwd): + resolvedReviewChatDrop( id: id, cwd: cwd, draggingLocation: draggingLocation, @@ -1721,7 +1721,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return sectionRect } - private func resolvedJobDrop( + private func resolvedReviewChatDrop( id: String, cwd: String, draggingLocation: NSPoint?, @@ -1731,7 +1731,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard uiState.sidebarReviewChatFilter.allowsReviewChatReordering else { return nil } - guard let destination = resolvedJobDropDestination( + guard let destination = resolvedReviewChatDropDestination( draggingLocation: draggingLocation, proposedItem: proposedItem, proposedChildIndex: index, @@ -1749,7 +1749,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let visibleInsertionIndex = max(0, min(destination.childIndex, destinationVisibleRows.count)) - let beforeJobID = jobBeforeID( + let beforeJobID = reviewChatBeforeJobID( movingJobID: id, visibleInsertionIndex: visibleInsertionIndex, visibleRows: destinationVisibleRows, @@ -1764,16 +1764,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard destination.movesToFullOrderEnd || displayOrderChanges else { return nil } - guard jobReorderWouldChange(id: id, inWorkspace: cwd, beforeJobID: beforeJobID) else { + guard reviewChatReorderWouldChange(id: id, inWorkspace: cwd, beforeJobID: beforeJobID) else { return nil } - let operation: SidebarResolvedDrop.Operation = .reorderJob( - id: id, + let operation: SidebarResolvedDrop.Operation = .reorderReviewChat( + jobID: id, cwd: cwd, beforeJobID: beforeJobID, displayIndex: clampedDisplayDestinationIndex ) - let dropPresentation = jobDropPresentation( + let dropPresentation = reviewChatDropPresentation( for: destination.workspace, childIndex: visibleInsertionIndex ) @@ -1784,7 +1784,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } - private func jobBeforeID( + private func reviewChatBeforeJobID( movingJobID: String, visibleInsertionIndex: Int, visibleRows: [ReviewMonitorSidebarReviewChatRow], @@ -1812,7 +1812,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return orderedJobs[nextIndex].id } - private func jobReorderWouldChange( + private func reviewChatReorderWouldChange( id: String, inWorkspace cwd: String, beforeJobID: String? @@ -1840,13 +1840,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD zip(reordered, ordered).contains { $0.0 !== $0.1 } } - private func resolvedJobDropDestination( + private func resolvedReviewChatDropDestination( draggingLocation: NSPoint?, proposedItem: Any?, proposedChildIndex index: Int, sourceCWD: String - ) -> SidebarJobDropDestination? { - if let destination = blankAreaJobDropDestination( + ) -> SidebarReviewChatDropDestination? { + if let destination = blankAreaReviewChatDropDestination( draggingLocation: draggingLocation, sourceCWD: sourceCWD ) { @@ -1856,7 +1856,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let section = workspaceSection(from: proposedItem), index != NSOutlineViewDropOnItemIndex { - return resolvedJobDropDestination( + return resolvedReviewChatDropDestination( in: section, sourceCWD: sourceCWD, proposedRootChildIndex: index @@ -1868,18 +1868,18 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return nil } - return resolvedJobDropDestination( + return resolvedReviewChatDropDestination( around: targetRow, draggingLocation: draggingLocation, sourceCWD: sourceCWD ) } - private func resolvedJobDropDestination( + private func resolvedReviewChatDropDestination( around targetRow: ReviewMonitorSidebarReviewChatRow, draggingLocation: NSPoint?, sourceCWD: String - ) -> SidebarJobDropDestination? { + ) -> SidebarReviewChatDropDestination? { guard let draggingLocation, let targetWorkspace = workspace(cwd: targetRow.cwd), let targetSection = workspaceSection(containing: targetWorkspace), @@ -1894,17 +1894,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let rootInsertionIndex = targetWorkspaceRootStartIndex + targetJobIndex + (draggingLocation.y < targetRowRect.midY ? 0 : 1) - return resolvedJobDropDestination( + return resolvedReviewChatDropDestination( in: targetSection, sourceCWD: sourceCWD, proposedRootChildIndex: rootInsertionIndex ) } - private func blankAreaJobDropDestination( + private func blankAreaReviewChatDropDestination( draggingLocation: NSPoint?, sourceCWD: String - ) -> SidebarJobDropDestination? { + ) -> SidebarReviewChatDropDestination? { guard let draggingLocation, outlineView.numberOfRows > 0 else { @@ -1921,18 +1921,18 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } - return SidebarJobDropDestination( + return SidebarReviewChatDropDestination( workspace: workspace, childIndex: visibleReviewRows(in: workspace).count, movesToFullOrderEnd: true ) } - private func resolvedJobDropDestination( + private func resolvedReviewChatDropDestination( in section: SidebarWorkspaceSection, sourceCWD: String, proposedRootChildIndex index: Int - ) -> SidebarJobDropDestination? { + ) -> SidebarReviewChatDropDestination? { guard let workspace = section.workspaces.first(where: { $0.cwd == sourceCWD }), let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.cwd == sourceCWD }) else { @@ -1948,14 +1948,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } - return SidebarJobDropDestination( + return SidebarReviewChatDropDestination( workspace: workspace, childIndex: rootInsertionIndex - workspaceRootStartIndex, movesToFullOrderEnd: false ) } - private func jobDropPresentation( + private func reviewChatDropPresentation( for workspace: CodexReviewWorkspace, childIndex: Int ) -> (item: Any?, childIndex: Int) { @@ -1981,13 +1981,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } moveWorkspaceSectionInOutline(id: id, toRootIndex: displayIndex) return true - case .reorderJob(let id, let cwd, let beforeJobID, let displayIndex): + case .reorderReviewChat(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) + moveReviewChatInOutline(id: id, in: workspace, toIndex: displayIndex) } return true } @@ -2722,14 +2722,14 @@ extension ReviewMonitorSidebarViewController { } @discardableResult - func performJobDropForTesting( + func performReviewChatDropForTesting( _ job: CodexReviewJob, proposedWorkspace: CodexReviewWorkspace, childIndex: Int ) -> Bool { guard let section = workspaceSection(containing: proposedWorkspace), let resolvedDrop = resolvedDrop( - for: .job(id: job.id, cwd: job.cwd), + for: .reviewChat(jobID: job.id, cwd: job.cwd), proposedItem: section, proposedChildIndex: childIndex ) else { @@ -2739,7 +2739,7 @@ extension ReviewMonitorSidebarViewController { } @discardableResult - func performJobDropForTesting( + func performReviewChatDropForTesting( _ job: CodexReviewJob, proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool @@ -2755,7 +2755,7 @@ extension ReviewMonitorSidebarViewController { y: hoveringBelowMidpoint ? targetRowRect.midY + 1 : targetRowRect.midY - 1 ) guard let resolvedDrop = resolvedDrop( - for: .job(id: job.id, cwd: job.cwd), + for: .reviewChat(jobID: job.id, cwd: job.cwd), draggingLocation: draggingLocation, proposedItem: targetRowItem, proposedChildIndex: NSOutlineViewDropOnItemIndex @@ -2766,14 +2766,14 @@ extension ReviewMonitorSidebarViewController { } @discardableResult - func performJobDropForTesting( + func performReviewChatDropForTesting( _ 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), + for: .reviewChat(jobID: job.id, cwd: job.cwd), proposedItem: section, proposedChildIndex: childIndex ) @@ -2783,9 +2783,9 @@ extension ReviewMonitorSidebarViewController { return applyResolvedDrop(resolvedDrop) } - func jobDropIsRejectedForTesting(_ job: CodexReviewJob) -> Bool { + func reviewChatDropIsRejectedForTesting(_ job: CodexReviewJob) -> Bool { resolvedDrop( - for: .job(id: job.id, cwd: job.cwd), + for: .reviewChat(jobID: job.id, cwd: job.cwd), proposedItem: nil, proposedChildIndex: NSOutlineViewDropOnItemIndex ) == nil @@ -2802,7 +2802,7 @@ extension ReviewMonitorSidebarViewController { 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), + for: .reviewChat(jobID: job.id, cwd: job.cwd), draggingLocation: draggingLocation, proposedItem: section, proposedChildIndex: section.reviewRows.count diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index b78f4f41..d05443df 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -348,7 +348,7 @@ struct ReviewUITests { #expect(sidebar.displayedSectionTitlesForTesting == [workspaceBeta.displayTitle, workspaceAlpha.displayTitle]) } - @Test func jobDropOnBlankAreaMovesToFullOrderEnd() async { + @Test func reviewChatDropOnBlankAreaMovesToFullOrderEnd() async { let firstJob = makeJob( id: "job-blank-area-reject", cwd: "/tmp/workspace-alpha", @@ -379,7 +379,7 @@ struct ReviewUITests { #expect(store.orderedJobs(in: workspace).map(\.id) == ["job-blank-area-peer", "job-blank-area-reject"]) } - @Test func jobDropReordersWithinWorkspaceAndPreservesSelection() async throws { + @Test func reviewChatDropReordersWithinWorkspaceAndPreservesSelection() async throws { let firstJob = makeJob( id: "job-1", cwd: "/tmp/workspace-alpha", @@ -412,7 +412,7 @@ struct ReviewUITests { let secondJobRowHeightBeforeDrop = try #require(sidebar.jobRowHeightForTesting(secondJob)) #expect(firstJobRowHeightBeforeDrop == secondJobRowHeightBeforeDrop) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( firstJob, proposedJob: secondJob, hoveringBelowMidpoint: false @@ -420,7 +420,7 @@ struct ReviewUITests { #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-1", "job-2"]) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( firstJob, proposedJob: secondJob, hoveringBelowMidpoint: true @@ -773,7 +773,7 @@ struct ReviewUITests { ]) } - @Test func jobDropWhileFilteredMapsVisibleIndexToStoreOrder() async throws { + @Test func reviewChatDropWhileFilteredMapsVisibleIndexToStoreOrder() async throws { let hiddenPrefix = makeJob( id: "job-hidden-prefix", cwd: "/tmp/workspace-alpha", @@ -820,7 +820,7 @@ struct ReviewUITests { #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( runningA, proposedJob: runningB, hoveringBelowMidpoint: false @@ -838,7 +838,7 @@ struct ReviewUITests { ]) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( runningA, proposedJob: runningB, hoveringBelowMidpoint: true @@ -869,7 +869,7 @@ struct ReviewUITests { ]) } - @Test func jobDropIsRejectedForLatestFinishedOnlyFilter() { + @Test func reviewChatDropIsRejectedForLatestFinishedOnlyFilter() { let runningJob = makeJob( id: "job-running", cwd: "/tmp/workspace-alpha", @@ -905,10 +905,10 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-finished-latest"]) - #expect(sidebar.performJobDropForTesting(latestJob, proposedWorkspace: workspace, childIndex: 0) == false) + #expect(sidebar.performReviewChatDropForTesting(latestJob, proposedWorkspace: workspace, childIndex: 0) == false) } - @Test func jobDropWhileRunningAndLatestFinishedFilterReordersVisibleJobs() async throws { + @Test func reviewChatDropWhileRunningAndLatestFinishedFilterReordersVisibleReviewChats() async throws { let runningJob = makeJob( id: "job-running", cwd: "/tmp/workspace-alpha", @@ -944,7 +944,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running", "job-finished-latest"]) - #expect(sidebar.performJobDropForTesting(runningJob, proposedWorkspace: workspace, childIndex: 2)) + #expect(sidebar.performReviewChatDropForTesting(runningJob, proposedWorkspace: workspace, childIndex: 2)) await Task.yield() try await waitForObservedValueFromCurrentObservation( @@ -1460,7 +1460,7 @@ struct ReviewUITests { #expect(sidebar.workspaceIsExpandedForTesting(betaWorkspace) == false) } - @Test func crossWorkspaceJobDropIsRejected() { + @Test func crossWorkspaceReviewChatDropIsRejected() { let alphaJob = makeJob( id: "job-alpha", cwd: "/tmp/workspace-alpha", @@ -1486,7 +1486,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.performJobDropForTesting(alphaJob, proposedWorkspace: betaWorkspace, childIndex: 0) == false) + #expect(sidebar.performReviewChatDropForTesting(alphaJob, proposedWorkspace: betaWorkspace, childIndex: 0) == false) #expect(store.orderedJobs(in: alphaWorkspace).map(\.id) == ["job-alpha"]) #expect(store.orderedJobs(in: betaWorkspace).map(\.id) == ["job-beta"]) } @@ -2696,14 +2696,14 @@ struct ReviewUITests { ]) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( firstRunningJob, proposedWorkspaceSectionContaining: secondWorkspace, childIndex: 3 ) == false) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( firstRunningJob, proposedWorkspaceSectionContaining: firstWorkspace, childIndex: 2 @@ -3000,7 +3000,7 @@ struct ReviewUITests { ]) } - @Test func workspaceSectionJobDropUsesRootChildIndexesForLaterWorkspaceJobs() async throws { + @Test func workspaceSectionReviewChatDropUsesRootChildIndexesForLaterWorkspaceReviewChats() async throws { let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit") defer { try? FileManager.default.removeItem(at: fixture.rootURL) @@ -3043,21 +3043,21 @@ struct ReviewUITests { ]) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( secondWorkspaceFirstJob, proposedWorkspaceSectionContaining: secondWorkspace, childIndex: 0 ) == false) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( secondWorkspaceFirstJob, proposedJob: firstWorkspaceJob, hoveringBelowMidpoint: true ) == false) #expect( - sidebar.performJobDropForTesting( + sidebar.performReviewChatDropForTesting( secondWorkspaceFirstJob, proposedJob: secondWorkspaceSecondJob, hoveringBelowMidpoint: true From 520a61d52e866bd1d4167d2340e3cd240eb872d8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:23:11 +0900 Subject: [PATCH 143/359] fix(review-ui): stream preview chat logs outside debug --- ...ReviewMonitorTransportViewController.swift | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 14177e37..1d0ddc31 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -206,18 +206,16 @@ final class ReviewMonitorTransportViewController: NSViewController { } else { selectedCodexChat.bind(toChatID: selectedChat.id) } - #if DEBUG - if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChat.id) { - startPreviewChatChangeStream( - previewStream, - activeTurnID: selectedChat.reviewIdentity?.turnID, - chatUpdatedAt: selectedChat.updatedAt, - target: .chat(selectedChat.id), - initialRestorationTarget: restorationTarget - ) - return - } - #endif + if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChat.id) { + startPreviewChatChangeStream( + previewStream, + activeTurnID: selectedChat.reviewIdentity?.turnID, + chatUpdatedAt: selectedChat.updatedAt, + target: .chat(selectedChat.id), + initialRestorationTarget: restorationTarget + ) + return + } startSelectedCodexChatLogStream( target: .chat(selectedChat.id), initialRestorationTarget: restorationTarget From 00452b00c32539b532ff417e562ec7703afc99ae Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:32:04 +0900 Subject: [PATCH 144/359] refactor(review-ui): name sidebar selection as review chats --- .../ReviewMonitorSidebarViewController.swift | 86 ++-- Tests/ReviewUITests/ReviewUIShellTests.swift | 16 +- Tests/ReviewUITests/ReviewUITests.swift | 380 +++++++++--------- 3 files changed, 241 insertions(+), 241 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 384b038b..aeaf8e0f 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -18,9 +18,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private enum Identifier { - static let tableColumn = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.Column") - static let jobCell = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.JobCell") - static let workspaceCell = NSUserInterfaceItemIdentifier("ReviewMonitorJobs.WorkspaceCell") + static let tableColumn = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.Column") + static let reviewChatCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.ReviewChatCell") + static let workspaceCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.WorkspaceCell") } private enum DragType { @@ -29,13 +29,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private struct SidebarRowHeights { let workspace: CGFloat - let job: CGFloat + let reviewChat: CGFloat @MainActor static func measure() -> SidebarRowHeights { SidebarRowHeights( workspace: measuredWorkspaceRowHeight(), - job: measuredJobRowHeight() + reviewChat: measuredReviewChatRowHeight() ) } @@ -47,7 +47,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } @MainActor - private static func measuredJobRowHeight() -> CGFloat { + private static func measuredReviewChatRowHeight() -> CGFloat { let job = CodexReviewJob( id: "row-height-measurement", sessionID: "row-height-measurement", @@ -65,7 +65,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) ) ) - let cellView = ReviewMonitorJobCellView() + let cellView = ReviewMonitorReviewChatCellView() cellView.configure(with: job) return ceil(cellView.fittingSize.height) } @@ -322,7 +322,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.indentationPerLevel = SidebarLayout.disclosureGutterWidth outlineView.indentationMarkerFollowsCell = false outlineView.rowSizeStyle = .custom - outlineView.rowHeight = rowHeights.job + outlineView.rowHeight = rowHeights.reviewChat outlineView.usesAutomaticRowHeights = false outlineView.floatsGroupRows = false outlineView.backgroundColor = .clear @@ -912,7 +912,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD setRootItem(rootItem, expanded: outlineView.isItemExpanded(rootItem) == false) } - private func restoreSelectedJobRowAfterExpansion(of section: SidebarWorkspaceSection) { + private func restoreSelectedReviewChatRowAfterExpansion(of section: SidebarWorkspaceSection) { guard let selectedReviewJob = reviewJobForCurrentChatSelection(), section.workspaces.contains(where: { $0.cwd == selectedReviewJob.cwd }) else { @@ -1712,12 +1712,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let isExpanded = workspaceSection(from: rootItem)?.isExpanded ?? false guard isExpanded, let lastRow = displayedReviewRows(inRootItem: rootItem).last, - let lastJobRow = row(forJobID: lastRow.jobID) + let lastReviewChatRow = row(forJobID: lastRow.jobID) else { return sectionRect } - sectionRect = sectionRect.union(outlineView.rect(ofRow: lastJobRow)) + sectionRect = sectionRect.union(outlineView.rect(ofRow: lastReviewChatRow)) return sectionRect } @@ -2050,9 +2050,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return rowHeights.workspace } if reviewRow(from: item) != nil { - return rowHeights.job + return rowHeights.reviewChat } - return rowHeights.job + return rowHeights.reviewChat } func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool { @@ -2134,7 +2134,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if section.isExpanded == false { section.isExpanded = true } - restoreSelectedJobRowAfterExpansion(of: section) + restoreSelectedReviewChatRowAfterExpansion(of: section) } } @@ -2155,7 +2155,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return ReviewMonitorWorkspaceRowView() } if reviewRow(from: item) != nil { - return ReviewMonitorJobTableRowView() + return ReviewMonitorReviewChatTableRowView() } return nil } @@ -2184,9 +2184,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } if let row = reviewRow(from: item) { - let view = (outlineView.makeView(withIdentifier: Identifier.jobCell, owner: self) as? ReviewMonitorJobCellView) - ?? ReviewMonitorJobCellView() - view.identifier = Identifier.jobCell + let view = (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) as? ReviewMonitorReviewChatCellView) + ?? ReviewMonitorReviewChatCellView() + view.identifier = Identifier.reviewChatCell view.configure(with: row) return view } @@ -2231,7 +2231,7 @@ extension ReviewMonitorSidebarViewController { return titles } - var selectedJobForTesting: CodexReviewJob? { + var selectedReviewChatJobForTesting: CodexReviewJob? { reviewJobForCurrentChatSelection() } @@ -2288,7 +2288,7 @@ extension ReviewMonitorSidebarViewController { emptyStateViewController.view.isHidden == false } - func selectJobForTesting(_ job: CodexReviewJob) { + func selectReviewChatForTesting(_ job: CodexReviewJob) { guard let row = row(forJobID: job.id) else { return } @@ -2320,7 +2320,7 @@ extension ReviewMonitorSidebarViewController { workspaceSection(containing: workspace).map(shouldAllowSelection(of:)) ?? false } - func displayedJobIDsForTesting(in workspace: CodexReviewWorkspace) -> [String] { + func displayedReviewChatJobIDsForTesting(in workspace: CodexReviewWorkspace) -> [String] { var jobIDs: [String] = [] for row in 0.. CGFloat? { @@ -2361,7 +2361,7 @@ extension ReviewMonitorSidebarViewController { return outlineView.rect(ofRow: row).height } - func jobRowHeightForTesting(_ job: CodexReviewJob) -> CGFloat? { + func reviewChatRowHeightForTesting(_ job: CodexReviewJob) -> CGFloat? { guard let row = row(forJobID: job.id) else { return nil } @@ -2395,7 +2395,7 @@ extension ReviewMonitorSidebarViewController { return disclosureFrame.width > 0 ? disclosureFrame.maxX : nil } - func jobCellMinXForTesting(_ job: CodexReviewJob) -> CGFloat? { + func reviewChatCellMinXForTesting(_ job: CodexReviewJob) -> CGFloat? { guard let row = row(forJobID: job.id) else { return nil } @@ -2404,27 +2404,27 @@ extension ReviewMonitorSidebarViewController { atColumn: 0, row: row, makeIfNecessary: true - ) as? ReviewMonitorJobCellView else { + ) as? ReviewMonitorReviewChatCellView else { return nil } outlineView.layoutSubtreeIfNeeded() return cellView.contentMinXForTesting(relativeTo: outlineView) } - func jobRowUsesReviewMonitorJobRowViewForTesting(_ job: CodexReviewJob) -> Bool { + func reviewChatRowUsesReviewMonitorChatRowViewForTesting(_ job: CodexReviewJob) -> Bool { guard let row = row(forJobID: job.id), let cellView = outlineView.view( atColumn: 0, row: row, makeIfNecessary: true - ) as? ReviewMonitorJobCellView + ) as? ReviewMonitorReviewChatCellView else { return false } - return cellView.isHostingReviewMonitorJobRowViewForTesting + return cellView.isHostingReviewMonitorChatRowViewForTesting } - func cancelJobForTesting(_ job: CodexReviewJob) async { + func cancelReviewChatForTesting(_ job: CodexReviewJob) async { guard let row = reviewRowsByJobID[job.id] else { return } @@ -3022,7 +3022,7 @@ private final class ReviewMonitorWorkspaceRowView: NSTableRowView { } @MainActor -private final class ReviewMonitorJobTableRowView: NSTableRowView { +private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { override var isEmphasized: Bool { get { false } set { } @@ -3030,7 +3030,7 @@ private final class ReviewMonitorJobTableRowView: NSTableRowView { } @MainActor -private final class ReviewMonitorJobCellView: NSTableCellView { +private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? override init(frame frameRect: NSRect) { @@ -3063,7 +3063,7 @@ private final class ReviewMonitorJobCellView: NSTableCellView { rootView: ReviewMonitorChatRowView(row: row) ) hostingView.translatesAutoresizingMaskIntoConstraints = false - hostingView.setAccessibilityIdentifier("review-monitor.job-row") + hostingView.setAccessibilityIdentifier("review-monitor.review-chat-row") addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: topAnchor), @@ -3080,7 +3080,7 @@ private final class ReviewMonitorJobCellView: NSTableCellView { } #if DEBUG - var isHostingReviewMonitorJobRowViewForTesting: Bool { + var isHostingReviewMonitorChatRowViewForTesting: Bool { hostingView != nil } @@ -3104,36 +3104,36 @@ private final class ReviewMonitorJobCellView: NSTableCellView { #if DEBUG @MainActor -func makeReviewMonitorJobCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { - let cellView = ReviewMonitorJobCellView() +func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { + let cellView = ReviewMonitorReviewChatCellView() cellView.configure(with: job) return cellView } @MainActor -func configureReviewMonitorJobCellViewForTesting( +func configureReviewMonitorReviewChatCellViewForTesting( _ cellView: NSTableCellView, job: CodexReviewJob ) { - guard let cellView = cellView as? ReviewMonitorJobCellView else { - fatalError("Expected ReviewMonitorJobCellView.") + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + fatalError("Expected ReviewMonitorReviewChatCellView.") } cellView.configure(with: job) } @MainActor -func reviewMonitorJobCellHostedJobIDForTesting(_ cellView: NSTableCellView) -> String? { - guard let cellView = cellView as? ReviewMonitorJobCellView else { +func reviewMonitorReviewChatCellHostedJobIDForTesting(_ cellView: NSTableCellView) -> String? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { return nil } return cellView.hostedJobIDForTesting } @MainActor -func reviewMonitorJobCellHostingViewIdentityForTesting( +func reviewMonitorReviewChatCellHostingViewIdentityForTesting( _ cellView: NSTableCellView ) -> ObjectIdentifier? { - guard let cellView = cellView as? ReviewMonitorJobCellView else { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { return nil } return cellView.hostingViewIdentityForTesting diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index d87d32ef..e55166c4 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -94,7 +94,7 @@ extension ReviewUITests { #expect(viewController.sidebarPresentationForTesting == .unavailable) } - @Test func splitViewShowsJobSidebarWhenServerRunningOnLoad() { + @Test func splitViewShowsReviewChatSidebarWhenServerRunningOnLoad() { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, @@ -718,12 +718,12 @@ extension ReviewUITests { @Test func previewContentViewControllerRendersSelectedChatLog() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) - let selectedJob = try #require( + let selectedReviewChatJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first ) - let selectedChatID = try #require(selectedJob.core.run.reviewThreadID) - let selectedTargetSummary = selectedJob.targetSummary + let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) + let selectedTargetSummary = selectedReviewChatJob.targetSummary let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -744,11 +744,11 @@ extension ReviewUITests { @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) - let selectedJob = try #require( + let selectedReviewChatJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first ) - let existingItemIDs = Set(selectedJob.timeline.items.map(\.id)) + let existingItemIDs = Set(selectedReviewChatJob.timeline.items.map(\.id)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -765,7 +765,7 @@ extension ReviewUITests { ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) let appendedItem = try #require( - selectedJob.timeline.items.first { existingItemIDs.contains($0.id) == false } + selectedReviewChatJob.timeline.items.first { existingItemIDs.contains($0.id) == false } ) let appendedText = try #require(diagnosticMessage(appendedItem).nilIfEmpty) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in @@ -1702,7 +1702,7 @@ private func renderDetailLogForShellLayoutTesting( viewController: ReviewMonitorSplitViewController, job: CodexReviewJob ) async throws { - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let chatID = try #require(job.reviewChatID) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index d05443df..e5cc7501 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -306,7 +306,7 @@ struct ReviewUITests { #expect(sidebar.blankAreaWorkspaceInsertionIndexForTesting(atEnd: true) == store.workspaces.count) } - @Test func workspaceDropOnJobRowReordersSection() async { + @Test func workspaceDropOnReviewChatRowReordersSection() async { let workspaceAlphaJob = makeJob( id: "job-workspace-alpha-reject", cwd: "/tmp/workspace-alpha", @@ -375,7 +375,7 @@ struct ReviewUITests { 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(sidebar.displayedReviewChatJobIDsForTesting(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"]) } @@ -404,20 +404,20 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectJobForTesting(firstJob) + sidebar.selectReviewChatForTesting(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) + let firstReviewChatRowHeightBeforeDrop = try #require(sidebar.reviewChatRowHeightForTesting(firstJob)) + let secondReviewChatRowHeightBeforeDrop = try #require(sidebar.reviewChatRowHeightForTesting(secondJob)) + #expect(firstReviewChatRowHeightBeforeDrop == secondReviewChatRowHeightBeforeDrop) #expect( sidebar.performReviewChatDropForTesting( firstJob, proposedJob: secondJob, hoveringBelowMidpoint: false ) == false) - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-1", "job-2"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-1", "job-2"]) #expect( sidebar.performReviewChatDropForTesting( @@ -426,13 +426,13 @@ struct ReviewUITests { hoveringBelowMidpoint: true )) await Task.yield() - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-2", "job-1"]) - #expect(sidebar.selectedJobForTesting?.id == "job-1") + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-2", "job-1"]) + #expect(sidebar.selectedReviewChatJobForTesting?.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) + #expect(sidebar.reviewChatRowHeightForTesting(firstJob) == firstReviewChatRowHeightBeforeDrop) + #expect(sidebar.reviewChatRowHeightForTesting(secondJob) == secondReviewChatRowHeightBeforeDrop) } @Test func sidebarRunningFilterKeepsWorkspacesAndShowsActiveJobs() async throws { @@ -479,15 +479,15 @@ struct ReviewUITests { from: { sidebar.sidebarTopologyObservationForTesting }, ["job-alpha-running", "job-alpha-queued"] ) { - sidebar.displayedJobIDsForTesting(in: alphaWorkspace) + sidebar.displayedReviewChatJobIDsForTesting(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) == []) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-running", "job-alpha-queued"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == []) } @Test func sidebarRunningFilterFollowsJobTerminalStateChanges() async throws { @@ -510,7 +510,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-filter-state"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-state"]) runningJob.updateStateForTesting( status: .succeeded, @@ -520,7 +520,7 @@ struct ReviewUITests { from: sidebar.sidebarTopologyObservationForTesting, [String]() ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } runningJob.updateStateForTesting(status: .running, clearEndedAt: true) @@ -528,11 +528,11 @@ struct ReviewUITests { from: sidebar.sidebarTopologyObservationForTesting, ["job-filter-state"] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } } - @Test func sidebarRunningFilterDoesNotClearHiddenSelectedJob() async throws { + @Test func sidebarRunningFilterDoesNotClearHiddenSelectedReviewChat() async throws { let runningJob = makeJob( id: "job-filter-running", cwd: "/tmp/workspace-alpha", @@ -557,19 +557,19 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectJobForTesting(completedJob) - #expect(sidebar.selectedJobForTesting?.id == "job-filter-completed") + sidebar.selectReviewChatForTesting(completedJob) + #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-filter-completed") uiState.sidebarReviewChatFilter = .running try await waitForObservedValueFromCurrentObservation( from: { sidebar.sidebarTopologyObservationForTesting }, ["job-filter-running"] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-filter-running"]) - #expect(sidebar.selectedJobForTesting?.id == "job-filter-completed") + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-running"]) + #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-filter-completed") } @Test func sidebarLatestFinishedFilterKeepsWorkspacesAndShowsLatestTerminalJob() async throws { @@ -645,14 +645,14 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectJobForTesting(alphaRunningJob) + sidebar.selectReviewChatForTesting(alphaRunningJob) uiState.sidebarReviewChatFilter = .latestFinished try await waitForObservedValueFromCurrentObservation( from: { sidebar.sidebarTopologyObservationForTesting }, ["job-alpha-failed"] ) { - sidebar.displayedJobIDsForTesting(in: alphaWorkspace) + sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) } #expect( sidebar.displayedSectionTitlesForTesting == [ @@ -660,10 +660,10 @@ struct ReviewUITests { "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") + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-failed"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == ["job-beta-cancelled"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: gammaWorkspace) == []) + #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-alpha-running") } @Test func sidebarLatestFinishedFilterFollowsTerminalDateChanges() async throws { @@ -701,14 +701,14 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-finished-second"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-finished-second"]) firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400)) try await waitForObservedValue( from: sidebar.sidebarTopologyObservationForTesting, ["job-finished-first"] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } runningJob.updateStateForTesting( @@ -719,7 +719,7 @@ struct ReviewUITests { from: sidebar.sidebarTopologyObservationForTesting, ["job-running"] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } } @@ -766,7 +766,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect( - sidebar.displayedJobIDsForTesting(in: workspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == [ "job-running", "job-queued", "job-finished-latest", @@ -817,7 +817,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) #expect( sidebar.performReviewChatDropForTesting( @@ -827,7 +827,7 @@ struct ReviewUITests { ) == false) await Task.yield() - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) #expect( store.orderedJobs(in: workspace).map(\.id) == [ "job-hidden-prefix", @@ -845,7 +845,7 @@ struct ReviewUITests { )) await Task.yield() - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"]) #expect( store.orderedJobs(in: workspace).map(\.id) == [ "job-hidden-prefix", @@ -858,7 +858,7 @@ struct ReviewUITests { #expect(sidebar.performJobBlankAreaDropForTesting(runningA)) await Task.yield() - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running-b", "job-running-a"]) #expect( store.orderedJobs(in: workspace).map(\.id) == [ "job-hidden-prefix", @@ -904,7 +904,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-finished-latest"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-finished-latest"]) #expect(sidebar.performReviewChatDropForTesting(latestJob, proposedWorkspace: workspace, childIndex: 0) == false) } @@ -943,7 +943,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-running", "job-finished-latest"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running", "job-finished-latest"]) #expect(sidebar.performReviewChatDropForTesting(runningJob, proposedWorkspace: workspace, childIndex: 2)) await Task.yield() @@ -951,7 +951,7 @@ struct ReviewUITests { from: { sidebar.sidebarTopologyObservationForTesting }, ["job-finished-latest", "job-running"] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } #expect( store.orderedJobs(in: workspace).map(\.id) == [ @@ -1003,7 +1003,7 @@ struct ReviewUITests { "job-sort-order-1", ] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange) @@ -1055,10 +1055,10 @@ struct ReviewUITests { "job-membership-2", ] ) { - sidebar.displayedJobIDsForTesting(in: workspace) + sidebar.displayedReviewChatJobIDsForTesting(in: workspace) } - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == ["job-membership-1", "job-membership-2"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-membership-1", "job-membership-2"]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange) #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeChange) #expect( @@ -1395,7 +1395,7 @@ struct ReviewUITests { ]) } - @Test func jobCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { + @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { let placeholderJob = makeJob( id: "job-placeholder", status: .queued, @@ -1407,18 +1407,18 @@ struct ReviewUITests { targetSummary: "Uncommitted changes" ) - let cellView = makeReviewMonitorJobCellViewForTesting(job: placeholderJob) + let cellView = makeReviewMonitorReviewChatCellViewForTesting(job: placeholderJob) let initialHostingViewIdentity = try #require( - reviewMonitorJobCellHostingViewIdentityForTesting(cellView) + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - let initialHostedJobID = reviewMonitorJobCellHostedJobIDForTesting(cellView) + let initialHostedJobID = reviewMonitorReviewChatCellHostedJobIDForTesting(cellView) - configureReviewMonitorJobCellViewForTesting(cellView, job: loadedJob) + configureReviewMonitorReviewChatCellViewForTesting(cellView, job: loadedJob) let updatedHostingViewIdentity = try #require( - reviewMonitorJobCellHostingViewIdentityForTesting(cellView) + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - let updatedHostedJobID = reviewMonitorJobCellHostedJobIDForTesting(cellView) + let updatedHostedJobID = reviewMonitorReviewChatCellHostedJobIDForTesting(cellView) #expect(initialHostedJobID == placeholderJob.id) #expect(updatedHostedJobID == loadedJob.id) @@ -1514,10 +1514,10 @@ struct ReviewUITests { #expect(sidebar.floatsGroupRowsEnabledForTesting == false) #expect(sidebar.draggingDestinationFeedbackStyleForTesting == .sourceList) #expect(sidebar.sidebarUsesAutomaticRowHeightsForTesting == false) - #expect(sidebar.jobRowUsesReviewMonitorJobRowViewForTesting(job)) + #expect(sidebar.reviewChatRowUsesReviewMonitorChatRowViewForTesting(job)) } - @Test func sidebarUsesMeasuredRowHeightsForWorkspaceAndJobRows() throws { + @Test func sidebarUsesMeasuredRowHeightsForWorkspaceAndReviewChatRows() throws { let job = makeJob( cwd: "/tmp/workspace-alpha", status: .running, @@ -1540,13 +1540,13 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting let workspaceRowHeight = try #require(sidebar.workspaceRowHeightForTesting(workspace)) - let jobRowHeight = try #require(sidebar.jobRowHeightForTesting(job)) + let reviewChatRowHeight = try #require(sidebar.reviewChatRowHeightForTesting(job)) #expect(workspaceRowHeight == sidebar.expectedWorkspaceRowRectHeightForTesting) - #expect(jobRowHeight == sidebar.expectedJobRowRectHeightForTesting) - #expect(workspaceRowHeight < jobRowHeight) + #expect(reviewChatRowHeight == sidebar.expectedReviewChatRowRectHeightForTesting) + #expect(workspaceRowHeight < reviewChatRowHeight) } - @Test func jobRowsUseLabelIconSlotInsteadOfOutlineChildIndent() throws { + @Test func reviewChatRowsUseLabelIconSlotInsteadOfOutlineChildIndent() throws { let job = makeJob( cwd: "/tmp/workspace-alpha", status: .running, @@ -1569,8 +1569,8 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting let workspaceCellMinX = try #require(sidebar.workspaceCellMinXForTesting(workspace)) - let jobCellMinX = try #require(sidebar.jobCellMinXForTesting(job)) - #expect(jobCellMinX < workspaceCellMinX) + let reviewChatCellMinX = try #require(sidebar.reviewChatCellMinXForTesting(job)) + #expect(reviewChatCellMinX < workspaceCellMinX) } @Test func workspaceContentStartsAfterNativeDisclosureGutter() throws { @@ -1618,7 +1618,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["workspace-alpha"]) - #expect(sidebar.displayedJobIDsForTesting(in: workspace) == []) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == []) #expect(sidebar.isShowingEmptyStateForTesting == false) let workspaceCellMinX = try #require(sidebar.workspaceCellMinXForTesting(workspace)) let disclosureMaxX = try #require(sidebar.workspaceDisclosureMaxXForTesting(workspace)) @@ -1776,7 +1776,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectJobForTesting(job) + sidebar.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -1852,7 +1852,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job) + await viewController.sidebarViewControllerForTesting.cancelReviewChatForTesting(job) #expect(job.core.lifecycle.status == .cancelled) #expect(job.core.output.summary == "Cancelled by user from Review Monitor.") @@ -1880,7 +1880,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - await viewController.sidebarViewControllerForTesting.cancelJobForTesting(job) + await viewController.sidebarViewControllerForTesting.cancelReviewChatForTesting(job) #expect(job.core.lifecycle.status == .running) #expect(job.core.output.summary == "Failed to cancel review: Cancellation failed.") @@ -2310,7 +2310,7 @@ struct ReviewUITests { #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") } - @Test func jobsPresentOnInitialLoadStayUnselected() { + @Test func reviewChatsPresentOnInitialLoadStayUnselected() { let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") let recentJob = makeJob(status: .succeeded, targetSummary: "Commit: abc123") let store = CodexReviewStore.makePreviewStore() @@ -2322,12 +2322,12 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } - @Test func selectingJobUpdatesDetailPane() async throws { + @Test func selectingReviewChatUpdatesDetailPane() async throws { let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes", logText: "Running review\n") let recentJob = makeJob( status: .succeeded, @@ -2345,7 +2345,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) let selectedSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -2462,7 +2462,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ workspaceCWD ]) - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) #expect(transport.workspaceFindingsTextIsSelectableForTesting) #expect(transport.workspaceFindingsTextIsEditableForTesting == false) #expect(transport.workspaceFindingsUsesFindBarForTesting) @@ -2555,8 +2555,8 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == ["job-first-worktree"]) - #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == ["job-first-worktree"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree"]) sidebar.clickWorkspaceHeaderForTesting(firstWorkspace) _ = try await awaitTransportRender(transport) @@ -2608,18 +2608,18 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == []) - #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree-newer-finished"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == []) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree-newer-finished"]) firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400)) try await waitForObservedValue( from: sidebar.sidebarTopologyObservationForTesting, ["job-first-worktree-older-finished"] ) { - sidebar.displayedJobIDsForTesting(in: firstWorkspace) + sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) } - #expect(sidebar.displayedJobIDsForTesting(in: secondWorkspace) == []) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == []) } @Test func sidebarRunningAndLatestFinishedFilterUsesLinkedWorktreeGroupLatestJob() async throws { @@ -2685,12 +2685,12 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) #expect( - sidebar.displayedJobIDsForTesting(in: firstWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == [ "job-first-worktree-running-a", "job-first-worktree-queued-b", ]) #expect( - sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ "job-second-worktree-queued", "job-second-worktree-latest-finished", ]) @@ -2711,12 +2711,12 @@ struct ReviewUITests { await Task.yield() #expect( - sidebar.displayedJobIDsForTesting(in: firstWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == [ "job-first-worktree-queued-b", "job-first-worktree-running-a", ]) #expect( - sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ "job-second-worktree-queued", "job-second-worktree-latest-finished", ]) @@ -3037,7 +3037,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) #expect( - sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ "job-second-workspace-first", "job-second-workspace-second", ]) @@ -3064,9 +3064,9 @@ struct ReviewUITests { )) await Task.yield() - #expect(sidebar.displayedJobIDsForTesting(in: firstWorkspace) == ["job-first-workspace"]) + #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == ["job-first-workspace"]) #expect( - sidebar.displayedJobIDsForTesting(in: secondWorkspace) == [ + sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ "job-second-workspace-second", "job-second-workspace-first", ]) @@ -3270,16 +3270,16 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, workspaces: []) try await waitForCondition { - sidebar.selectedWorkspaceSectionForTesting == nil && sidebar.selectedJobForTesting == nil + sidebar.selectedWorkspaceSectionForTesting == nil && sidebar.selectedReviewChatJobForTesting == nil && transport.isShowingEmptyStateForTesting } #expect(sidebar.selectedWorkspaceSectionForTesting == nil) - #expect(sidebar.selectedJobForTesting == nil) + #expect(sidebar.selectedReviewChatJobForTesting == nil) #expect(transport.isShowingEmptyStateForTesting) } - @Test func detailPaneRendersSelectedJobMonitorLogProjection() async throws { + @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws { let job = CodexReviewJob.makeForTesting( id: "job-monitor-log", cwd: "/tmp/workspace-alpha", @@ -3330,7 +3330,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -3380,7 +3380,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3466,7 +3466,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3531,7 +3531,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3595,7 +3595,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3941,7 +3941,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4019,7 +4019,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, @@ -4096,7 +4096,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, @@ -4258,7 +4258,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, @@ -4313,7 +4313,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, @@ -4377,7 +4377,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4415,7 +4415,7 @@ struct ReviewUITests { } } - @Test func switchingSelectedJobRebindsDetailPane() async throws { + @Test func switchingSelectedReviewChatRebindsDetailPane() async throws { let activeJob = makeJob( id: "job-active", status: .running, @@ -4440,7 +4440,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) let activeSnapshot = try await awaitTimelineRenderForTesting( activeJob, @@ -4451,7 +4451,7 @@ struct ReviewUITests { #expect(activeSnapshot.summary == nil) #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) let recentSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -4471,7 +4471,7 @@ struct ReviewUITests { #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", @@ -4489,7 +4489,7 @@ struct ReviewUITests { window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4500,7 +4500,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) } - @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( @@ -4530,7 +4530,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4541,7 +4541,7 @@ struct ReviewUITests { let activeOffset = transport.logVerticalScrollOffsetForTesting #expect(activeOffset > 0) #expect(transport.isLogPinnedToBottomForTesting == false) - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4549,7 +4549,7 @@ struct ReviewUITests { ) #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4560,7 +4560,7 @@ struct ReviewUITests { #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( @@ -4590,7 +4590,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4599,7 +4599,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4609,7 +4609,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) let snapshot = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4620,7 +4620,7 @@ struct ReviewUITests { #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", @@ -4639,7 +4639,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4663,7 +4663,7 @@ struct ReviewUITests { #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", @@ -4689,7 +4689,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -4698,7 +4698,7 @@ struct ReviewUITests { transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -4734,14 +4734,14 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectJobForTesting(longJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(longJob) let longSnapshot = try await awaitTimelineRenderForTesting( longJob, in: transport, @@ -4780,13 +4780,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4795,7 +4795,7 @@ struct ReviewUITests { expectLogVisibleFragmentsWithoutForcingLayout(transport) replaceTimelineLogTextForTesting(shortJob, longLog) - viewController.sidebarViewControllerForTesting.selectJobForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, @@ -4811,7 +4811,7 @@ struct ReviewUITests { expectLogVisibleFragmentsWithoutForcingLayout(transport) } - @Test func previouslySelectedJobUpdatesDoNotRepaintCurrentDetailPane() async throws { + @Test func previouslySelectedReviewChatUpdatesDoNotRepaintCurrentDetailPane() async throws { let activeJob = makeJob( id: "job-old-selection", status: .running, @@ -4836,13 +4836,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectJobForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4879,7 +4879,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -4888,7 +4888,7 @@ struct ReviewUITests { ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == job.id) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting?.id == job.id) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @@ -4916,7 +4916,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, @@ -4930,7 +4930,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ workspace.cwd ]) - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) #expect( transport.workspaceFindingSnapshotForTesting == .init( @@ -4949,7 +4949,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) store.loadForTesting( @@ -4957,12 +4957,12 @@ struct ReviewUITests { content: makeSidebarContent(from: [activeJob]) ) - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } - @Test func removingSelectedJobClearsSelectionWithoutAutoSelectingReplacement() async throws { + @Test func removingSelectedReviewChatClearsSelectionWithoutAutoSelectingReplacement() async throws { let activeJob = makeJob( id: "job-active", status: .running, @@ -4988,7 +4988,7 @@ struct ReviewUITests { let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) let activeSnapshot = try await awaitTimelineRenderForTesting( activeJob, @@ -5005,11 +5005,11 @@ struct ReviewUITests { from: sidebar.sidebarTopologyObservationForTesting, true ) { - sidebar.selectedJobForTesting == nil + sidebar.selectedReviewChatJobForTesting == nil } let emptySnapshot = try await awaitContentPaneRender(contentPane) - #expect(sidebar.selectedJobForTesting == nil) + #expect(sidebar.selectedReviewChatJobForTesting == nil) #expect(emptySnapshot.isShowingEmptyState) #expect(emptySnapshot.title == nil) #expect(emptySnapshot.summary == nil) @@ -5034,7 +5034,7 @@ struct ReviewUITests { defer { window.close() } let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -5060,7 +5060,7 @@ struct ReviewUITests { #expect(contentPane.renderSnapshotForTesting == emptySnapshot) } - @Test func inPlaceJobUpdateKeepsSelectionAndRefreshesDetailPane() async throws { + @Test func inPlaceReviewChatUpdateKeepsSelectionAndRefreshesDetailPane() async throws { let job = makeJob( id: "job-1", status: .running, @@ -5077,7 +5077,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -5093,12 +5093,12 @@ struct ReviewUITests { replaceTimelineLogTextForTesting(job, "Updated log") let updatedSnapshot = try await awaitTimelineRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedJobForTesting?.id == "job-1") + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting?.id == "job-1") #expect(updatedSnapshot.summary == nil) #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) } - @Test func selectedJobLogAppendUsesAppendPath() async throws { + @Test func selectedReviewChatLogAppendUsesAppendPath() async throws { let job = CodexReviewJob.makeForTesting( id: "job-append", cwd: "/tmp/workspace-alpha", @@ -5122,7 +5122,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5160,7 +5160,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5196,7 +5196,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5232,7 +5232,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5265,7 +5265,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5298,7 +5298,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5336,7 +5336,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5366,7 +5366,7 @@ struct ReviewUITests { < 0.5) } - @Test func selectedJobGroupedReplacementUsesReplacementPath() async throws { + @Test func selectedReviewChatGroupedReplacementUsesReplacementPath() async throws { let job = CodexReviewJob.makeForTesting( id: "job-reload", cwd: "/tmp/workspace-alpha", @@ -5386,7 +5386,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5431,7 +5431,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5471,7 +5471,7 @@ struct ReviewUITests { #expect(transport.logReloadCountForTesting == reloadCount) } - @Test func selectedJobMarkdownAppendReplacesTailBlockWithoutReload() async throws { + @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { let job = CodexReviewJob.makeForTesting( id: "job-markdown-append-fallback", cwd: "/tmp/workspace-alpha", @@ -5491,7 +5491,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5529,7 +5529,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5575,7 +5575,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5610,7 +5610,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5674,13 +5674,13 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting transport.setLogReduceMotionForTesting(false) - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -5689,7 +5689,7 @@ struct ReviewUITests { appendTimelineEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -5723,7 +5723,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5769,7 +5769,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5804,7 +5804,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5848,7 +5848,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5891,7 +5891,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5937,7 +5937,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5974,7 +5974,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6009,7 +6009,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6025,7 +6025,7 @@ struct ReviewUITests { #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", @@ -6051,7 +6051,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6061,7 +6061,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6069,7 +6069,7 @@ struct ReviewUITests { ) let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6099,7 +6099,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6135,7 +6135,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6166,7 +6166,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6198,7 +6198,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6241,7 +6241,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6270,7 +6270,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6355,7 +6355,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6404,7 +6404,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6554,7 +6554,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6574,7 +6574,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6614,7 +6614,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6635,7 +6635,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6673,7 +6673,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6690,7 +6690,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting == false) let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6721,7 +6721,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6762,7 +6762,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6810,7 +6810,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6860,7 +6860,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6900,7 +6900,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6943,7 +6943,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6988,7 +6988,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7033,7 +7033,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7100,7 +7100,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7137,7 +7137,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let snapshot = try await awaitTimelineRenderForTesting( job, @@ -7166,7 +7166,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectJobForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) let snapshot = try await awaitTimelineRenderForTesting( job, From 795f115c6f06863b3224bf686ffae423e15e78f2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:34:48 +0900 Subject: [PATCH 145/359] refactor(review-ui): isolate preview chat log source --- .../ReviewMonitorPreviewChatLogSource.swift | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index e322ca27..b0e64179 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -8,35 +8,36 @@ final class ReviewMonitorPreviewChatLogSource { let snapshot: ReviewMonitorCodexSidebarSnapshot let initialChat: ReviewMonitorCodexSidebarSnapshot.Chat? - private let jobsByChatID: [CodexThreadID: CodexReviewJob] + private let previewChatsByID: [CodexThreadID: PreviewReviewChat] init(jobs: [CodexReviewJob]) { var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] var sectionIndexesByCWD: [String: Int] = [:] - var jobsByChatID: [CodexThreadID: CodexReviewJob] = [:] + var previewChatsByID: [CodexThreadID: PreviewReviewChat] = [:] var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? for job in jobs { - guard let chat = job.reviewChatSelection else { + guard let previewChat = PreviewReviewChat(job: job) else { continue } - jobsByChatID[chat.id] = job + let chat = previewChat.chat + previewChatsByID[chat.id] = previewChat firstChat = firstChat ?? chat - if initialRunningChat == nil, job.core.lifecycle.status == .running { + if initialRunningChat == nil, previewChat.isRunning { initialRunningChat = chat } - if let sectionIndex = sectionIndexesByCWD[job.cwd] { + if let sectionIndex = sectionIndexesByCWD[previewChat.cwd] { sections[sectionIndex].uncategorizedChats.append(chat) } else { - sectionIndexesByCWD[job.cwd] = sections.count + sectionIndexesByCWD[previewChat.cwd] = sections.count sections.append( ReviewMonitorCodexSidebarSnapshot.Section( - rowID: .section(job.cwd), - id: job.cwd, - title: URL(fileURLWithPath: job.cwd).lastPathComponent, + rowID: .section(previewChat.cwd), + id: previewChat.cwd, + title: URL(fileURLWithPath: previewChat.cwd).lastPathComponent, workspaces: [], uncategorizedChats: [chat] )) @@ -45,15 +46,15 @@ final class ReviewMonitorPreviewChatLogSource { self.snapshot = ReviewMonitorCodexSidebarSnapshot(sections: sections) self.initialChat = initialRunningChat ?? firstChat - self.jobsByChatID = jobsByChatID + self.previewChatsByID = previewChatsByID } func chatChangeStream(for chatID: CodexThreadID) -> AsyncStream? { - guard let job = jobsByChatID[chatID] else { + guard let previewChat = previewChatsByID[chatID] else { return nil } let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - let subscription = PreviewChatLogSubscription(job: job, continuation: pair.continuation) + let subscription = PreviewChatLogSubscription(previewChat: previewChat, continuation: pair.continuation) subscription.start() pair.continuation.onTermination = { _ in Task { @MainActor in @@ -65,17 +66,47 @@ final class ReviewMonitorPreviewChatLogSource { } @MainActor -private final class PreviewChatLogSubscription { +private final class PreviewReviewChat { + let chat: ReviewMonitorCodexSidebarSnapshot.Chat private let job: CodexReviewJob + + var cwd: String { + job.cwd + } + + var isRunning: Bool { + job.core.lifecycle.status == .running + } + + init?(job: CodexReviewJob) { + guard let chat = job.reviewChatSelection else { + return nil + } + self.chat = chat + self.job = job + } + + func trackTimelineRevision() { + _ = job.timeline.revision + } + + func snapshot() -> CodexChatSnapshot { + CodexChatSnapshot.previewSnapshot(from: job) + } +} + +@MainActor +private final class PreviewChatLogSubscription { + private let previewChat: PreviewReviewChat private let continuation: AsyncStream.Continuation private var observation: PortableObservationTracking.Token? private var previousSnapshot: CodexChatSnapshot? init( - job: CodexReviewJob, + previewChat: PreviewReviewChat, continuation: AsyncStream.Continuation ) { - self.job = job + self.previewChat = previewChat self.continuation = continuation } @@ -85,7 +116,7 @@ private final class PreviewChatLogSubscription { guard let self else { return } - _ = job.timeline.revision + previewChat.trackTimelineRevision() publish() } } @@ -96,7 +127,7 @@ private final class PreviewChatLogSubscription { } private func publish() { - let snapshot = CodexChatSnapshot.previewSnapshot(from: job) + let snapshot = previewChat.snapshot() let changes = CodexChatChange.previewChanges(from: previousSnapshot, to: snapshot) previousSnapshot = snapshot for change in changes { From 6e8088ad0850e2578c93729d77d67cfd0d173f2b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:42:12 +0900 Subject: [PATCH 146/359] refactor(review-ui): isolate sidebar review chat index --- .../ReviewMonitorSidebarReviewChatIndex.swift | 43 +++++++++++++++++++ .../ReviewMonitorSidebarViewController.swift | 42 +++++++----------- 2 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift new file mode 100644 index 00000000..71bf431d --- /dev/null +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift @@ -0,0 +1,43 @@ +import CodexKit +import CodexReviewKit + +@MainActor +final class ReviewMonitorSidebarReviewChatIndex { + private var rowsByJobID: [String: ReviewMonitorSidebarReviewChatRow] = [:] + + func rows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { + jobs.map { job in + if let row = rowsByJobID[job.id] { + row.update(from: job) + return row + } + let row = ReviewMonitorSidebarReviewChatRow(job: job) + rowsByJobID[job.id] = row + return row + } + } + + func prune(keeping activeJobIDs: Set) { + rowsByJobID = rowsByJobID.filter { activeJobIDs.contains($0.key) } + } + + func row(jobID: String) -> ReviewMonitorSidebarReviewChatRow? { + rowsByJobID[jobID] + } + + func row(chatID: CodexThreadID) -> ReviewMonitorSidebarReviewChatRow? { + rowsByJobID.values.first { $0.chat?.id == chatID } + } + + func chat( + id: CodexThreadID, + in workspaces: [CodexReviewWorkspace] + ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let row = row(chatID: id), + workspaces.contains(where: { $0.cwd == row.cwd }) + else { + return nil + } + return row.chat + } +} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index aeaf8e0f..3016ecd8 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -207,9 +207,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() + private let reviewChatIndex = ReviewMonitorSidebarReviewChatIndex() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] - private var reviewRowsByJobID: [String: ReviewMonitorSidebarReviewChatRow] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] private var isReconcilingSelection = false #if DEBUG @@ -535,31 +535,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] { let workspaces = store.orderedWorkspaces - let activeJobIDs = Set(workspaces.flatMap { workspace in - store.orderedJobs(in: workspace).map(\.id) - }) - let topologies = workspaces.map { workspace in + let workspaceJobs = workspaces.map { workspace in + (workspace: workspace, jobs: store.orderedJobs(in: workspace)) + } + let activeJobIDs = Set(workspaceJobs.flatMap { $0.jobs.map(\.id) }) + let topologies = workspaceJobs.map { workspace, jobs in SidebarWorkspaceTopology( workspace: workspace, - reviewRows: reviewRows(for: store.orderedJobs(in: workspace)) + reviewRows: reviewChatIndex.rows(for: jobs) ) } - reviewRowsByJobID = reviewRowsByJobID.filter { activeJobIDs.contains($0.key) } + reviewChatIndex.prune(keeping: activeJobIDs) return topologies } - private func reviewRows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { - jobs.map { job in - if let row = reviewRowsByJobID[job.id] { - row.update(from: job) - return row - } - let row = ReviewMonitorSidebarReviewChatRow(job: job) - reviewRowsByJobID[job.id] = row - return row - } - } - private func sidebarRootTopologies( from workspaceTopologies: [SidebarWorkspaceTopology], filter: SidebarReviewChatFilter @@ -1340,7 +1329,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forJobID jobID: String) -> Int? { - guard let rowItem = reviewRowsByJobID[jobID] else { + guard let rowItem = reviewChatIndex.row(jobID: jobID) else { return nil } let row = outlineView.row(forItem: rowItem) @@ -1348,7 +1337,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forReviewChatID chatID: CodexThreadID) -> Int? { - guard let rowItem = reviewRowsByJobID.values.first(where: { $0.chat?.id == chatID }) else { + guard let rowItem = reviewChatIndex.row(chatID: chatID) else { return nil } let row = outlineView.row(forItem: rowItem) @@ -1426,7 +1415,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD id: CodexThreadID, in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - store.reviewJob(forChatID: id, in: workspaces)?.reviewChatSelection + reviewChatIndex.chat(id: id, in: workspaces) + ?? store.reviewJob(forChatID: id, in: workspaces)?.reviewChatSelection } private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { @@ -2425,7 +2415,7 @@ extension ReviewMonitorSidebarViewController { } func cancelReviewChatForTesting(_ job: CodexReviewJob) async { - guard let row = reviewRowsByJobID[job.id] else { + guard let row = reviewChatIndex.row(jobID: job.id) else { return } await performCancellation(for: row) @@ -2624,7 +2614,7 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob ) -> Bool { guard let section = workspaceSection(containing: workspace), - let targetRow = reviewRowsByJobID[targetJob.id] + let targetRow = reviewChatIndex.row(jobID: targetJob.id) else { return true } @@ -2642,7 +2632,7 @@ extension ReviewMonitorSidebarViewController { hoveringBelowMidpoint: Bool ) -> Bool { guard let section = workspaceSection(containing: workspace), - let targetRowItem = reviewRowsByJobID[targetJob.id], + let targetRowItem = reviewChatIndex.row(jobID: targetJob.id), let targetRow = row(forJobID: targetJob.id) else { return false @@ -2744,7 +2734,7 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool ) -> Bool { - guard let targetRowItem = reviewRowsByJobID[targetJob.id], + guard let targetRowItem = reviewChatIndex.row(jobID: targetJob.id), let targetRow = row(forJobID: targetJob.id) else { return false From d93cf7313f6c144d6575c1cd416d7823ba814447 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:46:31 +0900 Subject: [PATCH 147/359] test(review-ui): assert sidebar chat selection by chat id --- .../ReviewMonitorSidebarViewController.swift | 7 +++-- Tests/ReviewUITests/ReviewUITests.swift | 30 +++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 3016ecd8..7b9369e2 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2221,8 +2221,11 @@ extension ReviewMonitorSidebarViewController { return titles } - var selectedReviewChatJobForTesting: CodexReviewJob? { - reviewJobForCurrentChatSelection() + var selectedReviewChatIDForTesting: CodexThreadID? { + guard case .chat(let chat) = uiState.selection else { + return nil + } + return chat.id } var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index e5cc7501..07e69ec3 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -427,7 +427,7 @@ struct ReviewUITests { )) await Task.yield() #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-2", "job-1"]) - #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-1") + #expect(sidebar.selectedReviewChatIDForTesting == firstJob.reviewChatID) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop) #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) @@ -558,7 +558,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting sidebar.selectReviewChatForTesting(completedJob) - #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-filter-completed") + #expect(sidebar.selectedReviewChatIDForTesting == completedJob.reviewChatID) uiState.sidebarReviewChatFilter = .running try await waitForObservedValueFromCurrentObservation( @@ -569,7 +569,7 @@ struct ReviewUITests { } #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-running"]) - #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-filter-completed") + #expect(sidebar.selectedReviewChatIDForTesting == completedJob.reviewChatID) } @Test func sidebarLatestFinishedFilterKeepsWorkspacesAndShowsLatestTerminalJob() async throws { @@ -663,7 +663,7 @@ struct ReviewUITests { #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-failed"]) #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == ["job-beta-cancelled"]) #expect(sidebar.displayedReviewChatJobIDsForTesting(in: gammaWorkspace) == []) - #expect(sidebar.selectedReviewChatJobForTesting?.id == "job-alpha-running") + #expect(sidebar.selectedReviewChatIDForTesting == alphaRunningJob.reviewChatID) } @Test func sidebarLatestFinishedFilterFollowsTerminalDateChanges() async throws { @@ -2322,7 +2322,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } @@ -2462,7 +2462,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ workspaceCWD ]) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(transport.workspaceFindingsTextIsSelectableForTesting) #expect(transport.workspaceFindingsTextIsEditableForTesting == false) #expect(transport.workspaceFindingsUsesFindBarForTesting) @@ -3270,12 +3270,12 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, workspaces: []) try await waitForCondition { - sidebar.selectedWorkspaceSectionForTesting == nil && sidebar.selectedReviewChatJobForTesting == nil + sidebar.selectedWorkspaceSectionForTesting == nil && sidebar.selectedReviewChatIDForTesting == nil && transport.isShowingEmptyStateForTesting } #expect(sidebar.selectedWorkspaceSectionForTesting == nil) - #expect(sidebar.selectedReviewChatJobForTesting == nil) + #expect(sidebar.selectedReviewChatIDForTesting == nil) #expect(transport.isShowingEmptyStateForTesting) } @@ -4888,7 +4888,7 @@ struct ReviewUITests { ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting?.id == job.id) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatID) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @@ -4930,7 +4930,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ workspace.cwd ]) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect( transport.workspaceFindingSnapshotForTesting == .init( @@ -4949,7 +4949,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) store.loadForTesting( @@ -4957,7 +4957,7 @@ struct ReviewUITests { content: makeSidebarContent(from: [activeJob]) ) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting == nil) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } @@ -5005,11 +5005,11 @@ struct ReviewUITests { from: sidebar.sidebarTopologyObservationForTesting, true ) { - sidebar.selectedReviewChatJobForTesting == nil + sidebar.selectedReviewChatIDForTesting == nil } let emptySnapshot = try await awaitContentPaneRender(contentPane) - #expect(sidebar.selectedReviewChatJobForTesting == nil) + #expect(sidebar.selectedReviewChatIDForTesting == nil) #expect(emptySnapshot.isShowingEmptyState) #expect(emptySnapshot.title == nil) #expect(emptySnapshot.summary == nil) @@ -5093,7 +5093,7 @@ struct ReviewUITests { replaceTimelineLogTextForTesting(job, "Updated log") let updatedSnapshot = try await awaitTimelineRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatJobForTesting?.id == "job-1") + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatID) #expect(updatedSnapshot.summary == nil) #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) } From 2a8461ac037c726951d9dc80a579f5754d382616 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:51:58 +0900 Subject: [PATCH 148/359] refactor(review-ui): keep timeline projection in tests --- ...ReviewMonitorTransportViewController.swift | 19 ++------ .../ReviewMonitorTimelineLogProjection.swift | 3 +- .../ReviewMonitorTimelineTesting.swift | 47 +++++++++++++++++-- 3 files changed, 49 insertions(+), 20 deletions(-) rename {Sources/ReviewUI/Detail => Tests/ReviewUITests}/ReviewMonitorTimelineLogProjection.swift (99%) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 1d0ddc31..42789f63 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -31,9 +31,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private var logRenderGeneration: UInt64 = 0 private var appliedLogRenderGeneration: UInt64 = 0 private var hasAppliedBoundLog = false - #if DEBUG - private var timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() - #endif convenience init( store: CodexReviewStore, @@ -555,9 +552,6 @@ final class ReviewMonitorTransportViewController: NSViewController { appliedLogRenderGeneration = logRenderGeneration hasAppliedBoundLog = false logRenderer = ReviewMonitorLogRenderer() - #if DEBUG - timelineLogProjectionForTesting = ReviewMonitorTimelineLogProjection() - #endif } private func restorationTarget( @@ -1298,8 +1292,8 @@ final class ReviewMonitorTransportViewController: NSViewController { } @discardableResult - func renderTimelineDocumentForTesting( - _ timelineDocument: ReviewTimelineDocument, + func renderLogDocumentForTesting( + _ sourceDocument: ReviewMonitorLog.Document, target: DisplayedSelectionForTesting? = nil, restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, allowIncrementalUpdate: Bool @@ -1322,21 +1316,14 @@ final class ReviewMonitorTransportViewController: NSViewController { return logScrollTargetsByChatID[id] ?? .bottom } }() - let renderedDocument = logDocumentForTesting(from: timelineDocument) return renderBoundLog( - sourceDocument: renderedDocument, + sourceDocument: sourceDocument, target: resolvedTarget, restorationTarget: resolvedRestorationTarget, allowIncrementalUpdate: allowIncrementalUpdate ) } - private func logDocumentForTesting( - from timelineDocument: ReviewTimelineDocument - ) -> ReviewMonitorLog.Document { - timelineLogProjectionForTesting.render(timelineDocument: timelineDocument) - } - func copyLogSelectionForTesting() { logScrollView.copySelectionForTesting() } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift b/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift similarity index 99% rename from Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift rename to Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift index a0053f27..ff88479b 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTimelineLogProjection.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift @@ -1,6 +1,7 @@ import Foundation -import CodexReviewKit +@_spi(Testing) @testable import CodexReviewKit import ReviewMonitorRendering +@_spi(PreviewSupport) @testable import ReviewUI struct ReviewMonitorTimelineLogProjection: Sendable { private var documentProjection = ReviewMonitorLogDocumentProjection() diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 04c2015a..f93ec428 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -225,9 +225,15 @@ func renderTimelineForTesting( allowIncrementalUpdate: Bool ) throws -> Bool { let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - return transport.renderTimelineDocumentForTesting( - timelineDocument, - target: try timelineRenderTarget(for: job), + let target = try timelineRenderTarget(for: job) + let sourceDocument = TimelineLogProjectionStore.document( + from: timelineDocument, + transport: transport, + target: target + ) + return transport.renderLogDocumentForTesting( + sourceDocument, + target: target, restoring: restorationTarget, allowIncrementalUpdate: allowIncrementalUpdate ) @@ -268,6 +274,41 @@ private func timelineRenderTarget(for job: CodexReviewJob) throws -> ReviewMonit return .chat(chatID.rawValue) } +@MainActor +private enum TimelineLogProjectionStore { + private struct Key: Hashable { + var transportID: ObjectIdentifier + var targetID: String + } + + private static var projectionsByKey: [Key: ReviewMonitorTimelineLogProjection] = [:] + + static func document( + from timelineDocument: ReviewTimelineDocument, + transport: ReviewMonitorTransportViewController, + target: ReviewMonitorTransportViewController.DisplayedSelectionForTesting + ) -> ReviewMonitorLog.Document { + let key = Key(transportID: ObjectIdentifier(transport), targetID: target.projectionStoreID) + var projection = projectionsByKey[key] ?? ReviewMonitorTimelineLogProjection() + let document = projection.render(timelineDocument: timelineDocument) + projectionsByKey[key] = projection + return document + } +} + +private extension ReviewMonitorTransportViewController.DisplayedSelectionForTesting { + var projectionStoreID: String { + switch self { + case .workspaceSection(let id): + "workspaceSection:\(id)" + case .workspace(let id): + "workspace:\(id)" + case .chat(let id): + "chat:\(id)" + } + } +} + private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { switch entry.kind { case .command, .commandOutput: From c3cb5f1cf21dc7b88b9711e5dc60df19fe53ca1d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:56:51 +0900 Subject: [PATCH 149/359] refactor(review-ui): name empty sidebar around review chats --- Sources/ReviewUI/PlaceholderViewController.swift | 14 +++++++------- .../Jobs/ReviewMonitorSidebarViewController.swift | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) 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/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 7b9369e2..084d9789 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -195,7 +195,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let scrollView = NSScrollView() private let outlineView = ReviewMonitorSidebarOutlineView() private let accountsViewController: ReviewMonitorAccountsViewController - private let emptyStateViewController = PlaceholderViewController(content: .noReviewJobs) + private let emptyStateViewController = PlaceholderViewController(content: .noReviewChats) private let unavailableView: NSHostingView private let rowHeights: SidebarRowHeights From 055a060684c490b58f15a1b17fe717e642fa5f77 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:00:30 +0900 Subject: [PATCH 150/359] refactor(review-ui): clarify runtime job lookup --- .../Jobs/ReviewMonitorSidebarViewController.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 084d9789..0d195a39 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -902,19 +902,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedReviewChatRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard let selectedReviewJob = reviewJobForCurrentChatSelection(), - section.workspaces.contains(where: { $0.cwd == selectedReviewJob.cwd }) + guard let selectedRuntimeJob = runtimeJobForCurrentChatSelection(), + section.workspaces.contains(where: { $0.cwd == selectedRuntimeJob.cwd }) else { return } - let selectedReviewJobID = selectedReviewJob.id + let selectedRuntimeJobID = selectedRuntimeJob.id DispatchQueue.main.async { [weak self, weak section] in guard let self, let section, - let currentSelectedReviewJob = self.reviewJobForCurrentChatSelection(), + let currentSelectedRuntimeJob = self.runtimeJobForCurrentChatSelection(), section.isExpanded, - currentSelectedReviewJob.id == selectedReviewJobID, - let row = self.row(forJobID: selectedReviewJobID), + currentSelectedRuntimeJob.id == selectedRuntimeJobID, + let row = self.row(forJobID: selectedRuntimeJobID), self.outlineView.selectedRow != row else { return @@ -925,7 +925,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func reviewJobForCurrentChatSelection() -> CodexReviewJob? { + private func runtimeJobForCurrentChatSelection() -> CodexReviewJob? { switch uiState.selection { case .chat(let chat): return store.reviewJob(forChatID: chat.id, in: workspaces()) From 5a444da55a4a3dc45cbb896f902f301a4a8c2f20 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:08:26 +0900 Subject: [PATCH 151/359] refactor(review-ui): decouple chat row rendering from jobs --- .../Jobs/ReviewMonitorChatRowView.swift | 154 +++++++++++------- .../ReviewMonitorSidebarReviewChatIndex.swift | 51 +++++- .../ReviewMonitorSidebarViewController.swift | 7 +- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 4 files changed, 148 insertions(+), 66 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 9583db50..aa5cdab3 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -1,6 +1,19 @@ import Foundation import SwiftUI -import CodexReviewKit + +struct ReviewMonitorSidebarReviewChatRuntime: Equatable { + var jobID: String + var sessionID: String + var cwd: String + var fallbackTitle: String + var fallbackSubtitle: String? + var model: String? + var startedAt: Date? + var endedAt: Date? + var isRunning: Bool + var isTerminal: Bool + var cancellationRequested: Bool +} struct ReviewMonitorSidebarChatRow: Equatable { var id: String @@ -11,41 +24,37 @@ struct ReviewMonitorSidebarChatRow: Equatable { var endedAt: Date? var isRunning: Bool - @MainActor - init(job: CodexReviewJob) { - self.id = job.id - self.title = job.displayTitle - self.model = job.core.run.model - self.subtitle = Self.subtitleText(for: job) - self.startedAt = job.core.lifecycle.startedAt - self.endedAt = job.core.lifecycle.endedAt - self.isRunning = job.core.lifecycle.status == .running + init( + id: String, + title: String, + model: String?, + subtitle: String?, + startedAt: Date?, + endedAt: Date?, + isRunning: Bool + ) { + self.id = id + self.title = title + self.model = model + self.subtitle = subtitle + self.startedAt = startedAt + self.endedAt = endedAt + self.isRunning = isRunning } - @MainActor - private static func subtitleText(for job: CodexReviewJob) -> 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 + init( + chat: ReviewMonitorCodexSidebarSnapshot.Chat?, + runtime: ReviewMonitorSidebarReviewChatRuntime + ) { + self.init( + id: runtime.jobID, + title: chat?.title.trimmedNonEmpty ?? runtime.fallbackTitle, + model: runtime.model, + subtitle: chat?.preview?.trimmedNonEmpty ?? runtime.fallbackSubtitle, + startedAt: runtime.startedAt, + endedAt: runtime.endedAt, + isRunning: runtime.isRunning + ) } } @@ -59,23 +68,29 @@ final class ReviewMonitorSidebarReviewChatRow { private(set) var isTerminal: Bool private(set) var cancellationRequested: Bool - init(job: CodexReviewJob) { - self.jobID = job.id - self.sessionID = job.sessionID - self.cwd = job.cwd - self.chat = job.reviewChatSelection - self.presentation = ReviewMonitorSidebarChatRow(job: job) - self.isTerminal = job.isTerminal - self.cancellationRequested = job.cancellationRequested + init( + chat: ReviewMonitorCodexSidebarSnapshot.Chat?, + runtime: ReviewMonitorSidebarReviewChatRuntime + ) { + self.jobID = runtime.jobID + self.sessionID = runtime.sessionID + self.cwd = runtime.cwd + self.chat = chat + self.presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) + self.isTerminal = runtime.isTerminal + self.cancellationRequested = runtime.cancellationRequested } - func update(from job: CodexReviewJob) { - sessionID = job.sessionID - cwd = job.cwd - chat = job.reviewChatSelection - presentation = ReviewMonitorSidebarChatRow(job: job) - isTerminal = job.isTerminal - cancellationRequested = job.cancellationRequested + func update( + chat: ReviewMonitorCodexSidebarSnapshot.Chat?, + runtime: ReviewMonitorSidebarReviewChatRuntime + ) { + sessionID = runtime.sessionID + cwd = runtime.cwd + self.chat = chat + presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) + isTerminal = runtime.isTerminal + cancellationRequested = runtime.cancellationRequested } } @@ -143,19 +158,31 @@ struct ReviewMonitorChatRowTimerLabel: View { #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:{ - ReviewMonitorChatRowView(row: ReviewMonitorSidebarChatRow(job: job)) - } - } - } + Section("workspace-alpha") { + ReviewMonitorChatRowView( + row: ReviewMonitorSidebarChatRow( + id: "preview-running", + title: "Uncommitted changes", + model: "gpt-5.5", + subtitle: "Inspecting recent changes", + startedAt: Date(timeIntervalSinceNow: -640), + endedAt: nil, + isRunning: true + ) + ) + ReviewMonitorChatRowView( + row: ReviewMonitorSidebarChatRow( + id: "preview-completed", + title: "Base branch: main", + model: "gpt-5.5-codex", + subtitle: "No findings", + startedAt: Date(timeIntervalSinceNow: -3_600), + endedAt: Date(timeIntervalSinceNow: -2_900), + isRunning: false + ) + ) } } .frame(minWidth: 320) @@ -166,3 +193,10 @@ struct ReviewMonitorChatRowTimerLabel: View { } } #endif + +private extension String { + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift index 71bf431d..b7225400 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift @@ -7,11 +7,13 @@ final class ReviewMonitorSidebarReviewChatIndex { func rows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { jobs.map { job in + let chat = job.reviewChatSelection + let runtime = ReviewMonitorSidebarReviewChatRuntime(job: job) if let row = rowsByJobID[job.id] { - row.update(from: job) + row.update(chat: chat, runtime: runtime) return row } - let row = ReviewMonitorSidebarReviewChatRow(job: job) + let row = ReviewMonitorSidebarReviewChatRow(chat: chat, runtime: runtime) rowsByJobID[job.id] = row return row } @@ -41,3 +43,48 @@ final class ReviewMonitorSidebarReviewChatIndex { return row.chat } } + +extension ReviewMonitorSidebarReviewChatRuntime { + @MainActor + init(job: CodexReviewJob) { + self.init( + jobID: job.id, + sessionID: job.sessionID, + cwd: job.cwd, + fallbackTitle: job.displayTitle, + fallbackSubtitle: Self.subtitleText(for: job), + model: job.core.run.model, + startedAt: job.core.lifecycle.startedAt, + endedAt: job.core.lifecycle.endedAt, + isRunning: job.core.lifecycle.status == .running, + isTerminal: job.isTerminal, + cancellationRequested: job.cancellationRequested + ) + } + + @MainActor + private static func subtitleText(for job: CodexReviewJob) -> 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 + } +} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 0d195a39..3a725c0c 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -3037,9 +3037,10 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } func configure(with job: CodexReviewJob) { - objectValue = job - toolTip = job.cwd - configureRow(ReviewMonitorSidebarChatRow(job: job)) + configure(with: ReviewMonitorSidebarReviewChatRow( + chat: job.reviewChatSelection, + runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) + )) } func configure(with row: ReviewMonitorSidebarReviewChatRow) { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 07e69ec3..56cdb9d4 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1423,7 +1423,7 @@ struct ReviewUITests { #expect(initialHostedJobID == placeholderJob.id) #expect(updatedHostedJobID == loadedJob.id) #expect(initialHostingViewIdentity == updatedHostingViewIdentity) - #expect(cellView.objectValue as? CodexReviewJob === loadedJob) + #expect((cellView.objectValue as? ReviewMonitorSidebarReviewChatRow)?.jobID == loadedJob.id) #expect(cellView.toolTip == loadedJob.cwd) } From b5b31b8c3b6d8317185ef4a7b0288d5d8d88466c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:15:43 +0900 Subject: [PATCH 152/359] refactor(review-ui): isolate workspace findings index --- ...ReviewMonitorTransportViewController.swift | 106 +----------------- .../ReviewMonitorWorkspaceFindingsIndex.swift | 105 +++++++++++++++++ 2 files changed, 107 insertions(+), 104 deletions(-) create mode 100644 Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 42789f63..bf18bb18 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -254,7 +254,8 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - let entries = self.workspaceFindingEntries(for: self.currentWorkspaces(for: section)) + let entries = ReviewMonitorWorkspaceFindingsIndex(store: self.store) + .entries(for: section) self.renderWorkspaceFindings(entries: entries) } } @@ -295,109 +296,6 @@ 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 renderBoundLog( sourceDocument: ReviewMonitorLog.Document, diff --git a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift b/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift new file mode 100644 index 00000000..47ce4f55 --- /dev/null +++ b/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift @@ -0,0 +1,105 @@ +import Foundation +import CodexReviewKit + +@MainActor +struct ReviewMonitorWorkspaceFindingsIndex { + private let workspaces: [CodexReviewWorkspace] + private let jobsByWorkspaceCWD: [String: [CodexReviewJob]] + + init(store: CodexReviewStore) { + self.workspaces = store.orderedWorkspaces + self.jobsByWorkspaceCWD = Dictionary(grouping: store.orderedJobs, by: \.cwd) + } + + func entries( + for section: ReviewMonitorWorkspaceSectionSelection + ) -> [ReviewMonitorWorkspaceFindingsView.Entry] { + let workspacesByCWD = Dictionary( + uniqueKeysWithValues: workspaces.map { ($0.cwd, $0) } + ) + return section.workspaceCWDs + .compactMap { workspacesByCWD[$0] } + .flatMap(entries(in:)) + } + + private func entries( + in workspace: CodexReviewWorkspace + ) -> [ReviewMonitorWorkspaceFindingsView.Entry] { + let jobs = jobsByWorkspaceCWD[workspace.cwd, default: []] + return jobs.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 locationText( + for location: ParsedReviewResult.Finding.Location?, + in workspace: CodexReviewWorkspace + ) -> String? { + guard let location else { + return nil + } + + let path = workspaceRelativePath(location.path, in: workspace) ?? 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 + } +} From fb230da2a0414644f42bcb2c6bab58e0da7951e0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:20:01 +0900 Subject: [PATCH 153/359] refactor(review-ui): identify chat rows by chat id --- .../ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift | 2 +- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 6 +++--- Tests/ReviewUITests/ReviewUITests.swift | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index aa5cdab3..638436be 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -47,7 +47,7 @@ struct ReviewMonitorSidebarChatRow: Equatable { runtime: ReviewMonitorSidebarReviewChatRuntime ) { self.init( - id: runtime.jobID, + id: chat?.id.rawValue ?? runtime.jobID, title: chat?.title.trimmedNonEmpty ?? runtime.fallbackTitle, model: runtime.model, subtitle: chat?.preview?.trimmedNonEmpty ?? runtime.fallbackSubtitle, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 3a725c0c..1179de11 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -3078,7 +3078,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { hostingView != nil } - var hostedJobIDForTesting: String? { + var hostedRowIDForTesting: String? { hostingView?.rootView.row.id } @@ -3116,11 +3116,11 @@ func configureReviewMonitorReviewChatCellViewForTesting( } @MainActor -func reviewMonitorReviewChatCellHostedJobIDForTesting(_ cellView: NSTableCellView) -> String? { +func reviewMonitorReviewChatCellHostedRowIDForTesting(_ cellView: NSTableCellView) -> String? { guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { return nil } - return cellView.hostedJobIDForTesting + return cellView.hostedRowIDForTesting } @MainActor diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 56cdb9d4..21fb37d9 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1411,17 +1411,17 @@ struct ReviewUITests { let initialHostingViewIdentity = try #require( reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - let initialHostedJobID = reviewMonitorReviewChatCellHostedJobIDForTesting(cellView) + let initialHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) configureReviewMonitorReviewChatCellViewForTesting(cellView, job: loadedJob) let updatedHostingViewIdentity = try #require( reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - let updatedHostedJobID = reviewMonitorReviewChatCellHostedJobIDForTesting(cellView) + let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - #expect(initialHostedJobID == placeholderJob.id) - #expect(updatedHostedJobID == loadedJob.id) + #expect(initialHostedRowID == placeholderJob.id) + #expect(updatedHostedRowID == loadedJob.reviewChatID?.rawValue) #expect(initialHostingViewIdentity == updatedHostingViewIdentity) #expect((cellView.objectValue as? ReviewMonitorSidebarReviewChatRow)?.jobID == loadedJob.id) #expect(cellView.toolTip == loadedJob.cwd) From 94d70e6d86cd5a5165d368dc8b080a89f9e57220 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:21:34 +0900 Subject: [PATCH 154/359] refactor(review-ui): label sidebar as review chats --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 1179de11..28e0e1e4 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -330,7 +330,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.intercellSpacing = NSSize(width: 0, height: 12) outlineView.allowsEmptySelection = true outlineView.allowsMultipleSelection = false - outlineView.setAccessibilityIdentifier("review-monitor.job-list") + outlineView.setAccessibilityIdentifier("review-monitor.review-chat-list") outlineView.registerForDraggedTypes([DragType.sidebarItem]) outlineView.setDraggingSourceOperationMask(.move, forLocal: true) outlineView.setDraggingSourceOperationMask([], forLocal: false) @@ -2429,7 +2429,7 @@ extension ReviewMonitorSidebarViewController { let point = blankPointForTesting() precondition( outlineView.suppressesSelectionClearingForTesting(at: point), - "Expected a blank click target outside any job item." + "Expected a blank click target outside any review chat item." ) outlineView.mouseDown(with: mouseEventForTesting(at: point)) } @@ -2448,7 +2448,7 @@ extension ReviewMonitorSidebarViewController { ) { view.layoutSubtreeIfNeeded() guard let row = row(forJobID: job.id) else { - preconditionFailure("Job row is not visible.") + preconditionFailure("Review chat row is not visible.") } let rect = outlineView.rect(ofRow: row) let point = NSPoint(x: rect.midX, y: rect.midY) @@ -2858,7 +2858,7 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { withLevel level: Int, isSourceListGroupRow: Bool ) -> CGFloat { - // Workspace rows keep AppKit's native disclosure gutter. Job rows do + // Workspace rows keep AppKit's native disclosure gutter. Review chat rows do // not add outline-level indentation; the SwiftUI Label icon slot is // the visual indent. level <= 0 ? max(indentationPerLevel, 0) : 0 From 8558e60ca636417e1f6b5a1301d02764ccbad07b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:28:02 +0900 Subject: [PATCH 155/359] refactor(review-ui): narrow preview chat stream source --- .../ReviewMonitorPreviewChatLogSource.swift | 61 ++++++++----------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index b0e64179..0fc8aafc 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -68,30 +68,44 @@ final class ReviewMonitorPreviewChatLogSource { @MainActor private final class PreviewReviewChat { let chat: ReviewMonitorCodexSidebarSnapshot.Chat - private let job: CodexReviewJob + let cwd: String + let isRunning: Bool - var cwd: String { - job.cwd - } - - var isRunning: Bool { - job.core.lifecycle.status == .running - } + private let turn: CodexChatTurnStateSnapshot + private let phase: CodexDataPhase + private let timeline: ReviewTimeline init?(job: CodexReviewJob) { guard let chat = job.reviewChatSelection else { return nil } self.chat = chat - self.job = job + self.cwd = job.cwd + self.isRunning = job.core.lifecycle.status == .running + self.turn = CodexChatTurnStateSnapshot( + id: job.previewTurnID, + status: CodexTurnStatus(job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ) + self.phase = CodexDataPhase( + job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ) + self.timeline = job.timeline } func trackTimelineRevision() { - _ = job.timeline.revision + _ = timeline.revision } func snapshot() -> CodexChatSnapshot { - CodexChatSnapshot.previewSnapshot(from: job) + CodexChatSnapshot( + chatID: chat.id, + phase: phase, + turns: [turn], + items: timeline.items.map { CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) } + ) } } @@ -136,27 +150,6 @@ private final class PreviewChatLogSubscription { } } -private extension CodexChatSnapshot { - @MainActor - static func previewSnapshot(from job: CodexReviewJob) -> Self { - let turn = CodexChatTurnStateSnapshot( - id: job.previewTurnID, - status: CodexTurnStatus(job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, - usage: nil - ) - return .init( - chatID: job.previewChatID, - phase: CodexDataPhase( - job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage - ), - turns: [turn], - items: job.timeline.items.map { CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) } - ) - } -} - private extension CodexChatChange { static func previewChanges(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { guard let previous else { @@ -372,10 +365,6 @@ private extension CodexDataPhase { } private extension CodexReviewJob { - var previewChatID: CodexThreadID { - core.run.reviewThreadID.map(CodexThreadID.init(rawValue:)) ?? CodexThreadID(rawValue: id) - } - var previewTurnID: CodexTurnID { core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") } From e7e77959362a55274de5bb5038ee3f1bf59e4dea Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:33:04 +0900 Subject: [PATCH 156/359] refactor(review-ui): build preview chat log fixtures --- .../ReviewUI/ReviewMonitorContentView.swift | 4 +- .../ReviewMonitorPreviewChatLogSource.swift | 74 +++++-------------- .../ReviewMonitorPreviewContent.swift | 59 +++++++++++++++ 3 files changed, 82 insertions(+), 55 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 732df9c5..4e022fdc 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -310,7 +310,9 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( let uiState = ReviewMonitorUIState(auth: store.auth) let previewChatLogSource: ReviewMonitorPreviewChatLogSource? = if case .running = serverState { - ReviewMonitorPreviewChatLogSource(jobs: store.orderedJobs) + ReviewMonitorPreviewChatLogSource( + fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: store.orderedJobs) + ) } else { nil } diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 0fc8aafc..50215742 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -3,6 +3,16 @@ import CodexReviewKit import Foundation import ObservationBridge +@MainActor +struct ReviewMonitorPreviewChatLogFixture { + let chat: ReviewMonitorCodexSidebarSnapshot.Chat + let cwd: String + let isRunning: Bool + let turn: CodexChatTurnStateSnapshot + let phase: CodexDataPhase + let timeline: ReviewTimeline +} + @MainActor final class ReviewMonitorPreviewChatLogSource { let snapshot: ReviewMonitorCodexSidebarSnapshot @@ -10,18 +20,15 @@ final class ReviewMonitorPreviewChatLogSource { private let previewChatsByID: [CodexThreadID: PreviewReviewChat] - init(jobs: [CodexReviewJob]) { + init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] var sectionIndexesByCWD: [String: Int] = [:] var previewChatsByID: [CodexThreadID: PreviewReviewChat] = [:] var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? - for job in jobs { - guard let previewChat = PreviewReviewChat(job: job) else { - continue - } - + for fixture in fixtures { + let previewChat = PreviewReviewChat(fixture: fixture) let chat = previewChat.chat previewChatsByID[chat.id] = previewChat firstChat = firstChat ?? chat @@ -75,24 +82,13 @@ private final class PreviewReviewChat { private let phase: CodexDataPhase private let timeline: ReviewTimeline - init?(job: CodexReviewJob) { - guard let chat = job.reviewChatSelection else { - return nil - } - self.chat = chat - self.cwd = job.cwd - self.isRunning = job.core.lifecycle.status == .running - self.turn = CodexChatTurnStateSnapshot( - id: job.previewTurnID, - status: CodexTurnStatus(job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, - usage: nil - ) - self.phase = CodexDataPhase( - job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage - ) - self.timeline = job.timeline + init(fixture: ReviewMonitorPreviewChatLogFixture) { + self.chat = fixture.chat + self.cwd = fixture.cwd + self.isRunning = fixture.isRunning + self.turn = fixture.turn + self.phase = fixture.phase + self.timeline = fixture.timeline } func trackTimelineRevision() { @@ -322,19 +318,6 @@ private extension CodexThreadItem.Content { } private extension CodexTurnStatus { - init(_ jobState: ReviewJobState) { - switch jobState { - case .queued, .running: - self = .running - case .succeeded: - self = .completed - case .failed: - self = .failed - case .cancelled: - self = .cancelled - } - } - init(_ phase: ReviewItemPhase) { switch phase { case .awaitingApproval, .queued, .running, .waitingForInput: @@ -353,23 +336,6 @@ private extension CodexTurnStatus { } } -private extension CodexDataPhase { - init(_ jobState: ReviewJobState, errorMessage: String?) { - switch jobState { - case .queued, .running, .succeeded, .cancelled: - self = .loaded - case .failed: - self = .failed(errorMessage ?? "Review failed") - } - } -} - -private extension CodexReviewJob { - var previewTurnID: CodexTurnID { - core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") - } -} - private extension String { var nilIfEmpty: String? { isEmpty ? nil : self diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 14e8020e..b400ed5d 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -1,4 +1,5 @@ import Foundation +import CodexKit @_spi(Testing) import CodexReviewKit @_spi(PreviewSupport) @@ -206,6 +207,32 @@ public enum ReviewMonitorPreviewContent { return store } + static func makeChatLogFixtures( + from jobs: [CodexReviewJob] + ) -> [ReviewMonitorPreviewChatLogFixture] { + jobs.compactMap { job in + guard let chat = job.reviewChatSelection else { + return nil + } + return ReviewMonitorPreviewChatLogFixture( + chat: chat, + cwd: job.cwd, + isRunning: job.core.lifecycle.status == .running, + turn: CodexChatTurnStateSnapshot( + id: job.previewTurnID, + status: CodexTurnStatus(job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ), + phase: CodexDataPhase( + job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ), + timeline: job.timeline + ) + } + } + @_spi(PreviewSupport) public static func makeCommandOutputStore() -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( @@ -1216,3 +1243,35 @@ public enum ReviewMonitorPreviewContent { ] } } + +private extension CodexReviewJob { + var previewTurnID: CodexTurnID { + core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") + } +} + +private extension CodexTurnStatus { + init(_ jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .running + case .succeeded: + self = .completed + case .failed: + self = .failed + case .cancelled: + self = .cancelled + } + } +} + +private extension CodexDataPhase { + init(_ jobState: ReviewJobState, errorMessage: String?) { + switch jobState { + case .queued, .running, .succeeded, .cancelled: + self = .loaded + case .failed: + self = .failed(errorMessage ?? "Review failed") + } + } +} From 738be13f640255ccda8fe022593941a6202d2c73 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:36:55 +0900 Subject: [PATCH 157/359] refactor(review-ui): isolate review chat operations --- .../Jobs/ReviewMonitorChatRowView.swift | 31 +++++++++++++------ .../ReviewMonitorSidebarReviewChatIndex.swift | 8 +++-- .../ReviewMonitorSidebarViewController.swift | 13 +++++--- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 638436be..49657c9c 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -1,10 +1,14 @@ import Foundation import SwiftUI -struct ReviewMonitorSidebarReviewChatRuntime: Equatable { +struct ReviewMonitorSidebarReviewChatOperation: Equatable { var jobID: String var sessionID: String var cwd: String +} + +struct ReviewMonitorSidebarReviewChatRuntime: Equatable { + var operation: ReviewMonitorSidebarReviewChatOperation var fallbackTitle: String var fallbackSubtitle: String? var model: String? @@ -47,7 +51,7 @@ struct ReviewMonitorSidebarChatRow: Equatable { runtime: ReviewMonitorSidebarReviewChatRuntime ) { self.init( - id: chat?.id.rawValue ?? runtime.jobID, + id: chat?.id.rawValue ?? runtime.operation.jobID, title: chat?.title.trimmedNonEmpty ?? runtime.fallbackTitle, model: runtime.model, subtitle: chat?.preview?.trimmedNonEmpty ?? runtime.fallbackSubtitle, @@ -60,21 +64,29 @@ struct ReviewMonitorSidebarChatRow: Equatable { @MainActor final class ReviewMonitorSidebarReviewChatRow { - let jobID: String - private(set) var sessionID: String - private(set) var cwd: String + private(set) var operation: ReviewMonitorSidebarReviewChatOperation private(set) var chat: ReviewMonitorCodexSidebarSnapshot.Chat? private(set) var presentation: ReviewMonitorSidebarChatRow private(set) var isTerminal: Bool private(set) var cancellationRequested: Bool + var jobID: String { + operation.jobID + } + + var sessionID: String { + operation.sessionID + } + + var cwd: String { + operation.cwd + } + init( chat: ReviewMonitorCodexSidebarSnapshot.Chat?, runtime: ReviewMonitorSidebarReviewChatRuntime ) { - self.jobID = runtime.jobID - self.sessionID = runtime.sessionID - self.cwd = runtime.cwd + self.operation = runtime.operation self.chat = chat self.presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) self.isTerminal = runtime.isTerminal @@ -85,8 +97,7 @@ final class ReviewMonitorSidebarReviewChatRow { chat: ReviewMonitorCodexSidebarSnapshot.Chat?, runtime: ReviewMonitorSidebarReviewChatRuntime ) { - sessionID = runtime.sessionID - cwd = runtime.cwd + operation = runtime.operation self.chat = chat presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) isTerminal = runtime.isTerminal diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift index b7225400..aff76b0e 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift @@ -48,9 +48,11 @@ extension ReviewMonitorSidebarReviewChatRuntime { @MainActor init(job: CodexReviewJob) { self.init( - jobID: job.id, - sessionID: job.sessionID, - cwd: job.cwd, + operation: .init( + jobID: job.id, + sessionID: job.sessionID, + cwd: job.cwd + ), fallbackTitle: job.displayTitle, fallbackSubtitle: Self.subtitleText(for: job), model: job.core.run.model, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 28e0e1e4..92dd7795 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -971,9 +971,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return } + let operation = row.operation _ = try await store.cancelReview( - jobID: row.jobID, - sessionID: row.sessionID, + jobID: operation.jobID, + sessionID: operation.sessionID, cancellation: .userInterface() ) } @@ -989,9 +990,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func handleCancellationFailure(_ error: Error, for row: ReviewMonitorSidebarReviewChatRow) { let description = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) let message = description.isEmpty ? "Failed to cancel review." : description + let operation = row.operation try? store.recordCancellationFailure( - jobID: row.jobID, - sessionID: row.sessionID, + jobID: operation.jobID, + sessionID: operation.sessionID, message: message ) } @@ -1479,7 +1481,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return .workspaceSection(id: section.id) } if let row = reviewRow(from: item) { - return .reviewChat(jobID: row.jobID, cwd: row.cwd) + let operation = row.operation + return .reviewChat(jobID: operation.jobID, cwd: operation.cwd) } return nil } From 9b8617f472389ae5b858bfbf33b6bd10608ae84d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:45:21 +0900 Subject: [PATCH 158/359] refactor(review-ui): keep review chat operations explicit --- .../Jobs/ReviewMonitorChatRowView.swift | 12 ----- .../ReviewMonitorSidebarReviewChatIndex.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 45 ++++++++++--------- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 4 files changed, 25 insertions(+), 36 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 49657c9c..6d17e30a 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -70,18 +70,6 @@ final class ReviewMonitorSidebarReviewChatRow { private(set) var isTerminal: Bool private(set) var cancellationRequested: Bool - var jobID: String { - operation.jobID - } - - var sessionID: String { - operation.sessionID - } - - var cwd: String { - operation.cwd - } - init( chat: ReviewMonitorCodexSidebarSnapshot.Chat?, runtime: ReviewMonitorSidebarReviewChatRuntime diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift index aff76b0e..e3b653e1 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift @@ -36,7 +36,7 @@ final class ReviewMonitorSidebarReviewChatIndex { in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { guard let row = row(chatID: id), - workspaces.contains(where: { $0.cwd == row.cwd }) + workspaces.contains(where: { $0.cwd == row.operation.cwd }) else { return nil } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 92dd7795..71f77c08 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1114,14 +1114,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func moveReviewChatInOutline(id: String, in workspace: CodexReviewWorkspace, toIndex destinationIndex: Int) { let currentRows = displayedReviewRows(in: workspace) - guard let sourceIndex = currentRows.firstIndex(where: { $0.jobID == id }), + guard let sourceIndex = currentRows.firstIndex(where: { $0.operation.jobID == id }), let parentItem = rootItem(containing: workspace) else { return } let rootRows = displayedReviewRows(inRootItem: parentItem) - guard let sourceRootIndex = rootRows.firstIndex(where: { $0.jobID == id }), - let workspaceRootStartIndex = rootRows.firstIndex(where: { $0.cwd == workspace.cwd }) + guard let sourceRootIndex = rootRows.firstIndex(where: { $0.operation.jobID == id }), + let workspaceRootStartIndex = rootRows.firstIndex(where: { $0.operation.cwd == workspace.cwd }) else { return } @@ -1169,7 +1169,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD (0.. AnyObject? { - guard let workspace = workspace(cwd: row.cwd) else { + guard let workspace = workspace(cwd: row.operation.cwd) else { return nil } return rootItem(containing: workspace) @@ -1606,7 +1606,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } if let targetRow = reviewRow(from: proposedItem), - let targetWorkspace = workspace(cwd: targetRow.cwd), + let targetWorkspace = workspace(cwd: targetRow.operation.cwd), let targetSection = workspaceSection(containing: targetWorkspace), let targetRootIndex = rootIndex(forRootItem: targetSection) { @@ -1705,7 +1705,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let isExpanded = workspaceSection(from: rootItem)?.isExpanded ?? false guard isExpanded, let lastRow = displayedReviewRows(inRootItem: rootItem).last, - let lastReviewChatRow = row(forJobID: lastRow.jobID) + let lastReviewChatRow = row(forJobID: lastRow.operation.jobID) else { return sectionRect } @@ -1737,7 +1737,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let orderedJobs = store.orderedJobs(in: destination.workspace) let destinationVisibleRows = visibleReviewRows(in: destination.workspace) - guard let visibleSourceIndex = destinationVisibleRows.firstIndex(where: { $0.jobID == id }) else { + guard let visibleSourceIndex = destinationVisibleRows.firstIndex(where: { $0.operation.jobID == id }) else { return nil } @@ -1788,11 +1788,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } if visibleInsertionIndex < visibleRows.count { - let targetJobID = visibleRows[visibleInsertionIndex].jobID + let targetJobID = visibleRows[visibleInsertionIndex].operation.jobID return targetJobID == movingJobID ? movingJobID : targetJobID } guard let lastVisibleRow = visibleRows.last, - let lastVisibleIndex = orderedJobs.firstIndex(where: { $0.id == lastVisibleRow.jobID }) + let lastVisibleIndex = orderedJobs.firstIndex(where: { $0.id == lastVisibleRow.operation.jobID }) else { return nil } @@ -1874,11 +1874,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sourceCWD: String ) -> SidebarReviewChatDropDestination? { guard let draggingLocation, - let targetWorkspace = workspace(cwd: targetRow.cwd), + let targetWorkspace = workspace(cwd: targetRow.operation.cwd), let targetSection = workspaceSection(containing: targetWorkspace), - let targetWorkspaceRootStartIndex = targetSection.reviewRows.firstIndex(where: { $0.cwd == targetWorkspace.cwd }), - let targetJobIndex = visibleReviewRows(in: targetWorkspace).firstIndex(where: { $0.jobID == targetRow.jobID }), - let outlineRow = row(forJobID: targetRow.jobID) + let targetWorkspaceRootStartIndex = targetSection.reviewRows.firstIndex(where: { $0.operation.cwd == targetWorkspace.cwd }), + let targetJobIndex = visibleReviewRows(in: targetWorkspace) + .firstIndex(where: { $0.operation.jobID == targetRow.operation.jobID }), + let outlineRow = row(forJobID: targetRow.operation.jobID) else { return nil } @@ -1909,7 +1910,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let workspace = workspace(cwd: sourceCWD), let sourceSection = workspaceSection(containing: workspace), currentRootTopologies.last?.item === sourceSection, - sourceSection.reviewRows.last?.cwd == sourceCWD + sourceSection.reviewRows.last?.operation.cwd == sourceCWD else { return nil } @@ -1927,7 +1928,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedRootChildIndex index: Int ) -> SidebarReviewChatDropDestination? { guard let workspace = section.workspaces.first(where: { $0.cwd == sourceCWD }), - let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.cwd == sourceCWD }) + let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.operation.cwd == sourceCWD }) else { return nil } @@ -1954,7 +1955,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> (item: Any?, childIndex: Int) { guard let rootItem = rootItem(containing: workspace), let section = workspaceSection(from: rootItem), - let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.cwd == workspace.cwd }) + let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.operation.cwd == workspace.cwd }) else { return (workspace, childIndex) } @@ -2320,11 +2321,11 @@ extension ReviewMonitorSidebarViewController { var jobIDs: [String] = [] for row in 0.. Date: Sun, 28 Jun 2026 06:47:01 +0900 Subject: [PATCH 159/359] test(review-ui): select review chats by chat id --- .../Jobs/ReviewMonitorSidebarViewController.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 71f77c08..262cdfb7 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2286,7 +2286,14 @@ extension ReviewMonitorSidebarViewController { } func selectReviewChatForTesting(_ job: CodexReviewJob) { - guard let row = row(forJobID: job.id) else { + guard let chatID = job.reviewChatID else { + return + } + selectReviewChatForTesting(id: chatID) + } + + func selectReviewChatForTesting(id chatID: CodexThreadID) { + guard let row = row(forReviewChatID: chatID) else { return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) From 1f312bd8d591d04e61d5ec7399aaec8dbe399a81 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:49:50 +0900 Subject: [PATCH 160/359] test(review-ui): assert selected review chat ids --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 7 ------- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 262cdfb7..073c227b 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2497,13 +2497,6 @@ extension ReviewMonitorSidebarViewController { return outlineView.isItemExpanded(rootItem) } - var selectedOutlineJobIDForTesting: String? { - guard outlineView.selectedRow != -1 else { - return nil - } - return reviewRow(atRow: outlineView.selectedRow)?.operation.jobID - } - func toggleWorkspaceDisclosureForTesting(_ workspace: CodexReviewWorkspace) { guard row(for: workspace) != nil else { preconditionFailure("Workspace row is not visible.") diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 1d04cf49..fdc8a533 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1793,7 +1793,7 @@ struct ReviewUITests { try await waitForCondition { sidebar.workspaceIsExpandedForTesting(storedWorkspace) && sidebar.workspaceOutlineIsExpandedForTesting(storedWorkspace) - && sidebar.selectedOutlineJobIDForTesting == job.id + && sidebar.selectedReviewChatIDForTesting == job.reviewChatID } } From 43b18532d650387d98965a45ba3f82e1f64ce005 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:56:21 +0900 Subject: [PATCH 161/359] test(review-ui): exercise chat append stream path --- .../ReviewMonitorSidebarViewController.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 073c227b..0798f805 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2293,7 +2293,7 @@ extension ReviewMonitorSidebarViewController { } func selectReviewChatForTesting(id chatID: CodexThreadID) { - guard let row = row(forReviewChatID: chatID) else { + guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) ?? row(forReviewChatID: chatID) else { return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index fdc8a533..b5f23c5d 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5114,26 +5114,29 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + let previewChatLogSource = ReviewMonitorPreviewChatLogSource( + fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: [job]) + ) let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + store: store, + uiState: ReviewMonitorUIState(auth: store.auth), + previewChatLogSource: previewChatLogSource + ) 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.selectReviewChatForTesting(job) - _ = try await awaitTimelineRenderForTesting( - job, - in: transport, - allowIncrementalUpdate: false - ) + let chatID = try #require(job.reviewChatID) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) + _ = try await awaitTransportRender(transport) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitTransportRender(transport) { $0.log == "Initial log" } #expect(snapshot.log == "Initial log") #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logReloadCountForTesting == reloadCount) From 2c1d3920f205f7bd3f690ae94efa86fc215132b0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:59:22 +0900 Subject: [PATCH 162/359] test(review-ui): document chat-only selection fixtures --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 1 + Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 0798f805..a873d8db 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2286,6 +2286,7 @@ extension ReviewMonitorSidebarViewController { } func selectReviewChatForTesting(_ job: CodexReviewJob) { + // Jobs are review-operation fixtures here. Sidebar selection remains chat-only. guard let chatID = job.reviewChatID else { return } diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index f93ec428..38a4ea8c 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -216,6 +216,9 @@ func reviewMonitorLogText(for job: CodexReviewJob) -> String { return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) } +// Direct timeline rendering helpers bypass the production CodexChatChange stream. +// Keep them for timeline projection tests; new selected-chat detail tests should +// use ReviewMonitorPreviewChatLogSource or CodexAppServerTestRuntime instead. @MainActor @discardableResult func renderTimelineForTesting( From d8d78d379fb26503dd0a81e026b6125e7140e5aa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:08:21 +0900 Subject: [PATCH 163/359] test(review-ui): cover preview chat stream lifecycle --- Tests/ReviewUITests/ReviewUIShellTests.swift | 57 ++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index e55166c4..a366fa34 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -20,6 +20,63 @@ extension ReviewUITests { #expect(rootViewController.isSplitViewEmbeddedForTesting) } + @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let selectedReviewChatJob = try #require( + store.orderedJobs.first { $0.core.lifecycle.status == .running } + ?? store.orderedJobs.first + ) + let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) + let selectedTargetSummary = selectedReviewChatJob.targetSummary + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + + viewController.loadViewIfNeeded() + viewController.view.layoutSubtreeIfNeeded() + + let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting + let snapshot = try await awaitTransportRender(transport) { snapshot in + snapshot.log.contains(selectedTargetSummary) && snapshot.isShowingEmptyState == false + } + + #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) + #expect(snapshot.log.isEmpty == false) + } + + @Test func previewContentViewControllerStreamsSelectedChatLogDuringViewLifecycle() async throws { + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let selectedReviewChatJob = try #require( + store.orderedJobs.first { $0.core.lifecycle.status == .running } + ?? store.orderedJobs.first + ) + let existingItemIDs = Set(selectedReviewChatJob.timeline.items.map(\.id)) + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + + viewController.loadViewIfNeeded() + viewController.view.layoutSubtreeIfNeeded() + + let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting + let initialSnapshot = try await awaitTransportRender(transport) { snapshot in + snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false + } + + ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) + let appendedItem = try #require( + selectedReviewChatJob.timeline.items.first { existingItemIDs.contains($0.id) == false } + ) + let appendedText = try #require(diagnosticMessage(appendedItem).nilIfEmpty) + let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + snapshot.log.count > initialSnapshot.log.count + && snapshot.log.contains(appendedText) + && snapshot.isShowingEmptyState == false + } + + #expect(updatedSnapshot.log.contains(appendedText)) + } + @Test func bindingStoreAppliesInitialState() { let store = CodexReviewStore.makePreviewStore() let viewController = ReviewMonitorSplitViewController( From 939b1df57be064d954804589eb030cc7e9ea09b8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:12:44 +0900 Subject: [PATCH 164/359] fix(review-ui): prepare preview detail stream --- .../ReviewMonitorContentPreview.swift | 4 ++- .../ReviewUI/ReviewMonitorContentView.swift | 6 ++++ .../ReviewMonitorSplitViewController.swift | 11 ++++++++ Tests/ReviewUITests/ReviewUIShellTests.swift | 28 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentPreview.swift b/Sources/ReviewUI/ReviewMonitorContentPreview.swift index 737806cf..8641f88f 100644 --- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift +++ b/Sources/ReviewUI/ReviewMonitorContentPreview.swift @@ -30,12 +30,14 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable { var serverState: CodexReviewServerState = .running func makeNSViewController(context: Context) -> ReviewMonitorRootViewController { - makeReviewMonitorPreviewContentViewControllerForPreview( + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( authPhase: authPhase, account: account, serverState: serverState, previewStore: previewStore() ) + viewController.prepareForSwiftUIPreviewRendering() + return viewController } func updateNSViewController( diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 4e022fdc..8a8ff56d 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -252,6 +252,12 @@ final class ReviewMonitorRootViewController: NSViewController { #if DEBUG @MainActor extension ReviewMonitorRootViewController { + func prepareForSwiftUIPreviewRendering() { + loadViewIfNeeded() + splitViewController.prepareForSwiftUIPreviewRendering() + view.layoutSubtreeIfNeeded() + } + var splitViewControllerForTesting: ReviewMonitorSplitViewController { splitViewController } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 986520a7..0eca5090 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -516,6 +516,13 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe #if DEBUG @MainActor extension ReviewMonitorSplitViewController { + func prepareForSwiftUIPreviewRendering() { + loadViewIfNeeded() + sidebarViewController?.loadViewIfNeeded() + transportViewController?.loadViewIfNeeded() + view.layoutSubtreeIfNeeded() + } + enum AddAccountToolbarItemModeForTesting: Equatable { case add case progress @@ -570,6 +577,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.") diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index a366fa34..f8204e28 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -20,6 +20,34 @@ extension ReviewUITests { #expect(rootViewController.isSplitViewEmbeddedForTesting) } + @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws { + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let selectedReviewChatJob = try #require( + store.orderedJobs.first { $0.core.lifecycle.status == .running } + ?? store.orderedJobs.first + ) + let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + + #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) { snapshot in + snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false + } + + #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) + #expect(snapshot.log.isEmpty == false) + } + @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) let selectedReviewChatJob = try #require( From 2453b8e6c98d95ca0a05719186f75bcb08e6593a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:16:57 +0900 Subject: [PATCH 165/359] refactor(review-ui): reuse review chat identity for findings --- .../ReviewMonitorWorkspaceFindingsIndex.swift | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift b/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift index 47ce4f55..2c851c48 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift @@ -86,20 +86,6 @@ struct ReviewMonitorWorkspaceFindingsIndex { } 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 + job.reviewChatID?.rawValue ?? job.id } } From 5085548fc8e95a2f57e847e3358791ca87b94e9e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:21:26 +0900 Subject: [PATCH 166/359] test(review-ui): stream progress append through chat source --- Tests/ReviewUITests/ReviewUITests.swift | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index b5f23c5d..b87a1502 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5159,20 +5159,27 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + let previewChatLogSource = ReviewMonitorPreviewChatLogSource( + fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: [job]) + ) let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + store: store, + uiState: ReviewMonitorUIState(auth: store.auth), + previewChatLogSource: previewChatLogSource + ) + 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.selectReviewChatForTesting(job) - _ = try await awaitTimelineRenderForTesting( - job, - in: transport, - allowIncrementalUpdate: false - ) + let chatID = try #require(job.reviewChatID) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) + _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitTransportRender(transport) { $0.log.hasSuffix("stream.tick 001") } #expect(snapshot.log.hasSuffix("stream.tick 001")) #expect(transport.logWordGlowCountForTesting == wordGlowCount) } From 8c31dc928559f0d54575fd743343edc43a2a2c1a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:29:46 +0900 Subject: [PATCH 167/359] test(review-ui): require chat ids for sidebar selection --- .../ReviewMonitorSidebarViewController.swift | 8 - Tests/ReviewUITests/ReviewUIShellTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 190 +++++++++--------- 3 files changed, 101 insertions(+), 99 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index a873d8db..5f929ad5 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2285,14 +2285,6 @@ extension ReviewMonitorSidebarViewController { emptyStateViewController.view.isHidden == false } - func selectReviewChatForTesting(_ job: CodexReviewJob) { - // Jobs are review-operation fixtures here. Sidebar selection remains chat-only. - guard let chatID = job.reviewChatID else { - return - } - selectReviewChatForTesting(id: chatID) - } - func selectReviewChatForTesting(id chatID: CodexThreadID) { guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) ?? row(forReviewChatID: chatID) else { return diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index f8204e28..a741df2d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1787,7 +1787,7 @@ private func renderDetailLogForShellLayoutTesting( viewController: ReviewMonitorSplitViewController, job: CodexReviewJob ) async throws { - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let chatID = try #require(job.reviewChatID) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index b87a1502..e8c66405 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import ObservationBridge +import CodexKit import CodexReviewKit import ReviewMonitorRendering import SwiftUI @@ -20,6 +21,15 @@ private extension CodexReviewAuthModel { } } +@MainActor +func chatIDForTesting(_ job: CodexReviewJob) -> CodexThreadID { + guard let chatID = job.reviewChatID else { + Issue.record("Expected review job \(job.id) to have a chat id.") + return CodexThreadID(rawValue: job.id) + } + return chatID +} + @Suite(.serialized) @MainActor struct ReviewUITests { @@ -404,7 +414,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(firstJob) + sidebar.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) let fullReloadCountBeforeDrop = sidebar.sidebarFullReloadCountForTesting let workspaceReloadCountBeforeDrop = sidebar.sidebarWorkspaceReloadCountForTesting let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting @@ -557,7 +567,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(completedJob) + sidebar.selectReviewChatForTesting(id: chatIDForTesting(completedJob)) #expect(sidebar.selectedReviewChatIDForTesting == completedJob.reviewChatID) uiState.sidebarReviewChatFilter = .running @@ -645,7 +655,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(alphaRunningJob) + sidebar.selectReviewChatForTesting(id: chatIDForTesting(alphaRunningJob)) uiState.sidebarReviewChatFilter = .latestFinished try await waitForObservedValueFromCurrentObservation( @@ -1776,7 +1786,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(job) + sidebar.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -2345,7 +2355,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) let selectedSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -3330,7 +3340,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -3380,7 +3390,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3466,7 +3476,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3531,7 +3541,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3595,7 +3605,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -3941,7 +3951,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4019,7 +4029,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, @@ -4096,7 +4106,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, @@ -4258,7 +4268,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, @@ -4313,7 +4323,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, @@ -4377,7 +4387,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4440,7 +4450,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) let activeSnapshot = try await awaitTimelineRenderForTesting( activeJob, @@ -4451,7 +4461,7 @@ struct ReviewUITests { #expect(activeSnapshot.summary == nil) #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) let recentSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -4489,7 +4499,7 @@ struct ReviewUITests { window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4530,7 +4540,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4541,7 +4551,7 @@ struct ReviewUITests { let activeOffset = transport.logVerticalScrollOffsetForTesting #expect(activeOffset > 0) #expect(transport.isLogPinnedToBottomForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4549,7 +4559,7 @@ struct ReviewUITests { ) #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4590,7 +4600,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4599,7 +4609,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4609,7 +4619,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) let snapshot = try await awaitTimelineRenderForTesting( activeJob, in: transport, @@ -4639,7 +4649,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -4689,7 +4699,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -4698,7 +4708,7 @@ struct ReviewUITests { transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -4734,14 +4744,14 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(longJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(longJob)) let longSnapshot = try await awaitTimelineRenderForTesting( longJob, in: transport, @@ -4780,13 +4790,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4795,7 +4805,7 @@ struct ReviewUITests { expectLogVisibleFragmentsWithoutForcingLayout(transport) replaceTimelineLogTextForTesting(shortJob, longLog) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(shortJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) _ = try await awaitTimelineRenderForTesting( shortJob, in: transport, @@ -4836,13 +4846,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) _ = try await awaitTimelineRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(recentJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) _ = try await awaitTimelineRenderForTesting( recentJob, in: transport, @@ -4879,7 +4889,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -4916,7 +4926,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, @@ -4988,7 +4998,7 @@ struct ReviewUITests { let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(activeJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) let activeSnapshot = try await awaitTimelineRenderForTesting( activeJob, @@ -5034,7 +5044,7 @@ struct ReviewUITests { defer { window.close() } let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -5077,7 +5087,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -5206,7 +5216,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5242,7 +5252,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5275,7 +5285,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5308,7 +5318,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5346,7 +5356,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5396,7 +5406,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5441,7 +5451,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5501,7 +5511,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5539,7 +5549,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5585,7 +5595,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5620,7 +5630,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5684,13 +5694,13 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting transport.setLogReduceMotionForTesting(false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -5699,7 +5709,7 @@ struct ReviewUITests { appendTimelineEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -5733,7 +5743,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5779,7 +5789,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5814,7 +5824,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5858,7 +5868,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5901,7 +5911,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5947,7 +5957,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -5984,7 +5994,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6019,7 +6029,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6061,7 +6071,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6071,7 +6081,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6079,7 +6089,7 @@ struct ReviewUITests { ) let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6109,7 +6119,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6145,7 +6155,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6176,7 +6186,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6208,7 +6218,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6251,7 +6261,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6280,7 +6290,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6365,7 +6375,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6414,7 +6424,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6564,7 +6574,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6584,7 +6594,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6624,7 +6634,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6645,7 +6655,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6683,7 +6693,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(firstJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) _ = try await awaitTimelineRenderForTesting( firstJob, in: transport, @@ -6700,7 +6710,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting == false) let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(secondJob) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) _ = try await awaitTimelineRenderForTesting( secondJob, in: transport, @@ -6731,7 +6741,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6772,7 +6782,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6820,7 +6830,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6870,7 +6880,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6910,7 +6920,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6953,7 +6963,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -6998,7 +7008,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7043,7 +7053,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7110,7 +7120,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) _ = try await awaitTimelineRenderForTesting( job, in: transport, @@ -7147,7 +7157,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let snapshot = try await awaitTimelineRenderForTesting( job, @@ -7176,7 +7186,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(job) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let snapshot = try await awaitTimelineRenderForTesting( job, From b00ea43ad3e82039eda74b7044d3a7d05a2c6a31 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:57:15 +0900 Subject: [PATCH 168/359] fix(review-ui): observe Codex sidebar result changes --- .../ReviewMonitorSidebarViewController.swift | 25 ++++++ ...eviewMonitorCodexSidebarLibraryTests.swift | 76 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 5f929ad5..e292bf9d 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -203,9 +203,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarTopologyObservation: PortableObservationTracking.Token? private var sidebarFilterObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? + private var codexSidebarSnapshotObservation: PortableObservationTracking.Token? private var selectedChatSnapshotObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? + private var codexSidebarModelContext: CodexModelContext? private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private let reviewChatIndex = ReviewMonitorSidebarReviewChatIndex() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] @@ -245,6 +247,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() codexSidebarObservation?.cancel() + codexSidebarSnapshotObservation?.cancel() selectedChatSnapshotObservation?.cancel() codexSidebarFetchTask?.cancel() } @@ -413,16 +416,38 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func installCodexSidebarLibrary(modelContext: CodexModelContext?) { + if let modelContext, + let codexSidebarModelContext, + codexSidebarModelContext === modelContext, + codexSidebarLibrary != nil { + return + } codexSidebarFetchTask?.cancel() codexSidebarFetchTask = nil + codexSidebarSnapshotObservation?.cancel() + codexSidebarSnapshotObservation = nil guard let modelContext else { + codexSidebarModelContext = nil codexSidebarLibrary = nil applyPreviewCodexSidebarSnapshotIfNeeded() return } let library = ReviewMonitorCodexSidebarLibrary(modelContext: modelContext) + codexSidebarModelContext = modelContext codexSidebarLibrary = library + codexSidebarSnapshotObservation = withPortableContinuousObservation { [weak self, library] event in + guard let self, + self.codexSidebarLibrary === library + else { + return + } + let snapshot = library.snapshot + guard event.kind != .initial else { + return + } + self.applyCodexSidebarSnapshot(snapshot) + } codexSidebarFetchTask = Task { @MainActor [weak self, library] in do { try await library.performFetch() diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index d535f8d0..fbcded40 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -233,6 +233,82 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(selectedChat.id == CodexThreadID(rawValue: "thread-app")) #expect(selectedChat.title == "App review") } + + @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: [ + .init( + id: threadID, + workspace: repo, + name: "App review", + updatedAt: Date(timeIntervalSince1970: 5_000) + ), + ] + )) + + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting( + serverState: .running, + workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-job-store")] + ) + let uiState = ReviewMonitorUIState(auth: store.auth) + let viewController = ReviewMonitorSplitViewController( + store: store, + uiState: uiState, + modelContext: context + ) + viewController.loadViewIfNeeded() + + let sidebar = viewController.sidebarViewControllerForTesting + try await waitForCondition(timeout: .milliseconds(500)) { + sidebar.codexSidebarSnapshotForTesting? + .chat(id: threadID)? + .title == "App review" + } + try await waitForCondition(timeout: .milliseconds(500)) { + sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" + } + + let chat = context.model(for: threadID) + 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 runtime.transport.enqueueThreadList(.init( + threads: [ + .init( + id: threadID, + workspace: repo, + name: "App review renamed", + updatedAt: Date(timeIntervalSince1970: 6_000) + ), + ] + )) + try await chat.refresh(includeTurns: false) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + + try await waitForCondition { + sidebar.codexSidebarSnapshotForTesting? + .chat(id: threadID)? + .title == "App review renamed" + } + try await waitForCondition { + sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review renamed" + } + #expect(sidebar.displayedCodexSidebarTitlesForTesting == [ + repo.lastPathComponent, + repo.lastPathComponent, + "App review renamed", + ]) + } } private struct ThreadListParams: Decodable { From 3c1e0425f1619a4fc55955c9851f23fa333a427e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:06:20 +0900 Subject: [PATCH 169/359] refactor(review-kit): move workspace findings index to domain --- .../Model/CodexReviewWorkspaceFindings.swift} | 46 +++++++++++++------ ...ReviewMonitorTransportViewController.swift | 17 ++++++- 2 files changed, 48 insertions(+), 15 deletions(-) rename Sources/{ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift => CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift} (68%) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift b/Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift similarity index 68% rename from Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift rename to Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift index 2c851c48..4d3c5f44 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsIndex.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift @@ -1,32 +1,52 @@ import Foundation -import CodexReviewKit + +package struct CodexReviewWorkspaceFindingEntry: Equatable, Sendable { + package var threadID: String + package var targetSummary: String? + package var priority: Int? + package var title: String + package var body: String + package var locationText: String? + + package init( + threadID: String, + targetSummary: String? = nil, + priority: Int? = nil, + title: String, + body: String, + locationText: String? = nil + ) { + self.threadID = threadID + self.targetSummary = targetSummary + self.priority = priority + self.title = title + self.body = body + self.locationText = locationText + } +} @MainActor -struct ReviewMonitorWorkspaceFindingsIndex { +package struct CodexReviewWorkspaceFindingsIndex { private let workspaces: [CodexReviewWorkspace] private let jobsByWorkspaceCWD: [String: [CodexReviewJob]] - init(store: CodexReviewStore) { + package init(store: CodexReviewStore) { self.workspaces = store.orderedWorkspaces self.jobsByWorkspaceCWD = Dictionary(grouping: store.orderedJobs, by: \.cwd) } - func entries( - for section: ReviewMonitorWorkspaceSectionSelection - ) -> [ReviewMonitorWorkspaceFindingsView.Entry] { + package func entries(forWorkspaceCWDs workspaceCWDs: [String]) -> [CodexReviewWorkspaceFindingEntry] { let workspacesByCWD = Dictionary( uniqueKeysWithValues: workspaces.map { ($0.cwd, $0) } ) - return section.workspaceCWDs + return workspaceCWDs .compactMap { workspacesByCWD[$0] } .flatMap(entries(in:)) } - private func entries( - in workspace: CodexReviewWorkspace - ) -> [ReviewMonitorWorkspaceFindingsView.Entry] { + private func entries(in workspace: CodexReviewWorkspace) -> [CodexReviewWorkspaceFindingEntry] { let jobs = jobsByWorkspaceCWD[workspace.cwd, default: []] - return jobs.flatMap { job -> [ReviewMonitorWorkspaceFindingsView.Entry] in + return jobs.flatMap { job -> [CodexReviewWorkspaceFindingEntry] in guard let result = job.core.output.reviewResult, result.state == .hasFindings else { @@ -34,7 +54,7 @@ struct ReviewMonitorWorkspaceFindingsIndex { } let threadID = workspaceFindingThreadID(for: job) return result.findings.map { finding in - ReviewMonitorWorkspaceFindingsView.Entry( + CodexReviewWorkspaceFindingEntry( threadID: threadID, targetSummary: job.targetSummary, priority: finding.priority, @@ -86,6 +106,6 @@ struct ReviewMonitorWorkspaceFindingsIndex { } private func workspaceFindingThreadID(for job: CodexReviewJob) -> String { - job.reviewChatID?.rawValue ?? job.id + job.core.run.reviewThreadID?.nilIfEmpty ?? job.core.run.threadID?.nilIfEmpty ?? job.id } } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index bf18bb18..f11c3248 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -254,8 +254,9 @@ final class ReviewMonitorTransportViewController: NSViewController { else { return } - let entries = ReviewMonitorWorkspaceFindingsIndex(store: self.store) - .entries(for: section) + let entries = CodexReviewWorkspaceFindingsIndex(store: self.store) + .entries(forWorkspaceCWDs: section.workspaceCWDs) + .map(ReviewMonitorWorkspaceFindingsView.Entry.init) self.renderWorkspaceFindings(entries: entries) } } @@ -482,6 +483,18 @@ final class ReviewMonitorTransportViewController: NSViewController { } } +private extension ReviewMonitorWorkspaceFindingsView.Entry { + init(_ entry: CodexReviewWorkspaceFindingEntry) { + self.init( + threadID: entry.threadID, + targetSummary: entry.targetSummary, + priority: entry.priority, + title: entry.title, + body: entry.body, + locationText: entry.locationText + ) + } +} #if DEBUG @MainActor From 6bcad51c8256ef145b6c6a64a66790e66b78f1be Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:17:18 +0900 Subject: [PATCH 170/359] refactor(review-ui): drive preview logs from chat source --- .../ReviewUI/ReviewMonitorContentView.swift | 14 ++- .../ReviewMonitorPreviewChatLogSource.swift | 118 +++++++++++++++++- .../ReviewMonitorPreviewContent.swift | 13 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 32 ++--- Tests/ReviewUITests/ReviewUITests.swift | 23 +++- 5 files changed, 164 insertions(+), 36 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 8a8ff56d..a944f181 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -258,6 +258,11 @@ extension ReviewMonitorRootViewController { view.layoutSubtreeIfNeeded() } + @discardableResult + func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) -> Int? { + previewChatLogSource?.appendPreviewStreamTick(after: tick) + } + var splitViewControllerForTesting: ReviewMonitorSplitViewController { splitViewController } @@ -300,9 +305,10 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: CodexReviewStore? = nil ) -> ReviewMonitorRootViewController { let store: CodexReviewStore + let ownsPreviewStore = previewStore == nil switch serverState { case .running: - store = previewStore ?? ReviewMonitorPreviewContent.makeStore() + store = previewStore ?? ReviewMonitorPreviewContent.makeStore(streamInterval: nil) case .failed, .starting, .stopped: store = CodexReviewStore.makePreviewStore() store.serverState = serverState @@ -325,6 +331,12 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( if let initialChat = previewChatLogSource?.initialChat { uiState.selection = .chat(initialChat) } + if ownsPreviewStore, let previewChatLogSource { + store.previewSupportRetainer = ReviewMonitorPreviewChatLogStreamer( + source: previewChatLogSource, + interval: .milliseconds(40) + ) + } return ReviewMonitorRootViewController( store: store, uiState: uiState, diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 50215742..1ef10e4b 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -7,10 +7,11 @@ import ObservationBridge struct ReviewMonitorPreviewChatLogFixture { let chat: ReviewMonitorCodexSidebarSnapshot.Chat let cwd: String + let streamID: String let isRunning: Bool let turn: CodexChatTurnStateSnapshot let phase: CodexDataPhase - let timeline: ReviewTimeline + let initialItems: [ReviewTimelineItem] } @MainActor @@ -18,11 +19,13 @@ final class ReviewMonitorPreviewChatLogSource { let snapshot: ReviewMonitorCodexSidebarSnapshot let initialChat: ReviewMonitorCodexSidebarSnapshot.Chat? + private let previewChats: [PreviewReviewChat] private let previewChatsByID: [CodexThreadID: PreviewReviewChat] init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] var sectionIndexesByCWD: [String: Int] = [:] + var previewChats: [PreviewReviewChat] = [] var previewChatsByID: [CodexThreadID: PreviewReviewChat] = [:] var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? @@ -30,6 +33,7 @@ final class ReviewMonitorPreviewChatLogSource { for fixture in fixtures { let previewChat = PreviewReviewChat(fixture: fixture) let chat = previewChat.chat + previewChats.append(previewChat) previewChatsByID[chat.id] = previewChat firstChat = firstChat ?? chat if initialRunningChat == nil, previewChat.isRunning { @@ -53,6 +57,7 @@ final class ReviewMonitorPreviewChatLogSource { self.snapshot = ReviewMonitorCodexSidebarSnapshot(sections: sections) self.initialChat = initialRunningChat ?? firstChat + self.previewChats = previewChats self.previewChatsByID = previewChatsByID } @@ -70,25 +75,53 @@ final class ReviewMonitorPreviewChatLogSource { } return pair.stream } + + func applyPreviewDomainEvent(_ event: ReviewDomainEvent, to chatID: CodexThreadID) { + previewChatsByID[chatID]?.apply(event) + } + + @discardableResult + func appendPreviewStreamTick(after currentTick: Int = 0) -> Int { + let runningChats = previewChats.filter(\.isRunning) + guard runningChats.isEmpty == false else { + return currentTick + } + + let nextTick = currentTick + 1 + for (index, previewChat) in runningChats.enumerated() { + guard let frame = ReviewMonitorPreviewContent.streamFrame( + forJobAt: index, + tick: nextTick + ) else { + continue + } + previewChat.applyPreviewStreamStep(frame.step, cycle: frame.cycle) + } + return nextTick + } } @MainActor private final class PreviewReviewChat { let chat: ReviewMonitorCodexSidebarSnapshot.Chat let cwd: String + let streamID: String let isRunning: Bool private let turn: CodexChatTurnStateSnapshot private let phase: CodexDataPhase - private let timeline: ReviewTimeline + private let timeline = ReviewTimeline() init(fixture: ReviewMonitorPreviewChatLogFixture) { self.chat = fixture.chat self.cwd = fixture.cwd + self.streamID = fixture.streamID self.isRunning = fixture.isRunning self.turn = fixture.turn self.phase = fixture.phase - self.timeline = fixture.timeline + for item in fixture.initialItems { + timeline.apply(.itemUpdated(item.seed), at: item.updatedAt) + } } func trackTimelineRevision() { @@ -103,6 +136,70 @@ private final class PreviewReviewChat { items: timeline.items.map { CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) } ) } + + func apply(_ event: ReviewDomainEvent) { + timeline.apply(event) + } + + func applyPreviewStreamStep( + _ step: ReviewMonitorPreviewContent.PreviewTimelineStep, + cycle: Int + ) { + let itemID = ReviewMonitorPreviewContent.previewTimelineItemID( + itemName: step.itemName, + jobID: streamID, + cycle: cycle + ) + switch step.mode { + case .update: + timeline.apply(.itemUpdated(step.seed(id: itemID))) + case .complete: + timeline.apply(.itemCompleted(step.seed(id: itemID))) + case .textDelta: + timeline.apply( + .textDelta( + itemID: itemID, + kind: step.kind, + family: step.family, + content: step.content, + delta: step.deltaText ?? "" + )) + } + } +} + +@MainActor +final class ReviewMonitorPreviewChatLogStreamer { + private weak var source: ReviewMonitorPreviewChatLogSource? + private let interval: Duration + private var task: Task? + private var tick = 0 + + init(source: ReviewMonitorPreviewChatLogSource, interval: Duration) { + self.source = source + 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 source else { + task?.cancel() + return + } + tick = source.appendPreviewStreamTick(after: tick) + } } @MainActor @@ -231,6 +328,21 @@ private extension CodexChatItemSnapshot { } } +private extension ReviewTimelineItem { + var seed: ReviewTimelineItemSeed { + ReviewTimelineItemSeed( + id: id, + kind: kind, + family: family, + phase: phase, + content: content, + startedAt: startedAt, + completedAt: completedAt, + durationMs: durationMs + ) + } +} + private extension CodexThreadItem.Kind { init(_ kind: ReviewItemKind) { self.init(rawValue: kind.rawValue) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index b400ed5d..20961985 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -62,7 +62,7 @@ public enum ReviewMonitorPreviewContent { } } - private enum PreviewStreamMode { + package enum PreviewStreamMode { case update case complete case textDelta @@ -112,7 +112,7 @@ public enum ReviewMonitorPreviewContent { } } - private struct PreviewTimelineStep { + package struct PreviewTimelineStep { let itemName: String let kind: ReviewItemKind let family: ReviewItemFamily @@ -138,7 +138,7 @@ public enum ReviewMonitorPreviewContent { } } - private struct PreviewStreamFrame { + package struct PreviewStreamFrame { let step: PreviewTimelineStep let cycle: Int } @@ -217,6 +217,7 @@ public enum ReviewMonitorPreviewContent { return ReviewMonitorPreviewChatLogFixture( chat: chat, cwd: job.cwd, + streamID: job.id, isRunning: job.core.lifecycle.status == .running, turn: CodexChatTurnStateSnapshot( id: job.previewTurnID, @@ -228,7 +229,7 @@ public enum ReviewMonitorPreviewContent { job.core.lifecycle.status, errorMessage: job.core.lifecycle.errorMessage ), - timeline: job.timeline + initialItems: job.timeline.items ) } } @@ -292,7 +293,7 @@ public enum ReviewMonitorPreviewContent { return nextTick } - private static func streamFrame( + package static func streamFrame( forJobAt jobIndex: Int, tick: Int ) -> PreviewStreamFrame? { @@ -341,7 +342,7 @@ public enum ReviewMonitorPreviewContent { } } - private static func previewTimelineItemID( + package static func previewTimelineItemID( itemName: String, jobID: String, cycle: Int diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index a741df2d..8141591d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -74,11 +74,6 @@ extension ReviewUITests { @Test func previewContentViewControllerStreamsSelectedChatLogDuringViewLifecycle() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) - let selectedReviewChatJob = try #require( - store.orderedJobs.first { $0.core.lifecycle.status == .running } - ?? store.orderedJobs.first - ) - let existingItemIDs = Set(selectedReviewChatJob.timeline.items.map(\.id)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -91,18 +86,15 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) - let appendedItem = try #require( - selectedReviewChatJob.timeline.items.first { existingItemIDs.contains($0.id) == false } - ) - let appendedText = try #require(diagnosticMessage(appendedItem).nilIfEmpty) + let nextTick = try #require(viewController.appendPreviewChatLogStreamTickForTesting()) + #expect(nextTick == 1) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.count > initialSnapshot.log.count - && snapshot.log.contains(appendedText) + && snapshot.log.contains("Turn started") && snapshot.isShowingEmptyState == false } - #expect(updatedSnapshot.log.contains(appendedText)) + #expect(updatedSnapshot.log.contains("Turn started")) } @Test func bindingStoreAppliesInitialState() { @@ -829,11 +821,6 @@ extension ReviewUITests { @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws { let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) - let selectedReviewChatJob = try #require( - store.orderedJobs.first { $0.core.lifecycle.status == .running } - ?? store.orderedJobs.first - ) - let existingItemIDs = Set(selectedReviewChatJob.timeline.items.map(\.id)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -848,18 +835,15 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) - let appendedItem = try #require( - selectedReviewChatJob.timeline.items.first { existingItemIDs.contains($0.id) == false } - ) - let appendedText = try #require(diagnosticMessage(appendedItem).nilIfEmpty) + let nextTick = try #require(viewController.appendPreviewChatLogStreamTickForTesting()) + #expect(nextTick == 1) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.count > initialSnapshot.log.count - && snapshot.log.contains(appendedText) + && snapshot.log.contains("Turn started") && snapshot.isShowingEmptyState == false } - #expect(updatedSnapshot.log.contains(appendedText)) + #expect(updatedSnapshot.log.contains("Turn started")) } @Test func windowControllerUsesSeededAuthenticatedStateOnFirstPresentation() { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index e8c66405..d3f9c330 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5144,7 +5144,16 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " log")) + previewChatLogSource.applyPreviewDomainEvent( + .textDelta( + itemID: .init(rawValue: "msg_1"), + kind: .agentMessage, + family: .message, + content: .message(.init(text: "")), + delta: " log" + ), + to: chatID + ) let snapshot = try await awaitTransportRender(transport) { $0.log == "Initial log" } #expect(snapshot.log == "Initial log") @@ -5187,7 +5196,17 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + previewChatLogSource.applyPreviewDomainEvent( + .itemUpdated( + .init( + id: .init(rawValue: "progress_1"), + kind: ReviewItemKind(rawValue: "progress"), + family: .diagnostic, + phase: .completed, + content: .diagnostic(.init(message: "stream.tick 001")) + )), + to: chatID + ) let snapshot = try await awaitTransportRender(transport) { $0.log.hasSuffix("stream.tick 001") } #expect(snapshot.log.hasSuffix("stream.tick 001")) From 5cce7e5f31e7a3f11d6d3a10776ad69ec9d16e9b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:23:21 +0900 Subject: [PATCH 171/359] refactor(review-ui): prefer Codex sidebar chat selections --- .../ReviewMonitorSidebarViewController.swift | 39 +++++++++---- ...eviewMonitorCodexSidebarLibraryTests.swift | 55 +++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index e292bf9d..dd990d70 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -394,12 +394,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD selectedChatSnapshotObservation = withPortableContinuousObservation { [weak self, uiState] _ in guard let self, - case .chat(let selectedChat) = uiState.selection, - let currentChat = self.currentChatSelection(id: selectedChat.id), - currentChat != selectedChat + case .chat(let selectedChat) = uiState.selection else { return } + guard let currentChat = self.currentChatSelection(id: selectedChat.id) else { + uiState.selection = nil + return + } + guard currentChat != selectedChat else { + return + } uiState.selection = .chat(currentChat) } } @@ -854,8 +859,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) case .chat(let selectedChat): - guard let currentChat = codexChatSelection(id: selectedChat.id) - ?? reviewChatSelection(id: selectedChat.id) + guard let currentChat = currentChatSelection(id: selectedChat.id) else { uiState.selection = nil outlineView.deselectAll(nil) @@ -866,8 +870,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = .chat(currentChat) } - guard let row = row(forCodexSidebarSelectionID: .chat(selectedChat.id)) - ?? row(forReviewChatID: selectedChat.id) + guard let row = row(forCurrentChatSelectionID: selectedChat.id) else { return } @@ -1371,6 +1374,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return row == -1 ? nil : row } + private func row(forCurrentChatSelectionID chatID: CodexThreadID) -> Int? { + if isUsingCodexSidebarOutline { + return row(forCodexSidebarSelectionID: .chat(chatID)) + } + return row(forReviewChatID: chatID) + } + private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { let rowID: ReviewMonitorCodexSidebarRowID switch selectionID { @@ -1416,8 +1426,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .workspace(let workspace): return codexWorkspaceSelection(id: workspace.id) != nil case .chat(let chat): - return codexChatSelection(id: chat.id) != nil - || reviewChatSelection(id: chat.id, in: workspaces) != nil + return currentChatSelection(id: chat.id, in: workspaces) != nil } } @@ -1447,7 +1456,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - codexChatSelection(id: id) ?? reviewChatSelection(id: id) + currentChatSelection(id: id, in: workspaces()) + } + + private func currentChatSelection( + id: CodexThreadID, + in workspaces: [CodexReviewWorkspace] + ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + if isUsingCodexSidebarOutline { + return codexChatSelection(id: id) + } + return reviewChatSelection(id: id, in: workspaces) } private func codexWorkspaceSelection( diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index fbcded40..acc64e0d 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -234,6 +234,61 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(selectedChat.title == "App review") } + @Test func codexSidebarSelectionDoesNotFallBackToReviewJobRows() 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") + + try await runtime.transport.enqueueThreadList(.init( + threads: [ + .init( + id: visibleThreadID, + workspace: repo, + name: "App review", + updatedAt: Date(timeIntervalSince1970: 5_000) + ), + ] + )) + + let legacyJob = CodexReviewJob.makeForTesting( + id: "legacy-job", + cwd: repo.path, + targetSummary: "Legacy review row", + threadID: "legacy-review-thread", + turnID: "legacy-turn", + status: .running, + startedAt: Date(timeIntervalSince1970: 4_000), + summary: "Running legacy review." + ) + let legacyChat = try #require(legacyJob.reviewChatSelection) + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting( + serverState: .running, + workspaces: [CodexReviewWorkspace(cwd: repo.path)], + jobs: [legacyJob] + ) + 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(legacyChat.id)) == nil) + + uiState.selection = .chat(legacyChat) + + try await waitForCondition { + uiState.selection == nil && sidebar.selectedReviewChatIDForTesting == nil + } + } + @Test func sidebarViewControllerTracksCodexSidebarFetchResultChanges() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext From f28d6f18047048ea762b56468e94c9b3995e7a1c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:37:00 +0900 Subject: [PATCH 172/359] refactor(review-ui): store preview logs as chat snapshots --- .../ReviewMonitorPreviewChatLogSource.swift | 266 ++++++++++++++---- 1 file changed, 213 insertions(+), 53 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 1ef10e4b..b0b3bd5e 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -1,6 +1,7 @@ import CodexKit import CodexReviewKit import Foundation +import Observation import ObservationBridge @MainActor @@ -9,9 +10,30 @@ struct ReviewMonitorPreviewChatLogFixture { let cwd: String let streamID: String let isRunning: Bool - let turn: CodexChatTurnStateSnapshot - let phase: CodexDataPhase - let initialItems: [ReviewTimelineItem] + let initialSnapshot: CodexChatSnapshot + + init( + chat: ReviewMonitorCodexSidebarSnapshot.Chat, + cwd: String, + streamID: String, + isRunning: Bool, + turn: CodexChatTurnStateSnapshot, + phase: CodexDataPhase, + initialItems: [ReviewTimelineItem] + ) { + self.chat = chat + self.cwd = cwd + self.streamID = streamID + self.isRunning = isRunning + self.initialSnapshot = CodexChatSnapshot( + chatID: chat.id, + phase: phase, + turns: [turn], + items: initialItems.map { + CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) + } + ) + } } @MainActor @@ -102,43 +124,67 @@ final class ReviewMonitorPreviewChatLogSource { } @MainActor +@Observable private final class PreviewReviewChat { let chat: ReviewMonitorCodexSidebarSnapshot.Chat let cwd: String let streamID: String let isRunning: Bool - private let turn: CodexChatTurnStateSnapshot - private let phase: CodexDataPhase - private let timeline = ReviewTimeline() + private var revision = 0 + private var snapshotStorage: CodexChatSnapshot init(fixture: ReviewMonitorPreviewChatLogFixture) { self.chat = fixture.chat self.cwd = fixture.cwd self.streamID = fixture.streamID self.isRunning = fixture.isRunning - self.turn = fixture.turn - self.phase = fixture.phase - for item in fixture.initialItems { - timeline.apply(.itemUpdated(item.seed), at: item.updatedAt) - } + self.snapshotStorage = fixture.initialSnapshot } - func trackTimelineRevision() { - _ = timeline.revision + func trackRevision() { + _ = revision } func snapshot() -> CodexChatSnapshot { - CodexChatSnapshot( - chatID: chat.id, - phase: phase, - turns: [turn], - items: timeline.items.map { CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) } - ) + snapshotStorage } func apply(_ event: ReviewDomainEvent) { - timeline.apply(event) + switch event { + case .runStarted(let turnID, _, _): + snapshotStorage.turns = [ + .init( + id: CodexTurnID(rawValue: turnID.rawValue), + status: .running + ), + ] + snapshotStorage.items.removeAll(keepingCapacity: true) + snapshotStorage.phase = .loaded + bumpRevision() + + case .itemStarted(let seed), + .itemUpdated(let seed), + .itemCompleted(let seed): + upsertItem(seed) + + case .textDelta(let itemID, let kind, _, let content, let delta): + appendTextDelta( + delta, + itemID: itemID.rawValue, + kind: CodexThreadItem.Kind(kind), + content: content + ) + + case .reviewCompleted: + updateTurnStatus(.completed) + + case .reviewFailed: + updateTurnStatus(.failed) + + case .reviewCancelled: + updateTurnStatus(.cancelled) + } } func applyPreviewStreamStep( @@ -152,19 +198,74 @@ private final class PreviewReviewChat { ) switch step.mode { case .update: - timeline.apply(.itemUpdated(step.seed(id: itemID))) + upsertItem(step.seed(id: itemID)) case .complete: - timeline.apply(.itemCompleted(step.seed(id: itemID))) + upsertItem(step.seed(id: itemID)) case .textDelta: - timeline.apply( - .textDelta( - itemID: itemID, - kind: step.kind, - family: step.family, - content: step.content, - delta: step.deltaText ?? "" - )) + appendTextDelta( + step.deltaText ?? "", + itemID: itemID.rawValue, + kind: CodexThreadItem.Kind(step.kind), + content: step.content + ) + } + } + + private var currentTurnID: CodexTurnID? { + snapshotStorage.turns.last?.id + } + + private func upsertItem(_ seed: ReviewTimelineItemSeed) { + let item = CodexChatItemSnapshot(previewSeed: seed, turnID: currentTurnID) + if let index = snapshotStorage.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { + snapshotStorage.items[index] = item + } else { + snapshotStorage.items.append(item) + } + bumpRevision() + } + + private func appendTextDelta( + _ delta: String, + itemID: String, + kind: CodexThreadItem.Kind, + content: ReviewTimelineItem.Content + ) { + guard delta.isEmpty == false else { + return + } + let turnID = currentTurnID + if let index = snapshotStorage.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { + snapshotStorage.items[index].content.appendPreviewText(delta) + } else { + var item = CodexChatItemSnapshot( + id: itemID, + turnID: turnID, + kind: kind, + content: CodexThreadItem.Content( + previewContent: content, + id: itemID, + phase: .running, + fallbackRawKind: kind.rawValue + ) + ) + item.content.appendPreviewText(delta) + snapshotStorage.items.append(item) } + bumpRevision() + } + + private func updateTurnStatus(_ status: CodexTurnStatus) { + guard snapshotStorage.turns.isEmpty == false else { + return + } + snapshotStorage.turns[snapshotStorage.turns.count - 1].status = status + snapshotStorage.phase = .loaded + bumpRevision() + } + + private func bumpRevision() { + revision += 1 } } @@ -223,7 +324,7 @@ private final class PreviewChatLogSubscription { guard let self else { return } - previewChat.trackTimelineRevision() + previewChat.trackRevision() publish() } } @@ -328,21 +429,6 @@ private extension CodexChatItemSnapshot { } } -private extension ReviewTimelineItem { - var seed: ReviewTimelineItemSeed { - ReviewTimelineItemSeed( - id: id, - kind: kind, - family: family, - phase: phase, - content: content, - startedAt: startedAt, - completedAt: completedAt, - durationMs: durationMs - ) - } -} - private extension CodexThreadItem.Kind { init(_ kind: ReviewItemKind) { self.init(rawValue: kind.rawValue) @@ -352,7 +438,21 @@ private extension CodexThreadItem.Kind { private extension CodexThreadItem.Content { @MainActor init(previewItem item: ReviewTimelineItem) { - switch item.content { + self.init( + previewContent: item.content, + id: item.id.rawValue, + phase: item.phase, + fallbackRawKind: item.kind.rawValue + ) + } + + init( + previewContent content: ReviewTimelineItem.Content, + id: String, + phase: ReviewItemPhase, + fallbackRawKind: String + ) { + switch content { case .approval(let approval): self = .diagnostic( [ @@ -369,7 +469,7 @@ private extension CodexThreadItem.Content { cwd: command.cwd, output: command.output.nilIfEmpty, exitCode: command.exitCode, - status: command.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + status: command.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) )) case .contextCompaction(let compaction): self = .contextCompaction(compaction.title) @@ -380,10 +480,10 @@ private extension CodexThreadItem.Content { .init( path: fileChange.paths.first ?? fileChange.title.nilIfEmpty, output: fileChange.output.nilIfEmpty ?? fileChange.patch?.nilIfEmpty, - status: fileChange.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + status: fileChange.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) )) case .message(let message): - self = .message(.init(id: item.id.rawValue, role: .assistant, text: message.text)) + self = .message(.init(id: id, role: .assistant, text: message.text)) case .plan(let plan): self = .plan(plan.markdown) case .reasoning(let reasoning): @@ -399,7 +499,7 @@ private extension CodexThreadItem.Content { name: "web_search", arguments: search.query, result: search.result, - status: search.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + status: search.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) )) case .toolCall(let toolCall): self = .toolCall( @@ -410,7 +510,7 @@ private extension CodexThreadItem.Content { arguments: toolCall.arguments, result: toolCall.result, error: toolCall.error, - status: toolCall.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(item.phase) + status: toolCall.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) )) case .unknown(let unknown): let text = [ @@ -422,11 +522,71 @@ private extension CodexThreadItem.Content { .nilIfEmpty self = .unknown( .init( - rawType: unknown.rawKind?.rawValue ?? item.kind.rawValue, + rawType: unknown.rawKind?.rawValue ?? fallbackRawKind, text: text )) } } + + 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 + } + } +} + +private extension CodexChatItemSnapshot { + init(previewSeed seed: ReviewTimelineItemSeed, turnID: CodexTurnID?) { + self.init( + id: seed.id.rawValue, + turnID: turnID, + kind: CodexThreadItem.Kind(seed.kind), + content: CodexThreadItem.Content( + previewContent: seed.content, + id: seed.id.rawValue, + phase: seed.phase, + fallbackRawKind: seed.kind.rawValue + ), + rawPayload: nil + ) + } } private extension CodexTurnStatus { From 496e59bd7919a7d7303593c5e15d159f00a0937d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:40:17 +0900 Subject: [PATCH 173/359] refactor(review-ui): resolve legacy chats from sidebar index --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index dd990d70..9e38244f 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1452,7 +1452,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { reviewChatIndex.chat(id: id, in: workspaces) - ?? store.reviewJob(forChatID: id, in: workspaces)?.reviewChatSelection } private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { From ccd4756ff969e65bec530efe9bb5f2a97b298eba Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:46:36 +0900 Subject: [PATCH 174/359] refactor(review-ui): keep review chat cells row-driven --- .../ReviewMonitorSidebarReviewChatIndex.swift | 24 +++++++++++++++---- .../ReviewMonitorSidebarViewController.swift | 17 +++---------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift index e3b653e1..2d659a4e 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift @@ -7,13 +7,11 @@ final class ReviewMonitorSidebarReviewChatIndex { func rows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { jobs.map { job in - let chat = job.reviewChatSelection - let runtime = ReviewMonitorSidebarReviewChatRuntime(job: job) if let row = rowsByJobID[job.id] { - row.update(chat: chat, runtime: runtime) + row.update(job: job) return row } - let row = ReviewMonitorSidebarReviewChatRow(chat: chat, runtime: runtime) + let row = ReviewMonitorSidebarReviewChatRow(job: job) rowsByJobID[job.id] = row return row } @@ -44,6 +42,24 @@ final class ReviewMonitorSidebarReviewChatIndex { } } +extension ReviewMonitorSidebarReviewChatRow { + @MainActor + convenience init(job: CodexReviewJob) { + self.init( + chat: job.reviewChatSelection, + runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) + ) + } + + @MainActor + func update(job: CodexReviewJob) { + update( + chat: job.reviewChatSelection, + runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) + ) + } +} + extension ReviewMonitorSidebarReviewChatRuntime { @MainActor init(job: CodexReviewJob) { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 9e38244f..d4a11b18 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -66,7 +66,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) ) let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: job) + cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) return ceil(cellView.fittingSize.height) } } @@ -1443,10 +1443,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } - private func reviewChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - reviewChatSelection(id: id, in: workspaces()) - } - private func reviewChatSelection( id: CodexThreadID, in workspaces: [CodexReviewWorkspace] @@ -3076,13 +3072,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { nil } - func configure(with job: CodexReviewJob) { - configure(with: ReviewMonitorSidebarReviewChatRow( - chat: job.reviewChatSelection, - runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) - )) - } - func configure(with row: ReviewMonitorSidebarReviewChatRow) { objectValue = row toolTip = row.operation.cwd @@ -3140,7 +3129,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { @MainActor func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: job) + cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) return cellView } @@ -3152,7 +3141,7 @@ func configureReviewMonitorReviewChatCellViewForTesting( guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { fatalError("Expected ReviewMonitorReviewChatCellView.") } - cellView.configure(with: job) + cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) } @MainActor From c4240fee6ccbef8eac70f4e5bc865dd83d560eb2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:52:14 +0900 Subject: [PATCH 175/359] refactor(review-ui): keep runtime jobs out of Codex sidebar --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index d4a11b18..c9718cc6 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -954,6 +954,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func runtimeJobForCurrentChatSelection() -> CodexReviewJob? { + guard isUsingCodexSidebarOutline == false else { + return nil + } switch uiState.selection { case .chat(let chat): return store.reviewJob(forChatID: chat.id, in: workspaces()) From cef704c6541afac00217612c7de97c9ed57be47c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:07:04 +0900 Subject: [PATCH 176/359] fix(review-ui): stream preview launch chat logs --- .../ReviewMonitorWindowController.swift | 31 ++++++++ .../CodexReviewMonitorApp.swift | 15 +++- .../CodexReviewMonitorCITests.swift | 73 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 80389bf9..e998fe85 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -70,6 +70,7 @@ public final class ReviewMonitorWindowController: NSWindowController { convenience init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil @@ -77,6 +78,7 @@ public final class ReviewMonitorWindowController: NSWindowController { self.init( store: store, codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, frameAutosaveName: Self.frameAutosaveName, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults, @@ -87,6 +89,7 @@ public final class ReviewMonitorWindowController: NSWindowController { init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, + previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, frameAutosaveName: NSWindow.FrameAutosaveName, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, @@ -96,10 +99,14 @@ public final class ReviewMonitorWindowController: NSWindowController { auth: store.auth, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) + if let initialChat = previewChatLogSource?.initialChat { + uiState.selection = .chat(initialChat) + } let rootViewController = ReviewMonitorRootViewController( store: store, uiState: uiState, codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings ) @@ -142,6 +149,30 @@ public final class ReviewMonitorWindowController: NSWindowController { } } +@_spi(PreviewSupport) +public extension ReviewMonitorWindowController { + convenience init( + previewStore store: CodexReviewStore, + codexModelSource: ReviewMonitorCodexModelSource? = nil, + showSettings: (@MainActor () -> Void)? = nil + ) { + let previewChatLogSource = ReviewMonitorPreviewChatLogSource( + fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: store.orderedJobs) + ) + store.previewSupportRetainer = ReviewMonitorPreviewChatLogStreamer( + source: previewChatLogSource, + interval: .milliseconds(40) + ) + self.init( + store: store, + codexModelSource: codexModelSource, + previewChatLogSource: previewChatLogSource, + contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + showSettings: showSettings + ) + } +} + enum ReviewMonitorSidebar {} extension ReviewMonitorSidebar { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 04d7d5b5..0e6d65c9 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -273,11 +273,15 @@ struct ReviewMonitorAppComposition { } ) -> ReviewMonitorAppComposition { let codexModelSource = ReviewMonitorCodexModelSource() + var previewStore: CodexReviewStore? return ReviewMonitorAppComposition( makeStore: { context, presentationAnchorProvider in if context.requestsPreviewContent { - return ReviewMonitorPreviewContent.makeStore() + let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + previewStore = store + return store } + previewStore = nil return makeLiveStore( runtimePreferencesStore.load(), .init( @@ -295,7 +299,14 @@ struct ReviewMonitorAppComposition { ) }, makeWindowController: { store, showSettings in - ReviewMonitorWindowController( + if previewStore === store { + return ReviewMonitorWindowController( + previewStore: store, + codexModelSource: codexModelSource, + showSettings: showSettings + ) + } + return ReviewMonitorWindowController( store: store, codexModelSource: codexModelSource, showSettings: showSettings diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index 4932b86f..da9de831 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -2,6 +2,7 @@ import AppKit import Foundation import CodexReviewKit import CodexReviewHost +@_spi(PreviewSupport) @testable import ReviewUI import Testing @testable import CodexReviewMonitor @@ -248,6 +249,57 @@ struct CodexReviewMonitorCITests { #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.mockJobsKey: "1", + ], + arguments: [], + launchMode: .application + ) + let store = composition.makeStore(context) { + Issue.record("Preview store creation should not request a presentation anchor.") + return nil + } + + let windowController = composition.makeWindowController(store) {} + let rootViewController = try #require( + windowController.window?.contentViewController as? ReviewMonitorRootViewController + ) + rootViewController.prepareForSwiftUIPreviewRendering() + + 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(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 @@ -751,6 +803,27 @@ 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 final class WindowControllerFactoryRecorder { private(set) var makeCallCount = 0 From 9bdc4916209636973f9ce1b2c4ee1afb00da46b6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:10:44 +0900 Subject: [PATCH 177/359] refactor(review-ui): centralize preview chat source creation --- Sources/ReviewUI/ReviewMonitorContentView.swift | 4 +--- Sources/ReviewUI/ReviewMonitorPreviewContent.swift | 6 ++++++ Sources/ReviewUI/ReviewMonitorWindowController.swift | 4 +--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index a944f181..229a2e98 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -322,9 +322,7 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( let uiState = ReviewMonitorUIState(auth: store.auth) let previewChatLogSource: ReviewMonitorPreviewChatLogSource? = if case .running = serverState { - ReviewMonitorPreviewChatLogSource( - fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: store.orderedJobs) - ) + ReviewMonitorPreviewContent.makeChatLogSource(from: store) } else { nil } diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 20961985..0a443cfc 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -234,6 +234,12 @@ public enum ReviewMonitorPreviewContent { } } + static func makeChatLogSource(from store: CodexReviewStore) -> ReviewMonitorPreviewChatLogSource { + ReviewMonitorPreviewChatLogSource( + fixtures: makeChatLogFixtures(from: store.orderedJobs) + ) + } + @_spi(PreviewSupport) public static func makeCommandOutputStore() -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index e998fe85..6fc519a5 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -156,9 +156,7 @@ public extension ReviewMonitorWindowController { codexModelSource: ReviewMonitorCodexModelSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { - let previewChatLogSource = ReviewMonitorPreviewChatLogSource( - fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: store.orderedJobs) - ) + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) store.previewSupportRetainer = ReviewMonitorPreviewChatLogStreamer( source: previewChatLogSource, interval: .milliseconds(40) From cbd7b72a67de4c3db79e174db1314d821d0b401f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:21:11 +0900 Subject: [PATCH 178/359] refactor(review-ui): stream preview logs from chat source --- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- .../ReviewMonitorPreviewChatLogSource.swift | 4 + .../ReviewMonitorPreviewContent.swift | 101 +---------- Tests/ReviewUITests/ReviewUIShellTests.swift | 170 ++++++++++-------- .../CodexReviewMonitorApp.swift | 2 +- 5 files changed, 104 insertions(+), 175 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 229a2e98..3f6ff198 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -308,7 +308,7 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( let ownsPreviewStore = previewStore == nil switch serverState { case .running: - store = previewStore ?? ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + store = previewStore ?? ReviewMonitorPreviewContent.makeStore() case .failed, .starting, .stopped: store = CodexReviewStore.makePreviewStore() store.serverState = serverState diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index b0b3bd5e..b635d1d5 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -102,6 +102,10 @@ final class ReviewMonitorPreviewChatLogSource { previewChatsByID[chatID]?.apply(event) } + func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { + previewChatsByID[chatID]?.snapshot() + } + @discardableResult func appendPreviewStreamTick(after currentTick: Int = 0) -> Int { let runningChats = previewChats.filter(\.isRunning) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 0a443cfc..82b62165 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -143,48 +143,8 @@ public enum ReviewMonitorPreviewContent { 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 { + public static func makeStore() -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) ) @@ -198,12 +158,6 @@ public enum ReviewMonitorPreviewContent { workspaces: previewContent.workspaces, jobs: previewContent.jobs ) - if let streamInterval { - store.previewSupportRetainer = PreviewLogStreamer( - store: store, - interval: streamInterval - ) - } return store } @@ -273,32 +227,6 @@ public enum ReviewMonitorPreviewContent { 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) { - applyPreviewStreamStep(frame.step, to: job, cycle: frame.cycle) - } - } - return nextTick - } - package static func streamFrame( forJobAt jobIndex: Int, tick: Int @@ -321,33 +249,6 @@ public enum ReviewMonitorPreviewContent { ) } - private static func applyPreviewStreamStep( - _ step: PreviewTimelineStep, - to job: CodexReviewJob, - cycle: Int - ) { - let itemID = previewTimelineItemID( - itemName: step.itemName, - jobID: job.id, - cycle: cycle - ) - switch step.mode { - case .update: - job.timeline.apply(.itemUpdated(step.seed(id: itemID))) - case .complete: - job.timeline.apply(.itemCompleted(step.seed(id: itemID))) - case .textDelta: - job.timeline.apply( - .textDelta( - itemID: itemID, - kind: step.kind, - family: step.family, - content: step.content, - delta: step.deltaText ?? "" - )) - } - } - package static func previewTimelineItemID( itemName: String, jobID: String, diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 8141591d..d0c003c4 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1,4 +1,5 @@ import AppKit +import CodexKit import Foundation import ObservationBridge import SwiftUI @@ -21,7 +22,7 @@ extension ReviewUITests { } @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() let selectedReviewChatJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first @@ -49,7 +50,7 @@ extension ReviewUITests { } @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() let selectedReviewChatJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first @@ -73,7 +74,7 @@ extension ReviewUITests { } @Test func previewContentViewControllerStreamsSelectedChatLogDuringViewLifecycle() async throws { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -284,9 +285,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) @@ -794,7 +793,7 @@ extension ReviewUITests { } @Test func previewContentViewControllerRendersSelectedChatLog() async throws { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() let selectedReviewChatJob = try #require( store.orderedJobs.first { $0.core.lifecycle.status == .running } ?? store.orderedJobs.first @@ -820,7 +819,7 @@ extension ReviewUITests { } @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -1597,102 +1596,103 @@ 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 }) + @Test func previewRunningChatsAppendPseudoStreamWhenTicked() throws { + let source = ReviewMonitorPreviewContent.makeChatLogSource( + from: ReviewMonitorPreviewContent.makeStore() ) - let initialLog = reviewMonitorLogText(for: runningJob) - let initialItemCount = runningJob.timeline.items.count + let runningChat = try #require(source.initialChat) + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let initialItemCount = initialSnapshot.items.count - ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store) + source.appendPreviewStreamTick() - let appendedText = String(reviewMonitorLogText(for: runningJob).dropFirst(initialLog.count)) - let appendedItems = Array(runningJob.timeline.items.dropFirst(initialItemCount)) - #expect(reviewMonitorLogText(for: runningJob) != initialLog) + let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) + let appendedText = appendedItems.compactMap(\.text).joined(separator: "\n") + #expect(updatedSnapshot.items != initialSnapshot.items) #expect(appendedText.contains("Turn started")) #expect(appendedText.contains("delta/") == false) #expect(appendedText.count < 160) #expect(appendedItems.count == 1) - #expect(appendedItems.first?.kind == ReviewItemKind(rawValue: "event")) + #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 }) + @Test func previewChatStreamUsesMixedLogKinds() throws { + let source = ReviewMonitorPreviewContent.makeChatLogSource( + from: ReviewMonitorPreviewContent.makeStore() ) - let initialItemCount = runningJob.timeline.items.count + let runningChat = try #require(source.initialChat) + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let initialItemCount = initialSnapshot.items.count var tick = 0 for _ in 0..<720 { - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) + tick = source.appendPreviewStreamTick(after: tick) } - let appendedItems = Array(runningJob.timeline.items.dropFirst(initialItemCount)) - let appendedKinds = appendedItems.map(\.kind) - #expect(appendedKinds.contains(ReviewItemKind(rawValue: "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)) + let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) + let appendedKinds = appendedItems.map(\.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(\.id)).count == appendedItems.count) - let compactionItems = runningJob.timeline.items + let compactionItems = updatedSnapshot.items .dropFirst(initialItemCount) - .filter { $0.kind == .contextCompaction } + .filter { $0.kind.rawValue == "contextCompaction" } let compactionItem = try #require(compactionItems.last) - #expect(compactionItem.phase == .completed) #expect(contextCompactionTitle(compactionItem) == "Context automatically compacted") - #expect(contextCompactionStatus(compactionItem) == .completed) - let renderedLog = reviewMonitorLogText(for: runningJob) + let renderedLog = updatedSnapshot.items.compactMap(\.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 + @Test func previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() throws { + let source = ReviewMonitorPreviewContent.makeChatLogSource( + from: ReviewMonitorPreviewContent.makeStore() ) - let runningJob = try #require( - store.orderedJobs.first(where: { $0.core.lifecycle.status == .running }) - ) - let initialItemCount = runningJob.timeline.items.count + let runningChat = try #require(source.initialChat) + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let initialItemCount = initialSnapshot.items.count var tick = 0 - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.timeline.items.count == initialItemCount + 1) - #expect(runningJob.timeline.items.last?.kind == ReviewItemKind(rawValue: "event")) + tick = source.appendPreviewStreamTick(after: tick) + var snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + #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 = source.appendPreviewStreamTick(after: tick) } - #expect(runningJob.timeline.items.count == initialItemCount + 1) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + #expect(snapshot.items.count == initialItemCount + 1) - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.timeline.items.count == initialItemCount + 2) - #expect(runningJob.timeline.items.last?.kind == .plan) + tick = source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + #expect(snapshot.items.count == initialItemCount + 2) + #expect(snapshot.items.last?.kind.rawValue == "plan") - for _ in 0..<180 where runningJob.timeline.items.last?.kind != .reasoning { - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) + for _ in 0..<180 where snapshot.items.last?.kind.rawValue != "reasoning" { + tick = source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) } - let firstReasoning = try #require(runningJob.timeline.items.last) - #expect(firstReasoning.kind == .reasoning) + 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 item = try #require(runningJob.timeline.item(for: reasoningID)) + tick = source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let item = try #require(snapshot.items.first { $0.id == reasoningID }) let text = reasoningText(item) if text == latestReasoningText { break @@ -1701,21 +1701,21 @@ extension ReviewUITests { } #expect(latestReasoningText.count > initialReasoningText.count) - let countAfterReasoning = runningJob.timeline.items.count + let countAfterReasoning = snapshot.items.count for _ in 0..<37 { - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) + tick = source.appendPreviewStreamTick(after: tick) } - #expect(runningJob.timeline.items.count == countAfterReasoning) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + #expect(snapshot.items.count == countAfterReasoning) - tick = ReviewMonitorPreviewContent.appendPreviewStreamTick(to: store, after: tick) - #expect(runningJob.timeline.items.count == countAfterReasoning + 1) - #expect(runningJob.timeline.items.last?.kind == .commandExecution) + tick = source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + #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 store = ReviewMonitorPreviewContent.makeStore() let firstWorkspace = try #require(store.workspaces.sorted { $0.cwd < $1.cwd }.first) let harness = makeWindowHarness(store: store) let viewController = harness.viewController @@ -1740,6 +1740,14 @@ private func diagnosticMessage(_ item: ReviewTimelineItem) -> String { return "" } +@MainActor +private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { + if case .diagnostic(let message) = item.content { + return message + } + return "" +} + @MainActor private func reasoningText(_ item: ReviewTimelineItem) -> String { if case .reasoning(let reasoning) = item.content { @@ -1748,6 +1756,14 @@ private func reasoningText(_ item: ReviewTimelineItem) -> String { return "" } +@MainActor +private func reasoningText(_ item: CodexChatItemSnapshot) -> String { + if case .reasoning(let reasoning) = item.content { + return reasoning.text + } + return "" +} + @MainActor private func contextCompactionTitle(_ item: ReviewTimelineItem) -> String { if case .contextCompaction(let contextCompaction) = item.content { @@ -1756,6 +1772,14 @@ private func contextCompactionTitle(_ item: ReviewTimelineItem) -> String { return "" } +@MainActor +private func contextCompactionTitle(_ item: CodexChatItemSnapshot) -> String { + if case .contextCompaction(let title) = item.content { + return title ?? "" + } + return "" +} + @MainActor private func contextCompactionStatus(_ item: ReviewTimelineItem) -> ReviewContextCompactionStatus? { if case .contextCompaction(let contextCompaction) = item.content { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 0e6d65c9..f74063fa 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -277,7 +277,7 @@ struct ReviewMonitorAppComposition { return ReviewMonitorAppComposition( makeStore: { context, presentationAnchorProvider in if context.requestsPreviewContent { - let store = ReviewMonitorPreviewContent.makeStore(streamInterval: nil) + let store = ReviewMonitorPreviewContent.makeStore() previewStore = store return store } From 5422b66d04a71878067f8988e38edb2881150561 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:25:34 +0900 Subject: [PATCH 179/359] refactor(review-ui): seed preview chats with snapshots --- .../ReviewMonitorPreviewChatLogSource.swift | 15 ++-------- .../ReviewMonitorPreviewContent.swift | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index b635d1d5..f101105f 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -17,22 +17,13 @@ struct ReviewMonitorPreviewChatLogFixture { cwd: String, streamID: String, isRunning: Bool, - turn: CodexChatTurnStateSnapshot, - phase: CodexDataPhase, - initialItems: [ReviewTimelineItem] + initialSnapshot: CodexChatSnapshot ) { self.chat = chat self.cwd = cwd self.streamID = streamID self.isRunning = isRunning - self.initialSnapshot = CodexChatSnapshot( - chatID: chat.id, - phase: phase, - turns: [turn], - items: initialItems.map { - CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) - } - ) + self.initialSnapshot = initialSnapshot } } @@ -387,7 +378,7 @@ private extension CodexChatChange { } } -private extension CodexChatItemSnapshot { +extension CodexChatItemSnapshot { @MainActor init(previewItem item: ReviewTimelineItem, turnID: CodexTurnID) { self.init( diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 82b62165..82cf7182 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -168,22 +168,29 @@ public enum ReviewMonitorPreviewContent { guard let chat = job.reviewChatSelection else { return nil } + let turn = CodexChatTurnStateSnapshot( + id: job.previewTurnID, + status: CodexTurnStatus(job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ) + let initialSnapshot = CodexChatSnapshot( + chatID: chat.id, + phase: CodexDataPhase( + job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ), + turns: [turn], + items: job.timeline.items.map { + CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) + } + ) return ReviewMonitorPreviewChatLogFixture( chat: chat, cwd: job.cwd, streamID: job.id, isRunning: job.core.lifecycle.status == .running, - turn: CodexChatTurnStateSnapshot( - id: job.previewTurnID, - status: CodexTurnStatus(job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, - usage: nil - ), - phase: CodexDataPhase( - job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage - ), - initialItems: job.timeline.items + initialSnapshot: initialSnapshot ) } } From aba8043b8843ac2382b7491050b65019ffcafa21 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:29:02 +0900 Subject: [PATCH 180/359] refactor(review-ui): mark job sidebar index as legacy --- ...MonitorSidebarLegacyReviewChatIndex.swift} | 2 +- .../ReviewMonitorSidebarViewController.swift | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) rename Sources/ReviewUI/Sidebar/Jobs/{ReviewMonitorSidebarReviewChatIndex.swift => ReviewMonitorSidebarLegacyReviewChatIndex.swift} (98%) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift similarity index 98% rename from Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift rename to Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift index 2d659a4e..9f984dea 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift @@ -2,7 +2,7 @@ import CodexKit import CodexReviewKit @MainActor -final class ReviewMonitorSidebarReviewChatIndex { +final class ReviewMonitorSidebarLegacyReviewChatIndex { private var rowsByJobID: [String: ReviewMonitorSidebarReviewChatRow] = [:] func rows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index c9718cc6..426521f4 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -209,7 +209,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private var codexSidebarModelContext: CodexModelContext? private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() - private let reviewChatIndex = ReviewMonitorSidebarReviewChatIndex() + private let legacyReviewChatIndex = ReviewMonitorSidebarLegacyReviewChatIndex() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] @@ -572,10 +572,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let topologies = workspaceJobs.map { workspace, jobs in SidebarWorkspaceTopology( workspace: workspace, - reviewRows: reviewChatIndex.rows(for: jobs) + reviewRows: legacyReviewChatIndex.rows(for: jobs) ) } - reviewChatIndex.prune(keeping: activeJobIDs) + legacyReviewChatIndex.prune(keeping: activeJobIDs) return topologies } @@ -1362,7 +1362,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forJobID jobID: String) -> Int? { - guard let rowItem = reviewChatIndex.row(jobID: jobID) else { + guard let rowItem = legacyReviewChatIndex.row(jobID: jobID) else { return nil } let row = outlineView.row(forItem: rowItem) @@ -1370,7 +1370,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forReviewChatID chatID: CodexThreadID) -> Int? { - guard let rowItem = reviewChatIndex.row(chatID: chatID) else { + guard let rowItem = legacyReviewChatIndex.row(chatID: chatID) else { return nil } let row = outlineView.row(forItem: rowItem) @@ -1446,11 +1446,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } - private func reviewChatSelection( + private func legacyReviewChatSelection( id: CodexThreadID, in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - reviewChatIndex.chat(id: id, in: workspaces) + legacyReviewChatIndex.chat(id: id, in: workspaces) } private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { @@ -1464,7 +1464,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if isUsingCodexSidebarOutline { return codexChatSelection(id: id) } - return reviewChatSelection(id: id, in: workspaces) + return legacyReviewChatSelection(id: id, in: workspaces) } private func codexWorkspaceSelection( @@ -2464,7 +2464,7 @@ extension ReviewMonitorSidebarViewController { } func cancelReviewChatForTesting(_ job: CodexReviewJob) async { - guard let row = reviewChatIndex.row(jobID: job.id) else { + guard let row = legacyReviewChatIndex.row(jobID: job.id) else { return } await performCancellation(for: row) @@ -2656,7 +2656,7 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob ) -> Bool { guard let section = workspaceSection(containing: workspace), - let targetRow = reviewChatIndex.row(jobID: targetJob.id) + let targetRow = legacyReviewChatIndex.row(jobID: targetJob.id) else { return true } @@ -2674,7 +2674,7 @@ extension ReviewMonitorSidebarViewController { hoveringBelowMidpoint: Bool ) -> Bool { guard let section = workspaceSection(containing: workspace), - let targetRowItem = reviewChatIndex.row(jobID: targetJob.id), + let targetRowItem = legacyReviewChatIndex.row(jobID: targetJob.id), let targetRow = row(forJobID: targetJob.id) else { return false @@ -2776,7 +2776,7 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool ) -> Bool { - guard let targetRowItem = reviewChatIndex.row(jobID: targetJob.id), + guard let targetRowItem = legacyReviewChatIndex.row(jobID: targetJob.id), let targetRow = row(forJobID: targetJob.id) else { return false From 41616dde6b126585319d339e9c3a5f859206f15a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:32:11 +0900 Subject: [PATCH 181/359] refactor(review-ui): name job chat projection legacy --- ...eviewJob+LegacyReviewChatProjection.swift} | 14 ++++++------ .../ReviewMonitorPreviewContent.swift | 2 +- ...wMonitorSidebarLegacyReviewChatIndex.swift | 4 ++-- .../ReviewMonitorSidebarViewController.swift | 2 +- ...eviewMonitorCodexSidebarLibraryTests.swift | 2 +- .../ReviewMonitorSelectedCodexChatTests.swift | 6 ++--- .../ReviewMonitorTimelineTesting.swift | 2 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 22 +++++++++---------- 9 files changed, 28 insertions(+), 28 deletions(-) rename Sources/ReviewUI/{CodexReviewJob+ReviewChatIdentity.swift => CodexReviewJob+LegacyReviewChatProjection.swift} (81%) diff --git a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift similarity index 81% rename from Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift rename to Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift index 71f3b9e0..6fd1cad0 100644 --- a/Sources/ReviewUI/CodexReviewJob+ReviewChatIdentity.swift +++ b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift @@ -3,7 +3,7 @@ import CodexReviewKit extension CodexReviewJob { @MainActor - var reviewChatIdentity: CodexReviewIdentity? { + var legacyReviewChatIdentity: CodexReviewIdentity? { guard let sourceThreadID = core.run.threadID?.nilIfEmpty, let turnID = core.run.turnID?.nilIfEmpty else { @@ -18,7 +18,7 @@ extension CodexReviewJob { } @MainActor - var reviewChatID: CodexThreadID? { + var legacyReviewChatID: CodexThreadID? { if let reviewThreadID = core.run.reviewThreadID?.nilIfEmpty { return CodexThreadID(rawValue: reviewThreadID) } @@ -29,8 +29,8 @@ extension CodexReviewJob { } @MainActor - var reviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let chatID = reviewChatID else { + var legacyReviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let chatID = legacyReviewChatID else { return nil } return ReviewMonitorCodexSidebarSnapshot.Chat( @@ -40,19 +40,19 @@ extension CodexReviewJob { preview: core.output.lastAgentMessage?.nilIfEmpty ?? core.output.summary.nilIfEmpty, workspaceCWD: cwd, updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - reviewIdentity: reviewChatIdentity + reviewIdentity: legacyReviewChatIdentity ) } } extension CodexReviewStore { @MainActor - func reviewJob( + func legacyReviewJob( forChatID chatID: CodexThreadID, in workspaces: [CodexReviewWorkspace]? = nil ) -> CodexReviewJob? { orderedJobs.first { job in - job.reviewChatID == chatID + job.legacyReviewChatID == chatID && (workspaces?.contains { $0.cwd == job.cwd } ?? true) } } diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 82cf7182..29617410 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -165,7 +165,7 @@ public enum ReviewMonitorPreviewContent { from jobs: [CodexReviewJob] ) -> [ReviewMonitorPreviewChatLogFixture] { jobs.compactMap { job in - guard let chat = job.reviewChatSelection else { + guard let chat = job.legacyReviewChatSelection else { return nil } let turn = CodexChatTurnStateSnapshot( diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift index 9f984dea..ef44da15 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift @@ -46,7 +46,7 @@ extension ReviewMonitorSidebarReviewChatRow { @MainActor convenience init(job: CodexReviewJob) { self.init( - chat: job.reviewChatSelection, + chat: job.legacyReviewChatSelection, runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) ) } @@ -54,7 +54,7 @@ extension ReviewMonitorSidebarReviewChatRow { @MainActor func update(job: CodexReviewJob) { update( - chat: job.reviewChatSelection, + chat: job.legacyReviewChatSelection, runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) ) } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 426521f4..9f17555b 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -959,7 +959,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } switch uiState.selection { case .chat(let chat): - return store.reviewJob(forChatID: chat.id, in: workspaces()) + return store.legacyReviewJob(forChatID: chat.id, in: workspaces()) case .workspaceSection, .workspace, nil: return nil } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index acc64e0d..28c435df 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -261,7 +261,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { startedAt: Date(timeIntervalSince1970: 4_000), summary: "Running legacy review." ) - let legacyChat = try #require(legacyJob.reviewChatSelection) + let legacyChat = try #require(legacyJob.legacyReviewChatSelection) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index ba7fd0ea..3d3e3336 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -165,7 +165,7 @@ struct ReviewMonitorSelectedCodexChatTests { } } - @Test func reviewChatSelectionCanStartAfterRunIdentifiersArrive() async throws { + @Test func legacyReviewChatSelectionCanStartAfterRunIdentifiersArrive() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -206,7 +206,7 @@ struct ReviewMonitorSelectedCodexChatTests { reviewThreadID: nil, turnID: nil ) - #expect(job.reviewChatSelection == nil) + #expect(job.legacyReviewChatSelection == nil) job.core.run.threadID = "source-thread" job.core.run.reviewThreadID = "review-thread" @@ -464,7 +464,7 @@ struct ReviewMonitorSelectedCodexChatTests { _ job: CodexReviewJob, in uiState: ReviewMonitorUIState ) throws { - let chat = try #require(job.reviewChatSelection) + let chat = try #require(job.legacyReviewChatSelection) uiState.selection = .chat(chat) } } diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 38a4ea8c..28cafdae 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -273,7 +273,7 @@ func awaitTimelineRenderForTesting( @MainActor private func timelineRenderTarget(for job: CodexReviewJob) throws -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { - let chatID = try #require(job.reviewChatID) + let chatID = try #require(job.legacyReviewChatID) return .chat(chatID.rawValue) } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index d0c003c4..f78cc4a8 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1796,7 +1796,7 @@ private func renderDetailLogForShellLayoutTesting( job: CodexReviewJob ) async throws { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let chatID = try #require(job.reviewChatID) + let chatID = try #require(job.legacyReviewChatID) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { transport.renderedStateForTesting.selection == expectedSelection diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index d3f9c330..289e4ec8 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -23,7 +23,7 @@ private extension CodexReviewAuthModel { @MainActor func chatIDForTesting(_ job: CodexReviewJob) -> CodexThreadID { - guard let chatID = job.reviewChatID else { + guard let chatID = job.legacyReviewChatID else { Issue.record("Expected review job \(job.id) to have a chat id.") return CodexThreadID(rawValue: job.id) } @@ -437,7 +437,7 @@ struct ReviewUITests { )) await Task.yield() #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-2", "job-1"]) - #expect(sidebar.selectedReviewChatIDForTesting == firstJob.reviewChatID) + #expect(sidebar.selectedReviewChatIDForTesting == firstJob.legacyReviewChatID) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop) #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop) #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) @@ -568,7 +568,7 @@ struct ReviewUITests { let sidebar = viewController.sidebarViewControllerForTesting sidebar.selectReviewChatForTesting(id: chatIDForTesting(completedJob)) - #expect(sidebar.selectedReviewChatIDForTesting == completedJob.reviewChatID) + #expect(sidebar.selectedReviewChatIDForTesting == completedJob.legacyReviewChatID) uiState.sidebarReviewChatFilter = .running try await waitForObservedValueFromCurrentObservation( @@ -579,7 +579,7 @@ struct ReviewUITests { } #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-running"]) - #expect(sidebar.selectedReviewChatIDForTesting == completedJob.reviewChatID) + #expect(sidebar.selectedReviewChatIDForTesting == completedJob.legacyReviewChatID) } @Test func sidebarLatestFinishedFilterKeepsWorkspacesAndShowsLatestTerminalJob() async throws { @@ -673,7 +673,7 @@ struct ReviewUITests { #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-failed"]) #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == ["job-beta-cancelled"]) #expect(sidebar.displayedReviewChatJobIDsForTesting(in: gammaWorkspace) == []) - #expect(sidebar.selectedReviewChatIDForTesting == alphaRunningJob.reviewChatID) + #expect(sidebar.selectedReviewChatIDForTesting == alphaRunningJob.legacyReviewChatID) } @Test func sidebarLatestFinishedFilterFollowsTerminalDateChanges() async throws { @@ -1431,7 +1431,7 @@ struct ReviewUITests { let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) #expect(initialHostedRowID == placeholderJob.id) - #expect(updatedHostedRowID == loadedJob.reviewChatID?.rawValue) + #expect(updatedHostedRowID == loadedJob.legacyReviewChatID?.rawValue) #expect(initialHostingViewIdentity == updatedHostingViewIdentity) #expect((cellView.objectValue as? ReviewMonitorSidebarReviewChatRow)?.operation.jobID == loadedJob.id) #expect(cellView.toolTip == loadedJob.cwd) @@ -1803,7 +1803,7 @@ struct ReviewUITests { try await waitForCondition { sidebar.workspaceIsExpandedForTesting(storedWorkspace) && sidebar.workspaceOutlineIsExpandedForTesting(storedWorkspace) - && sidebar.selectedReviewChatIDForTesting == job.reviewChatID + && sidebar.selectedReviewChatIDForTesting == job.legacyReviewChatID } } @@ -4898,7 +4898,7 @@ struct ReviewUITests { ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatID) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.legacyReviewChatID) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @@ -5103,7 +5103,7 @@ struct ReviewUITests { replaceTimelineLogTextForTesting(job, "Updated log") let updatedSnapshot = try await awaitTimelineRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatID) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.legacyReviewChatID) #expect(updatedSnapshot.summary == nil) #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) } @@ -5138,7 +5138,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.reviewChatID) + let chatID = try #require(job.legacyReviewChatID) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) @@ -5192,7 +5192,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.reviewChatID) + let chatID = try #require(job.legacyReviewChatID) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting From 0baacd7c6ed159c160c5ac23bf8668073b973102 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:36:23 +0900 Subject: [PATCH 182/359] refactor(review-ui): remove timeline renderer dependency --- Package.swift | 2 +- .../ReviewUI/Detail/ReviewMonitorTransportViewController.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 63077587..b5c61e7d 100644 --- a/Package.swift +++ b/Package.swift @@ -93,7 +93,6 @@ let package = Package( name: "ReviewUI", dependencies: [ "CodexReviewKit", - "ReviewMonitorRendering", "TextTransitions", .product(name: "CodexKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), @@ -168,6 +167,7 @@ let package = Package( .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", + "ReviewMonitorRendering", "ReviewUI", ], swiftSettings: [ diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index f11c3248..df41d3d3 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -2,7 +2,6 @@ import AppKit import CodexKit import CodexReviewKit import ObservationBridge -import ReviewMonitorRendering @MainActor final class ReviewMonitorTransportViewController: NSViewController { From 05cb0777eff42d487863f38d785595c6e2dfc5f0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:57:18 +0900 Subject: [PATCH 183/359] refactor(review-ui): keep timeline renderer test-only --- Package.swift | 20 - .../ReviewTimelineDocumentRendererTests.swift | 654 ------------------ .../ReviewMonitorTimelineLogProjection.swift | 1 - .../ReviewMonitorTimelineTesting.swift | 1 - .../ReviewTimelineDocumentForTesting.swift | 2 + ...ewTimelineDocumentRendererForTesting.swift | 2 + Tests/ReviewUITests/ReviewUITests.swift | 1 - 7 files changed, 4 insertions(+), 677 deletions(-) delete mode 100644 Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift rename Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift => Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift (98%) rename Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift => Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift (98%) diff --git a/Package.swift b/Package.swift index b5c61e7d..663bb703 100644 --- a/Package.swift +++ b/Package.swift @@ -101,15 +101,6 @@ let package = Package( .swiftLanguageMode(.v6), ] ), - .target( - name: "ReviewMonitorRendering", - dependencies: [ - "CodexReviewKit", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .target( name: "TextTransitions", swiftSettings: [ @@ -167,23 +158,12 @@ let package = Package( .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", - "ReviewMonitorRendering", "ReviewUI", ], swiftSettings: [ .swiftLanguageMode(.v6), ] ), - .testTarget( - name: "ReviewMonitorRenderingTests", - dependencies: [ - "CodexReviewKit", - "ReviewMonitorRendering", - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), .testTarget( name: "TextTransitionsTests", dependencies: [ diff --git a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift b/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift deleted file mode 100644 index 979df3a6..00000000 --- a/Tests/ReviewMonitorRenderingTests/ReviewTimelineDocumentRendererTests.swift +++ /dev/null @@ -1,654 +0,0 @@ -import Foundation -import Testing -import CodexReviewKit -@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 plainTextIsDerivedFromDocumentAndMatchesLogText() 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/ReviewMonitorTimelineLogProjection.swift b/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift index ff88479b..5cdbb68c 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift @@ -1,6 +1,5 @@ import Foundation @_spi(Testing) @testable import CodexReviewKit -import ReviewMonitorRendering @_spi(PreviewSupport) @testable import ReviewUI struct ReviewMonitorTimelineLogProjection: Sendable { diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 28cafdae..4e1456e8 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -1,7 +1,6 @@ import Foundation import Testing @_spi(Testing) @testable import CodexReviewKit -import ReviewMonitorRendering @_spi(PreviewSupport) @testable import ReviewUI struct ReviewTimelineEntryForTesting: Sendable, Hashable { diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift b/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift similarity index 98% rename from Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift rename to Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift index b90c4c46..2664de9b 100644 --- a/Sources/ReviewMonitorRendering/ReviewTimelineDocument.swift +++ b/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift @@ -1,6 +1,8 @@ import Foundation import CodexReviewKit +// Test-only bridge for legacy ReviewTimeline fixtures. Production detail rendering +// uses CodexChatChange streams through ReviewMonitorSelectedCodexChat. public struct ReviewTimelineDocument: Codable, Equatable, Sendable { public struct Block: Identifiable, Codable, Equatable, Sendable { public struct ID: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral, CustomStringConvertible { diff --git a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift b/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift similarity index 98% rename from Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift rename to Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift index 9ca5f0a5..30133a38 100644 --- a/Sources/ReviewMonitorRendering/ReviewTimelineDocumentRenderer.swift +++ b/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift @@ -1,6 +1,8 @@ import Foundation import CodexReviewKit +// Kept in ReviewUITests while legacy job timeline fixtures still seed UI tests. +// Runtime UI code should not depend on this renderer. public struct ReviewTimelineDocumentRenderer: Sendable { public init() {} diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 289e4ec8..4066f78d 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -3,7 +3,6 @@ import Foundation import ObservationBridge import CodexKit import CodexReviewKit -import ReviewMonitorRendering import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit From 81fcb08c358665bcda5ddbc25a13a68b3f446219 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:02:28 +0900 Subject: [PATCH 184/359] refactor(review-ui): update preview chats with codex items --- .../ReviewMonitorPreviewChatLogSource.swift | 92 +++++++++---------- Tests/ReviewUITests/ReviewUITests.swift | 28 ++---- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index f101105f..8ce55e54 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -89,8 +89,23 @@ final class ReviewMonitorPreviewChatLogSource { return pair.stream } - func applyPreviewDomainEvent(_ event: ReviewDomainEvent, to chatID: CodexThreadID) { - previewChatsByID[chatID]?.apply(event) + func upsertPreviewItem( + id: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, + to chatID: CodexThreadID + ) { + previewChatsByID[chatID]?.upsertItem(id: id, kind: kind, content: content) + } + + func appendPreviewText( + _ delta: String, + to chatID: CodexThreadID, + itemID: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content + ) { + previewChatsByID[chatID]?.appendTextDelta(delta, itemID: itemID, kind: kind, content: content) } func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { @@ -145,43 +160,6 @@ private final class PreviewReviewChat { snapshotStorage } - func apply(_ event: ReviewDomainEvent) { - switch event { - case .runStarted(let turnID, _, _): - snapshotStorage.turns = [ - .init( - id: CodexTurnID(rawValue: turnID.rawValue), - status: .running - ), - ] - snapshotStorage.items.removeAll(keepingCapacity: true) - snapshotStorage.phase = .loaded - bumpRevision() - - case .itemStarted(let seed), - .itemUpdated(let seed), - .itemCompleted(let seed): - upsertItem(seed) - - case .textDelta(let itemID, let kind, _, let content, let delta): - appendTextDelta( - delta, - itemID: itemID.rawValue, - kind: CodexThreadItem.Kind(kind), - content: content - ) - - case .reviewCompleted: - updateTurnStatus(.completed) - - case .reviewFailed: - updateTurnStatus(.failed) - - case .reviewCancelled: - updateTurnStatus(.cancelled) - } - } - func applyPreviewStreamStep( _ step: ReviewMonitorPreviewContent.PreviewTimelineStep, cycle: Int @@ -201,7 +179,12 @@ private final class PreviewReviewChat { step.deltaText ?? "", itemID: itemID.rawValue, kind: CodexThreadItem.Kind(step.kind), - content: step.content + content: CodexThreadItem.Content( + previewContent: step.content, + id: itemID.rawValue, + phase: step.phase, + fallbackRawKind: step.kind.rawValue + ) ) } } @@ -212,6 +195,24 @@ private final class PreviewReviewChat { private func upsertItem(_ seed: ReviewTimelineItemSeed) { let item = CodexChatItemSnapshot(previewSeed: seed, turnID: currentTurnID) + upsertItem(item) + } + + func upsertItem( + id: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content + ) { + let item = CodexChatItemSnapshot( + id: id, + turnID: currentTurnID, + kind: kind, + content: content + ) + upsertItem(item) + } + + private func upsertItem(_ item: CodexChatItemSnapshot) { if let index = snapshotStorage.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { snapshotStorage.items[index] = item } else { @@ -220,11 +221,11 @@ private final class PreviewReviewChat { bumpRevision() } - private func appendTextDelta( + func appendTextDelta( _ delta: String, itemID: String, kind: CodexThreadItem.Kind, - content: ReviewTimelineItem.Content + content: CodexThreadItem.Content ) { guard delta.isEmpty == false else { return @@ -237,12 +238,7 @@ private final class PreviewReviewChat { id: itemID, turnID: turnID, kind: kind, - content: CodexThreadItem.Content( - previewContent: content, - id: itemID, - phase: .running, - fallbackRawKind: kind.rawValue - ) + content: content ) item.content.appendPreviewText(delta) snapshotStorage.items.append(item) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 4066f78d..f19f953a 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5143,15 +5143,12 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - previewChatLogSource.applyPreviewDomainEvent( - .textDelta( - itemID: .init(rawValue: "msg_1"), - kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: " log" - ), - to: chatID + previewChatLogSource.appendPreviewText( + " log", + to: chatID, + itemID: "msg_1", + kind: .agentMessage, + content: .message(.init(id: "msg_1", role: .assistant, text: "")) ) let snapshot = try await awaitTransportRender(transport) { $0.log == "Initial log" } @@ -5195,15 +5192,10 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting - previewChatLogSource.applyPreviewDomainEvent( - .itemUpdated( - .init( - id: .init(rawValue: "progress_1"), - kind: ReviewItemKind(rawValue: "progress"), - family: .diagnostic, - phase: .completed, - content: .diagnostic(.init(message: "stream.tick 001")) - )), + previewChatLogSource.upsertPreviewItem( + id: "progress_1", + kind: CodexThreadItem.Kind(rawValue: "progress"), + content: .diagnostic("stream.tick 001"), to: chatID ) From 8fae9bd484aa51686880c9263ac756b17155e694 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:09:42 +0900 Subject: [PATCH 185/359] refactor(review-ui): restore legacy rows by chat selection --- ...ReviewJob+LegacyReviewChatProjection.swift | 13 ---------- .../ReviewMonitorSidebarViewController.swift | 26 ++++++------------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift index 6fd1cad0..89f5a706 100644 --- a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift +++ b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift @@ -44,16 +44,3 @@ extension CodexReviewJob { ) } } - -extension CodexReviewStore { - @MainActor - func legacyReviewJob( - forChatID chatID: CodexThreadID, - in workspaces: [CodexReviewWorkspace]? = nil - ) -> CodexReviewJob? { - orderedJobs.first { job in - job.legacyReviewChatID == chatID - && (workspaces?.contains { $0.cwd == job.cwd } ?? true) - } - } -} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 9f17555b..f9bee04e 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -930,19 +930,21 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedReviewChatRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard let selectedRuntimeJob = runtimeJobForCurrentChatSelection(), - section.workspaces.contains(where: { $0.cwd == selectedRuntimeJob.cwd }) + guard isUsingCodexSidebarOutline == false, + case .chat(let selectedChat) = uiState.selection, + let selectedWorkspaceCWD = selectedChat.workspaceCWD, + section.workspaces.contains(where: { $0.cwd == selectedWorkspaceCWD }) else { return } - let selectedRuntimeJobID = selectedRuntimeJob.id + let selectedChatID = selectedChat.id DispatchQueue.main.async { [weak self, weak section] in guard let self, let section, - let currentSelectedRuntimeJob = self.runtimeJobForCurrentChatSelection(), + let currentSelectedChat = self.currentChatSelection(id: selectedChatID), section.isExpanded, - currentSelectedRuntimeJob.id == selectedRuntimeJobID, - let row = self.row(forJobID: selectedRuntimeJobID), + currentSelectedChat.workspaceCWD == selectedWorkspaceCWD, + let row = self.row(forCurrentChatSelectionID: selectedChatID), self.outlineView.selectedRow != row else { return @@ -953,18 +955,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func runtimeJobForCurrentChatSelection() -> CodexReviewJob? { - guard isUsingCodexSidebarOutline == false else { - return nil - } - switch uiState.selection { - case .chat(let chat): - return store.legacyReviewJob(forChatID: chat.id, in: workspaces()) - case .workspaceSection, .workspace, nil: - return nil - } - } - private func makeContextMenu(at point: NSPoint) -> NSMenu? { let row = outlineView.row(at: point) guard row != -1, From 3889c9b8893c202e5335f12f7a75df7698da5167 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:14:43 +0900 Subject: [PATCH 186/359] refactor(review-ui): name preview stream by chat --- .../ReviewUI/ReviewMonitorPreviewChatLogSource.swift | 2 +- Sources/ReviewUI/ReviewMonitorPreviewContent.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 8ce55e54..bb4649de 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -122,7 +122,7 @@ final class ReviewMonitorPreviewChatLogSource { let nextTick = currentTick + 1 for (index, previewChat) in runningChats.enumerated() { guard let frame = ReviewMonitorPreviewContent.streamFrame( - forJobAt: index, + forRunningChatAt: index, tick: nextTick ) else { continue diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 29617410..a86986ab 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -235,24 +235,24 @@ public enum ReviewMonitorPreviewContent { } package static func streamFrame( - forJobAt jobIndex: Int, + forRunningChatAt runningChatIndex: Int, tick: Int ) -> PreviewStreamFrame? { guard previewStreamTimeline.isEmpty == false else { return nil } - let jobTick = tick - jobIndex * previewJobTickOffset - guard jobTick > 0 else { + let chatTick = tick - runningChatIndex * previewChatTickOffset + guard chatTick > 0 else { return nil } - let offset = (jobTick - 1) % previewStreamTimeline.count + let offset = (chatTick - 1) % previewStreamTimeline.count guard let step = previewStreamTimeline[offset] else { return nil } return PreviewStreamFrame( step: step, - cycle: (jobTick - 1) / previewStreamTimeline.count + cycle: (chatTick - 1) / previewStreamTimeline.count ) } @@ -322,7 +322,7 @@ public enum ReviewMonitorPreviewContent { private static let interItemDelayFrameCount = 39 private static let commandCompletionDelayFrameCount = 60 private static let compactionCompletionDelayFrameCount = 15 - private static let previewJobTickOffset = 21 + private static let previewChatTickOffset = 21 private static let previewStreamTemplates: [PreviewStreamTemplate] = [ .init( From 39d32a43456f405f9e602dac58be45fa436f68e9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:27:15 +0900 Subject: [PATCH 187/359] refactor(review-ui): seed preview chat source directly --- .../ReviewUI/ReviewMonitorContentView.swift | 3 +- .../ReviewMonitorPreviewChatLogSource.swift | 17 +++ .../ReviewMonitorPreviewContent.swift | 109 +++++++++++++++++- .../ReviewMonitorWindowController.swift | 3 +- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 3f6ff198..a3a6e7e5 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -330,8 +330,9 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( uiState.selection = .chat(initialChat) } if ownsPreviewStore, let previewChatLogSource { - store.previewSupportRetainer = ReviewMonitorPreviewChatLogStreamer( + ReviewMonitorPreviewContent.retainChatLogStreamer( source: previewChatLogSource, + in: store, interval: .milliseconds(40) ) } diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index bb4649de..3da4b652 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -294,6 +294,23 @@ final class ReviewMonitorPreviewChatLogStreamer { } } +@MainActor +final class ReviewMonitorPreviewRuntimeSupport { + let chatLogSource: ReviewMonitorPreviewChatLogSource + private var streamer: ReviewMonitorPreviewChatLogStreamer? + + init(chatLogSource: ReviewMonitorPreviewChatLogSource) { + self.chatLogSource = chatLogSource + } + + func startStreaming(interval: Duration) { + streamer = ReviewMonitorPreviewChatLogStreamer( + source: chatLogSource, + interval: interval + ) + } +} + @MainActor private final class PreviewChatLogSubscription { private let previewChat: PreviewReviewChat diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index a86986ab..85362fc7 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -143,6 +143,12 @@ public enum ReviewMonitorPreviewContent { let cycle: Int } + private struct PreviewSidebarContent { + var workspaces: [CodexReviewWorkspace] + var jobs: [CodexReviewJob] + var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] + } + @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( @@ -158,6 +164,12 @@ public enum ReviewMonitorPreviewContent { workspaces: previewContent.workspaces, jobs: previewContent.jobs ) + let chatLogSource = ReviewMonitorPreviewChatLogSource( + fixtures: previewContent.chatLogFixtures + ) + store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( + chatLogSource: chatLogSource + ) return store } @@ -196,11 +208,30 @@ public enum ReviewMonitorPreviewContent { } static func makeChatLogSource(from store: CodexReviewStore) -> ReviewMonitorPreviewChatLogSource { - ReviewMonitorPreviewChatLogSource( + if let previewSupport = store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport { + return previewSupport.chatLogSource + } + return ReviewMonitorPreviewChatLogSource( fixtures: makeChatLogFixtures(from: store.orderedJobs) ) } + static func retainChatLogStreamer( + source: ReviewMonitorPreviewChatLogSource, + in store: CodexReviewStore, + interval: Duration + ) { + let previewSupport: ReviewMonitorPreviewRuntimeSupport + if let existingSupport = store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport, + existingSupport.chatLogSource === source { + previewSupport = existingSupport + } else { + previewSupport = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) + store.previewSupportRetainer = previewSupport + } + previewSupport.startStreaming(interval: interval) + } + @_spi(PreviewSupport) public static func makeCommandOutputStore() -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( @@ -645,7 +676,7 @@ public enum ReviewMonitorPreviewContent { ] } - private static func makeSidebarContent() -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) { + private static func makeSidebarContent() -> PreviewSidebarContent { let now = Date() let workspacePaths = [ "/path/to/workspace-alpha", @@ -655,11 +686,13 @@ public enum ReviewMonitorPreviewContent { var workspaces: [CodexReviewWorkspace] = [] var jobs: [CodexReviewJob] = [] + var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] = [] 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( + for (jobIndex, definition) in makeJobDefinitions(for: workspaceName).enumerated() { + let timelineItems = makePreviewTimelineItems(for: definition, workspaceName: workspaceName) + let job = makeJob( id: "preview-\(workspaceIndex)-\(jobIndex)", cwd: cwd, model: definition.model, @@ -675,11 +708,75 @@ public enum ReviewMonitorPreviewContent { cwd: cwd ), lastAgentMessage: definition.lastAgentMessage, - timelineItems: makePreviewTimelineItems(for: definition, workspaceName: workspaceName) + timelineItems: timelineItems ) + jobs.append(job) + if let fixture = makeChatLogFixture(for: job, timelineItems: timelineItems) { + chatLogFixtures.append(fixture) + } } } - return (workspaces, jobs) + return PreviewSidebarContent( + workspaces: workspaces, + jobs: jobs, + chatLogFixtures: chatLogFixtures + ) + } + + private static func makeChatLogFixture( + for job: CodexReviewJob, + timelineItems: [PreviewTimelineItemTemplate] + ) -> ReviewMonitorPreviewChatLogFixture? { + guard let chat = job.legacyReviewChatSelection else { + return nil + } + let turn = CodexChatTurnStateSnapshot( + id: job.previewTurnID, + status: CodexTurnStatus(job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ) + let initialSnapshot = CodexChatSnapshot( + chatID: chat.id, + phase: CodexDataPhase( + job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ), + turns: [turn], + items: makeInitialChatItems( + for: job, + timelineItems: timelineItems, + turnID: turn.id + ) + ) + return ReviewMonitorPreviewChatLogFixture( + chat: chat, + cwd: job.cwd, + streamID: job.id, + isRunning: job.core.lifecycle.status == .running, + initialSnapshot: initialSnapshot + ) + } + + private static func makeInitialChatItems( + for job: CodexReviewJob, + timelineItems: [PreviewTimelineItemTemplate], + turnID: CodexTurnID + ) -> [CodexChatItemSnapshot] { + let timeline = ReviewTimeline() + for item in timelineItems { + let itemID = previewTimelineItemID( + itemName: item.itemName, + jobID: job.id, + cycle: 0 + ) + let seed = item.seed(id: itemID) + let event: ReviewDomainEvent = item.phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed) + timeline.apply(event) + } + return timeline.items.map { + CodexChatItemSnapshot(previewItem: $0, turnID: turnID) + } } private static func makeJob( diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 6fc519a5..cddf438e 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -157,8 +157,9 @@ public extension ReviewMonitorWindowController { showSettings: (@MainActor () -> Void)? = nil ) { let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) - store.previewSupportRetainer = ReviewMonitorPreviewChatLogStreamer( + ReviewMonitorPreviewContent.retainChatLogStreamer( source: previewChatLogSource, + in: store, interval: .milliseconds(40) ) self.init( From dc5d3626030367a104f749ff84cd6e214c1aacd4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:34:19 +0900 Subject: [PATCH 188/359] refactor(review-ui): seed command preview chat source directly --- Sources/ReviewUI/ReviewMonitorPreviewContent.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 85362fc7..57d3321b 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -240,6 +240,7 @@ public enum ReviewMonitorPreviewContent { let accounts = makePreviewAccounts() let cwd = "/path/to/workspace-alpha" let now = Date() + let timelineItems = makeCommandOutputPreviewTimelineItems() let job = makeJob( id: "preview-command-output-panel", cwd: cwd, @@ -252,7 +253,7 @@ public enum ReviewMonitorPreviewContent { hasFinalReview: false, reviewResult: nil, lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.", - timelineItems: makeCommandOutputPreviewTimelineItems() + timelineItems: timelineItems ) store.loadForTesting( serverState: .running, @@ -262,6 +263,11 @@ public enum ReviewMonitorPreviewContent { workspaces: [CodexReviewWorkspace(cwd: cwd)], jobs: [job] ) + if let fixture = makeChatLogFixture(for: job, timelineItems: timelineItems) { + store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( + chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) + ) + } return store } From f44c9bf345d69ae9658b7eb5389ea5b024ec4f70 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:42:51 +0900 Subject: [PATCH 189/359] refactor(review-ui): drop job timeline preview fallback --- .../ReviewMonitorPreviewContent.swift | 38 +------- .../ReviewMonitorTimelineTesting.swift | 87 +++++++++++++++++++ Tests/ReviewUITests/ReviewUITests.swift | 22 ++--- 3 files changed, 96 insertions(+), 51 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 57d3321b..5aefa5f7 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -173,47 +173,11 @@ public enum ReviewMonitorPreviewContent { return store } - static func makeChatLogFixtures( - from jobs: [CodexReviewJob] - ) -> [ReviewMonitorPreviewChatLogFixture] { - jobs.compactMap { job in - guard let chat = job.legacyReviewChatSelection else { - return nil - } - let turn = CodexChatTurnStateSnapshot( - id: job.previewTurnID, - status: CodexTurnStatus(job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, - usage: nil - ) - let initialSnapshot = CodexChatSnapshot( - chatID: chat.id, - phase: CodexDataPhase( - job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage - ), - turns: [turn], - items: job.timeline.items.map { - CodexChatItemSnapshot(previewItem: $0, turnID: turn.id) - } - ) - return ReviewMonitorPreviewChatLogFixture( - chat: chat, - cwd: job.cwd, - streamID: job.id, - isRunning: job.core.lifecycle.status == .running, - initialSnapshot: initialSnapshot - ) - } - } - static func makeChatLogSource(from store: CodexReviewStore) -> ReviewMonitorPreviewChatLogSource { if let previewSupport = store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport { return previewSupport.chatLogSource } - return ReviewMonitorPreviewChatLogSource( - fixtures: makeChatLogFixtures(from: store.orderedJobs) - ) + return ReviewMonitorPreviewChatLogSource(fixtures: []) } static func retainChatLogStreamer( diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 4e1456e8..879b6249 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import CodexKit @_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI @@ -270,6 +271,60 @@ func awaitTimelineRenderForTesting( } } +@MainActor +func makePreviewChatLogSourceForTesting( + job: CodexReviewJob, + items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] +) throws -> ReviewMonitorPreviewChatLogSource { + ReviewMonitorPreviewChatLogSource( + fixtures: [try makePreviewChatLogFixtureForTesting(job: job, items: makeItems)] + ) +} + +@MainActor +func makePreviewMessageItemForTesting( + id: String, + text: String, + turnID: CodexTurnID +) -> CodexChatItemSnapshot { + CodexChatItemSnapshot( + id: id, + turnID: turnID, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, text: text)) + ) +} + +@MainActor +private func makePreviewChatLogFixtureForTesting( + job: CodexReviewJob, + items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] +) throws -> ReviewMonitorPreviewChatLogFixture { + let chat = try #require(job.legacyReviewChatSelection) + let turn = CodexChatTurnStateSnapshot( + id: job.previewTurnIDForTesting, + status: CodexTurnStatus(reviewJobStateForTesting: job.core.lifecycle.status), + errorDescription: job.core.lifecycle.errorMessage, + usage: nil + ) + let initialSnapshot = CodexChatSnapshot( + chatID: chat.id, + phase: CodexDataPhase( + reviewJobStateForTesting: job.core.lifecycle.status, + errorMessage: job.core.lifecycle.errorMessage + ), + turns: [turn], + items: makeItems(turn.id) + ) + return ReviewMonitorPreviewChatLogFixture( + chat: chat, + cwd: job.cwd, + streamID: job.id, + isRunning: job.core.lifecycle.status == .running, + initialSnapshot: initialSnapshot + ) +} + @MainActor private func timelineRenderTarget(for job: CodexReviewJob) throws -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { let chatID = try #require(job.legacyReviewChatID) @@ -311,6 +366,38 @@ private extension ReviewMonitorTransportViewController.DisplayedSelectionForTest } } +private extension CodexReviewJob { + var previewTurnIDForTesting: CodexTurnID { + core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") + } +} + +private extension CodexTurnStatus { + init(reviewJobStateForTesting jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .running + case .succeeded: + self = .completed + case .failed: + self = .failed + case .cancelled: + self = .cancelled + } + } +} + +private extension CodexDataPhase { + init(reviewJobStateForTesting jobState: ReviewJobState, errorMessage: String?) { + switch jobState { + case .queued, .running, .succeeded, .cancelled: + self = .loaded + case .failed: + self = .failed(errorMessage ?? "Review failed") + } + } +} + private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { switch entry.kind { case .command, .commandOutput: diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index f19f953a..21e6ba3c 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5116,16 +5116,13 @@ struct ReviewUITests { turnID: UUID().uuidString, status: .running, startedAt: Date(timeIntervalSince1970: 200), - summary: "Running review.", - timelineEntries: [ - .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") - ] + summary: "Running review." ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = ReviewMonitorPreviewChatLogSource( - fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: [job]) - ) + let previewChatLogSource = try makePreviewChatLogSourceForTesting(job: job) { turnID in + [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] + } let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -5167,16 +5164,13 @@ struct ReviewUITests { turnID: UUID().uuidString, status: .running, startedAt: Date(timeIntervalSince1970: 200), - summary: "Running review.", - timelineEntries: [ - .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") - ] + summary: "Running review." ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = ReviewMonitorPreviewChatLogSource( - fixtures: ReviewMonitorPreviewContent.makeChatLogFixtures(from: [job]) - ) + let previewChatLogSource = try makePreviewChatLogSourceForTesting(job: job) { turnID in + [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] + } let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), From b816ed0bc02175e731718ab553ad38b71eb6758a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:48:09 +0900 Subject: [PATCH 190/359] refactor(review-ui): stop seeding preview jobs with timelines --- .../ReviewMonitorPreviewContent.swift | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 5aefa5f7..e1bfaa2b 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -216,8 +216,7 @@ public enum ReviewMonitorPreviewContent { 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.", - timelineItems: timelineItems + lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view." ) store.loadForTesting( serverState: .running, @@ -677,8 +676,7 @@ public enum ReviewMonitorPreviewContent { jobIndex: jobIndex, cwd: cwd ), - lastAgentMessage: definition.lastAgentMessage, - timelineItems: timelineItems + lastAgentMessage: definition.lastAgentMessage ) jobs.append(job) if let fixture = makeChatLogFixture(for: job, timelineItems: timelineItems) { @@ -760,10 +758,9 @@ public enum ReviewMonitorPreviewContent { summary: String, hasFinalReview: Bool, reviewResult: ParsedReviewResult?, - lastAgentMessage: String, - timelineItems: [PreviewTimelineItemTemplate] + lastAgentMessage: String ) -> CodexReviewJob { - let job = CodexReviewJob.makeForTesting( + CodexReviewJob.makeForTesting( id: id, cwd: cwd, targetSummary: targetSummary, @@ -778,21 +775,6 @@ public enum ReviewMonitorPreviewContent { reviewResult: reviewResult, lastAgentMessage: lastAgentMessage ) - seedPreviewTimeline(timelineItems, in: job) - return job - } - - private static func seedPreviewTimeline(_ timelineItems: [PreviewTimelineItemTemplate], in job: CodexReviewJob) { - for item in timelineItems { - let itemID = previewTimelineItemID( - itemName: item.itemName, - jobID: job.id, - cycle: 0 - ) - let seed = item.seed(id: itemID) - let event: ReviewDomainEvent = item.phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed) - job.timeline.apply(event) - } } private static func diagnosticItem( From 8499ad75133287577b0974fa50bd3a8fb37183e6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:51:51 +0900 Subject: [PATCH 191/359] refactor(review): hide job timeline from public API --- Sources/CodexReviewKit/Model/CodexReviewJob.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/CodexReviewJob.swift index 7370e822..82004044 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewJob.swift @@ -5,7 +5,7 @@ import Observation @Observable public final class CodexReviewJob: Identifiable, Hashable { public nonisolated let id: String - public let timeline: ReviewTimeline + package let timeline: ReviewTimeline public let sessionID: String public let cwd: String public internal(set) var sortOrder: Double From 892ef872bd849c4c96dbccb5e6dd32b6abd87590 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:00:59 +0900 Subject: [PATCH 192/359] refactor(review): keep timeline models package-scoped --- .../Domain/ReviewDomainEvent.swift | 4 +- .../Domain/ReviewIdentifiers.swift | 2 +- .../Domain/ReviewTimeline.swift | 2 +- .../Domain/ReviewTimelineItem.swift | 2 +- .../ReviewTimelineDocumentForTesting.swift | 268 +++++++++--------- ...ewTimelineDocumentRendererForTesting.swift | 8 +- 6 files changed, 143 insertions(+), 143 deletions(-) diff --git a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift index c0d89489..c176c4dc 100644 --- a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift +++ b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift @@ -1,6 +1,6 @@ import Foundation -public enum ReviewDomainEvent: Equatable, Sendable { +package enum ReviewDomainEvent: Equatable, Sendable { case runStarted(turnID: ReviewTurn.ID, reviewThreadID: ReviewThread.ID?, model: String?) case itemStarted(ReviewTimelineItemSeed) case itemUpdated(ReviewTimelineItemSeed) @@ -11,7 +11,7 @@ public enum ReviewDomainEvent: Equatable, Sendable { case reviewCancelled(String) } -public struct ReviewTimelineItemSeed: Equatable, Sendable { +package struct ReviewTimelineItemSeed: Equatable, Sendable { public var id: ReviewTimelineItem.ID public var kind: ReviewItemKind public var family: ReviewItemFamily diff --git a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift index 2ca106a2..ae2eb291 100644 --- a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift +++ b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift @@ -34,7 +34,7 @@ public struct ReviewTurnID: ReviewStringIdentifier, CustomStringConvertible { } } -public struct ReviewTimelineItemID: ReviewStringIdentifier, CustomStringConvertible { +package struct ReviewTimelineItemID: ReviewStringIdentifier, CustomStringConvertible { public var rawValue: String public init(rawValue: String) { diff --git a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift index f9519d5d..2defe967 100644 --- a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift +++ b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift @@ -3,7 +3,7 @@ import Observation @MainActor @Observable -public final class ReviewTimeline { +package final class ReviewTimeline { public struct Revision: RawRepresentable, Codable, Hashable, Sendable, Comparable { public var rawValue: UInt64 diff --git a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift index c2d0c153..bf417798 100644 --- a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift +++ b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift @@ -3,7 +3,7 @@ import Observation @MainActor @Observable -public final class ReviewTimelineItem: Identifiable, Hashable { +package final class ReviewTimelineItem: Identifiable, Hashable { public typealias ID = ReviewTimelineItemID public enum Content: Equatable, Codable, Sendable { diff --git a/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift b/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift index 2664de9b..42b9dca9 100644 --- a/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift +++ b/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift @@ -3,44 +3,44 @@ import CodexReviewKit // Test-only bridge for legacy ReviewTimeline fixtures. Production detail rendering // uses CodexChatChange streams through ReviewMonitorSelectedCodexChat. -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 +struct ReviewTimelineDocument: Codable, Equatable, Sendable { + struct Block: Identifiable, Codable, Equatable, Sendable { + struct ID: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral, CustomStringConvertible { + var rawValue: String - public init(rawValue: String) { + init(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { + init(stringLiteral value: String) { self.init(rawValue: value) } - public init(itemID: ReviewTimelineItem.ID) { + init(itemID: ReviewTimelineItem.ID) { self.init(rawValue: itemID.rawValue) } - public var description: String { + 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( + var id: ID + var sourceItemID: ReviewTimelineItem.ID + var kind: ReviewItemKind + var family: ReviewItemFamily + var phase: ReviewItemPhase + var isActive: Bool + var primaryText: String + var rawTranscriptText: String + var content: Content + var createdAt: Date + var updatedAt: Date + var startedAt: Date? + var completedAt: Date? + var durationMs: Int? + + init( id: ID, sourceItemID: ReviewTimelineItem.ID, kind: ReviewItemKind, @@ -73,7 +73,7 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - public enum Content: Codable, Equatable, Sendable { + enum Content: Codable, Equatable, Sendable { case approval(Approval) case command(Command) case contextCompaction(ContextCompaction) @@ -86,7 +86,7 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { case toolCall(ToolCall) case unknown(Unknown) - public var type: String { + var type: String { switch self { case .approval: "approval" @@ -114,15 +114,15 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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? + struct Approval: Codable, Equatable, Sendable { + var title: String + var detail: String? + var decision: ReviewApprovalDecision? + var scope: ReviewApprovalScope? + var risk: ReviewApprovalRisk? + var status: ReviewApprovalStatus? - public init( + init( title: String, detail: String? = nil, decision: ReviewApprovalDecision? = nil, @@ -139,15 +139,15 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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? + struct Command: Codable, Equatable, Sendable { + struct Action: Codable, Equatable, Sendable { + var kind: ReviewCommandActionKind + var command: String? + var name: String? + var path: String? + var query: String? - public init( + init( kind: ReviewCommandActionKind, command: String? = nil, name: String? = nil, @@ -162,18 +162,18 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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( + var title: String + var command: String + var cwd: String? + var output: String + var exitCode: Int? + var status: ReviewCommandStatus? + var source: ReviewCommandSource? + var processID: String? + var actions: [Action] + var durationMs: Int? + + init( title: String, command: String, cwd: String? = nil, @@ -198,13 +198,13 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - public struct ContextCompaction: Codable, Equatable, Sendable { - public var title: String - public var status: ReviewContextCompactionStatus? - public var inputTokens: Int? - public var outputTokens: Int? + struct ContextCompaction: Codable, Equatable, Sendable { + var title: String + var status: ReviewContextCompactionStatus? + var inputTokens: Int? + var outputTokens: Int? - public init( + init( title: String, status: ReviewContextCompactionStatus? = nil, inputTokens: Int? = nil, @@ -217,14 +217,14 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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? + struct Diagnostic: Codable, Equatable, Sendable { + struct Retry: Codable, Equatable, Sendable { + var state: ReviewDiagnosticRetryState + var attempt: Int? + var maxAttempts: Int? + var delayMs: Int? - public init( + init( state: ReviewDiagnosticRetryState, attempt: Int? = nil, maxAttempts: Int? = nil, @@ -237,11 +237,11 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - public var message: String - public var severity: ReviewDiagnosticSeverity? - public var retry: Retry? + var message: String + var severity: ReviewDiagnosticSeverity? + var retry: Retry? - public init( + init( message: String, severity: ReviewDiagnosticSeverity? = nil, retry: Retry? = nil @@ -252,14 +252,14 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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? + struct FileChange: Codable, Equatable, Sendable { + var title: String + var output: String + var paths: [String] + var patch: String? + var status: ReviewFileChangeStatus? - public init( + init( title: String, output: String = "", paths: [String] = [], @@ -274,40 +274,40 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - public struct Message: Codable, Equatable, Sendable { - public var text: String + struct Message: Codable, Equatable, Sendable { + var text: String - public init(text: String) { + init(text: String) { self.text = text } } - public struct Plan: Codable, Equatable, Sendable { - public var markdown: String + struct Plan: Codable, Equatable, Sendable { + var markdown: String - public init(markdown: String) { + init(markdown: String) { self.markdown = markdown } } - public struct Reasoning: Codable, Equatable, Sendable { - public var text: String - public var style: ReviewTimelineItem.Reasoning.Style + struct Reasoning: Codable, Equatable, Sendable { + var text: String + var style: ReviewTimelineItem.Reasoning.Style - public init(text: String, style: ReviewTimelineItem.Reasoning.Style) { + 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? + struct Search: Codable, Equatable, Sendable { + var query: String + var result: String? + var status: ReviewSearchStatus? + var resultCount: Int? + var durationMs: Int? - public init( + init( query: String, result: String? = nil, status: ReviewSearchStatus? = nil, @@ -322,21 +322,21 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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( + struct ToolCall: Codable, Equatable, Sendable { + var namespace: String? + var server: String? + var name: String? + var arguments: String? + var result: String? + var error: String? + var status: ReviewToolCallStatus? + var durationMs: Int? + var appContext: ReviewAppContext? + var pluginID: ReviewPluginID? + var callID: ReviewToolCall.ID? + var progress: String? + + init( namespace: String? = nil, server: String? = nil, name: String? = nil, @@ -365,26 +365,26 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - public struct Unknown: Codable, Equatable, Sendable { - public struct RawReference: Codable, Equatable, Sendable { - public var kind: ReviewRawReferenceKind - public var value: String - public var label: String? + struct Unknown: Codable, Equatable, Sendable { + struct RawReference: Codable, Equatable, Sendable { + var kind: ReviewRawReferenceKind + var value: String + var label: String? - public init(kind: ReviewRawReferenceKind, value: String, label: String? = nil) { + 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] + var title: String + var detail: String? + var rawKind: ReviewItemKind? + var rawStatus: String? + var references: [RawReference] - public init( + init( title: String, detail: String? = nil, rawKind: ReviewItemKind? = nil, @@ -399,17 +399,17 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { } } - 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( + var timelineRevision: ReviewTimeline.Revision + var orderedBlockIDs: [Block.ID] + var activeBlockIDs: [Block.ID] + var activeBlockCount: Int + var latestActivityBlockID: Block.ID? + var terminalStatus: ReviewLifecycleStatus? + var terminalSummary: String? + var terminalResult: String? + var blocks: [Block] + + init( timelineRevision: ReviewTimeline.Revision, orderedBlockIDs: [Block.ID], activeBlockIDs: [Block.ID], @@ -431,7 +431,7 @@ public struct ReviewTimelineDocument: Codable, Equatable, Sendable { self.blocks = blocks } - public var plainText: String { + var plainText: String { blocks.map(\.rawTranscriptText).filter { $0.isEmpty == false }.joined(separator: "\n\n") } } diff --git a/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift b/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift index 30133a38..ded05b4d 100644 --- a/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift +++ b/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift @@ -3,11 +3,11 @@ import CodexReviewKit // Kept in ReviewUITests while legacy job timeline fixtures still seed UI tests. // Runtime UI code should not depend on this renderer. -public struct ReviewTimelineDocumentRenderer: Sendable { - public init() {} +struct ReviewTimelineDocumentRenderer: Sendable { + init() {} @MainActor - public func document(from timeline: ReviewTimeline) -> ReviewTimelineDocument { + func document(from timeline: ReviewTimeline) -> ReviewTimelineDocument { let activeBlockIDs = timeline.orderedItemIDs .filter { timeline.activeItemIDs.contains($0) } .map(ReviewTimelineDocument.Block.ID.init(itemID:)) @@ -28,7 +28,7 @@ public struct ReviewTimelineDocumentRenderer: Sendable { } @MainActor - public func plainText(from timeline: ReviewTimeline) -> String { + func plainText(from timeline: ReviewTimeline) -> String { document(from: timeline).plainText } From 2b38eb26136abc0e5d4976491799e3932210d469 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:10:33 +0900 Subject: [PATCH 193/359] refactor(review-ui): select chats without review identity --- ...ReviewJob+LegacyReviewChatProjection.swift | 18 +---- ...ReviewMonitorTransportViewController.swift | 14 +--- .../ReviewMonitorSelectedCodexChat.swift | 76 +++++-------------- ...onitorSelectedCodexChatLogProjection.swift | 2 - .../ReviewMonitorCodexSidebarLibrary.swift | 5 +- .../ReviewMonitorSelectedCodexChatTests.swift | 23 ++---- 6 files changed, 25 insertions(+), 113 deletions(-) diff --git a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift index 89f5a706..bfef437b 100644 --- a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift +++ b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift @@ -2,21 +2,6 @@ import CodexKit import CodexReviewKit extension CodexReviewJob { - @MainActor - var legacyReviewChatIdentity: CodexReviewIdentity? { - guard let sourceThreadID = core.run.threadID?.nilIfEmpty, - let turnID = core.run.turnID?.nilIfEmpty - else { - return nil - } - return CodexReviewIdentity( - threadID: CodexThreadID(rawValue: sourceThreadID), - turnID: CodexTurnID(rawValue: turnID), - reviewThreadID: core.run.reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)), - model: core.run.model?.nilIfEmpty - ) - } - @MainActor var legacyReviewChatID: CodexThreadID? { if let reviewThreadID = core.run.reviewThreadID?.nilIfEmpty { @@ -39,8 +24,7 @@ extension CodexReviewJob { title: displayTitle, preview: core.output.lastAgentMessage?.nilIfEmpty ?? core.output.summary.nilIfEmpty, workspaceCWD: cwd, - updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - reviewIdentity: legacyReviewChatIdentity + updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt ) } } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index df41d3d3..40878d85 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -143,7 +143,6 @@ final class ReviewMonitorTransportViewController: NSViewController { return displayedSelection != selection?.id case .chat(let selectedChat): return boundChatID != selectedChat.id - || selectedCodexChat.identity != selectedChat.reviewIdentity || displayedSelection != selection?.id case nil: return displayedSelection != nil @@ -197,15 +196,10 @@ final class ReviewMonitorTransportViewController: NSViewController { resetLogRenderer() boundChatID = selectedChat.id let restorationTarget = restorationTarget(chatID: selectedChat.id) - if let reviewIdentity = selectedChat.reviewIdentity { - selectedCodexChat.bind(to: reviewIdentity) - } else { - selectedCodexChat.bind(toChatID: selectedChat.id) - } + selectedCodexChat.bind(toChatID: selectedChat.id) if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChat.id) { startPreviewChatChangeStream( previewStream, - activeTurnID: selectedChat.reviewIdentity?.turnID, chatUpdatedAt: selectedChat.updatedAt, target: .chat(selectedChat.id), initialRestorationTarget: restorationTarget @@ -341,7 +335,6 @@ final class ReviewMonitorTransportViewController: NSViewController { private func startPreviewChatChangeStream( _ stream: AsyncStream, - activeTurnID: CodexTurnID?, chatUpdatedAt: Date?, target: LogRenderTarget, initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget @@ -359,7 +352,6 @@ final class ReviewMonitorTransportViewController: NSViewController { guard let logChange = projection.apply( change, - activeTurnID: activeTurnID, chatCreatedAt: nil, chatUpdatedAt: chatUpdatedAt ) @@ -534,10 +526,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { selectedWorkspaceFindingsObservation } - var selectedCodexChatReviewIdentityForTesting: CodexReviewIdentity? { - selectedCodexChat.identity - } - var selectedCodexChatIDForTesting: String? { selectedCodexChat.chat?.id.rawValue } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 5bfa8784..3cab7c87 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -6,35 +6,6 @@ import ObservationBridge @MainActor @Observable final class ReviewMonitorSelectedCodexChat { - private enum BindingTarget: Equatable { - case review(CodexReviewIdentity) - case chat(CodexThreadID) - - var reviewIdentity: CodexReviewIdentity? { - guard case .review(let identity) = self else { - return nil - } - return identity - } - - var chatID: CodexThreadID { - switch self { - case .review(let identity): - identity.activeTurnThreadID - case .chat(let id): - id - } - } - - var activeTurnID: CodexTurnID? { - guard case .review(let identity) = self else { - return nil - } - return identity.turnID - } - } - - private(set) var identity: CodexReviewIdentity? private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? var phase: CodexDataPhase { @@ -49,9 +20,9 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private weak var boundModelContext: CodexModelContext? @ObservationIgnored - private var boundTarget: BindingTarget? + private var boundChatID: CodexThreadID? @ObservationIgnored - private var target: BindingTarget? + private var targetChatID: CodexThreadID? @ObservationIgnored private var observation: CodexChatObservation? @ObservationIgnored @@ -75,18 +46,13 @@ final class ReviewMonitorSelectedCodexChat { cancelObservation() } - func bind(to identity: CodexReviewIdentity?) { - boundTarget = identity.map(BindingTarget.review) - refreshBinding() - } - func bind(toChatID chatID: CodexThreadID?) { - boundTarget = chatID.map(BindingTarget.chat) + boundChatID = chatID refreshBinding() } func unbind() { - boundTarget = nil + boundChatID = nil refreshBinding() } @@ -116,33 +82,32 @@ final class ReviewMonitorSelectedCodexChat { } private func refreshBinding() { - let nextTarget = boundTarget + let nextChatID = boundChatID let nextModelContext = modelSource?.modelContext - guard nextTarget != target || nextModelContext !== boundModelContext else { + guard nextChatID != targetChatID || nextModelContext !== boundModelContext else { return } cancelObservation() - target = nextTarget - identity = nextTarget?.reviewIdentity - chatID = nextTarget?.chatID + targetChatID = nextChatID + chatID = nextChatID chat = nil publishLogSourceChange(.clear) logProjection.reset() boundModelContext = nextModelContext - guard let nextTarget, let modelContext = nextModelContext else { + guard let nextChatID, let modelContext = nextModelContext else { return } - let nextChat = modelContext.model(for: nextTarget.chatID) + let nextChat = modelContext.model(for: nextChatID) chat = nextChat - observationTask = Task { @MainActor [weak self, nextChat, nextTarget, modelContext] in + observationTask = Task { @MainActor [weak self, nextChat, nextChatID, modelContext] in do { - let observation = try await Self.observe(nextTarget, modelContext: modelContext, chat: nextChat) + let observation = try await Self.observe(chat: nextChat, modelContext: modelContext) guard Task.isCancelled == false, let self, - self.target == nextTarget, + self.targetChatID == nextChatID, self.chat === nextChat else { observation.cancel() @@ -151,7 +116,7 @@ final class ReviewMonitorSelectedCodexChat { self.observation = observation for await change in observation.changes { guard Task.isCancelled == false, - self.target == nextTarget, + self.targetChatID == nextChatID, self.chat === nextChat else { break @@ -159,7 +124,6 @@ final class ReviewMonitorSelectedCodexChat { self.publishLogSourceChange( self.logProjection.apply( change, - activeTurnID: nextTarget.activeTurnID, chatCreatedAt: nextChat.createdAt, chatUpdatedAt: nextChat.updatedAt )) @@ -188,15 +152,9 @@ final class ReviewMonitorSelectedCodexChat { } private static func observe( - _ target: BindingTarget, - modelContext: CodexModelContext, - chat: CodexChat + chat: CodexChat, + modelContext: CodexModelContext ) async throws -> CodexChatObservation { - switch target { - case .review(let identity): - try await modelContext.observe(identity) - case .chat: - try await chat.observe() - } + try await modelContext.observe(chat) } } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift index 1ba2e87e..1bc4aa23 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift @@ -42,11 +42,9 @@ struct ReviewMonitorSelectedCodexChatLogProjection { mutating func apply( _ change: CodexChatChange, - activeTurnID: CodexTurnID?, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewMonitorLogSourceChange? { - turnProjection.selection = activeTurnID.map(CodexChatTurnSelection.turn) ?? .latest let update = turnProjection.apply(change) guard update.affectsSelectedTurn else { return nil diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 178ecdad..8e0a0006 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -65,7 +65,6 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { package var preview: String? package var workspaceCWD: String? package var updatedAt: Date? - package var reviewIdentity: CodexReviewIdentity? package init( rowID: ReviewMonitorCodexSidebarRowID, @@ -73,8 +72,7 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { title: String, preview: String?, workspaceCWD: String?, - updatedAt: Date?, - reviewIdentity: CodexReviewIdentity? = nil + updatedAt: Date? ) { self.rowID = rowID self.id = id @@ -82,7 +80,6 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { self.preview = preview self.workspaceCWD = workspaceCWD self.updatedAt = updatedAt - self.reviewIdentity = reviewIdentity } } diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 3d3e3336..ef50fd72 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -7,7 +7,7 @@ import Testing @Suite("ReviewMonitor selected Codex chat", .serialized) @MainActor struct ReviewMonitorSelectedCodexChatTests { - @Test func selectedReviewChatObservesReviewIdentity() async throws { + @Test func selectedReviewChatObservesChatID() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) @@ -44,20 +44,14 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - let reviewIdentity = CodexReviewIdentity( - threadID: CodexThreadID(rawValue: "source-thread"), - turnID: CodexTurnID(rawValue: "turn-1"), - reviewThreadID: CodexThreadID(rawValue: "review-thread") - ) uiState.selection = .chat( .init( - rowID: .chat(reviewIdentity.activeTurnThreadID), - id: reviewIdentity.activeTurnThreadID, + rowID: .chat(CodexThreadID(rawValue: "review-thread")), + id: CodexThreadID(rawValue: "review-thread"), title: "Review", preview: nil, workspaceCWD: "/tmp/project", - updatedAt: nil, - reviewIdentity: reviewIdentity + updatedAt: nil )) try await waitForCondition { @@ -65,9 +59,6 @@ struct ReviewMonitorSelectedCodexChatTests { && transport.selectedCodexChatPhaseForTesting == .loading && transport.selectedCodexChatItemTextsForTesting == ["Review snapshot"] } - #expect( - transport.selectedCodexChatReviewIdentityForTesting?.activeTurnThreadID == "review-thread" - ) #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) } @@ -101,7 +92,7 @@ struct ReviewMonitorSelectedCodexChatTests { uiState.selection = nil try await waitForCondition { - transport.selectedCodexChatIDForTesting == nil && transport.selectedCodexChatReviewIdentityForTesting == nil + transport.selectedCodexChatIDForTesting == nil && transport.selectedCodexChatPhaseForTesting == .idle } } @@ -151,9 +142,6 @@ struct ReviewMonitorSelectedCodexChatTests { in: uiState ) - try await waitForCondition { - transport.selectedCodexChatReviewIdentityForTesting?.activeTurnThreadID == "review-thread" - } #expect(transport.selectedCodexChatIDForTesting == nil) modelSource.install(container: CodexModelContainer(appServer: runtime.server)) @@ -416,7 +404,6 @@ struct ReviewMonitorSelectedCodexChatTests { snapshot.log.contains("Generic chat snapshot") } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) - #expect(transport.selectedCodexChatReviewIdentityForTesting == nil) #expect(transport.selectedCodexChatIDForTesting == "chat-thread") try await runtime.transport.emitServerNotification( From 6a8e659ed41afc8d7ee386971947c9c553c4b4f6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:21:02 +0900 Subject: [PATCH 194/359] refactor(review-ui): render codex sidebar chats as chat rows --- .../Jobs/ReviewMonitorChatRowView.swift | 12 ++++++ .../ReviewMonitorCodexSidebarLibrary.swift | 4 ++ .../ReviewMonitorSidebarViewController.swift | 38 ++++++++++++++++++- ...eviewMonitorCodexSidebarLibraryTests.swift | 13 ++++--- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 6d17e30a..e108615e 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -60,6 +60,18 @@ struct ReviewMonitorSidebarChatRow: Equatable { isRunning: runtime.isRunning ) } + + init(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { + self.init( + id: chat.id.rawValue, + title: chat.title, + model: chat.model, + subtitle: chat.preview?.trimmedNonEmpty, + startedAt: nil, + endedAt: chat.updatedAt, + isRunning: false + ) + } } @MainActor diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 8e0a0006..3df59368 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -63,6 +63,7 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { package var id: CodexThreadID package var title: String package var preview: String? + package var model: String? package var workspaceCWD: String? package var updatedAt: Date? @@ -71,6 +72,7 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { id: CodexThreadID, title: String, preview: String?, + model: String? = nil, workspaceCWD: String?, updatedAt: Date? ) { @@ -78,6 +80,7 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { self.id = id self.title = title self.preview = preview + self.model = model self.workspaceCWD = workspaceCWD self.updatedAt = updatedAt } @@ -396,6 +399,7 @@ package final class ReviewMonitorCodexSidebarLibrary { id: chat.id, title: chat.title, preview: chat.preview, + model: chat.modelProvider, workspaceCWD: chat.workspace?.url.path, updatedAt: chat.updatedAt ) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index f9bee04e..bd16b822 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -2069,7 +2069,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { - if codexSidebarNode(from: item) != nil { + if let node = codexSidebarNode(from: item) { + if case .chat = node.item { + return rowHeights.reviewChat + } return rowHeights.workspace } if workspaceSection(from: item) != nil { @@ -2174,7 +2177,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { - if codexSidebarNode(from: item) != nil { + if let node = codexSidebarNode(from: item) { + if case .chat = node.item { + return ReviewMonitorReviewChatTableRowView() + } return ReviewMonitorWorkspaceRowView() } if workspaceSection(from: item) != nil { @@ -2192,6 +2198,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD item: Any ) -> NSView? { if let node = codexSidebarNode(from: item) { + if case .chat(let chat) = node.item { + let view = (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) as? ReviewMonitorReviewChatCellView) + ?? ReviewMonitorReviewChatCellView() + view.identifier = Identifier.reviewChatCell + view.objectValue = node + view.configure(with: chat) + return view + } + let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView) ?? ReviewMonitorWorkspaceCellView() view.identifier = Identifier.workspaceCell @@ -2286,6 +2301,19 @@ extension ReviewMonitorSidebarViewController { codexSidebarOutlineTree.node(rowID: rowID)?.title } + func codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(_ id: CodexThreadID) -> Bool { + guard let row = row(forCodexSidebarSelectionID: .chat(id)), + let cellView = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: true + ) as? ReviewMonitorReviewChatCellView + else { + return false + } + return cellView.isHostingReviewMonitorChatRowViewForTesting + } + func selectCodexSidebarRowForTesting(rowID: ReviewMonitorCodexSidebarRowID) { guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return @@ -3071,6 +3099,12 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { configureRow(row.presentation) } + func configure(with chat: ReviewMonitorCodexSidebarSnapshot.Chat) { + objectValue = chat + toolTip = chat.workspaceCWD ?? chat.preview ?? chat.title + configureRow(ReviewMonitorSidebarChatRow(chat: chat)) + } + private func configureRow(_ row: ReviewMonitorSidebarChatRow) { if let hostingView { hostingView.rootView.row = row diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 28c435df..45b90a47 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -185,11 +185,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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: "thread-app", + id: threadID, workspace: repo, name: "App review", updatedAt: Date(timeIntervalSince1970: 5_000) @@ -213,24 +214,26 @@ struct ReviewMonitorCodexSidebarLibraryTests { let sidebar = viewController.sidebarViewControllerForTesting try await waitForCondition { sidebar.codexSidebarSnapshotForTesting? - .chat(id: CodexThreadID(rawValue: "thread-app"))? + .chat(id: threadID)? .title == "App review" } try await waitForCondition { sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent] - && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == "App review" + && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" } #expect(sidebar.displayedCodexSidebarTitlesForTesting == [ repo.lastPathComponent, repo.lastPathComponent, "App review", ]) - sidebar.selectCodexSidebarRowForTesting(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) + #expect(sidebar.codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(threadID)) + + sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) guard case .chat(let selectedChat) = uiState.selection else { Issue.record("Expected selecting a Codex sidebar chat row to select the chat.") return } - #expect(selectedChat.id == CodexThreadID(rawValue: "thread-app")) + #expect(selectedChat.id == threadID) #expect(selectedChat.title == "App review") } From e7ee67df147282920120314d3c4733765416ea8f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:28:03 +0900 Subject: [PATCH 195/359] refactor(review-ui): build preview logs from chat fixtures --- .../ReviewMonitorPreviewContent.swift | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index e1bfaa2b..dfbad458 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -205,8 +205,12 @@ public enum ReviewMonitorPreviewContent { let cwd = "/path/to/workspace-alpha" let now = Date() let timelineItems = makeCommandOutputPreviewTimelineItems() + let chatID = CodexThreadID(rawValue: "preview-command-output-panel") + let turnID = CodexTurnID(rawValue: "preview-command-output-turn") let job = makeJob( id: "preview-command-output-panel", + threadID: chatID.rawValue, + turnID: turnID.rawValue, cwd: cwd, model: "gpt-5.4", status: .running, @@ -226,11 +230,15 @@ public enum ReviewMonitorPreviewContent { workspaces: [CodexReviewWorkspace(cwd: cwd)], jobs: [job] ) - if let fixture = makeChatLogFixture(for: job, timelineItems: timelineItems) { - store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( - chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) - ) - } + let fixture = makeChatLogFixture( + chatID: chatID, + turnID: turnID, + for: job, + timelineItems: timelineItems + ) + store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( + chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) + ) return store } @@ -661,8 +669,12 @@ public enum ReviewMonitorPreviewContent { workspaces.append(CodexReviewWorkspace(cwd: cwd)) for (jobIndex, definition) in makeJobDefinitions(for: workspaceName).enumerated() { let timelineItems = makePreviewTimelineItems(for: definition, workspaceName: workspaceName) + let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(jobIndex)") + let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(jobIndex)") let job = makeJob( id: "preview-\(workspaceIndex)-\(jobIndex)", + threadID: chatID.rawValue, + turnID: turnID.rawValue, cwd: cwd, model: definition.model, status: definition.status, @@ -679,9 +691,13 @@ public enum ReviewMonitorPreviewContent { lastAgentMessage: definition.lastAgentMessage ) jobs.append(job) - if let fixture = makeChatLogFixture(for: job, timelineItems: timelineItems) { - chatLogFixtures.append(fixture) - } + chatLogFixtures.append( + makeChatLogFixture( + chatID: chatID, + turnID: turnID, + for: job, + timelineItems: timelineItems + )) } } return PreviewSidebarContent( @@ -692,14 +708,22 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( + chatID: CodexThreadID, + turnID: CodexTurnID, for job: CodexReviewJob, timelineItems: [PreviewTimelineItemTemplate] - ) -> ReviewMonitorPreviewChatLogFixture? { - guard let chat = job.legacyReviewChatSelection else { - return nil - } + ) -> ReviewMonitorPreviewChatLogFixture { + let chat = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: job.displayTitle, + preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, + model: job.core.run.model, + workspaceCWD: job.cwd, + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt + ) let turn = CodexChatTurnStateSnapshot( - id: job.previewTurnID, + id: turnID, status: CodexTurnStatus(job.core.lifecycle.status), errorDescription: job.core.lifecycle.errorMessage, usage: nil @@ -749,6 +773,8 @@ public enum ReviewMonitorPreviewContent { private static func makeJob( id: String, + threadID: String, + turnID: String, cwd: String, model: String, status: ReviewJobState, @@ -765,8 +791,8 @@ public enum ReviewMonitorPreviewContent { cwd: cwd, targetSummary: targetSummary, model: model, - threadID: UUID().uuidString, - turnID: UUID().uuidString, + threadID: threadID, + turnID: turnID, status: status, startedAt: startedAt, endedAt: endedAt, @@ -1208,12 +1234,6 @@ public enum ReviewMonitorPreviewContent { } } -private extension CodexReviewJob { - var previewTurnID: CodexTurnID { - core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") - } -} - private extension CodexTurnStatus { init(_ jobState: ReviewJobState) { switch jobState { From 4a5b1186aa5681dc537aacfaa4777524f3d4af3c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:32:06 +0900 Subject: [PATCH 196/359] test(review-ui): select codex chats without review jobs --- .../ReviewMonitorSelectedCodexChatTests.swift | 127 +++--------------- 1 file changed, 17 insertions(+), 110 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index ef50fd72..a22d7bab 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -77,14 +77,7 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - try selectReviewChat( - makeRunningReviewJob( - sourceThreadID: "source-thread", - reviewThreadID: "review-thread", - turnID: "turn-1" - ), - in: uiState - ) + selectChat(id: "review-thread", in: uiState) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" && transport.selectedCodexChatPhaseForTesting == .loaded @@ -133,14 +126,7 @@ struct ReviewMonitorSelectedCodexChatTests { codexModelSource: modelSource ) transport.loadViewIfNeeded() - try selectReviewChat( - makeRunningReviewJob( - sourceThreadID: "source-thread", - reviewThreadID: "review-thread", - turnID: "turn-1" - ), - in: uiState - ) + selectChat(id: "review-thread", in: uiState) #expect(transport.selectedCodexChatIDForTesting == nil) @@ -153,62 +139,6 @@ struct ReviewMonitorSelectedCodexChatTests { } } - @Test func legacyReviewChatSelectionCanStartAfterRunIdentifiersArrive() 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: "Arrived after selection" - )) - ) - ] - ) - ] - )) - - let store = CodexReviewStore.makePreviewStore() - let uiState = ReviewMonitorUIState(auth: store.auth) - let transport = ReviewMonitorTransportViewController( - store: store, - uiState: uiState, - modelContext: modelContext - ) - transport.loadViewIfNeeded() - let job = makeRunningReviewJob( - sourceThreadID: nil, - reviewThreadID: nil, - turnID: nil - ) - #expect(job.legacyReviewChatSelection == nil) - - job.core.run.threadID = "source-thread" - job.core.run.reviewThreadID = "review-thread" - job.core.run.turnID = "turn-1" - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline trigger")) - try selectReviewChat(job, in: uiState) - - try await waitForCondition { - transport.selectedCodexChatIDForTesting == "review-thread" - && transport.selectedCodexChatPhaseForTesting == .loading - && transport.selectedCodexChatItemTextsForTesting == ["Arrived after selection"] - } - } - @Test func selectedReviewChatRendersCodexChatTurnAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext @@ -246,13 +176,7 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - let job = makeRunningReviewJob( - sourceThreadID: "source-thread", - reviewThreadID: "review-thread", - turnID: "turn-1" - ) - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Timeline fallback")) - try selectReviewChat(job, in: uiState) + selectChat(id: "review-thread", in: uiState) let initialSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Chat snapshot") @@ -317,12 +241,7 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - let job = makeRunningReviewJob( - sourceThreadID: "source-thread", - reviewThreadID: "review-thread", - turnID: "turn-1" - ) - try selectReviewChat(job, in: uiState) + selectChat(id: "review-thread", in: uiState) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log == "Initial" @@ -427,32 +346,20 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) } - private func makeRunningReviewJob( - sourceThreadID: String?, - reviewThreadID: String?, - turnID: String? - ) -> CodexReviewJob { - let job = CodexReviewJob.makeForTesting( - id: "job-1", - sessionID: "session-1", - cwd: "/tmp/project", - targetSummary: "Uncommitted changes", - threadID: sourceThreadID, - turnID: turnID, - status: .running, - summary: "Running" - ) - job.core.run.attemptID = "attempt-1" - job.core.run.reviewThreadID = reviewThreadID - return job - } - - private func selectReviewChat( - _ job: CodexReviewJob, + private func selectChat( + id: String, + title: String = "Review", in uiState: ReviewMonitorUIState - ) throws { - let chat = try #require(job.legacyReviewChatSelection) - uiState.selection = .chat(chat) + ) { + let chatID = CodexThreadID(rawValue: id) + uiState.selection = .chat(.init( + rowID: .chat(chatID), + id: chatID, + title: title, + preview: nil, + workspaceCWD: "/tmp/project", + updatedAt: nil + )) } } From 6cafd94d5d9f1a010dbba267fc3b02fcfd088138 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:33:18 +0900 Subject: [PATCH 197/359] test(review-ui): avoid legacy job chat projection in sidebar tests --- .../ReviewMonitorCodexSidebarLibraryTests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 45b90a47..759df390 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -242,6 +242,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() let visibleThreadID = CodexThreadID(rawValue: "thread-app") + let hiddenLegacyThreadID = CodexThreadID(rawValue: "legacy-review-thread") try await runtime.transport.enqueueThreadList(.init( threads: [ @@ -264,7 +265,14 @@ struct ReviewMonitorCodexSidebarLibraryTests { startedAt: Date(timeIntervalSince1970: 4_000), summary: "Running legacy review." ) - let legacyChat = try #require(legacyJob.legacyReviewChatSelection) + let legacyChat = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(hiddenLegacyThreadID), + id: hiddenLegacyThreadID, + title: "Legacy review row", + preview: "Running legacy review.", + workspaceCWD: repo.path, + updatedAt: Date(timeIntervalSince1970: 4_000) + ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, From c0b5c294c0808cf737a5c2c1c49c9bb3ccd7f447 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:33:12 +0900 Subject: [PATCH 198/359] fix(app-server): handle codex thread status variants --- .../AppServerCodexReviewBackend.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index d517e66d..97af170a 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -497,10 +497,14 @@ private enum AppServerTypedReviewEventAdapter { .init(events: reasoningPartEvents(part), controlThreadID: controlThreadID) case .reasoningDelta(let delta, _): .init(events: reasoningDeltaEvents(delta), controlThreadID: controlThreadID) - case .tokenUsageUpdated, .statusChanged(.running): + case .tokenUsageUpdated: .init(events: [], controlThreadID: controlThreadID) - case .statusChanged(.closed): + 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), From 16b69bcd7c5088b5534e1a55136526b0fd23c86e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:39:45 +0900 Subject: [PATCH 199/359] refactor(review-ui): model codex sidebar filter and order --- .../ReviewMonitorPreviewContent.swift | 4 +- .../Jobs/ReviewMonitorChatRowView.swift | 6 +- .../ReviewMonitorCodexSidebarLibrary.swift | 206 +++++++++++++++++- ...eviewJob+ReviewChatProjectionTesting.swift | 41 ++++ ...eviewMonitorCodexSidebarLibraryTests.swift | 189 ++++++++++++++-- 5 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index dfbad458..2fa073b0 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -720,7 +720,9 @@ public enum ReviewMonitorPreviewContent { preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, model: job.core.run.model, workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) ) let turn = CodexChatTurnStateSnapshot( id: turnID, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index e108615e..535d5c33 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -67,9 +67,9 @@ struct ReviewMonitorSidebarChatRow: Equatable { title: chat.title, model: chat.model, subtitle: chat.preview?.trimmedNonEmpty, - startedAt: nil, - endedAt: chat.updatedAt, - isRunning: false + startedAt: chat.isRunning ? chat.activityDate : nil, + endedAt: chat.isRunning ? nil : chat.activityDate, + isRunning: chat.isRunning ) } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 3df59368..14b8aba4 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -1,4 +1,5 @@ import CodexKit +import CodexReviewKit import Foundation package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringConvertible { @@ -38,10 +39,19 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { } var selection: ReviewMonitorWorkspaceSectionSelection { - ReviewMonitorWorkspaceSectionSelection( + var workspaceCWDs = workspaces.map(\.cwd) + for chat in uncategorizedChats { + guard let cwd = chat.workspaceCWD, + workspaceCWDs.contains(cwd) == false + else { + continue + } + workspaceCWDs.append(cwd) + } + return ReviewMonitorWorkspaceSectionSelection( id: id, title: title, - workspaceCWDs: workspaces.map(\.cwd) + workspaceCWDs: workspaceCWDs ) } } @@ -66,6 +76,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { package var model: String? package var workspaceCWD: String? package var updatedAt: Date? + package var recencyAt: Date? + package var status: CodexThreadStatus? package init( rowID: ReviewMonitorCodexSidebarRowID, @@ -74,7 +86,9 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { preview: String?, model: String? = nil, workspaceCWD: String?, - updatedAt: Date? + updatedAt: Date?, + recencyAt: Date? = nil, + status: CodexThreadStatus? = nil ) { self.rowID = rowID self.id = id @@ -83,6 +97,20 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { self.model = model self.workspaceCWD = workspaceCWD self.updatedAt = updatedAt + self.recencyAt = recencyAt + self.status = status + } + + var activityDate: Date? { + recencyAt ?? updatedAt + } + + var isRunning: Bool { + status?.isActive == true + } + + var isFinished: Bool { + status.map { $0.isActive == false } ?? false } } @@ -129,6 +157,174 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { } return nil } + + func filtered(by filter: SidebarReviewChatFilter) -> ReviewMonitorCodexSidebarSnapshot { + guard filter.isActive else { + return self + } + + return ReviewMonitorCodexSidebarSnapshot( + sections: sections.map { section in + let latestFinishedChatID = filter.contains(.latestFinished) + ? Self.latestFinishedChat(in: section.allChats)?.id + : nil + return Section( + rowID: section.rowID, + id: section.id, + title: section.title, + workspaces: section.workspaces.map { workspace in + Workspace( + rowID: workspace.rowID, + id: workspace.id, + cwd: workspace.cwd, + title: workspace.title, + chats: workspace.chats.filter { + Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) + } + ) + }, + uncategorizedChats: section.uncategorizedChats.filter { + Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) + } + ) + } + ) + } + + private static func includes( + _ chat: Chat, + filter: SidebarReviewChatFilter, + latestFinishedChatID: CodexThreadID? + ) -> Bool { + if filter.contains(.running), chat.isRunning { + return true + } + if filter.contains(.latestFinished), chat.id == latestFinishedChatID { + return true + } + return false + } + + private static func latestFinishedChat(in chats: [Chat]) -> Chat? { + var latestChat: Chat? + var latestDate = Date.distantPast + for chat in chats { + guard chat.isFinished else { + continue + } + let finishedAt = chat.activityDate ?? .distantPast + if latestChat == nil || finishedAt > latestDate { + latestChat = chat + latestDate = finishedAt + } + } + return latestChat + } +} + +private extension ReviewMonitorCodexSidebarSnapshot.Section { + var allChats: [ReviewMonitorCodexSidebarSnapshot.Chat] { + workspaces.flatMap(\.chats) + uncategorizedChats + } +} + +struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { + private var sectionIDs: [String] = [] + private var chatIDsByContainer: [ReviewMonitorCodexSidebarRowID: [CodexThreadID]] = [:] + + func applying(to snapshot: ReviewMonitorCodexSidebarSnapshot) -> ReviewMonitorCodexSidebarSnapshot { + ReviewMonitorCodexSidebarSnapshot( + sections: ordered(snapshot.sections, by: sectionIDs, id: \.id).map { section in + ReviewMonitorCodexSidebarSnapshot.Section( + rowID: section.rowID, + id: section.id, + title: section.title, + workspaces: section.workspaces.map { workspace in + ReviewMonitorCodexSidebarSnapshot.Workspace( + rowID: workspace.rowID, + id: workspace.id, + cwd: workspace.cwd, + title: workspace.title, + chats: ordered( + workspace.chats, + by: chatIDsByContainer[workspace.rowID] ?? [], + id: \.id + ) + ) + }, + uncategorizedChats: ordered( + section.uncategorizedChats, + by: chatIDsByContainer[section.rowID] ?? [], + id: \.id + ) + ) + } + ) + } + + mutating func reorderSection(id: String, before targetID: String?) -> Bool { + Self.reorder(id: id, before: targetID, in: §ionIDs) + } + + mutating func reorderChat( + id: CodexThreadID, + in container: ReviewMonitorCodexSidebarRowID, + before targetID: CodexThreadID? + ) -> Bool { + var ids = chatIDsByContainer[container] ?? [] + let didChange = Self.reorder(id: id, before: targetID, in: &ids) + chatIDsByContainer[container] = ids + return didChange + } + + 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 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 + } +} + +extension CodexThreadStatus { + init(reviewJobState: ReviewJobState) { + switch reviewJobState { + case .queued, .running: + self = .active(activeFlags: []) + case .succeeded, .failed, .cancelled: + self = .idle + } + } } package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { @@ -401,7 +597,9 @@ package final class ReviewMonitorCodexSidebarLibrary { preview: chat.preview, model: chat.modelProvider, workspaceCWD: chat.workspace?.url.path, - updatedAt: chat.updatedAt + updatedAt: chat.updatedAt, + recencyAt: chat.recencyAt, + status: chat.status ) } } diff --git a/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift b/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift new file mode 100644 index 00000000..2ef22988 --- /dev/null +++ b/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift @@ -0,0 +1,41 @@ +import CodexKit +@_spi(Testing) @testable import CodexReviewKit +@_spi(PreviewSupport) @testable import ReviewUI + +extension CodexReviewJob { + @MainActor + var legacyReviewChatID: CodexThreadID? { + if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { + return CodexThreadID(rawValue: reviewThreadID) + } + if let threadID = nonEmptyReviewChatProjectionStringForTesting(core.run.threadID) { + return CodexThreadID(rawValue: threadID) + } + return nil + } + + @MainActor + var legacyReviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let chatID = legacyReviewChatID else { + return nil + } + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: displayTitle, + preview: nonEmptyReviewChatProjectionStringForTesting(core.output.lastAgentMessage) + ?? nonEmptyReviewChatProjectionStringForTesting(core.output.summary), + workspaceCWD: cwd, + updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, + recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobState: core.lifecycle.status) + ) + } +} + +private func nonEmptyReviewChatProjectionStringForTesting(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value +} diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 759df390..35c26fdf 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -181,6 +181,120 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(workspace.children.isEmpty) } + @Test func sidebarSnapshotRunningFilterUsesThreadStatus() throws { + let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + let runningThreadID = CodexThreadID(rawValue: "thread-running") + let idleThreadID = CodexThreadID(rawValue: "thread-idle") + let snapshot = sidebarSnapshot( + workspaceID: workspaceID, + chats: [ + sidebarChat( + id: runningThreadID, + title: "Running review", + workspaceID: workspaceID, + updatedAt: Date(timeIntervalSince1970: 20), + status: .active(activeFlags: []) + ), + sidebarChat( + id: idleThreadID, + title: "Idle review", + workspaceID: workspaceID, + updatedAt: Date(timeIntervalSince1970: 30), + status: .idle + ), + ] + ) + + let filtered = snapshot.filtered(by: .running) + let workspace = try #require(filtered.sections.first?.workspaces.first) + #expect(workspace.chats.map(\.id) == [runningThreadID]) + #expect(filtered.chat(id: idleThreadID) == nil) + } + + @Test func sidebarSnapshotLatestFinishedFilterUsesSectionActivityDate() throws { + let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + let olderFinishedID = CodexThreadID(rawValue: "thread-older-finished") + let newerFinishedID = CodexThreadID(rawValue: "thread-newer-finished") + let runningThreadID = CodexThreadID(rawValue: "thread-running") + let snapshot = sidebarSnapshot( + workspaceID: workspaceID, + chats: [ + sidebarChat( + id: olderFinishedID, + title: "Older finished", + workspaceID: workspaceID, + updatedAt: Date(timeIntervalSince1970: 100), + status: .idle + ), + sidebarChat( + id: newerFinishedID, + title: "Newer finished", + workspaceID: workspaceID, + updatedAt: Date(timeIntervalSince1970: 300), + status: .idle + ), + sidebarChat( + id: runningThreadID, + title: "Running review", + workspaceID: workspaceID, + updatedAt: Date(timeIntervalSince1970: 400), + status: .active(activeFlags: []) + ), + ] + ) + + let latestFinished = snapshot.filtered(by: .latestFinished) + let combined: SidebarReviewChatFilter = [.running, .latestFinished] + let combinedFiltered = snapshot.filtered(by: combined) + + #expect(latestFinished.sections.first?.workspaces.first?.chats.map(\.id) == [newerFinishedID]) + #expect(combinedFiltered.sections.first?.workspaces.first?.chats.map(\.id) == [ + newerFinishedID, + runningThreadID, + ]) + } + + @Test func sidebarPresentationOrderReordersSectionsAndChatsLocally() throws { + let alphaWorkspaceID = CodexWorkspaceID(rawValue: "/tmp/Alpha") + let betaWorkspaceID = CodexWorkspaceID(rawValue: "/tmp/Beta") + let alphaFirstID = CodexThreadID(rawValue: "thread-alpha-first") + let alphaSecondID = CodexThreadID(rawValue: "thread-alpha-second") + let betaThreadID = CodexThreadID(rawValue: "thread-beta") + let alpha = sidebarSection( + id: "alpha", + title: "Alpha", + workspaceID: alphaWorkspaceID, + chats: [ + sidebarChat(id: alphaFirstID, title: "Alpha first", workspaceID: alphaWorkspaceID), + sidebarChat(id: alphaSecondID, title: "Alpha second", workspaceID: alphaWorkspaceID), + ] + ) + let beta = sidebarSection( + id: "beta", + title: "Beta", + workspaceID: betaWorkspaceID, + chats: [ + sidebarChat(id: betaThreadID, title: "Beta", workspaceID: betaWorkspaceID), + ] + ) + var order = ReviewMonitorCodexSidebarPresentationOrder() + + let didReorderSection = order.reorderSection(id: "beta", before: "alpha") + let didReorderChat = order.reorderChat(id: alphaSecondID, in: .workspace(alphaWorkspaceID), before: alphaFirstID) + + #expect(didReorderSection) + #expect(didReorderChat) + + let ordered = order.applying(to: ReviewMonitorCodexSidebarSnapshot(sections: [alpha, beta])) + + #expect(ordered.sections.map(\.id) == ["beta", "alpha"]) + #expect(ordered.sections[1].workspaces.first?.chats.map(\.id) == [ + alphaSecondID, + alphaFirstID, + ]) + #expect(ordered.sections[0].workspaces.first?.chats.map(\.id) == [betaThreadID]) + } + @Test func sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -387,35 +501,70 @@ private func sidebarSnapshot( chatTitle: String, includesChat: Bool ) -> ReviewMonitorCodexSidebarSnapshot { - let chat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(threadID), + let chat = sidebarChat( id: threadID, title: chatTitle, - preview: nil, - workspaceCWD: workspaceID.rawValue, - updatedAt: nil + workspaceID: workspaceID + ) + return sidebarSnapshot( + workspaceID: workspaceID, + chats: includesChat ? [chat] : [] ) +} + +private func sidebarSnapshot( + workspaceID: CodexWorkspaceID, + chats: [ReviewMonitorCodexSidebarSnapshot.Chat] +) -> ReviewMonitorCodexSidebarSnapshot { return ReviewMonitorCodexSidebarSnapshot( sections: [ - ReviewMonitorCodexSidebarSnapshot.Section( - rowID: .section("repo"), - id: "repo", - title: "Repo", - workspaces: [ - ReviewMonitorCodexSidebarSnapshot.Workspace( - rowID: .workspace(workspaceID), - id: workspaceID, - cwd: workspaceID.rawValue, - title: "App", - chats: includesChat ? [chat] : [] - ), - ], - uncategorizedChats: [] - ), + sidebarSection(id: "repo", title: "Repo", workspaceID: workspaceID, chats: chats), ] ) } +private func sidebarSection( + id: String, + title: String, + workspaceID: CodexWorkspaceID, + chats: [ReviewMonitorCodexSidebarSnapshot.Chat] +) -> ReviewMonitorCodexSidebarSnapshot.Section { + ReviewMonitorCodexSidebarSnapshot.Section( + rowID: .section(id), + id: id, + title: title, + workspaces: [ + ReviewMonitorCodexSidebarSnapshot.Workspace( + rowID: .workspace(workspaceID), + id: workspaceID, + cwd: workspaceID.rawValue, + title: URL(fileURLWithPath: workspaceID.rawValue).lastPathComponent, + chats: chats + ), + ], + uncategorizedChats: [] + ) +} + +private func sidebarChat( + id: CodexThreadID, + title: String, + workspaceID: CodexWorkspaceID, + updatedAt: Date? = nil, + status: CodexThreadStatus? = nil +) -> ReviewMonitorCodexSidebarSnapshot.Chat { + ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(id), + id: id, + title: title, + preview: nil, + workspaceCWD: workspaceID.rawValue, + updatedAt: updatedAt, + recencyAt: updatedAt, + status: status + ) +} + 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) From d58c79b79a99ec81b6ceb094976730f8b9ef1edc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:53:27 +0900 Subject: [PATCH 200/359] refactor(review-ui): drive sidebar drag order from codex chats --- ...ReviewJob+LegacyReviewChatProjection.swift | 30 - .../ReviewMonitorCodexSidebarLibrary.swift | 36 +- ...wMonitorSidebarLegacyReviewChatIndex.swift | 108 --- .../ReviewMonitorSidebarViewController.swift | 730 ++++++++++++------ ...eviewMonitorCodexSidebarLibraryTests.swift | 112 ++- 5 files changed, 619 insertions(+), 397 deletions(-) delete mode 100644 Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift delete mode 100644 Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift diff --git a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift b/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift deleted file mode 100644 index bfef437b..00000000 --- a/Sources/ReviewUI/CodexReviewJob+LegacyReviewChatProjection.swift +++ /dev/null @@ -1,30 +0,0 @@ -import CodexKit -import CodexReviewKit - -extension CodexReviewJob { - @MainActor - var legacyReviewChatID: CodexThreadID? { - if let reviewThreadID = core.run.reviewThreadID?.nilIfEmpty { - return CodexThreadID(rawValue: reviewThreadID) - } - if let threadID = core.run.threadID?.nilIfEmpty { - return CodexThreadID(rawValue: threadID) - } - return nil - } - - @MainActor - var legacyReviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let chatID = legacyReviewChatID else { - return nil - } - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: displayTitle, - preview: core.output.lastAgentMessage?.nilIfEmpty ?? core.output.summary.nilIfEmpty, - workspaceCWD: cwd, - updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt - ) - } -} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 14b8aba4..a4f8c2d4 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -269,14 +269,39 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { mutating func reorderChat( id: CodexThreadID, in container: ReviewMonitorCodexSidebarRowID, + currentOrder: [CodexThreadID], before targetID: CodexThreadID? ) -> Bool { - var ids = chatIDsByContainer[container] ?? [] + 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 snapshot: ReviewMonitorCodexSidebarSnapshot) { + let activeSectionIDs = snapshot.sections.map(\.id) + sectionIDs = sectionIDs.filter { activeSectionIDs.contains($0) } + + var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:] + for section in snapshot.sections { + activeChatIDsByContainer[section.rowID] = Set(section.uncategorizedChats.map(\.id)) + for workspace in section.workspaces { + activeChatIDsByContainer[workspace.rowID] = Set(workspace.chats.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 ordered( _ elements: [Element], by preferredIDs: [ID], @@ -296,6 +321,15 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { }.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?, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift deleted file mode 100644 index ef44da15..00000000 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarLegacyReviewChatIndex.swift +++ /dev/null @@ -1,108 +0,0 @@ -import CodexKit -import CodexReviewKit - -@MainActor -final class ReviewMonitorSidebarLegacyReviewChatIndex { - private var rowsByJobID: [String: ReviewMonitorSidebarReviewChatRow] = [:] - - func rows(for jobs: [CodexReviewJob]) -> [ReviewMonitorSidebarReviewChatRow] { - jobs.map { job in - if let row = rowsByJobID[job.id] { - row.update(job: job) - return row - } - let row = ReviewMonitorSidebarReviewChatRow(job: job) - rowsByJobID[job.id] = row - return row - } - } - - func prune(keeping activeJobIDs: Set) { - rowsByJobID = rowsByJobID.filter { activeJobIDs.contains($0.key) } - } - - func row(jobID: String) -> ReviewMonitorSidebarReviewChatRow? { - rowsByJobID[jobID] - } - - func row(chatID: CodexThreadID) -> ReviewMonitorSidebarReviewChatRow? { - rowsByJobID.values.first { $0.chat?.id == chatID } - } - - func chat( - id: CodexThreadID, - in workspaces: [CodexReviewWorkspace] - ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let row = row(chatID: id), - workspaces.contains(where: { $0.cwd == row.operation.cwd }) - else { - return nil - } - return row.chat - } -} - -extension ReviewMonitorSidebarReviewChatRow { - @MainActor - convenience init(job: CodexReviewJob) { - self.init( - chat: job.legacyReviewChatSelection, - runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) - ) - } - - @MainActor - func update(job: CodexReviewJob) { - update( - chat: job.legacyReviewChatSelection, - runtime: ReviewMonitorSidebarReviewChatRuntime(job: job) - ) - } -} - -extension ReviewMonitorSidebarReviewChatRuntime { - @MainActor - init(job: CodexReviewJob) { - self.init( - operation: .init( - jobID: job.id, - sessionID: job.sessionID, - cwd: job.cwd - ), - fallbackTitle: job.displayTitle, - fallbackSubtitle: Self.subtitleText(for: job), - model: job.core.run.model, - startedAt: job.core.lifecycle.startedAt, - endedAt: job.core.lifecycle.endedAt, - isRunning: job.core.lifecycle.status == .running, - isTerminal: job.isTerminal, - cancellationRequested: job.cancellationRequested - ) - } - - @MainActor - private static func subtitleText(for job: CodexReviewJob) -> 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 - } -} diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index bd16b822..7e644208 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -48,25 +48,18 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD @MainActor private static func measuredReviewChatRowHeight() -> 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" - ) - ) + let chatID = CodexThreadID(rawValue: "row-height-measurement") + let chat = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: "Uncommitted changes", + preview: "Review output preview", + model: "gpt-5.5", + workspaceCWD: "/tmp/workspace", + updatedAt: Date(timeIntervalSince1970: 0) ) let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) + cellView.configure(with: chat) return ceil(cellView.fittingSize.height) } } @@ -74,6 +67,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private enum SidebarDragPayload: Codable, Equatable { case workspaceSection(id: String) case reviewChat(jobID: String, cwd: String) + case codexSection(id: String) + case codexChat(id: String, containerRowID: String) } private struct SidebarResolvedDrop { @@ -81,6 +76,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case none case reorderWorkspaceSection(id: String, cwds: [String], beforeCWD: String?, displayIndex: Int) case reorderReviewChat(jobID: String, cwd: String, beforeJobID: String?, displayIndex: Int) + case reorderCodexSection(id: String, beforeID: String?) + case reorderCodexChat( + id: CodexThreadID, + container: ReviewMonitorCodexSidebarRowID, + currentOrder: [CodexThreadID], + beforeID: CodexThreadID? + ) } let operation: Operation @@ -103,6 +105,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let movesToFullOrderEnd: Bool } + private struct SidebarCodexChatDropDestination { + let container: ReviewMonitorCodexSidebarRowID + let childIndex: Int + let dropItem: Any? + let dropChildIndex: Int + } + private struct DisplayedSidebarTopologySnapshot { let rootTopologies: [SidebarRootTopology] @@ -208,8 +217,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private var codexSidebarModelContext: CodexModelContext? + private var codexSidebarUnfilteredSnapshot = ReviewMonitorCodexSidebarSnapshot(sections: []) + private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() - private let legacyReviewChatIndex = ReviewMonitorSidebarLegacyReviewChatIndex() private var workspaceSectionIdentitiesByCWD: [String: ReviewMonitorWorkspaceSectionIdentity] = [:] private var workspaceSectionsByID: [String: SidebarWorkspaceSection] = [:] private var currentRootTopologies: [SidebarRootTopology] = [] @@ -260,8 +270,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD super.viewDidLoad() configureHierarchy() configureOutlineView() - bindObservation() applyPreviewCodexSidebarSnapshotIfNeeded() + bindObservation() bindCodexSidebarLibrary() } @@ -359,38 +369,25 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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, hasCodexSidebarContent: self.hasCodexSidebarContent )) } - let initialFilter = uiState.sidebarReviewChatFilter sidebarFilterObservation = withPortableContinuousObservation { [weak self, uiState] event in - let filter = uiState.sidebarReviewChatFilter + _ = uiState.sidebarReviewChatFilter guard event.kind != .initial else { return } - let animatedInitialDelivery = true - Task { @MainActor [weak self] in - self?.bindSidebarStoreTopologyObservation( - filter: filter, - animatedInitialDelivery: animatedInitialDelivery - ) - } + self?.applyFilteredCodexSidebarSnapshot() } - bindSidebarStoreTopologyObservation( - filter: initialFilter, - animatedInitialDelivery: false - ) + sidebarTopologyObservation = nil + currentRootTopologies = [] selectedChatSnapshotObservation = withPortableContinuousObservation { [weak self, uiState] _ in guard let self, @@ -476,6 +473,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarSnapshot(_ snapshot: ReviewMonitorCodexSidebarSnapshot) { + codexSidebarUnfilteredSnapshot = snapshot + codexSidebarPresentationOrder.prune(to: snapshot) + applyFilteredCodexSidebarSnapshot() + } + + private func applyFilteredCodexSidebarSnapshot() { + let snapshot = codexSidebarPresentationOrder + .applying(to: codexSidebarUnfilteredSnapshot) + .filtered(by: uiState.sidebarReviewChatFilter) let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline codexSidebarOutlineTree.apply(snapshot: snapshot) applySidebarKind(sidebarKind) @@ -529,8 +535,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD Self.sidebarKind( sidebarSelection: uiState.sidebarSelection, serverState: store.serverState, - hasReviewJobs: store.hasReviewJobs, - hasWorkspaces: store.workspaces.isEmpty == false, hasCodexSidebarContent: hasCodexSidebarContent ) } @@ -538,8 +542,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private static func sidebarKind( sidebarSelection: SidebarPickerSelection?, serverState: CodexReviewServerState, - hasReviewJobs: Bool, - hasWorkspaces: Bool, hasCodexSidebarContent: Bool ) -> SidebarKind { if sidebarSelection == .account { @@ -551,8 +553,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .running: break } - let hasSidebarContent = hasReviewJobs || hasWorkspaces || hasCodexSidebarContent - return hasSidebarContent ? .chatList : .empty + return hasCodexSidebarContent ? .chatList : .empty } private var hasCodexSidebarContent: Bool { @@ -560,23 +561,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private var isUsingCodexSidebarOutline: Bool { - hasCodexSidebarContent + true } private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] { - let workspaces = store.orderedWorkspaces - let workspaceJobs = workspaces.map { workspace in - (workspace: workspace, jobs: store.orderedJobs(in: workspace)) - } - let activeJobIDs = Set(workspaceJobs.flatMap { $0.jobs.map(\.id) }) - let topologies = workspaceJobs.map { workspace, jobs in - SidebarWorkspaceTopology( - workspace: workspace, - reviewRows: legacyReviewChatIndex.rows(for: jobs) - ) - } - legacyReviewChatIndex.prune(keeping: activeJobIDs) - return topologies + [] } private func sidebarRootTopologies( @@ -822,7 +811,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch selection { case .workspaceSection(let selectedSection): - guard let currentSection = workspaceSection(id: selectedSection.id) else { + guard let currentSection = codexWorkspaceSectionSelection(id: selectedSection.id), + let row = row(forCodexSidebarSelectionID: .workspaceSection(selectedSection.id)) + else { uiState.selection = nil outlineView.deselectAll(nil) return @@ -832,9 +823,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = .workspaceSection(currentSection.selection) } - guard let row = row(forRootItem: currentSection) else { - return - } guard outlineView.selectedRow != row else { return } @@ -930,52 +918,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func restoreSelectedReviewChatRowAfterExpansion(of section: SidebarWorkspaceSection) { - guard isUsingCodexSidebarOutline == false, - case .chat(let selectedChat) = uiState.selection, - let selectedWorkspaceCWD = selectedChat.workspaceCWD, - section.workspaces.contains(where: { $0.cwd == selectedWorkspaceCWD }) - else { - return - } - let selectedChatID = selectedChat.id - DispatchQueue.main.async { [weak self, weak section] in - guard let self, - let section, - let currentSelectedChat = self.currentChatSelection(id: selectedChatID), - section.isExpanded, - currentSelectedChat.workspaceCWD == selectedWorkspaceCWD, - let row = self.row(forCurrentChatSelectionID: selectedChatID), - self.outlineView.selectedRow != row - else { - return - } - self.isReconcilingSelection = true - self.outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - self.isReconcilingSelection = false - } + _ = section } private func makeContextMenu(at point: NSPoint) -> NSMenu? { - let row = outlineView.row(at: point) - guard row != -1, - let reviewRow = reviewRow(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 = reviewRow - cancelItem.isEnabled = reviewRow.isTerminal == false && reviewRow.cancellationRequested == false - menu.addItem(cancelItem) - return menu + _ = point + return nil } @objc @@ -1275,7 +1223,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if codexSidebarNode(from: item) != nil { return true } - return workspaceSection(from: item) != nil || reviewRow(from: item) != nil + return false } private func configureCodexSidebarCell( @@ -1314,11 +1262,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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 + row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: workspace.cwd))) + ?? row(forCodexSidebarSelectionID: .workspaceSection(workspace.cwd)) + } + + private func row(for chatID: CodexThreadID) -> Int? { + row(forCodexSidebarSelectionID: .chat(chatID)) } private func row(forRootItem rootItem: AnyObject) -> Int? { @@ -1352,26 +1301,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forJobID jobID: String) -> Int? { - guard let rowItem = legacyReviewChatIndex.row(jobID: jobID) else { - return nil - } - let row = outlineView.row(forItem: rowItem) - return row == -1 ? nil : row - } - - private func row(forReviewChatID chatID: CodexThreadID) -> Int? { - guard let rowItem = legacyReviewChatIndex.row(chatID: chatID) else { - return nil - } - let row = outlineView.row(forItem: rowItem) - return row == -1 ? nil : row + _ = jobID + return nil } private func row(forCurrentChatSelectionID chatID: CodexThreadID) -> Int? { - if isUsingCodexSidebarOutline { - return row(forCodexSidebarSelectionID: .chat(chatID)) - } - return row(forReviewChatID: chatID) + row(forCodexSidebarSelectionID: .chat(chatID)) } private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { @@ -1412,10 +1347,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } switch selection { case .workspaceSection(let section): - return workspaceSectionsByID[section.id] != nil - || section.workspaceCWDs.contains { cwd in - workspaces.contains(where: { $0.cwd == cwd }) - } + return codexWorkspaceSectionSelection(id: section.id) != nil case .workspace(let workspace): return codexWorkspaceSelection(id: workspace.id) != nil case .chat(let chat): @@ -1436,13 +1368,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return job } - private func legacyReviewChatSelection( - id: CodexThreadID, - in workspaces: [CodexReviewWorkspace] - ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - legacyReviewChatIndex.chat(id: id, in: workspaces) - } - private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { currentChatSelection(id: id, in: workspaces()) } @@ -1451,10 +1376,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD id: CodexThreadID, in workspaces: [CodexReviewWorkspace] ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - if isUsingCodexSidebarOutline { - return codexChatSelection(id: id) - } - return legacyReviewChatSelection(id: id, in: workspaces) + _ = workspaces + return codexSidebarUnfilteredSnapshot.chat(id: id) } private func codexWorkspaceSelection( @@ -1468,6 +1391,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return workspace } + private func codexWorkspaceSectionSelection( + id: String + ) -> ReviewMonitorCodexSidebarSnapshot.Section? { + guard let node = codexSidebarOutlineTree.node(rowID: .section(id)), + case .section(let section) = node.item + else { + return nil + } + return section + } + private func codexChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), case .chat(let chat) = node.item @@ -1509,14 +1443,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func dragPayload(for item: Any) -> SidebarDragPayload? { - if let section = workspaceSection(from: item) { - return .workspaceSection(id: section.id) + guard let node = codexSidebarNode(from: item) else { + return nil } - if let row = reviewRow(from: item) { - let operation = row.operation - return .reviewChat(jobID: operation.jobID, cwd: operation.cwd) + switch node.item { + case .section(let section): + return .codexSection(id: section.id) + case .workspace: + return nil + case .chat(let chat): + guard let parent = outlineView.parent(forItem: node) as? ReviewMonitorCodexSidebarOutlineNode else { + return nil + } + return .codexChat(id: chat.id.rawValue, containerRowID: parent.rowID.rawValue) } - return nil } private func makePasteboardItem(for payload: SidebarDragPayload) -> NSPasteboardItem? { @@ -1564,7 +1504,290 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedItem: proposedItem, proposedChildIndex: index ) + case .codexSection(let id): + resolvedCodexSectionDrop( + id: 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 resolvedCodexSectionDrop( + id: String, + draggingLocation: NSPoint?, + proposedItem: Any?, + proposedChildIndex index: Int + ) -> SidebarResolvedDrop? { + guard let sourceNode = codexSidebarOutlineTree.node(rowID: .section(id)), + 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 displayDestinationIndex = clampedDestinationIndex > sourceIndex + ? clampedDestinationIndex - 1 + : clampedDestinationIndex + guard displayDestinationIndex != sourceIndex else { + return nil + } + + let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } + let beforeID: String? + if displayDestinationIndex < remainingRoots.count, + case .section(let section) = remainingRoots[displayDestinationIndex].item { + beforeID = section.id + } else { + beforeID = nil + } + + return SidebarResolvedDrop( + operation: .reorderCodexSection(id: id, beforeID: beforeID), + dropItem: nil, + dropChildIndex: clampedDestinationIndex + ) + } + + private func resolvedCodexChatDrop( + id: CodexThreadID, + container: ReviewMonitorCodexSidebarRowID, + draggingLocation: NSPoint?, + proposedItem: Any?, + proposedChildIndex index: Int + ) -> 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 targetChat) = targetNode.item, + index == NSOutlineViewDropOnItemIndex, + let parentNode = outlineView.parent(forItem: targetNode) as? ReviewMonitorCodexSidebarOutlineNode, + parentNode.rowID == sourceContainer, + let targetIndex = codexVisibleChatIDs(in: parentNode.rowID).firstIndex(of: targetChat.id), + let draggingLocation, + let targetRow = row(forCodexSidebarSelectionID: .chat(targetChat.id)) + 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? { + guard let rootRow = row(forCodexSidebarSelectionID: root.selectionID) 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(let section): + return section.uncategorizedChats.isEmpty == false + case .workspace: + return true + case .chat: + return false + } + } + + private func codexVisibleChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { + guard let node = codexSidebarOutlineTree.node(rowID: container) else { + return [] + } + return node.children.compactMap { child in + if case .chat(let chat) = child.item { + return chat.id + } + return nil + } + } + + private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { + for section in codexSidebarUnfilteredSnapshot.sections { + if section.rowID == container { + return section.uncategorizedChats.map(\.id) + } + for workspace in section.workspaces where workspace.rowID == container { + return workspace.chats.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] } private func resolvedWorkspaceSectionDrop( @@ -2016,52 +2239,51 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD moveReviewChatInOutline(id: id, in: workspace, toIndex: displayIndex) } return true + case .reorderCodexSection(let id, let beforeID): + guard codexSidebarPresentationOrder.reorderSection(id: id, before: beforeID) else { + return false + } + applyFilteredCodexSidebarSnapshot() + 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 + } + applyFilteredCodexSidebarSnapshot() + return true } } func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { - if isUsingCodexSidebarOutline { - guard let item else { - return codexSidebarOutlineTree.roots.count - } - if let node = codexSidebarNode(from: item) { - return node.children.count - } - return 0 - } guard let item else { - return currentRootTopologies.count + return codexSidebarOutlineTree.roots.count } - if let section = workspaceSection(from: item) { - return section.reviewRows.count + if let node = codexSidebarNode(from: item) { + return node.children.count } return 0 } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { - if isUsingCodexSidebarOutline { - guard let item else { - return codexSidebarOutlineTree.roots[index] - } - if let node = codexSidebarNode(from: item) { - return node.children[index] - } - fatalError("Unsupported Codex sidebar item.") - } guard let item else { - return currentRootTopologies[index].item + return codexSidebarOutlineTree.roots[index] } - if let section = workspaceSection(from: item) { - return section.reviewRows[index] + if let node = codexSidebarNode(from: item) { + return node.children[index] } - fatalError("Unsupported sidebar item.") + fatalError("Unsupported Codex sidebar item.") } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { if let node = codexSidebarNode(from: item) { return node.isExpandable } - return workspaceSection(from: item) != nil + return false } func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { @@ -2075,12 +2297,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } return rowHeights.workspace } - if workspaceSection(from: item) != nil { - return rowHeights.workspace - } - if reviewRow(from: item) != nil { - return rowHeights.reviewChat - } return rowHeights.reviewChat } @@ -2183,12 +2399,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } return ReviewMonitorWorkspaceRowView() } - if workspaceSection(from: item) != nil { - return ReviewMonitorWorkspaceRowView() - } - if reviewRow(from: item) != nil { - return ReviewMonitorReviewChatTableRowView() - } return nil } @@ -2215,22 +2425,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return view } - 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 row = reviewRow(from: item) { - let view = (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) as? ReviewMonitorReviewChatCellView) - ?? ReviewMonitorReviewChatCellView() - view.identifier = Identifier.reviewChatCell - view.configure(with: row) - return view - } return nil } @@ -2301,6 +2495,47 @@ extension ReviewMonitorSidebarViewController { codexSidebarOutlineTree.node(rowID: rowID)?.title } + 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 performCodexSectionDropForTesting(id: String, toIndex index: Int) -> Bool { + guard let resolvedDrop = resolvedDrop( + for: .codexSection(id: id), + 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 codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(_ id: CodexThreadID) -> Bool { guard let row = row(forCodexSidebarSelectionID: .chat(id)), let cellView = outlineView.view( @@ -2346,7 +2581,7 @@ extension ReviewMonitorSidebarViewController { } func selectReviewChatForTesting(id chatID: CodexThreadID) { - guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) ?? row(forReviewChatID: chatID) else { + guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -2482,10 +2717,7 @@ extension ReviewMonitorSidebarViewController { } func cancelReviewChatForTesting(_ job: CodexReviewJob) async { - guard let row = legacyReviewChatIndex.row(jobID: job.id) else { - return - } - await performCancellation(for: row) + _ = job } func clickBlankAreaForTesting() { @@ -2673,16 +2905,9 @@ extension ReviewMonitorSidebarViewController { _ workspace: CodexReviewWorkspace, proposedJob targetJob: CodexReviewJob ) -> Bool { - guard let section = workspaceSection(containing: workspace), - let targetRow = legacyReviewChatIndex.row(jobID: targetJob.id) - else { - return true - } - return resolvedDrop( - for: .workspaceSection(id: section.id), - proposedItem: targetRow, - proposedChildIndex: NSOutlineViewDropOnItemIndex - ) == nil + _ = workspace + _ = targetJob + return true } @discardableResult @@ -2691,26 +2916,10 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool ) -> Bool { - guard let section = workspaceSection(containing: workspace), - let targetRowItem = legacyReviewChatIndex.row(jobID: targetJob.id), - 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: targetRowItem, - proposedChildIndex: NSOutlineViewDropOnItemIndex - ) else { - return false - } - return applyResolvedDrop(resolvedDrop) + _ = workspace + _ = targetJob + _ = hoveringBelowMidpoint + return false } func workspaceSectionCanStartDragForTesting(containing workspace: CodexReviewWorkspace) -> Bool { @@ -2794,25 +3003,10 @@ extension ReviewMonitorSidebarViewController { proposedJob targetJob: CodexReviewJob, hoveringBelowMidpoint: Bool ) -> Bool { - guard let targetRowItem = legacyReviewChatIndex.row(jobID: targetJob.id), - 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: .reviewChat(jobID: job.id, cwd: job.cwd), - draggingLocation: draggingLocation, - proposedItem: targetRowItem, - proposedChildIndex: NSOutlineViewDropOnItemIndex - ) else { - return false - } - return applyResolvedDrop(resolvedDrop) + _ = job + _ = targetJob + _ = hoveringBelowMidpoint + return false } @discardableResult @@ -3153,10 +3347,32 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } #if DEBUG +@MainActor +private func reviewMonitorReviewChatCellChatForTesting( + job: CodexReviewJob +) -> ReviewMonitorCodexSidebarSnapshot.Chat { + let chatID = CodexThreadID( + rawValue: job.core.run.reviewThreadID?.nilIfEmpty + ?? job.core.run.threadID?.nilIfEmpty + ?? job.id + ) + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: job.displayTitle, + preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, + model: job.core.run.model, + workspaceCWD: job.cwd, + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) + ) +} + @MainActor func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) + cellView.configure(with: reviewMonitorReviewChatCellChatForTesting(job: job)) return cellView } @@ -3168,7 +3384,7 @@ func configureReviewMonitorReviewChatCellViewForTesting( guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { fatalError("Expected ReviewMonitorReviewChatCellView.") } - cellView.configure(with: ReviewMonitorSidebarReviewChatRow(job: job)) + cellView.configure(with: reviewMonitorReviewChatCellChatForTesting(job: job)) } @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 35c26fdf..8e7da6df 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -280,7 +280,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { var order = ReviewMonitorCodexSidebarPresentationOrder() let didReorderSection = order.reorderSection(id: "beta", before: "alpha") - let didReorderChat = order.reorderChat(id: alphaSecondID, in: .workspace(alphaWorkspaceID), before: alphaFirstID) + let didReorderChat = order.reorderChat( + id: alphaSecondID, + in: .workspace(alphaWorkspaceID), + currentOrder: [alphaFirstID, alphaSecondID], + before: alphaFirstID + ) #expect(didReorderSection) #expect(didReorderChat) @@ -489,6 +494,111 @@ struct ReviewMonitorCodexSidebarLibraryTests { "App review renamed", ]) } + + @Test func sidebarViewControllerReordersCodexSectionsLocally() 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, workspaces: []) + 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 snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) + let firstSection = try #require(snapshot.sections.first) + let secondSection = try #require(snapshot.sections.dropFirst().first) + + #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID)) + #expect(sidebar.performCodexSectionDropForTesting(id: secondSection.id, toIndex: 0)) + #expect(sidebar.codexSidebarRootTitlesForTesting == [ + secondSection.title, + firstSection.title, + ]) + #expect(sidebar.codexSidebarSnapshotForTesting?.sections.map(\.id) == [ + firstSection.id, + secondSection.id, + ]) + } + + @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, workspaces: []) + 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 snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) + let container = try #require(snapshot.sections.first?.workspaces.first?.rowID) + + #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(sidebar.codexSidebarSnapshotForTesting?.sections.first?.workspaces.first?.chats.map(\.id) == [ + firstThreadID, + secondThreadID, + ]) + } } private struct ThreadListParams: Decodable { From 3799f6b1cf9acd6d4ed32bd87636f26cf834c138 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:15:15 +0900 Subject: [PATCH 201/359] refactor(review-ui): render codex sidebar rows from observable nodes --- ...ReviewMonitorTransportViewController.swift | 25 + .../Jobs/ReviewMonitorChatRowView.swift | 143 +- .../ReviewMonitorCodexSidebarLibrary.swift | 52 +- .../ReviewMonitorSidebarViewController.swift | 2796 ++++--------- ...eviewMonitorCodexSidebarLibraryTests.swift | 426 +- .../ReviewMonitorTimelineTesting.swift | 10 +- Tests/ReviewUITests/ReviewUITests.swift | 3670 ++++------------- 7 files changed, 1835 insertions(+), 5287 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 40878d85..cc59ad1f 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1222,6 +1222,31 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { ) } + func bindLogRenderTargetForTesting(_ target: DisplayedSelectionForTesting) { + switch target { + case .chat(let id): + let chatID = CodexThreadID(rawValue: id) + if boundChatID != chatID { + let isSwitchingRenderedChat = boundChatID != nil + cacheBoundLogScrollTarget() + if isSwitchingRenderedChat { + logScrollView.resetFindStateForContentReuse() + } + selectedChatLogTask?.cancel() + selectedChatLogTask = nil + resetLogRenderer() + boundChatID = chatID + } + clearDisplayedWorkspace() + hidePlaceholder() + logScrollView.isHidden = false + workspaceFindingsView.isHidden = true + displayedSelection = .chat(chatID) + case .workspaceSection, .workspace: + break + } + } + func copyLogSelectionForTesting() { logScrollView.copySelectionForTesting() } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift index 535d5c33..2fd1b0c1 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift @@ -1,24 +1,6 @@ import Foundation import SwiftUI -struct ReviewMonitorSidebarReviewChatOperation: Equatable { - var jobID: String - var sessionID: String - var cwd: String -} - -struct ReviewMonitorSidebarReviewChatRuntime: Equatable { - var operation: ReviewMonitorSidebarReviewChatOperation - var fallbackTitle: String - var fallbackSubtitle: String? - var model: String? - var startedAt: Date? - var endedAt: Date? - var isRunning: Bool - var isTerminal: Bool - var cancellationRequested: Bool -} - struct ReviewMonitorSidebarChatRow: Equatable { var id: String var title: String @@ -46,21 +28,6 @@ struct ReviewMonitorSidebarChatRow: Equatable { self.isRunning = isRunning } - init( - chat: ReviewMonitorCodexSidebarSnapshot.Chat?, - runtime: ReviewMonitorSidebarReviewChatRuntime - ) { - self.init( - id: chat?.id.rawValue ?? runtime.operation.jobID, - title: chat?.title.trimmedNonEmpty ?? runtime.fallbackTitle, - model: runtime.model, - subtitle: chat?.preview?.trimmedNonEmpty ?? runtime.fallbackSubtitle, - startedAt: runtime.startedAt, - endedAt: runtime.endedAt, - isRunning: runtime.isRunning - ) - } - init(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { self.init( id: chat.id.rawValue, @@ -74,38 +41,7 @@ struct ReviewMonitorSidebarChatRow: Equatable { } } -@MainActor -final class ReviewMonitorSidebarReviewChatRow { - private(set) var operation: ReviewMonitorSidebarReviewChatOperation - private(set) var chat: ReviewMonitorCodexSidebarSnapshot.Chat? - private(set) var presentation: ReviewMonitorSidebarChatRow - private(set) var isTerminal: Bool - private(set) var cancellationRequested: Bool - - init( - chat: ReviewMonitorCodexSidebarSnapshot.Chat?, - runtime: ReviewMonitorSidebarReviewChatRuntime - ) { - self.operation = runtime.operation - self.chat = chat - self.presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) - self.isTerminal = runtime.isTerminal - self.cancellationRequested = runtime.cancellationRequested - } - - func update( - chat: ReviewMonitorCodexSidebarSnapshot.Chat?, - runtime: ReviewMonitorSidebarReviewChatRuntime - ) { - operation = runtime.operation - self.chat = chat - presentation = ReviewMonitorSidebarChatRow(chat: chat, runtime: runtime) - isTerminal = runtime.isTerminal - cancellationRequested = runtime.cancellationRequested - } -} - -struct ReviewMonitorChatRowView: View { +struct ReviewMonitorChatRowContentView: View { var row: ReviewMonitorSidebarChatRow var body: some View { @@ -151,6 +87,23 @@ struct ReviewMonitorChatRowView: View { } } +struct ReviewMonitorChatRowView: View { + var node: ReviewMonitorCodexSidebarOutlineNode + + var row: ReviewMonitorSidebarChatRow? { + guard case .chat(let chat) = node.item else { + return nil + } + return ReviewMonitorSidebarChatRow(chat: chat) + } + + var body: some View { + if let row { + ReviewMonitorChatRowContentView(row: row) + } + } +} + struct ReviewMonitorChatRowTimerLabel: View { var row: ReviewMonitorSidebarChatRow @@ -168,41 +121,41 @@ struct ReviewMonitorChatRowTimerLabel: View { } #if DEBUG -#Preview { - NavigationSplitView { - List { - Section("workspace-alpha") { - ReviewMonitorChatRowView( - row: ReviewMonitorSidebarChatRow( - id: "preview-running", - title: "Uncommitted changes", - model: "gpt-5.5", - subtitle: "Inspecting recent changes", - startedAt: Date(timeIntervalSinceNow: -640), - endedAt: nil, - isRunning: true + #Preview { + NavigationSplitView { + List { + Section("workspace-alpha") { + ReviewMonitorChatRowContentView( + row: ReviewMonitorSidebarChatRow( + id: "preview-running", + title: "Uncommitted changes", + model: "gpt-5.5", + subtitle: "Inspecting recent changes", + startedAt: Date(timeIntervalSinceNow: -640), + endedAt: nil, + isRunning: true + ) ) - ) - ReviewMonitorChatRowView( - row: ReviewMonitorSidebarChatRow( - id: "preview-completed", - title: "Base branch: main", - model: "gpt-5.5-codex", - subtitle: "No findings", - startedAt: Date(timeIntervalSinceNow: -3_600), - endedAt: Date(timeIntervalSinceNow: -2_900), - isRunning: false + ReviewMonitorChatRowContentView( + row: ReviewMonitorSidebarChatRow( + id: "preview-completed", + title: "Base branch: main", + model: "gpt-5.5-codex", + subtitle: "No findings", + startedAt: Date(timeIntervalSinceNow: -3_600), + endedAt: Date(timeIntervalSinceNow: -2_900), + isRunning: false + ) ) - ) + } + } + .frame(minWidth: 320) + } detail: { + ContentUnavailableView { + Text(verbatim: "Preview") } - } - .frame(minWidth: 320) - } detail: { - ContentUnavailableView { - Text(verbatim: "Preview") } } -} #endif private extension String { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index a4f8c2d4..1d73db5b 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -1,6 +1,7 @@ import CodexKit import CodexReviewKit import Foundation +import Observation package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringConvertible { package var rawValue: String @@ -42,7 +43,7 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { var workspaceCWDs = workspaces.map(\.cwd) for chat in uncategorizedChats { guard let cwd = chat.workspaceCWD, - workspaceCWDs.contains(cwd) == false + workspaceCWDs.contains(cwd) == false else { continue } @@ -165,7 +166,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { return ReviewMonitorCodexSidebarSnapshot( sections: sections.map { section in - let latestFinishedChatID = filter.contains(.latestFinished) + let latestFinishedChatID = + filter.contains(.latestFinished) ? Self.latestFinishedChat(in: section.allChats)?.id : nil return Section( @@ -339,7 +341,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { ids.removeAll { $0 == id } let insertionIndex: Int if let targetID, - let targetIndex = ids.firstIndex(of: targetID) + let targetIndex = ids.firstIndex(of: targetID) { insertionIndex = targetIndex } else { @@ -418,19 +420,34 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { @MainActor final class ReviewMonitorCodexSidebarOutlineTree { + struct ApplyResult: Equatable { + var topologyChanged: Bool + } + private var nodesByRowID: [ReviewMonitorCodexSidebarRowID: ReviewMonitorCodexSidebarOutlineNode] = [:] private(set) var roots: [ReviewMonitorCodexSidebarOutlineNode] = [] - func apply(snapshot: ReviewMonitorCodexSidebarSnapshot) { + func apply(snapshot: ReviewMonitorCodexSidebarSnapshot) -> ApplyResult { + let oldTopology = topology var activeRowIDs: Set = [] roots = snapshot.outlineItems.map { node(for: $0, activeRowIDs: &activeRowIDs) } nodesByRowID = nodesByRowID.filter { activeRowIDs.contains($0.key) } + return ApplyResult(topologyChanged: topology != oldTopology) } 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 func node( for item: ReviewMonitorCodexSidebarOutlineItem, activeRowIDs: inout Set @@ -438,20 +455,32 @@ final class ReviewMonitorCodexSidebarOutlineTree { activeRowIDs.insert(item.rowID) let node = nodesByRowID[item.rowID] ?? ReviewMonitorCodexSidebarOutlineNode(item: item) nodesByRowID[item.rowID] = node - node.item = item - node.children = item.children.map { child in + if node.item != item { + node.item = item + } + let children = item.children.map { child in self.node(for: child, activeRowIDs: &activeRowIDs) } + if node.children.map(\.rowID) != children.map(\.rowID) { + node.children = children + } return node } } @MainActor +private struct ReviewMonitorCodexSidebarOutlineTopology: Equatable { + var roots: [ReviewMonitorCodexSidebarRowID] + var childrenByRowID: [ReviewMonitorCodexSidebarRowID: [ReviewMonitorCodexSidebarRowID]] +} + +@MainActor +@Observable final class ReviewMonitorCodexSidebarOutlineNode { fileprivate(set) var item: ReviewMonitorCodexSidebarOutlineItem fileprivate(set) var children: [ReviewMonitorCodexSidebarOutlineNode] = [] - fileprivate init(item: ReviewMonitorCodexSidebarOutlineItem) { + init(item: ReviewMonitorCodexSidebarOutlineItem) { self.item = item } @@ -583,10 +612,11 @@ package final class ReviewMonitorCodexSidebarLibrary { workspaces[index].chats.append(chat) } else { workspaceIndexesByID[workspace.id] = workspaces.count - workspaces.append(ReviewMonitorCodexSidebarWorkspace( - workspace: workspace, - chats: [chat] - )) + workspaces.append( + ReviewMonitorCodexSidebarWorkspace( + workspace: workspace, + chats: [chat] + )) } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 7e644208..2dc43944 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -59,14 +59,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD updatedAt: Date(timeIntervalSince1970: 0) ) let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: chat) + cellView.configure(with: ReviewMonitorCodexSidebarOutlineNode(item: .chat(chat))) return ceil(cellView.fittingSize.height) } } private enum SidebarDragPayload: Codable, Equatable { - case workspaceSection(id: String) - case reviewChat(jobID: String, cwd: String) case codexSection(id: String) case codexChat(id: String, containerRowID: String) } @@ -74,8 +72,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private struct SidebarResolvedDrop { enum Operation { case none - case reorderWorkspaceSection(id: String, cwds: [String], beforeCWD: String?, displayIndex: Int) - case reorderReviewChat(jobID: String, cwd: String, beforeJobID: String?, displayIndex: Int) case reorderCodexSection(id: String, beforeID: String?) case reorderCodexChat( id: CodexThreadID, @@ -90,21 +86,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let dropChildIndex: Int } - private struct SidebarWorkspaceTopology { - let workspace: CodexReviewWorkspace - let reviewRows: [ReviewMonitorSidebarReviewChatRow] - } - - private struct SidebarWorkspaceDropDestination { - let rootInsertionIndex: Int - } - - private struct SidebarReviewChatDropDestination { - let workspace: CodexReviewWorkspace - let childIndex: Int - let movesToFullOrderEnd: Bool - } - private struct SidebarCodexChatDropDestination { let container: ReviewMonitorCodexSidebarRowID let childIndex: Int @@ -112,91 +93,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let dropChildIndex: Int } - 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 reviewRows: [ReviewMonitorSidebarReviewChatRow] - var isExpanded: Bool - - init( - id: String, - title: String, - workspaces: [CodexReviewWorkspace], - reviewRows: [ReviewMonitorSidebarReviewChatRow] - ) { - self.id = id - self.title = title - self.workspaces = workspaces - self.reviewRows = reviewRows - 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 reviewRows: [ReviewMonitorSidebarReviewChatRow] - } - - 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 codexModelSource: ReviewMonitorCodexModelSource? @@ -209,7 +105,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let rowHeights: SidebarRowHeights private var sidebarKindObservation: PortableObservationTracking.Token? - private var sidebarTopologyObservation: PortableObservationTracking.Token? private var sidebarFilterObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? private var codexSidebarSnapshotObservation: PortableObservationTracking.Token? @@ -220,16 +115,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarUnfilteredSnapshot = ReviewMonitorCodexSidebarSnapshot(sections: []) private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() - 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 + #if DEBUG + private var fullReloadCountForTesting = 0 + #endif init( store: CodexReviewStore, @@ -254,7 +143,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD isolated deinit { sidebarKindObservation?.cancel() - sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() codexSidebarObservation?.cancel() codexSidebarSnapshotObservation?.cancel() @@ -362,7 +250,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func bindObservation() { sidebarKindObservation?.cancel() - sidebarTopologyObservation?.cancel() sidebarFilterObservation?.cancel() selectedChatSnapshotObservation?.cancel() @@ -372,11 +259,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let self else { return } - self.applySidebarKind(Self.sidebarKind( - sidebarSelection: sidebarSelection, - serverState: serverState, - hasCodexSidebarContent: self.hasCodexSidebarContent - )) + self.applySidebarKind( + Self.sidebarKind( + sidebarSelection: sidebarSelection, + serverState: serverState, + hasCodexSidebarContent: self.hasCodexSidebarContent + )) } sidebarFilterObservation = withPortableContinuousObservation { [weak self, uiState] event in @@ -386,15 +274,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } self?.applyFilteredCodexSidebarSnapshot() } - sidebarTopologyObservation = nil - currentRootTopologies = [] selectedChatSnapshotObservation = withPortableContinuousObservation { [weak self, uiState] _ in guard let self, - case .chat(let selectedChat) = uiState.selection + case .chat(let selectedChat) = uiState.selection else { return } + guard self.hasCodexSidebarContent else { + return + } guard let currentChat = self.currentChatSelection(id: selectedChat.id) else { uiState.selection = nil return @@ -419,9 +308,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func installCodexSidebarLibrary(modelContext: CodexModelContext?) { if let modelContext, - let codexSidebarModelContext, - codexSidebarModelContext === modelContext, - codexSidebarLibrary != nil { + let codexSidebarModelContext, + codexSidebarModelContext === modelContext, + codexSidebarLibrary != nil + { return } codexSidebarFetchTask?.cancel() @@ -440,7 +330,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarLibrary = library codexSidebarSnapshotObservation = withPortableContinuousObservation { [weak self, library] event in guard let self, - self.codexSidebarLibrary === library + self.codexSidebarLibrary === library else { return } @@ -479,18 +369,27 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyFilteredCodexSidebarSnapshot() { - let snapshot = codexSidebarPresentationOrder + let snapshot = + codexSidebarPresentationOrder .applying(to: codexSidebarUnfilteredSnapshot) .filtered(by: uiState.sidebarReviewChatFilter) let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline - codexSidebarOutlineTree.apply(snapshot: snapshot) + let applyResult = codexSidebarOutlineTree.apply(snapshot: snapshot) applySidebarKind(sidebarKind) - if wasUsingCodexSidebarOutline || isUsingCodexSidebarOutline { + guard wasUsingCodexSidebarOutline || isUsingCodexSidebarOutline else { + return + } + if applyResult.topologyChanged { reloadCodexSidebarOutline() + } else if isUsingCodexSidebarOutline { + reconcileOutlineSelection() } } private func reloadCodexSidebarOutline() { + #if DEBUG + fullReloadCountForTesting += 1 + #endif isReconcilingSelection = true outlineView.reloadData() expandCodexSidebarNodes(codexSidebarOutlineTree.roots) @@ -509,28 +408,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func bindSidebarStoreTopologyObservation( - filter: SidebarReviewChatFilter, - 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, @@ -564,218 +441,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD true } - private func sidebarWorkspaceTopologies() -> [SidebarWorkspaceTopology] { - [] - } - - private func sidebarRootTopologies( - from workspaceTopologies: [SidebarWorkspaceTopology], - filter: SidebarReviewChatFilter - ) -> [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 sectionRows = Self.visibleReviewRows( - in: topologies.flatMap(\.reviewRows), - filter: filter - ) - let section = workspaceSection( - identity: identity, - workspaces: topologies.map(\.workspace), - reviewRows: sectionRows - ) - return SidebarRootTopology( - item: section, - workspaces: section.workspaces, - reviewRows: section.reviewRows - ) - } - 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], - reviewRows: [ReviewMonitorSidebarReviewChatRow] - ) -> SidebarWorkspaceSection { - if let section = workspaceSectionsByID[identity.id] { - section.title = identity.title - section.workspaces = workspaces - section.reviewRows = reviewRows - return section - } - - let section = SidebarWorkspaceSection( - id: identity.id, - title: identity.title, - workspaces: workspaces, - reviewRows: reviewRows - ) - 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 - ) - applyReviewRowMembershipChange(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 applyReviewRowMembershipChange( - _ 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 displayedRows = displayedReviewRows(inRootItem: rootItem) - let targetRows = topology.reviewRows - guard hasSameIdentityOrder(displayedRows, targetRows) == false else { - continue - } - if outlineView.isItemExpanded(rootItem) { - applyMembershipChange( - currentItems: displayedRows, - targetItems: targetRows, - 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: @@ -812,7 +477,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch selection { case .workspaceSection(let selectedSection): guard let currentSection = codexWorkspaceSectionSelection(id: selectedSection.id), - let row = row(forCodexSidebarSelectionID: .workspaceSection(selectedSection.id)) + let row = row(forCodexSidebarSelectionID: .workspaceSection(selectedSection.id)) else { uiState.selection = nil outlineView.deselectAll(nil) @@ -830,7 +495,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .workspace(let selectedWorkspace): guard let currentWorkspace = codexWorkspaceSelection(id: selectedWorkspace.id), - let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspace.id)) + let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspace.id)) else { uiState.selection = nil outlineView.deselectAll(nil) @@ -892,559 +557,109 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .chat(let chat): uiState.selection = .chat(chat) } - } else if let section = workspaceSection(from: item) { - uiState.selection = .workspaceSection(section.selection) - } else if let row = reviewRow(from: item) { - uiState.selection = row.chat.map(ReviewMonitorSelection.chat) } else { uiState.selection = nil } } - private func triggerCancellation(for row: ReviewMonitorSidebarReviewChatRow) { - Task { @MainActor [weak self] in - guard let self else { - return - } - await performCancellation(for: row) - } - } - - private func toggleWorkspaceExpansion(_ workspace: CodexReviewWorkspace) { - guard let rootItem = rootItem(containing: workspace) else { - return - } - setRootItem(rootItem, expanded: outlineView.isItemExpanded(rootItem) == false) - } - - private func restoreSelectedReviewChatRowAfterExpansion(of section: SidebarWorkspaceSection) { - _ = section - } - private func makeContextMenu(at point: NSPoint) -> NSMenu? { _ = point return nil } - @objc - private func handleCancelMenuItem(_ sender: NSMenuItem) { - guard let row = sender.representedObject as? ReviewMonitorSidebarReviewChatRow else { - return - } - triggerCancellation(for: row) + private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { + item as? ReviewMonitorCodexSidebarOutlineNode } - private func requestCancellation(for row: ReviewMonitorSidebarReviewChatRow) async throws { - guard row.isTerminal == false, - row.cancellationRequested == false - else { - return + private func shouldAllowSelection(of item: Any?) -> Bool { + if codexSidebarNode(from: item) != nil { + return true } - let operation = row.operation - _ = try await store.cancelReview( - jobID: operation.jobID, - sessionID: operation.sessionID, - cancellation: .userInterface() - ) + return false } - private func performCancellation(for row: ReviewMonitorSidebarReviewChatRow) async { - do { - try await requestCancellation(for: row) - } catch { - handleCancellationFailure(error, for: row) - } + private func row(for workspace: CodexReviewWorkspace) -> Int? { + row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: workspace.cwd))) + ?? row(forCodexSidebarSelectionID: .workspaceSection(workspace.cwd)) } - private func handleCancellationFailure(_ error: Error, for row: ReviewMonitorSidebarReviewChatRow) { - let description = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - let message = description.isEmpty ? "Failed to cancel review." : description - let operation = row.operation - try? store.recordCancellationFailure( - jobID: operation.jobID, - sessionID: operation.sessionID, - message: message - ) + private func row(for chatID: CodexThreadID) -> Int? { + row(forCodexSidebarSelectionID: .chat(chatID)) } - 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) - } + private func row(forCurrentChatSelectionID chatID: CodexThreadID) -> Int? { + row(forCodexSidebarSelectionID: .chat(chatID)) } - @discardableResult - private func applyMembershipChange( - currentItems: [Item], - targetItems: [Item], - parent: Any?, - animated: Bool - ) -> [Item] { - guard hasSameIdentityOrder(currentItems, targetItems) == false else { - return [] + private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { + let rowID: ReviewMonitorCodexSidebarRowID + switch selectionID { + case .workspace(let id): + rowID = .workspace(id) + case .chat(let id): + rowID = .chat(id) + case .workspaceSection(let id): + rowID = .section(id) } - - 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) - } + guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { + return nil } - return insertedItems - } - - private func hasSameIdentityOrder(_ lhs: [Item], _ rhs: [Item]) -> Bool { - lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { $0 === $1 } + let row = outlineView.row(forItem: node) + return row == -1 ? nil : row } - private func moveWorkspaceSectionInOutline(id: String, toRootIndex destinationIndex: Int) { - guard let section = workspaceSection(id: id) else { - return + private func selectionStillExists(_ selection: ReviewMonitorSelection?) -> Bool { + guard let selection else { + return true + } + switch selection { + case .workspaceSection(let section): + return codexWorkspaceSectionSelection(id: section.id) != nil + case .workspace(let workspace): + return codexWorkspaceSelection(id: workspace.id) != nil + case .chat(let chat): + return currentChatSelection(id: chat.id) != nil } - 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 currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + codexSidebarUnfilteredSnapshot.chat(id: id) } - private func moveReviewChatInOutline(id: String, in workspace: CodexReviewWorkspace, toIndex destinationIndex: Int) { - let currentRows = displayedReviewRows(in: workspace) - guard let sourceIndex = currentRows.firstIndex(where: { $0.operation.jobID == id }), - let parentItem = rootItem(containing: workspace) - else { - return - } - let rootRows = displayedReviewRows(inRootItem: parentItem) - guard let sourceRootIndex = rootRows.firstIndex(where: { $0.operation.jobID == id }), - let workspaceRootStartIndex = rootRows.firstIndex(where: { $0.operation.cwd == workspace.cwd }) - else { - return - } - let destinationRootIndex = workspaceRootStartIndex + destinationIndex - guard sourceIndex != destinationIndex, - sourceRootIndex != destinationRootIndex + private func codexWorkspaceSelection( + id: CodexWorkspaceID + ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { + guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), + case .workspace(let workspace) = node.item else { - return + return nil } - moveOutlineItem(from: sourceRootIndex, to: destinationRootIndex, parent: parentItem) + return workspace } - 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() + private func codexWorkspaceSectionSelection( + id: String + ) -> ReviewMonitorCodexSidebarSnapshot.Section? { + guard let node = codexSidebarOutlineTree.node(rowID: .section(id)), + case .section(let section) = node.item + else { + return nil } - reconcileSelectionAfterOutlineMutation() + return section } - private func displayedRootItems() -> [AnyObject] { - (0.. ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), + case .chat(let chat) = node.item + else { return nil } + return chat } - private func displayedReviewRows(in workspace: CodexReviewWorkspace) -> [ReviewMonitorSidebarReviewChatRow] { - (0.. [ReviewMonitorSidebarReviewChatRow] { - (0.. [ReviewMonitorSidebarReviewChatRow] { - guard let section = workspaceSection(containing: workspace) else { - return [] - } - return section.reviewRows.filter { $0.operation.cwd == workspace.cwd } - } - - private static func visibleReviewRows( - in rows: [ReviewMonitorSidebarReviewChatRow], - filter: SidebarReviewChatFilter - ) -> [ReviewMonitorSidebarReviewChatRow] { - guard filter.isActive else { - return rows - } - - let latestFinishedRow = filter.contains(.latestFinished) - ? latestFinishedRow(in: rows) - : nil - return rows.filter { row in - if filter.contains(.running), - row.isTerminal == false - { - return true - } - return latestFinishedRow.map { $0 === row } ?? false - } - } - - private static func latestFinishedRow( - in orderedRows: [ReviewMonitorSidebarReviewChatRow] - ) -> ReviewMonitorSidebarReviewChatRow? { - var latestRow: ReviewMonitorSidebarReviewChatRow? - var latestDate = Date.distantPast - for row in orderedRows { - guard row.isTerminal else { - continue - } - let finishedAt = row.presentation.endedAt - ?? row.presentation.startedAt - ?? .distantPast - if latestRow == nil || finishedAt > latestDate { - latestRow = row - latestDate = finishedAt - } - } - return latestRow - } - - private func workspaceSection(from item: Any?) -> SidebarWorkspaceSection? { - item as? SidebarWorkspaceSection - } - - private func reviewRow(from item: Any?) -> ReviewMonitorSidebarReviewChatRow? { - item as? ReviewMonitorSidebarReviewChatRow - } - - private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { - item as? ReviewMonitorCodexSidebarOutlineNode - } - - private func shouldAllowSelection(of item: Any?) -> Bool { - if codexSidebarNode(from: item) != nil { - return true - } - return false - } - - private func configureCodexSidebarCell( - _ view: ReviewMonitorWorkspaceCellView, - node: ReviewMonitorCodexSidebarOutlineNode - ) { - switch node.item { - case .section(let section): - view.configure(title: section.title, toolTip: section.title, systemSymbolName: "folder") - case .workspace(let workspace): - view.configure(title: workspace.title, toolTip: workspace.cwd, systemSymbolName: "folder") - case .chat(let chat): - view.configure( - title: chat.title, - toolTip: chat.preview ?? chat.title, - systemSymbolName: "bubble.left" - ) - } - } - - private func workspaces() -> [CodexReviewWorkspace] { - store.orderedWorkspaces - } - - private func filteredJobCount(in workspace: CodexReviewWorkspace) -> Int { - visibleReviewRows(in: workspace).count - } - - private func reviewRow(atRow row: Int) -> ReviewMonitorSidebarReviewChatRow? { - guard row >= 0, - let item = outlineView.item(atRow: row) - else { - return nil - } - return reviewRow(from: item) - } - - private func row(for workspace: CodexReviewWorkspace) -> Int? { - row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: workspace.cwd))) - ?? row(forCodexSidebarSelectionID: .workspaceSection(workspace.cwd)) - } - - private func row(for chatID: CodexThreadID) -> Int? { - row(forCodexSidebarSelectionID: .chat(chatID)) - } - - 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 row: ReviewMonitorSidebarReviewChatRow) -> AnyObject? { - guard let workspace = workspace(cwd: row.operation.cwd) else { - return nil - } - return rootItem(containing: workspace) - } - - private func row(forJobID jobID: String) -> Int? { - _ = jobID - return nil - } - - private func row(forCurrentChatSelectionID chatID: CodexThreadID) -> Int? { - row(forCodexSidebarSelectionID: .chat(chatID)) - } - - private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { - let rowID: ReviewMonitorCodexSidebarRowID - switch selectionID { - case .workspace(let id): - rowID = .workspace(id) - case .chat(let id): - rowID = .chat(id) - case .workspaceSection(let id): - rowID = .section(id) - } - guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { - return nil - } - let row = outlineView.row(forItem: node) - 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 codexWorkspaceSectionSelection(id: section.id) != nil - case .workspace(let workspace): - return codexWorkspaceSelection(id: workspace.id) != nil - case .chat(let chat): - return currentChatSelection(id: chat.id, in: workspaces) != nil - } - } - - 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 currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - currentChatSelection(id: id, in: workspaces()) - } - - private func currentChatSelection( - id: CodexThreadID, - in workspaces: [CodexReviewWorkspace] - ) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - _ = workspaces - return codexSidebarUnfilteredSnapshot.chat(id: id) - } - - private func codexWorkspaceSelection( - id: CodexWorkspaceID - ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { - guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), - case .workspace(let workspace) = node.item - else { - return nil - } - return workspace - } - - private func codexWorkspaceSectionSelection( - id: String - ) -> ReviewMonitorCodexSidebarSnapshot.Section? { - guard let node = codexSidebarOutlineTree.node(rowID: .section(id)), - case .section(let section) = node.item - else { - return nil - } - return section - } - - private func codexChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), - case .chat(let chat) = node.item - else { - return nil - } - return chat - } - - 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? { - guard let node = codexSidebarNode(from: item) else { - return nil + private func dragPayload(for item: Any) -> SidebarDragPayload? { + guard let node = codexSidebarNode(from: item) else { + return nil } switch node.item { case .section(let section): @@ -1470,8 +685,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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) + draggingSource === outlineView, + let data = draggingInfo.draggingPasteboard.data(forType: DragType.sidebarItem) else { return nil } @@ -1489,21 +704,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> SidebarResolvedDrop? { switch payload { - case .workspaceSection(let id): - resolvedWorkspaceSectionDrop( - id: id, - draggingLocation: draggingLocation, - proposedItem: proposedItem, - proposedChildIndex: index - ) - case .reviewChat(let id, let cwd): - resolvedReviewChatDrop( - id: id, - cwd: cwd, - draggingLocation: draggingLocation, - proposedItem: proposedItem, - proposedChildIndex: index - ) case .codexSection(let id): resolvedCodexSectionDrop( id: id, @@ -1529,18 +729,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> SidebarResolvedDrop? { guard let sourceNode = codexSidebarOutlineTree.node(rowID: .section(id)), - let sourceIndex = codexRootIndex(for: sourceNode), - let destinationIndex = codexRootInsertionIndex( + 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 displayDestinationIndex = clampedDestinationIndex > sourceIndex + let displayDestinationIndex = + clampedDestinationIndex > sourceIndex ? clampedDestinationIndex - 1 : clampedDestinationIndex guard displayDestinationIndex != sourceIndex else { @@ -1550,7 +751,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } let beforeID: String? if displayDestinationIndex < remainingRoots.count, - case .section(let section) = remainingRoots[displayDestinationIndex].item { + case .section(let section) = remainingRoots[displayDestinationIndex].item + { beforeID = section.id } else { beforeID = nil @@ -1571,13 +773,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> SidebarResolvedDrop? { guard uiState.sidebarReviewChatFilter.allowsReviewChatReordering, - let destination = resolvedCodexChatDropDestination( + let destination = resolvedCodexChatDropDestination( sourceContainer: container, draggingLocation: draggingLocation, proposedItem: proposedItem, proposedChildIndex: index - ), - destination.container == container + ), + destination.container == container else { return nil } @@ -1585,7 +787,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let currentOrder = codexUnfilteredChatIDs(in: container) let visibleOrder = codexVisibleChatIDs(in: container) guard currentOrder.contains(id), - let visibleSourceIndex = visibleOrder.firstIndex(of: id) + let visibleSourceIndex = visibleOrder.firstIndex(of: id) else { return nil } @@ -1597,7 +799,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD visibleOrder: visibleOrder, currentOrder: currentOrder ) - let displayDestinationIndex = visibleInsertionIndex > visibleSourceIndex + let displayDestinationIndex = + visibleInsertionIndex > visibleSourceIndex ? visibleInsertionIndex - 1 : visibleInsertionIndex guard displayDestinationIndex != visibleSourceIndex || beforeID == nil else { @@ -1623,8 +826,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> SidebarCodexChatDropDestination? { if let containerNode = codexSidebarNode(from: proposedItem), - isCodexChatContainer(containerNode), - index != NSOutlineViewDropOnItemIndex { + isCodexChatContainer(containerNode), + index != NSOutlineViewDropOnItemIndex + { let childIndex = max(0, min(index, codexVisibleChatIDs(in: containerNode.rowID).count)) return SidebarCodexChatDropDestination( container: containerNode.rowID, @@ -1635,13 +839,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } guard let targetNode = codexSidebarNode(from: proposedItem), - case .chat(let targetChat) = targetNode.item, - index == NSOutlineViewDropOnItemIndex, - let parentNode = outlineView.parent(forItem: targetNode) as? ReviewMonitorCodexSidebarOutlineNode, - parentNode.rowID == sourceContainer, - let targetIndex = codexVisibleChatIDs(in: parentNode.rowID).firstIndex(of: targetChat.id), - let draggingLocation, - let targetRow = row(forCodexSidebarSelectionID: .chat(targetChat.id)) + case .chat(let targetChat) = targetNode.item, + index == NSOutlineViewDropOnItemIndex, + let parentNode = outlineView.parent(forItem: targetNode) as? ReviewMonitorCodexSidebarOutlineNode, + parentNode.rowID == sourceContainer, + let targetIndex = codexVisibleChatIDs(in: parentNode.rowID).firstIndex(of: targetChat.id), + let draggingLocation, + let targetRow = row(forCodexSidebarSelectionID: .chat(targetChat.id)) else { return nil } @@ -1662,12 +866,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> Int? { if proposedItem == nil, - index != NSOutlineViewDropOnItemIndex { + index != NSOutlineViewDropOnItemIndex + { return max(0, min(index, codexSidebarOutlineTree.roots.count)) } if let draggingLocation, - outlineView.numberOfRows > 0 { + outlineView.numberOfRows > 0 + { let firstRowRect = outlineView.rect(ofRow: 0) if draggingLocation.y < firstRowRect.minY { return 0 @@ -1679,13 +885,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } guard let targetRoot = codexRootNode(for: proposedItem), - let targetIndex = codexRootIndex(for: targetRoot) + let targetIndex = codexRootIndex(for: targetRoot) else { return nil } guard let draggingLocation, - let targetRect = codexRootRect(for: targetRoot) + let targetRect = codexRootRect(for: targetRoot) else { return targetIndex } @@ -1777,497 +983,55 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return targetID == movingID ? movingID : targetID } guard let lastVisibleID = visibleOrder.last, - let lastVisibleIndex = currentOrder.firstIndex(of: lastVisibleID) + let lastVisibleIndex = currentOrder.firstIndex(of: lastVisibleID) else { return nil } let nextIndex = lastVisibleIndex + 1 guard nextIndex < currentOrder.count, - currentOrder[nextIndex] != movingID + currentOrder[nextIndex] != movingID else { return nil } return currentOrder[nextIndex] } - 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 + @discardableResult + private func applyResolvedDrop(_ resolvedDrop: SidebarResolvedDrop) -> Bool { + switch resolvedDrop.operation { + case .none: + return false + case .reorderCodexSection(let id, let beforeID): + guard codexSidebarPresentationOrder.reorderSection(id: id, before: beforeID) else { + return false + } + applyFilteredCodexSidebarSnapshot() + 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 + } + applyFilteredCodexSidebarSnapshot() + return true } - 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) + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + guard let item else { + return codexSidebarOutlineTree.roots.count } - - if let blankAreaDestination = blankAreaWorkspaceDropDestination(draggingLocation: draggingLocation) { - return blankAreaDestination + if let node = codexSidebarNode(from: item) { + return node.children.count } - - if let targetSection = workspaceSection(from: proposedItem), - let targetRootIndex = rootIndex(forRootItem: targetSection) - { - return workspaceRootDropDestination( - rootInsertionIndex: workspaceRootInsertionIndex( - aroundRootItem: targetSection, - defaultIndex: targetRootIndex, - draggingLocation: draggingLocation - ) - ) - } - - if let targetRow = reviewRow(from: proposedItem), - let targetWorkspace = workspace(cwd: targetRow.operation.cwd), - 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 lastRow = displayedReviewRows(inRootItem: rootItem).last, - let lastReviewChatRow = row(forJobID: lastRow.operation.jobID) - else { - return sectionRect - } - - sectionRect = sectionRect.union(outlineView.rect(ofRow: lastReviewChatRow)) - return sectionRect - } - - private func resolvedReviewChatDrop( - id: String, - cwd: String, - draggingLocation: NSPoint?, - proposedItem: Any?, - proposedChildIndex index: Int - ) -> SidebarResolvedDrop? { - guard uiState.sidebarReviewChatFilter.allowsReviewChatReordering else { - return nil - } - guard let destination = resolvedReviewChatDropDestination( - draggingLocation: draggingLocation, - proposedItem: proposedItem, - proposedChildIndex: index, - sourceCWD: cwd - ), - destination.workspace.cwd == cwd - else { - return nil - } - - let orderedJobs = store.orderedJobs(in: destination.workspace) - let destinationVisibleRows = visibleReviewRows(in: destination.workspace) - guard let visibleSourceIndex = destinationVisibleRows.firstIndex(where: { $0.operation.jobID == id }) else { - return nil - } - - let visibleInsertionIndex = max(0, min(destination.childIndex, destinationVisibleRows.count)) - let beforeJobID = reviewChatBeforeJobID( - movingJobID: id, - visibleInsertionIndex: visibleInsertionIndex, - visibleRows: destinationVisibleRows, - orderedJobs: orderedJobs, - movesToFullOrderEnd: destination.movesToFullOrderEnd - ) - let displayDestinationIndex = visibleInsertionIndex > visibleSourceIndex - ? visibleInsertionIndex - 1 - : visibleInsertionIndex - let clampedDisplayDestinationIndex = max(0, min(displayDestinationIndex, destinationVisibleRows.count - 1)) - let displayOrderChanges = clampedDisplayDestinationIndex != visibleSourceIndex - guard destination.movesToFullOrderEnd || displayOrderChanges else { - return nil - } - guard reviewChatReorderWouldChange(id: id, inWorkspace: cwd, beforeJobID: beforeJobID) else { - return nil - } - let operation: SidebarResolvedDrop.Operation = .reorderReviewChat( - jobID: id, - cwd: cwd, - beforeJobID: beforeJobID, - displayIndex: clampedDisplayDestinationIndex - ) - let dropPresentation = reviewChatDropPresentation( - for: destination.workspace, - childIndex: visibleInsertionIndex - ) - return SidebarResolvedDrop( - operation: operation, - dropItem: dropPresentation.item, - dropChildIndex: dropPresentation.childIndex - ) - } - - private func reviewChatBeforeJobID( - movingJobID: String, - visibleInsertionIndex: Int, - visibleRows: [ReviewMonitorSidebarReviewChatRow], - orderedJobs: [CodexReviewJob], - movesToFullOrderEnd: Bool - ) -> String? { - if movesToFullOrderEnd { - return nil - } - if visibleInsertionIndex < visibleRows.count { - let targetJobID = visibleRows[visibleInsertionIndex].operation.jobID - return targetJobID == movingJobID ? movingJobID : targetJobID - } - guard let lastVisibleRow = visibleRows.last, - let lastVisibleIndex = orderedJobs.firstIndex(where: { $0.id == lastVisibleRow.operation.jobID }) - else { - return nil - } - let nextIndex = lastVisibleIndex + 1 - guard nextIndex < orderedJobs.count, - orderedJobs[nextIndex].id != movingJobID - else { - return nil - } - return orderedJobs[nextIndex].id - } - - private func reviewChatReorderWouldChange( - 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 resolvedReviewChatDropDestination( - draggingLocation: NSPoint?, - proposedItem: Any?, - proposedChildIndex index: Int, - sourceCWD: String - ) -> SidebarReviewChatDropDestination? { - if let destination = blankAreaReviewChatDropDestination( - draggingLocation: draggingLocation, - sourceCWD: sourceCWD - ) { - return destination - } - - if let section = workspaceSection(from: proposedItem), - index != NSOutlineViewDropOnItemIndex - { - return resolvedReviewChatDropDestination( - in: section, - sourceCWD: sourceCWD, - proposedRootChildIndex: index - ) - } - - guard let targetRow = reviewRow(from: proposedItem), - index == NSOutlineViewDropOnItemIndex - else { - return nil - } - return resolvedReviewChatDropDestination( - around: targetRow, - draggingLocation: draggingLocation, - sourceCWD: sourceCWD - ) - } - - private func resolvedReviewChatDropDestination( - around targetRow: ReviewMonitorSidebarReviewChatRow, - draggingLocation: NSPoint?, - sourceCWD: String - ) -> SidebarReviewChatDropDestination? { - guard let draggingLocation, - let targetWorkspace = workspace(cwd: targetRow.operation.cwd), - let targetSection = workspaceSection(containing: targetWorkspace), - let targetWorkspaceRootStartIndex = targetSection.reviewRows.firstIndex(where: { $0.operation.cwd == targetWorkspace.cwd }), - let targetJobIndex = visibleReviewRows(in: targetWorkspace) - .firstIndex(where: { $0.operation.jobID == targetRow.operation.jobID }), - let outlineRow = row(forJobID: targetRow.operation.jobID) - else { - return nil - } - - let targetRowRect = outlineView.rect(ofRow: outlineRow) - let rootInsertionIndex = targetWorkspaceRootStartIndex - + targetJobIndex - + (draggingLocation.y < targetRowRect.midY ? 0 : 1) - return resolvedReviewChatDropDestination( - in: targetSection, - sourceCWD: sourceCWD, - proposedRootChildIndex: rootInsertionIndex - ) - } - - private func blankAreaReviewChatDropDestination( - draggingLocation: NSPoint?, - sourceCWD: String - ) -> SidebarReviewChatDropDestination? { - 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.reviewRows.last?.operation.cwd == sourceCWD - else { - return nil - } - - return SidebarReviewChatDropDestination( - workspace: workspace, - childIndex: visibleReviewRows(in: workspace).count, - movesToFullOrderEnd: true - ) - } - - private func resolvedReviewChatDropDestination( - in section: SidebarWorkspaceSection, - sourceCWD: String, - proposedRootChildIndex index: Int - ) -> SidebarReviewChatDropDestination? { - guard let workspace = section.workspaces.first(where: { $0.cwd == sourceCWD }), - let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.operation.cwd == sourceCWD }) - else { - return nil - } - - let workspaceJobCount = visibleReviewRows(in: workspace).count - let workspaceRootEndIndex = workspaceRootStartIndex + workspaceJobCount - let rootInsertionIndex = max(0, min(index, section.reviewRows.count)) - guard rootInsertionIndex >= workspaceRootStartIndex, - rootInsertionIndex <= workspaceRootEndIndex - else { - return nil - } - - return SidebarReviewChatDropDestination( - workspace: workspace, - childIndex: rootInsertionIndex - workspaceRootStartIndex, - movesToFullOrderEnd: false - ) - } - - private func reviewChatDropPresentation( - for workspace: CodexReviewWorkspace, - childIndex: Int - ) -> (item: Any?, childIndex: Int) { - guard let rootItem = rootItem(containing: workspace), - let section = workspaceSection(from: rootItem), - let workspaceRootStartIndex = section.reviewRows.firstIndex(where: { $0.operation.cwd == workspace.cwd }) - else { - return (workspace, childIndex) - } - - let rootChildIndex = workspaceRootStartIndex + childIndex - return (section, max(0, min(rootChildIndex, section.reviewRows.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 .reorderReviewChat(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 { - moveReviewChatInOutline(id: id, in: workspace, toIndex: displayIndex) - } - return true - case .reorderCodexSection(let id, let beforeID): - guard codexSidebarPresentationOrder.reorderSection(id: id, before: beforeID) else { - return false - } - applyFilteredCodexSidebarSnapshot() - 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 - } - applyFilteredCodexSidebarSnapshot() - 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 - } + return 0 + } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { guard let item else { @@ -2330,12 +1094,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> NSDragOperation { let draggingLocation = outlineView.convert(info.draggingLocation, from: nil) guard let payload = dragPayload(from: info), - let resolvedDrop = resolvedDrop( + let resolvedDrop = resolvedDrop( for: payload, draggingLocation: draggingLocation, proposedItem: item, proposedChildIndex: index - ) + ) else { clearDropTarget() return [] @@ -2356,12 +1120,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let draggingLocation = outlineView.convert(info.draggingLocation, from: nil) guard let payload = dragPayload(from: info), - let resolvedDrop = resolvedDrop( + let resolvedDrop = resolvedDrop( for: payload, draggingLocation: draggingLocation, proposedItem: item, proposedChildIndex: index - ) + ) else { return false } @@ -2374,22 +1138,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func outlineViewItemDidExpand(_ notification: Notification) { - let item = notification.userInfo?["NSObject"] - if let section = workspaceSection(from: item) { - if section.isExpanded == false { - section.isExpanded = true - } - restoreSelectedReviewChatRowAfterExpansion(of: section) - } + _ = notification } func outlineViewItemDidCollapse(_ notification: Notification) { - let item = notification.userInfo?["NSObject"] - if let section = workspaceSection(from: item) { - if section.isExpanded { - section.isExpanded = false - } - } + _ = notification } func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { @@ -2408,20 +1161,24 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD item: Any ) -> NSView? { if let node = codexSidebarNode(from: item) { - if case .chat(let chat) = node.item { - let view = (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) as? ReviewMonitorReviewChatCellView) + if case .chat = node.item { + let view = + (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) + as? ReviewMonitorReviewChatCellView) ?? ReviewMonitorReviewChatCellView() view.identifier = Identifier.reviewChatCell view.objectValue = node - view.configure(with: chat) + view.configure(with: node) return view } - let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView) + let view = + (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) + as? ReviewMonitorWorkspaceCellView) ?? ReviewMonitorWorkspaceCellView() view.identifier = Identifier.workspaceCell view.objectValue = node - configureCodexSidebarCell(view, node: node) + view.configure(with: node) return view } @@ -2431,664 +1188,478 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } #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.. String? { - codexSidebarOutlineTree.node(rowID: rowID)?.title - } - func displayedCodexChatIDsForTesting(container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { - codexVisibleChatIDs(in: container) - } - - func codexSidebarCanStartDragForTesting(rowID: ReviewMonitorCodexSidebarRowID) -> Bool { - guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { - return false + var accountsViewControllerForTesting: ReviewMonitorAccountsViewController { + accountsViewController.loadViewIfNeeded() + return accountsViewController } - return dragPayload(for: node) != nil - } - @discardableResult - func performCodexSectionDropForTesting(id: String, toIndex index: Int) -> Bool { - guard let resolvedDrop = resolvedDrop( - for: .codexSection(id: id), - proposedItem: nil, - proposedChildIndex: index - ) else { - return false + var displayedSectionTitlesForTesting: [String] { + codexSidebarRootTitlesForTesting } - 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 + var selectedReviewChatIDForTesting: CodexThreadID? { + guard case .chat(let chat) = uiState.selection else { + return nil + } + return chat.id } - return applyResolvedDrop(resolvedDrop) - } - func codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(_ id: CodexThreadID) -> Bool { - guard let row = row(forCodexSidebarSelectionID: .chat(id)), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return false + var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { + uiState.selectedWorkspaceSectionEntry } - return cellView.isHostingReviewMonitorChatRowViewForTesting - } - func selectCodexSidebarRowForTesting(rowID: ReviewMonitorCodexSidebarRowID) { - guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { - return + var codexSidebarSnapshotForTesting: ReviewMonitorCodexSidebarSnapshot? { + codexSidebarLibrary?.snapshot } - let row = outlineView.row(forItem: node) - guard row != -1 else { - return - } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - } - var sidebarFullReloadCountForTesting: Int { - fullReloadCountForTesting - } - - var sidebarWorkspaceReloadCountForTesting: Int { - workspaceReloadCountForTesting - } - - var sidebarIncrementalMoveCountForTesting: Int { - incrementalMoveCountForTesting - } - - var sidebarIncrementalMembershipChangeCountForTesting: Int { - incrementalMembershipChangeCountForTesting - } - - var isShowingEmptyStateForTesting: Bool { - emptyStateViewController.view.isHidden == false - } + var codexSidebarRootTitlesForTesting: [String] { + codexSidebarOutlineTree.roots.map(\.title) + } - func selectReviewChatForTesting(id chatID: CodexThreadID) { - guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { - return + var displayedCodexSidebarTitlesForTesting: [String] { + (0.. String? { + codexSidebarOutlineTree.node(rowID: rowID)?.title } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - } - func clearSelectionForTesting() { - uiState.selection = nil - outlineView.deselectAll(nil) - } + func displayedCodexChatIDsForTesting(container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { + codexVisibleChatIDs(in: container) + } - var allWorkspaceRowsExpandedForTesting: Bool { - currentRootTopologies.allSatisfy { topology in - guard let section = workspaceSection(from: topology.item) else { + func codexSidebarCanStartDragForTesting(rowID: ReviewMonitorCodexSidebarRowID) -> Bool { + guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return false } - return section.isExpanded && outlineView.isItemExpanded(section) + return dragPayload(for: node) != nil } - } - func workspaceIsSelectableForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - workspaceSection(containing: workspace).map(shouldAllowSelection(of:)) ?? false - } - - func displayedReviewChatJobIDsForTesting(in workspace: CodexReviewWorkspace) -> [String] { - var jobIDs: [String] = [] - for row in 0.. Bool { + guard + let resolvedDrop = resolvedDrop( + for: .codexSection(id: id), + proposedItem: nil, + proposedChildIndex: index + ) else { - continue + return false } - jobIDs.append(reviewRow.operation.jobID) - } - return jobIDs - } - - var floatsGroupRowsEnabledForTesting: Bool { - outlineView.floatsGroupRows - } - - var draggingDestinationFeedbackStyleForTesting: NSTableView.DraggingDestinationFeedbackStyle { - outlineView.draggingDestinationFeedbackStyle - } - - var sidebarUsesAutomaticRowHeightsForTesting: Bool { - outlineView.usesAutomaticRowHeights - } - - var expectedWorkspaceRowRectHeightForTesting: CGFloat { - rowHeights.workspace + outlineView.intercellSpacing.height - } - - var expectedReviewChatRowRectHeightForTesting: CGFloat { - rowHeights.reviewChat + outlineView.intercellSpacing.height - } - - func workspaceRowHeightForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { - guard let row = row(for: workspace) else { - return nil + 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) } - view.layoutSubtreeIfNeeded() - return outlineView.rect(ofRow: row).height - } - func reviewChatRowHeightForTesting(_ job: CodexReviewJob) -> CGFloat? { - guard let row = row(forJobID: job.id) else { - return nil + func codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(_ id: CodexThreadID) -> Bool { + guard let row = row(forCodexSidebarSelectionID: .chat(id)), + let cellView = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: true + ) as? ReviewMonitorReviewChatCellView + else { + return false + } + return cellView.isHostingReviewMonitorChatRowViewForTesting } - 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 + 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.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } - outlineView.layoutSubtreeIfNeeded() - return cellView.contentMinXForTesting(relativeTo: outlineView) - } - func workspaceDisclosureMaxXForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { - guard let row = row(for: workspace) else { - return nil + var sidebarFullReloadCountForTesting: Int { + fullReloadCountForTesting } - view.layoutSubtreeIfNeeded() - outlineView.layoutSubtreeIfNeeded() - let disclosureFrame = outlineView.frameOfOutlineCell(atRow: row) - return disclosureFrame.width > 0 ? disclosureFrame.maxX : nil - } - func reviewChatCellMinXForTesting(_ 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? ReviewMonitorReviewChatCellView else { - return nil + var isShowingEmptyStateForTesting: Bool { + emptyStateViewController.view.isHidden == false } - outlineView.layoutSubtreeIfNeeded() - return cellView.contentMinXForTesting(relativeTo: outlineView) - } - func reviewChatRowUsesReviewMonitorChatRowViewForTesting(_ job: CodexReviewJob) -> Bool { - guard let row = row(forJobID: job.id), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return false + func selectReviewChatForTesting(id chatID: CodexThreadID) { + guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { + uiState.selection = .chat(testingChatSelectionFallback(id: chatID)) + return + } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } - return cellView.isHostingReviewMonitorChatRowViewForTesting - } - - func cancelReviewChatForTesting(_ job: CodexReviewJob) async { - _ = job - } - - func clickBlankAreaForTesting() { - view.layoutSubtreeIfNeeded() - let point = blankPointForTesting() - precondition( - outlineView.suppressesSelectionClearingForTesting(at: point), - "Expected a blank click target outside any review chat 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.") + private func testingChatSelectionFallback( + id chatID: CodexThreadID + ) -> ReviewMonitorCodexSidebarSnapshot.Chat { + if let job = store.orderedJobs.first(where: { job in + let reviewThreadID = job.core.run.reviewThreadID?.nilIfEmpty + let threadID = job.core.run.threadID?.nilIfEmpty + return reviewThreadID == chatID.rawValue || threadID == chatID.rawValue + }) { + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: job.displayTitle, + preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, + model: job.core.run.model, + workspaceCWD: job.cwd, + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) + ) + } + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: chatID.rawValue, + preview: nil, + workspaceCWD: nil, + updatedAt: nil + ) } - 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("Review chat row is not visible.") + func selectWorkspaceForTesting(_ workspace: CodexReviewWorkspace) { + guard let row = row(for: workspace) else { + uiState.selection = .workspaceSection( + .init( + id: workspace.cwd, + title: workspace.displayTitle, + workspaceCWDs: [workspace.cwd] + )) + return + } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } - 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) - } + func clearSelectionForTesting() { + uiState.selection = nil + outlineView.deselectAll(nil) + } - var sidebarHasFirstResponderForTesting: Bool { - view.window?.firstResponder === outlineView - } + var allWorkspaceRowsExpandedForTesting: Bool { + codexSidebarOutlineTree.roots.allSatisfy { outlineView.isItemExpanded($0) } + } - var isPresentingContextMenuForTesting: Bool { - outlineView.isPresentingContextMenuForTesting - } + func workspaceIsSelectableForTesting(_ workspace: CodexReviewWorkspace) -> Bool { + row(for: workspace).map { shouldAllowSelection(of: outlineView.item(atRow: $0)) } ?? false + } - var acceptsFirstResponderForTesting: Bool { - outlineView.acceptsFirstResponderForTesting - } + var floatsGroupRowsEnabledForTesting: Bool { + outlineView.floatsGroupRows + } - var hasTemporaryContextMenuForTesting: Bool { - outlineView.menu != nil - } + var draggingDestinationFeedbackStyleForTesting: NSTableView.DraggingDestinationFeedbackStyle { + outlineView.draggingDestinationFeedbackStyle + } - func workspaceIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - workspaceSection(containing: workspace)?.isExpanded ?? false - } + var sidebarUsesAutomaticRowHeightsForTesting: Bool { + outlineView.usesAutomaticRowHeights + } - func workspaceOutlineIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - guard let rootItem = rootItem(containing: workspace) else { - return false + var expectedWorkspaceRowRectHeightForTesting: CGFloat { + rowHeights.workspace + outlineView.intercellSpacing.height } - return outlineView.isItemExpanded(rootItem) - } - func toggleWorkspaceDisclosureForTesting(_ workspace: CodexReviewWorkspace) { - guard row(for: workspace) != nil else { - preconditionFailure("Workspace row is not visible.") + var expectedReviewChatRowRectHeightForTesting: CGFloat { + rowHeights.reviewChat + outlineView.intercellSpacing.height } - toggleWorkspaceExpansion(workspace) - } - func collapseWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { - guard let rootItem = rootItem(containing: workspace), - row(for: workspace) != nil - else { - preconditionFailure("Workspace row is not visible.") + func workspaceRowHeightForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { + guard let row = row(for: workspace) else { + return nil + } + view.layoutSubtreeIfNeeded() + return outlineView.rect(ofRow: row).height } - outlineView.collapseItem(rootItem) - } - func expandWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { - guard let rootItem = rootItem(containing: workspace), - row(for: workspace) != nil - else { - preconditionFailure("Workspace row is not visible.") + func reviewChatRowHeightForTesting(_ chatID: CodexThreadID) -> CGFloat? { + guard let row = row(for: chatID) else { + return nil + } + view.layoutSubtreeIfNeeded() + return outlineView.rect(ofRow: row).height } - 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 + 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) } - 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() - } + 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 + } - var sidebarVisibleHeightForTesting: CGFloat { - scrollView.documentVisibleRect.height - } + 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) + } - var safeAreaFrameForTesting: NSRect { - view.safeAreaRect - } + func reviewChatRowUsesReviewMonitorChatRowViewForTesting(_ chatID: CodexThreadID) -> Bool { + guard let row = row(for: chatID), + let cellView = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: true + ) as? ReviewMonitorReviewChatCellView + else { + return false + } + return cellView.isHostingReviewMonitorChatRowViewForTesting + } - var scrollViewFrameForTesting: NSRect { - scrollView.frame - } + 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(_ workspace: CodexReviewWorkspace) { + view.layoutSubtreeIfNeeded() + guard let row = row(for: workspace) else { + uiState.selection = .workspaceSection( + .init( + id: workspace.cwd, + title: workspace.displayTitle, + workspaceCWDs: [workspace.cwd] + )) + return + } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } - var sidebarDocumentHeightForTesting: CGFloat { - outlineView.frame.height - } + func focusSidebarForTesting() { + _ = view.window?.makeFirstResponder(outlineView) + } - var sidebarOutlineContentHeightForTesting: CGFloat { - guard outlineView.numberOfRows > 0 else { - return 0 + var sidebarHasFirstResponderForTesting: Bool { + view.window?.firstResponder === outlineView } - return outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY - } - var sidebarMaximumVerticalScrollOffsetForTesting: CGFloat { - max(0, sidebarDocumentHeightForTesting - sidebarVisibleHeightForTesting) - } + var isPresentingContextMenuForTesting: Bool { + outlineView.isPresentingContextMenuForTesting + } - var sidebarVisibleRectForTesting: NSRect { - scrollView.documentVisibleRect - } + var acceptsFirstResponderForTesting: Bool { + outlineView.acceptsFirstResponderForTesting + } - var sidebarFirstRowRectForTesting: NSRect { - guard outlineView.numberOfRows > 0 else { - return .zero + var hasTemporaryContextMenuForTesting: Bool { + outlineView.menu != nil } - return outlineView.rect(ofRow: 0) - } - var sidebarLastRowRectForTesting: NSRect { - guard outlineView.numberOfRows > 0 else { - return .zero + func workspaceIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { + workspaceOutlineIsExpandedForTesting(workspace) } - 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 + func workspaceOutlineIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { + guard let row = row(for: workspace), + let item = outlineView.item(atRow: row) + else { + return false + } + return outlineView.isItemExpanded(item) } - 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 + func toggleWorkspaceDisclosureForTesting(_ workspace: CodexReviewWorkspace) { + guard let row = row(for: workspace), + let item = outlineView.item(atRow: row) + else { + preconditionFailure("Workspace row is not visible.") + } + if outlineView.isItemExpanded(item) { + outlineView.collapseItem(item) + } else { + outlineView.expandItem(item) + } } - return applyResolvedDrop(resolvedDrop) - } - func workspaceDropIsRejectedForTesting( - _ workspace: CodexReviewWorkspace, - proposedJob targetJob: CodexReviewJob - ) -> Bool { - _ = workspace - _ = targetJob - return true - } + func collapseWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { + guard let row = row(for: workspace), + let item = outlineView.item(atRow: row) + else { + preconditionFailure("Workspace row is not visible.") + } + outlineView.collapseItem(item) + } - @discardableResult - func performWorkspaceDropForTesting( - _ workspace: CodexReviewWorkspace, - proposedJob targetJob: CodexReviewJob, - hoveringBelowMidpoint: Bool - ) -> Bool { - _ = workspace - _ = targetJob - _ = hoveringBelowMidpoint - return false - } + func expandWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { + guard let row = row(for: workspace), + let item = outlineView.item(atRow: row) + else { + preconditionFailure("Workspace row is not visible.") + } + outlineView.expandItem(item) + } - func workspaceSectionCanStartDragForTesting(containing workspace: CodexReviewWorkspace) -> Bool { - guard let section = workspaceSection(containing: workspace) else { - return false + 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 } - 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 + func scrollSidebarToOffsetForTesting(_ yOffset: CGFloat) { + let clampedOffset = max(0, yOffset) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedOffset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + view.layoutSubtreeIfNeeded() } - return applyResolvedDrop(resolvedDrop) - } - func workspaceInsertionIndexForTesting( - _ workspace: CodexReviewWorkspace, - hoveringBelowMidpoint: Bool - ) -> Int? { - guard let sectionRect = workspaceSectionRect(for: workspace) else { - return nil + var sidebarVisibleHeightForTesting: CGFloat { + scrollView.documentVisibleRect.height } - 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 + var safeAreaFrameForTesting: NSRect { + view.safeAreaRect } - 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 performReviewChatDropForTesting( - _ job: CodexReviewJob, - proposedWorkspace: CodexReviewWorkspace, - childIndex: Int - ) -> Bool { - guard let section = workspaceSection(containing: proposedWorkspace), - let resolvedDrop = resolvedDrop( - for: .reviewChat(jobID: job.id, cwd: job.cwd), - proposedItem: section, - proposedChildIndex: childIndex - ) else { - return false + var scrollViewFrameForTesting: NSRect { + scrollView.frame } - return applyResolvedDrop(resolvedDrop) - } - @discardableResult - func performReviewChatDropForTesting( - _ job: CodexReviewJob, - proposedJob targetJob: CodexReviewJob, - hoveringBelowMidpoint: Bool - ) -> Bool { - _ = job - _ = targetJob - _ = hoveringBelowMidpoint - return false - } + var sidebarDocumentHeightForTesting: CGFloat { + outlineView.frame.height + } - @discardableResult - func performReviewChatDropForTesting( - _ job: CodexReviewJob, - proposedWorkspaceSectionContaining workspace: CodexReviewWorkspace, - childIndex: Int - ) -> Bool { - guard let section = workspaceSection(containing: workspace), - let resolvedDrop = resolvedDrop( - for: .reviewChat(jobID: job.id, cwd: job.cwd), - proposedItem: section, - proposedChildIndex: childIndex - ) - else { - return false + var sidebarOutlineContentHeightForTesting: CGFloat { + guard outlineView.numberOfRows > 0 else { + return 0 + } + return outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY } - return applyResolvedDrop(resolvedDrop) - } - func reviewChatDropIsRejectedForTesting(_ job: CodexReviewJob) -> Bool { - resolvedDrop( - for: .reviewChat(jobID: job.id, cwd: job.cwd), - proposedItem: nil, - proposedChildIndex: NSOutlineViewDropOnItemIndex - ) == nil - } + var sidebarMaximumVerticalScrollOffsetForTesting: CGFloat { + max(0, sidebarDocumentHeightForTesting - sidebarVisibleHeightForTesting) + } - @discardableResult - func performJobBlankAreaDropForTesting(_ job: CodexReviewJob) -> Bool { - guard outlineView.numberOfRows > 0, - let workspace = workspace(cwd: job.cwd), - let section = workspaceSection(containing: workspace) - else { - return false + var sidebarVisibleRectForTesting: NSRect { + scrollView.documentVisibleRect } - let lastRowRect = outlineView.rect(ofRow: outlineView.numberOfRows - 1) - let draggingLocation = NSPoint(x: lastRowRect.midX, y: lastRowRect.maxY + 1) - guard let resolvedDrop = resolvedDrop( - for: .reviewChat(jobID: job.id, cwd: job.cwd), - draggingLocation: draggingLocation, - proposedItem: section, - proposedChildIndex: section.reviewRows.count - ) else { - return false + + var sidebarFirstRowRectForTesting: NSRect { + guard outlineView.numberOfRows > 0 else { + return .zero + } + return outlineView.rect(ofRow: 0) } - 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 + var sidebarLastRowRectForTesting: NSRect { + guard outlineView.numberOfRows > 0 else { + return .zero + } + return outlineView.rect(ofRow: outlineView.numberOfRows - 1) } - 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.") + 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) } - 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.") + + 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 } - return event } -} #endif @MainActor @@ -3224,44 +1795,44 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { 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 + #if DEBUG + func suppressesSelectionClearingForTesting(at point: NSPoint) -> Bool { + shouldSuppressSelectionClearing(at: point) } - beginContextMenuPresentation(with: contextMenu) - presenter(contextMenu) - if isPresentingContextMenu { - endContextMenuPresentation() + + 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 isPresentingContextMenuForTesting: Bool { + isPresentingContextMenu + } - var acceptsFirstResponderForTesting: Bool { - acceptsFirstResponder - } + var acceptsFirstResponderForTesting: Bool { + acceptsFirstResponder + } -#endif + #endif } @MainActor private final class ReviewMonitorWorkspaceRowView: NSTableRowView { override var isEmphasized: Bool { get { false } - set { } + set {} } } @@ -3269,42 +1840,50 @@ private final class ReviewMonitorWorkspaceRowView: NSTableRowView { private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { override var isEmphasized: Bool { get { false } - set { } + set {} } } @MainActor private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? + private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? + private var nodeObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) configureHierarchy() } + isolated deinit { + nodeObservation?.cancel() + } + @available(*, unavailable) required init?(coder: NSCoder) { nil } - func configure(with row: ReviewMonitorSidebarReviewChatRow) { - objectValue = row - toolTip = row.operation.cwd - configureRow(row.presentation) - } - - func configure(with chat: ReviewMonitorCodexSidebarSnapshot.Chat) { - objectValue = chat - toolTip = chat.workspaceCWD ?? chat.preview ?? chat.title - configureRow(ReviewMonitorSidebarChatRow(chat: chat)) - } - - private func configureRow(_ row: ReviewMonitorSidebarChatRow) { + func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { + objectValue = node + guard boundNode !== node else { + return + } + boundNode = node + nodeObservation?.cancel() + nodeObservation = withPortableContinuousObservation { [weak self, node] _ in + guard let self, + case .chat(let chat) = node.item + else { + return + } + self.toolTip = chat.workspaceCWD ?? chat.preview ?? chat.title + } if let hostingView { - hostingView.rootView.row = row + hostingView.rootView.node = node } else { let hostingView = NSHostingView( - rootView: ReviewMonitorChatRowView(row: row) + rootView: ReviewMonitorChatRowView(node: node) ) hostingView.translatesAutoresizingMaskIntoConstraints = false hostingView.setAccessibilityIdentifier("review-monitor.review-chat-row") @@ -3313,7 +1892,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { hostingView.topAnchor.constraint(equalTo: topAnchor), hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) self.hostingView = hostingView } @@ -3324,86 +1903,92 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } #if DEBUG - var isHostingReviewMonitorChatRowViewForTesting: Bool { - hostingView != nil - } + var isHostingReviewMonitorChatRowViewForTesting: Bool { + hostingView != nil + } - var hostedRowIDForTesting: String? { - hostingView?.rootView.row.id - } + var hostedRowIDForTesting: String? { + hostingView?.rootView.row?.id + } - var hostingViewIdentityForTesting: ObjectIdentifier? { - hostingView.map(ObjectIdentifier.init) - } + var hostingViewIdentityForTesting: ObjectIdentifier? { + hostingView.map(ObjectIdentifier.init) + } - func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? { - guard let hostingView else { - return nil + func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? { + guard let hostingView else { + return nil + } + layoutSubtreeIfNeeded() + return convert(hostingView.frame, to: view).minX } - layoutSubtreeIfNeeded() - return convert(hostingView.frame, to: view).minX - } #endif } #if DEBUG -@MainActor -private func reviewMonitorReviewChatCellChatForTesting( - job: CodexReviewJob -) -> ReviewMonitorCodexSidebarSnapshot.Chat { - let chatID = CodexThreadID( - rawValue: job.core.run.reviewThreadID?.nilIfEmpty - ?? job.core.run.threadID?.nilIfEmpty - ?? job.id - ) - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: job.displayTitle, - preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, - model: job.core.run.model, - workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) - ) -} + @MainActor + private func reviewMonitorReviewChatCellChatForTesting( + job: CodexReviewJob + ) -> ReviewMonitorCodexSidebarSnapshot.Chat { + let chatID = CodexThreadID( + rawValue: job.core.run.reviewThreadID?.nilIfEmpty + ?? job.core.run.threadID?.nilIfEmpty + ?? job.id + ) + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: job.displayTitle, + preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, + model: job.core.run.model, + workspaceCWD: job.cwd, + updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) + ) + } -@MainActor -func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { - let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: reviewMonitorReviewChatCellChatForTesting(job: job)) - return cellView -} + @MainActor + func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { + let cellView = ReviewMonitorReviewChatCellView() + cellView.configure( + with: ReviewMonitorCodexSidebarOutlineNode( + item: .chat(reviewMonitorReviewChatCellChatForTesting(job: job)) + )) + return cellView + } -@MainActor -func configureReviewMonitorReviewChatCellViewForTesting( - _ cellView: NSTableCellView, - job: CodexReviewJob -) { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - fatalError("Expected ReviewMonitorReviewChatCellView.") - } - cellView.configure(with: reviewMonitorReviewChatCellChatForTesting(job: job)) -} + @MainActor + func configureReviewMonitorReviewChatCellViewForTesting( + _ cellView: NSTableCellView, + job: CodexReviewJob + ) { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + fatalError("Expected ReviewMonitorReviewChatCellView.") + } + cellView.configure( + with: ReviewMonitorCodexSidebarOutlineNode( + item: .chat(reviewMonitorReviewChatCellChatForTesting(job: job)) + )) + } -@MainActor -func reviewMonitorReviewChatCellHostedRowIDForTesting(_ cellView: NSTableCellView) -> String? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil + @MainActor + func reviewMonitorReviewChatCellHostedRowIDForTesting(_ cellView: NSTableCellView) -> String? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + return nil + } + return cellView.hostedRowIDForTesting } - return cellView.hostedRowIDForTesting -} -@MainActor -func reviewMonitorReviewChatCellHostingViewIdentityForTesting( - _ cellView: NSTableCellView -) -> ObjectIdentifier? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil + @MainActor + func reviewMonitorReviewChatCellHostingViewIdentityForTesting( + _ cellView: NSTableCellView + ) -> ObjectIdentifier? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + return nil + } + return cellView.hostingViewIdentityForTesting } - return cellView.hostingViewIdentityForTesting -} #endif @MainActor @@ -3411,12 +1996,18 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private let iconView = NSImageView() private let titleLabel = NSTextField(labelWithString: "") private let contentStack = NSStackView() + private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? + private var nodeObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) configureHierarchy() } + isolated deinit { + nodeObservation?.cancel() + } + @available(*, unavailable) required init?(coder: NSCoder) { nil @@ -3427,12 +2018,39 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { configure(title: workspace.displayTitle, toolTip: workspace.cwd) } + func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { + objectValue = node + guard boundNode !== node else { + return + } + boundNode = node + nodeObservation?.cancel() + nodeObservation = withPortableContinuousObservation { [weak self, node] _ in + self?.render(node.item) + } + } + func configure(title: String, toolTip: String, systemSymbolName: String = "folder") { iconView.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) titleLabel.stringValue = title self.toolTip = toolTip } + private func render(_ item: ReviewMonitorCodexSidebarOutlineItem) { + switch item { + case .section(let section): + configure(title: section.title, toolTip: section.title, systemSymbolName: "folder") + case .workspace(let workspace): + configure(title: workspace.title, toolTip: workspace.cwd, systemSymbolName: "folder") + case .chat(let chat): + configure( + title: chat.title, + toolTip: chat.preview ?? chat.title, + systemSymbolName: "bubble.left" + ) + } + } + private func configureHierarchy() { translatesAutoresizingMaskIntoConstraints = false @@ -3462,14 +2080,14 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { contentStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), contentStack.topAnchor.constraint(equalTo: topAnchor, constant: 4), contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - contentStack.centerYAnchor.constraint(equalTo: centerYAnchor) + contentStack.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } #if DEBUG - func contentMinXForTesting(relativeTo view: NSView) -> CGFloat { - layoutSubtreeIfNeeded() - return convert(contentStack.frame, to: view).minX - } + func contentMinXForTesting(relativeTo view: NSView) -> CGFloat { + layoutSubtreeIfNeeded() + return convert(contentStack.frame, to: view).minX + } #endif } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 8e7da6df..26921578 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -15,22 +15,23 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + 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 library = ReviewMonitorCodexSidebarLibrary(modelContext: context) try await library.performFetch() @@ -62,10 +63,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(outlineSection.title == section.title) #expect(outlineSection.selectionID == .workspaceSection(section.id)) #expect(outlineSection.isExpandable) - #expect(outlineSection.children.map(\.rowID.rawValue) == [ - "workspace:\(resolvedAppPath)", - "workspace:\(resolvedToolsPath)", - ]) + #expect( + outlineSection.children.map(\.rowID.rawValue) == [ + "workspace:\(resolvedAppPath)", + "workspace:\(resolvedToolsPath)", + ]) #expect(outlineAppWorkspace.rowID == snapshotAppWorkspace.rowID) #expect(outlineAppWorkspace.title == "App") #expect(outlineAppWorkspace.selectionID == .workspace(snapshotAppWorkspace.id)) @@ -73,14 +75,16 @@ struct ReviewMonitorCodexSidebarLibraryTests { let outlineAppChat = try #require(outlineAppWorkspace.children.first) #expect(outlineAppChat.selectionID == .chat(snapshotAppChat.id)) #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) - #expect(library.snapshot.outlineItem(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == .chat(snapshotAppChat)) - #expect(library.snapshot.rowIDs.map(\.rawValue) == [ - "section:\(section.id)", - "workspace:\(resolvedAppPath)", - "chat:thread-app", - "workspace:\(resolvedToolsPath)", - "chat:thread-tools", - ]) + #expect( + library.snapshot.outlineItem(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == .chat(snapshotAppChat)) + #expect( + library.snapshot.rowIDs.map(\.rawValue) == [ + "section:\(section.id)", + "workspace:\(resolvedAppPath)", + "chat:thread-app", + "workspace:\(resolvedToolsPath)", + "chat:thread-tools", + ]) } @Test func defaultRequestFetchesReviewThreadsOnly() async throws { @@ -101,16 +105,17 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + try await runtime.transport.enqueueThreadList( + .init( + threads: [ + .init( + id: "thread-uncategorized", + name: "Floating review", + preview: "Uncategorized preview", + updatedAt: Date(timeIntervalSince1970: 4_000) + ) + ] + )) let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) try await library.performFetch() @@ -131,10 +136,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(outlineChat.selectionID == .chat(chat.id)) #expect(outlineChat.isExpandable == false) #expect(library.snapshot.outlineItem(rowID: chat.rowID) == .chat(chat)) - #expect(section.rowIDs.map(\.rawValue) == [ - section.rowID.rawValue, - "chat:thread-uncategorized", - ]) + #expect( + section.rowIDs.map(\.rawValue) == [ + section.rowID.rawValue, + "chat:thread-uncategorized", + ]) } @Test func sidebarOutlineTreePreservesNodeIdentityAcrossSnapshots() throws { @@ -142,23 +148,29 @@ struct ReviewMonitorCodexSidebarLibraryTests { let threadID = CodexThreadID(rawValue: "thread-app") let tree = ReviewMonitorCodexSidebarOutlineTree() - tree.apply(snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Initial review", - includesChat: true - )) + #expect( + tree.apply( + snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Initial review", + includesChat: true + ) + ).topologyChanged) let root = try #require(tree.roots.first) let workspace = try #require(tree.node(rowID: .workspace(workspaceID))) let chat = try #require(tree.node(rowID: .chat(threadID))) - tree.apply(snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Updated review", - includesChat: true - )) + #expect( + tree.apply( + snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Updated review", + includesChat: true + ) + ).topologyChanged == false) let updatedRoot = try #require(tree.roots.first) let updatedWorkspace = try #require(tree.node(rowID: .workspace(workspaceID))) @@ -170,12 +182,15 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(root.children.first === workspace) #expect(workspace.children.first === chat) - tree.apply(snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Removed review", - includesChat: false - )) + #expect( + tree.apply( + snapshot: sidebarSnapshot( + workspaceID: workspaceID, + threadID: threadID, + chatTitle: "Removed review", + includesChat: false + ) + ).topologyChanged) #expect(tree.node(rowID: .chat(threadID)) == nil) #expect(workspace.children.isEmpty) @@ -248,10 +263,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { let combinedFiltered = snapshot.filtered(by: combined) #expect(latestFinished.sections.first?.workspaces.first?.chats.map(\.id) == [newerFinishedID]) - #expect(combinedFiltered.sections.first?.workspaces.first?.chats.map(\.id) == [ - newerFinishedID, - runningThreadID, - ]) + #expect( + combinedFiltered.sections.first?.workspaces.first?.chats.map(\.id) == [ + newerFinishedID, + runningThreadID, + ]) } @Test func sidebarPresentationOrderReordersSectionsAndChatsLocally() throws { @@ -274,7 +290,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { title: "Beta", workspaceID: betaWorkspaceID, chats: [ - sidebarChat(id: betaThreadID, title: "Beta", workspaceID: betaWorkspaceID), + sidebarChat(id: betaThreadID, title: "Beta", workspaceID: betaWorkspaceID) ] ) var order = ReviewMonitorCodexSidebarPresentationOrder() @@ -293,10 +309,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { let ordered = order.applying(to: ReviewMonitorCodexSidebarSnapshot(sections: [alpha, beta])) #expect(ordered.sections.map(\.id) == ["beta", "alpha"]) - #expect(ordered.sections[1].workspaces.first?.chats.map(\.id) == [ - alphaSecondID, - alphaFirstID, - ]) + #expect( + ordered.sections[1].workspaces.first?.chats.map(\.id) == [ + alphaSecondID, + alphaFirstID, + ]) #expect(ordered.sections[0].workspaces.first?.chats.map(\.id) == [betaThreadID]) } @@ -306,16 +323,17 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + 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( @@ -340,11 +358,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent] && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" } - #expect(sidebar.displayedCodexSidebarTitlesForTesting == [ - repo.lastPathComponent, - repo.lastPathComponent, - "App review", - ]) + #expect( + sidebar.displayedCodexSidebarTitlesForTesting == [ + repo.lastPathComponent, + repo.lastPathComponent, + "App review", + ]) #expect(sidebar.codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(threadID)) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) @@ -363,16 +382,17 @@ struct ReviewMonitorCodexSidebarLibraryTests { let visibleThreadID = CodexThreadID(rawValue: "thread-app") let hiddenLegacyThreadID = CodexThreadID(rawValue: "legacy-review-thread") - try await runtime.transport.enqueueThreadList(.init( - threads: [ - .init( - id: visibleThreadID, - workspace: repo, - name: "App review", - updatedAt: Date(timeIntervalSince1970: 5_000) - ), - ] - )) + try await runtime.transport.enqueueThreadList( + .init( + threads: [ + .init( + id: visibleThreadID, + workspace: repo, + name: "App review", + updatedAt: Date(timeIntervalSince1970: 5_000) + ) + ] + )) let legacyJob = CodexReviewJob.makeForTesting( id: "legacy-job", @@ -425,16 +445,17 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + 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( @@ -458,25 +479,28 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition(timeout: .milliseconds(500)) { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" } + let fullReloadCountBeforeContentUpdate = sidebar.sidebarFullReloadCountForTesting let chat = context.model(for: threadID) 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 runtime.transport.enqueueThreadList(.init( - threads: [ - .init( - id: threadID, - workspace: repo, - name: "App review renamed", - updatedAt: Date(timeIntervalSince1970: 6_000) - ), - ] - )) + try await runtime.transport.enqueueThreadRead( + .init( + id: threadID, + workspace: repo, + name: "App review renamed", + updatedAt: Date(timeIntervalSince1970: 6_000) + )) + try await runtime.transport.enqueueThreadList( + .init( + threads: [ + .init( + id: threadID, + workspace: repo, + name: "App review renamed", + updatedAt: Date(timeIntervalSince1970: 6_000) + ) + ] + )) try await chat.refresh(includeTurns: false) #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) @@ -488,11 +512,13 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review renamed" } - #expect(sidebar.displayedCodexSidebarTitlesForTesting == [ - repo.lastPathComponent, - repo.lastPathComponent, - "App review renamed", - ]) + #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate) + #expect( + sidebar.displayedCodexSidebarTitlesForTesting == [ + repo.lastPathComponent, + repo.lastPathComponent, + "App review renamed", + ]) } @Test func sidebarViewControllerReordersCodexSectionsLocally() async throws { @@ -501,22 +527,23 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + 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, workspaces: []) @@ -540,14 +567,16 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID)) #expect(sidebar.performCodexSectionDropForTesting(id: secondSection.id, toIndex: 0)) - #expect(sidebar.codexSidebarRootTitlesForTesting == [ - secondSection.title, - firstSection.title, - ]) - #expect(sidebar.codexSidebarSnapshotForTesting?.sections.map(\.id) == [ - firstSection.id, - secondSection.id, - ]) + #expect( + sidebar.codexSidebarRootTitlesForTesting == [ + secondSection.title, + firstSection.title, + ]) + #expect( + sidebar.codexSidebarSnapshotForTesting?.sections.map(\.id) == [ + firstSection.id, + secondSection.id, + ]) } @Test func sidebarViewControllerReordersCodexChatsLocallyWithinContainer() async throws { @@ -557,22 +586,23 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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) - ), - ] - )) + 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, workspaces: []) @@ -594,10 +624,64 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: .chat(secondThreadID))) #expect(sidebar.performCodexChatDropForTesting(id: secondThreadID, container: container, childIndex: 0)) #expect(sidebar.displayedCodexChatIDsForTesting(container: container) == [secondThreadID, firstThreadID]) - #expect(sidebar.codexSidebarSnapshotForTesting?.sections.first?.workspaces.first?.chats.map(\.id) == [ - firstThreadID, - secondThreadID, - ]) + #expect( + sidebar.codexSidebarSnapshotForTesting?.sections.first?.workspaces.first?.chats.map(\.id) == [ + firstThreadID, + secondThreadID, + ]) + } + + @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, workspaces: []) + 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) } } @@ -628,7 +712,7 @@ private func sidebarSnapshot( ) -> ReviewMonitorCodexSidebarSnapshot { return ReviewMonitorCodexSidebarSnapshot( sections: [ - sidebarSection(id: "repo", title: "Repo", workspaceID: workspaceID, chats: chats), + sidebarSection(id: "repo", title: "Repo", workspaceID: workspaceID, chats: chats) ] ) } @@ -650,7 +734,7 @@ private func sidebarSection( cwd: workspaceID.rawValue, title: URL(fileURLWithPath: workspaceID.rawValue).lastPathComponent, chats: chats - ), + ) ], uncategorizedChats: [] ) diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 879b6249..eb57c469 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -229,6 +229,7 @@ func renderTimelineForTesting( ) throws -> Bool { let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) let target = try timelineRenderTarget(for: job) + transport.bindLogRenderTargetForTesting(target) let sourceDocument = TimelineLogProjectionStore.document( from: timelineDocument, transport: transport, @@ -252,11 +253,6 @@ func awaitTimelineRenderForTesting( matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil ) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { let expectedLog = reviewMonitorLogText(for: job) - let expectedTarget = try timelineRenderTarget(for: job) - try await waitForCondition(timeout: timeout) { - transport.renderedStateForTesting.selection == expectedTarget - && transport.renderedStateForTesting.snapshot.isShowingEmptyState == false - } _ = try renderTimelineForTesting( job, in: transport, @@ -326,7 +322,9 @@ private func makePreviewChatLogFixtureForTesting( } @MainActor -private func timelineRenderTarget(for job: CodexReviewJob) throws -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting { +private func timelineRenderTarget(for job: CodexReviewJob) throws + -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting +{ let chatID = try #require(job.legacyReviewChatID) return .chat(chatID.rawValue) } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 21e6ba3c..78036655 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1,4 +1,5 @@ import AppKit +import CodexAppServerKitTesting import Foundation import ObservationBridge import CodexKit @@ -33,3052 +34,917 @@ func chatIDForTesting(_ job: CodexReviewJob) -> CodexThreadID { @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" - ) + @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: [workspaceBetaJob, workspaceAlphaJob]) + authPhase: .signingIn( + .init( + title: "Sign in with ChatGPT", + detail: "Open the browser to continue." + ) + ), + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - 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) + 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 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" - ) + @Test func addAccountToolbarItemDoesNotStickInProgressModeWhenAuthenticationEndsImmediately() async throws { let store = CodexReviewStore.makePreviewStore() + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) + authPhase: .signedOut, + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - 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() + 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)) - #expect( - sidebar.displayedSectionTitlesForTesting == [ - "workspace-beta", - "workspace-alpha", - ]) - #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop) - #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop) - #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) - } + viewController.attach(to: window) + let sidebarItem = try #require(viewController.splitViewItems.first) + sidebarItem.isCollapsed = false + window.layoutIfNeeded() + try await waitForAddAccountToolbarItemHidden(viewController, false) - @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() + #expect(viewController.addAccountToolbarItemModeForTesting == .add) - 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 - } + store.auth.updatePhase( + .signingIn( + .init( + title: "Sign in with ChatGPT", + detail: "Open the browser to continue." + ) + ) + ) + store.auth.updatePhase(.signedOut) - #expect(sidebar.performWorkspaceDropForTesting(workspaceBeta, proposedWorkspace: workspaceAlpha)) - #expect( - sidebar.displayedSectionTitlesForTesting == [ - "workspace-beta", - "workspace-alpha", - ]) + try await waitForAddAccountToolbarMode(viewController, .add) + #expect(viewController.addAccountToolbarItemModeForTesting == .add) } - @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) + @Test func addAccountToolbarItemProvidesOverflowMenuFallback() async throws { let store = CodexReviewStore.makePreviewStore() + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, - workspaces: [alphaWorkspace], - jobs: [alphaJob] + authPhase: .signedOut, + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - 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 + 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)) - store.loadForTesting( - serverState: .running, - workspaces: [alphaWorkspace, betaWorkspace], - jobs: [alphaJob, betaJob] + 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 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) + try await waitForAddAccountToolbarMode(viewController, .progress) + #expect(viewController.addAccountToolbarMenuTitleForTesting == "Cancel Sign-In") } - @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) + @Test func addAccountToolbarItemStaysVisibleDuringAuthenticationOutsideAccountSidebar() async throws { let store = CodexReviewStore.makePreviewStore() + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, - workspaces: [alphaWorkspace, betaWorkspace], - jobs: [alphaJob, betaJob] + authPhase: .signingIn( + .init( + title: "Sign in with ChatGPT", + detail: "Open the browser to continue." + ) + ), + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - 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 + 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)) - store.loadForTesting( - serverState: .running, - workspaces: [betaWorkspace, alphaWorkspace], - jobs: [alphaJob, betaJob] - ) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - [ - "workspace-beta", - "workspace-alpha", - ] - ) { - sidebar.displayedSectionTitlesForTesting - } + viewController.attach(to: window) + let sidebarItem = try #require(viewController.splitViewItems.first) + sidebarItem.isCollapsed = true + window.layoutIfNeeded() - #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeChange) - #expect( - sidebar.sidebarIncrementalMembershipChangeCountForTesting == incrementalMembershipChangeCountBeforeChange) - #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeChange + 1) + #expect(viewController.addAccountToolbarItemIsHiddenForTesting == false) + #expect(viewController.addAccountToolbarItemModeForTesting == .progress) } - @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" - ) + @Test func addAccountToolbarItemRehidesAfterAuthenticationEndsOutsideAccountSidebar() async throws { let store = CodexReviewStore.makePreviewStore() + let activeAccount = CodexReviewAccount(email: "first@example.com", planType: "pro") store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) + authPhase: .signingIn( + .init( + title: "Sign in with ChatGPT", + detail: "Open the browser to continue." + ) + ), + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - 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 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(sidebar.workspaceInsertionIndexForTesting(workspaceAlpha, hoveringBelowMidpoint: false) == 0) - #expect(sidebar.workspaceInsertionIndexForTesting(workspaceAlpha, hoveringBelowMidpoint: true) == 1) + #expect(viewController.addAccountToolbarItemIsHiddenForTesting) } - @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" - ) + @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, - content: makeSidebarContent(from: [workspaceBetaJob, workspaceAlphaJob]) + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) - 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 workspaceDropOnReviewChatRowReordersSection() 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)) + let uiState = ReviewMonitorUIState(auth: store.auth) + uiState.sidebarSelection = .account + let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) 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 - } + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedActiveAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "active@example.com" } + ) - #expect(sidebar.displayedSectionTitlesForTesting == [workspaceAlpha.displayTitle, workspaceBeta.displayTitle]) + #expect(accountsViewController.accountListUsesOutlineViewForTesting) #expect( - sidebar.performWorkspaceDropForTesting( - workspaceAlpha, - proposedJob: betaJob, - hoveringBelowMidpoint: true - )) - await Task.yield() - #expect(sidebar.displayedSectionTitlesForTesting == [workspaceBeta.displayTitle, workspaceAlpha.displayTitle]) + accountsViewController.displayedAccountEmailsForTesting == [ + "active@example.com", + "other@example.com", + ]) + #expect(accountsViewController.accountRowUsesReviewMonitorAccountCellViewForTesting(displayedActiveAccount)) + #expect(accountsViewController.accountRowUsesSwiftUIRowViewForTesting(displayedActiveAccount)) } - @Test func reviewChatDropOnBlankAreaMovesToFullOrderEnd() 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") + @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, - workspaces: [workspace], - jobs: [firstJob, secondJob] + account: firstAccount, + persistedAccounts: [firstAccount, secondAccount, thirdAccount], + workspaces: [] ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let uiState = ReviewMonitorUIState(auth: store.auth) + uiState.sidebarSelection = .account + let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.performJobBlankAreaDropForTesting(firstJob)) - await Task.yield() - #expect(sidebar.displayedReviewChatJobIDsForTesting(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 reviewChatDropReordersWithinWorkspaceAndPreservesSelection() 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 accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedFirstAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "first@example.com" } ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) - viewController.loadViewIfNeeded() - - let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - let fullReloadCountBeforeDrop = sidebar.sidebarFullReloadCountForTesting - let workspaceReloadCountBeforeDrop = sidebar.sidebarWorkspaceReloadCountForTesting - let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting - let firstReviewChatRowHeightBeforeDrop = try #require(sidebar.reviewChatRowHeightForTesting(firstJob)) - let secondReviewChatRowHeightBeforeDrop = try #require(sidebar.reviewChatRowHeightForTesting(secondJob)) - #expect(firstReviewChatRowHeightBeforeDrop == secondReviewChatRowHeightBeforeDrop) - #expect( - sidebar.performReviewChatDropForTesting( - firstJob, - proposedJob: secondJob, - hoveringBelowMidpoint: false - ) == false) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-1", "job-2"]) + let fullReloadCountBeforeDrop = accountsViewController.accountFullReloadCountForTesting + let incrementalMembershipChangeCountBeforeDrop = accountsViewController + .accountIncrementalMembershipChangeCountForTesting + let incrementalMoveCountBeforeDrop = accountsViewController.accountIncrementalMoveCountForTesting #expect( - sidebar.performReviewChatDropForTesting( - firstJob, - proposedJob: secondJob, - hoveringBelowMidpoint: true + await accountsViewController.performAccountDropForTesting( + displayedFirstAccount, + proposedChildIndex: 2 )) - await Task.yield() - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-2", "job-1"]) - #expect(sidebar.selectedReviewChatIDForTesting == firstJob.legacyReviewChatID) - #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeDrop) - #expect(sidebar.sidebarWorkspaceReloadCountForTesting == workspaceReloadCountBeforeDrop) - #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) - #expect(sidebar.reviewChatRowHeightForTesting(firstJob) == firstReviewChatRowHeightBeforeDrop) - #expect(sidebar.reviewChatRowHeightForTesting(secondJob) == secondReviewChatRowHeightBeforeDrop) - } - - @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" + #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 ) - let alphaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-alpha") - let betaWorkspace = CodexReviewWorkspace(cwd: "/tmp/workspace-beta") + #expect(accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) + } + + @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, - workspaces: [alphaWorkspace, betaWorkspace], - jobs: [runningJob, queuedJob, completedAlphaJob, completedBetaJob] + 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 sidebar = viewController.sidebarViewControllerForTesting - uiState.sidebarReviewChatFilter = .running + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedFirstAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "first@example.com" } + ) - try await waitForObservedValueFromCurrentObservation( - from: { sidebar.sidebarTopologyObservationForTesting }, - ["job-alpha-running", "job-alpha-queued"] + #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", + ] ) { - sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) + accountsViewController.displayedAccountEmailsForTesting } #expect( - sidebar.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", + accountsViewController.displayedAccountEmailsForTesting == [ + "second@example.com", + "first@example.com", + "detached@example.com", ]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-running", "job-alpha-queued"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == []) } - @Test func sidebarRunningFilterFollowsJobTerminalStateChanges() async throws { - let runningJob = makeJob( - id: "job-filter-state", - cwd: "/tmp/workspace-alpha", + @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { + let placeholderJob = makeJob( + id: "job-placeholder", + status: .queued, + targetSummary: "Queued review" + ) + let loadedJob = makeJob( + id: "job-loaded", 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 cellView = makeReviewMonitorReviewChatCellViewForTesting(job: placeholderJob) + let initialHostingViewIdentity = try #require( + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarReviewChatFilter = .running - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() + let initialHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-state"]) + configureReviewMonitorReviewChatCellViewForTesting(cellView, job: loadedJob) - runningJob.updateStateForTesting( - status: .succeeded, - endedAt: Date(timeIntervalSince1970: 201) + let updatedHostingViewIdentity = try #require( + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - [String]() - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) - } + let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - runningJob.updateStateForTesting(status: .running, clearEndedAt: true) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - ["job-filter-state"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) + #expect(initialHostedRowID == placeholderJob.id) + #expect(updatedHostedRowID == loadedJob.legacyReviewChatID?.rawValue) + #expect(initialHostingViewIdentity == updatedHostingViewIdentity) + let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) + guard case .chat(let objectChat) = objectNode.item else { + Issue.record("Expected cell to bind a Codex chat node.") + return } + #expect(objectChat.id == loadedJob.legacyReviewChatID) + #expect(cellView.toolTip == loadedJob.cwd) } - @Test func sidebarRunningFilterDoesNotClearHiddenSelectedReviewChat() 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") + @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, - workspaces: [workspace], - jobs: [runningJob, completedJob] + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + 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.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - sidebar.selectReviewChatForTesting(id: chatIDForTesting(completedJob)) - #expect(sidebar.selectedReviewChatIDForTesting == completedJob.legacyReviewChatID) + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedOtherAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "other@example.com" } + ) + accountsViewController.focusAccountListForTesting() - uiState.sidebarReviewChatFilter = .running - try await waitForObservedValueFromCurrentObservation( - from: { sidebar.sidebarTopologyObservationForTesting }, - ["job-filter-running"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) - } + #expect(accountsViewController.accountListHasFirstResponderForTesting) + #expect(accountsViewController.acceptsFirstResponderForTesting) + #expect(accountsViewController.hasTemporaryContextMenuForTesting == false) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-filter-running"]) - #expect(sidebar.selectedReviewChatIDForTesting == completedJob.legacyReviewChatID) - } + 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) + } - @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.selectReviewChatForTesting(id: chatIDForTesting(alphaRunningJob)) - uiState.sidebarReviewChatFilter = .latestFinished - - try await waitForObservedValueFromCurrentObservation( - from: { sidebar.sidebarTopologyObservationForTesting }, - ["job-alpha-failed"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) - } #expect( - sidebar.displayedSectionTitlesForTesting == [ - "workspace-alpha", - "workspace-beta", - "workspace-gamma", + presentedTitles == [ + "other@example.com", + "Switch", + "Refresh", + "Sign Out", ]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: alphaWorkspace) == ["job-alpha-failed"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: betaWorkspace) == ["job-beta-cancelled"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: gammaWorkspace) == []) - #expect(sidebar.selectedReviewChatIDForTesting == alphaRunningJob.legacyReviewChatID) + #expect(presentedHostingMenu) + #expect(accountsViewController.isPresentingContextMenuForTesting == false) + #expect(accountsViewController.acceptsFirstResponderForTesting) + #expect(accountsViewController.accountListHasFirstResponderForTesting) + #expect(accountsViewController.hasTemporaryContextMenuForTesting == false) } - @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() + @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: [workspace], - jobs: [firstJob, secondJob, runningJob] + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarReviewChatFilter = .latestFinished + 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.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-finished-second"]) + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedOtherAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "other@example.com" } + ) - firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400)) try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - ["job-finished-first"] + from: accountsViewController.accountListObservationForTesting, + true ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) + accountsViewController.selectedAccountEmailForTesting == "active@example.com" } - runningJob.updateStateForTesting( - status: .cancelled, - endedAt: Date(timeIntervalSince1970: 500) - ) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - ["job-running"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) + #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") + #expect(accountsViewController.allowsUserSelectionForTesting(displayedOtherAccount) == false) + for _ in 0..<10 { + await Task.yield() } - } - - @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.sidebarReviewChatFilter = [.running, .latestFinished] - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() - - let sidebar = viewController.sidebarViewControllerForTesting - #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == [ - "job-running", - "job-queued", - "job-finished-latest", - ]) - } - - @Test func reviewChatDropWhileFilteredMapsVisibleIndexToStoreOrder() 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.sidebarReviewChatFilter = .running - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() - - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running-a", "job-running-b"]) - - #expect( - sidebar.performReviewChatDropForTesting( - runningA, - proposedJob: runningB, - hoveringBelowMidpoint: false - ) == false) - await Task.yield() - - #expect(sidebar.displayedReviewChatJobIDsForTesting(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.performReviewChatDropForTesting( - runningA, - proposedJob: runningB, - hoveringBelowMidpoint: true - )) - await Task.yield() - - #expect(sidebar.displayedReviewChatJobIDsForTesting(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.displayedReviewChatJobIDsForTesting(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 reviewChatDropIsRejectedForLatestFinishedOnlyFilter() { - 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.sidebarReviewChatFilter = .latestFinished - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-finished-latest"]) - #expect(sidebar.performReviewChatDropForTesting(latestJob, proposedWorkspace: workspace, childIndex: 0) == false) + #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") + #expect(store.auth.selectedAccount?.email == "active@example.com") + #expect(backend.switchAccountCallCount() == 0) } - @Test func reviewChatDropWhileRunningAndLatestFinishedFilterReordersVisibleReviewChats() 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") + @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: [workspace], - jobs: [runningJob, olderJob, latestJob] + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarReviewChatFilter = [.running, .latestFinished] + uiState.sidebarSelection = .account let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: workspace) == ["job-running", "job-finished-latest"]) - #expect(sidebar.performReviewChatDropForTesting(runningJob, proposedWorkspace: workspace, childIndex: 2)) - await Task.yield() - - try await waitForObservedValueFromCurrentObservation( - from: { sidebar.sidebarTopologyObservationForTesting }, - ["job-finished-latest", "job-running"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(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] + 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-sort-order-2", - "job-sort-order-1", - ] + from: accountsViewController.accountListObservationForTesting, + true ) { - sidebar.displayedReviewChatJobIDsForTesting(in: workspace) + accountsViewController.selectedAccountEmailForTesting == "active@example.com" } - #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.displayedReviewChatJobIDsForTesting(in: workspace) - } - - #expect(sidebar.displayedReviewChatJobIDsForTesting(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 = 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], - 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 = CodexReviewAccount(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 = CodexReviewAccount(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 = 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], - 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 = 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], - 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 = 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: [] - ) - 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 = 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: 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 = 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: 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 reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() 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 = makeReviewMonitorReviewChatCellViewForTesting(job: placeholderJob) - let initialHostingViewIdentity = try #require( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - ) - let initialHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - - configureReviewMonitorReviewChatCellViewForTesting(cellView, job: loadedJob) - - let updatedHostingViewIdentity = try #require( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - ) - let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - - #expect(initialHostedRowID == placeholderJob.id) - #expect(updatedHostedRowID == loadedJob.legacyReviewChatID?.rawValue) - #expect(initialHostingViewIdentity == updatedHostingViewIdentity) - #expect((cellView.objectValue as? ReviewMonitorSidebarReviewChatRow)?.operation.jobID == loadedJob.id) - #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 crossWorkspaceReviewChatDropIsRejected() { - 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.performReviewChatDropForTesting(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.reviewChatRowUsesReviewMonitorChatRowViewForTesting(job)) - } - - @Test func sidebarUsesMeasuredRowHeightsForWorkspaceAndReviewChatRows() 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 reviewChatRowHeight = try #require(sidebar.reviewChatRowHeightForTesting(job)) - #expect(workspaceRowHeight == sidebar.expectedWorkspaceRowRectHeightForTesting) - #expect(reviewChatRowHeight == sidebar.expectedReviewChatRowRectHeightForTesting) - #expect(workspaceRowHeight < reviewChatRowHeight) - } - - @Test func reviewChatRowsUseLabelIconSlotInsteadOfOutlineChildIndent() 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 reviewChatCellMinX = try #require(sidebar.reviewChatCellMinXForTesting(job)) - #expect(reviewChatCellMinX < 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.displayedReviewChatJobIDsForTesting(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.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( - job, - in: transport, - allowIncrementalUpdate: false - ) - - 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.selectedReviewChatIDForTesting == job.legacyReviewChatID - } - } - - @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." - ) - let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running, - content: makeSidebarContent(from: [job]) - ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) - viewController.loadViewIfNeeded() - - await viewController.sidebarViewControllerForTesting.cancelReviewChatForTesting(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]) - ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) - viewController.loadViewIfNeeded() - - await viewController.sidebarViewControllerForTesting.cancelReviewChatForTesting(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 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) - } - - #expect(presentedTitles == ["Cancel"]) - #expect(sidebar.isPresentingContextMenuForTesting == false) - #expect(sidebar.acceptsFirstResponderForTesting) - #expect(sidebar.sidebarHasFirstResponderForTesting) - #expect(sidebar.hasTemporaryContextMenuForTesting == false) - } - - @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: [] - ) - 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.loadViewIfNeeded() - - let accountsViewController = viewController - .sidebarViewControllerForTesting - .accountsViewControllerForTesting - let displayedOtherAccount = try #require( - store.auth.persistedAccounts.first { $0.email == "other@example.com" } - ) - accountsViewController.focusAccountListForTesting() - - #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( - 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 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, - account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - 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.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" - } - - #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") - #expect(accountsViewController.allowsUserSelectionForTesting(displayedOtherAccount) == false) - for _ in 0..<10 { - await Task.yield() - } - - #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, - 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 displayedOtherAccount = try #require( - store.auth.persistedAccounts.first { $0.email == "other@example.com" } - ) - try await waitForObservedValue( - from: accountsViewController.accountListObservationForTesting, - true - ) { - accountsViewController.selectedAccountEmailForTesting == "active@example.com" - } - - #expect( - accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount) - == displayedOtherAccount.accountKey) - #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") - #expect(store.auth.selectedAccount?.email == "active@example.com") - } - - @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, - account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - 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.loadViewIfNeeded() - viewController.view.layoutSubtreeIfNeeded() - - let accountsViewController = viewController - .sidebarViewControllerForTesting - .accountsViewControllerForTesting - try await waitForObservedValue( - from: accountsViewController.accountListObservationForTesting, - true - ) { - accountsViewController.selectedAccountEmailForTesting == "active@example.com" - } - - accountsViewController.clickBlankAreaForTesting() - for _ in 0..<10 { - await Task.yield() - } - - #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, - account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - 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.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 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.accountIncrementalMembershipChangeCountForTesting - == incrementalMembershipChangeCountBeforeSelectionChange - ) - #expect( - accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange) - } - - @Test func accountContentUpdateDoesNotReloadOutlineTopology() async throws { - let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro") - let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running, - account: activeAccount, - persistedAccounts: [activeAccount], - 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) - 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(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 accountListTracksDetachedCurrentSessionMembership() async throws { - let savedAccount = CodexReviewAccount(email: "saved@example.com", planType: "pro") - let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running, - account: savedAccount, - persistedAccounts: [savedAccount], - 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.loadViewIfNeeded() - - 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(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 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, - account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - 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.loadViewIfNeeded() - - let accountsViewController = viewController - .sidebarViewControllerForTesting - .accountsViewControllerForTesting - let displayedOtherAccount = try #require( - store.auth.persistedAccounts.first { $0.email == "other@example.com" } - ) - - 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 reviewChatsPresentOnInitialLoadStayUnselected() { - 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.selectedReviewChatIDForTesting == nil) - #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) - #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) - } - - @Test func selectingReviewChatUpdatesDetailPane() 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.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) - - let selectedSnapshot = try await awaitTimelineRenderForTesting( - recentJob, - in: transport, - allowIncrementalUpdate: false - ) - #expect( - selectedSnapshot - == .init( - title: nil, - summary: nil, - log: reviewMonitorLogText(for: recentJob), - 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.") - replaceTimelineLogTextForTesting(activeJob, "Old selection log") - appendTimelineEntryForTesting( - recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) - - let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: 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.selectedReviewChatIDForTesting == 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(window.title == workspace.displayTitle) - #expect(window.subtitle == workspace.cwd) - - let findItem = textFinderMenuItemForTesting(.showFindInterface) - #expect(viewController.validateUserInterfaceItem(findItem)) - viewController.performTextFinderAction(findItem) - #expect(transport.workspaceFindingsFindBarVisibleForTesting) - } - - @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() - store.loadForTesting( - serverState: .running, - workspaces: [firstWorkspace, secondWorkspace], - jobs: [firstJob, secondJob] - ) - let backend = makeWindowHarness(store: store) - let viewController = backend.viewController - let window = backend.window - defer { window.close() } - let sidebar = viewController.sidebarViewControllerForTesting - let transport = viewController.transportViewControllerForTesting - - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == ["job-first-worktree"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree"]) - - sidebar.clickWorkspaceHeaderForTesting(firstWorkspace) - _ = try await awaitTransportRender(transport) - - 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" - } - } - - @Test func sidebarLatestFinishedFilterUsesLinkedWorktreeGroupLatestJob() 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-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" - ) - let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running, - workspaces: [firstWorkspace, secondWorkspace], - jobs: [firstJob, secondJob] - ) - let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarReviewChatFilter = .latestFinished - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() - - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == []) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == ["job-second-worktree-newer-finished"]) - - firstJob.updateStateForTesting(endedAt: Date(timeIntervalSince1970: 400)) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - ["job-first-worktree-older-finished"] - ) { - sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) - } - - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == []) - } - - @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" - ) - let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running, - workspaces: [firstWorkspace, secondWorkspace], - jobs: [ - firstRunningJob, - firstHiddenFinishedJob, - firstQueuedJob, - secondQueuedJob, - secondLatestFinishedJob, - ] - ) - let uiState = ReviewMonitorUIState(auth: store.auth) - uiState.sidebarReviewChatFilter = [.running, .latestFinished] - let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) - viewController.loadViewIfNeeded() - - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == [ - "job-first-worktree-running-a", - "job-first-worktree-queued-b", - ]) - #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ - "job-second-worktree-queued", - "job-second-worktree-latest-finished", - ]) - - #expect( - sidebar.performReviewChatDropForTesting( - firstRunningJob, - proposedWorkspaceSectionContaining: secondWorkspace, - childIndex: 3 - ) == false) - - #expect( - sidebar.performReviewChatDropForTesting( - firstRunningJob, - proposedWorkspaceSectionContaining: firstWorkspace, - childIndex: 2 - )) - await Task.yield() - - #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == [ - "job-first-worktree-queued-b", - "job-first-worktree-running-a", - ]) - #expect( - sidebar.displayedReviewChatJobIDsForTesting(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", - ]) + accountsViewController.dragPasteboardAccountKeyForTesting(displayedOtherAccount) + == displayedOtherAccount.accountKey) + #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") + #expect(store.auth.selectedAccount?.email == "active@example.com") } - @Test func workspaceSectionSelectionExpandsWhenLinkedWorktreeArrives() 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-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" - ) + @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], - jobs: [firstJob] + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) - let backend = makeWindowHarness(store: store) - let viewController = backend.viewController - let window = backend.window + let uiState = ReviewMonitorUIState(auth: store.auth) + uiState.sidebarSelection = .account + let viewController = ReviewMonitorSplitViewController(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() + viewController.view.layoutSubtreeIfNeeded() - store.loadForTesting( - serverState: .running, - workspaces: [firstWorkspace, secondWorkspace], - jobs: [firstJob, secondJob] - ) + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting 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] + + accountsViewController.clickBlankAreaForTesting() + for _ in 0..<10 { + await Task.yield() } - #expect(window.title == "CodexReviewKit") - #expect(window.subtitle == "2 workspaces") + #expect(accountsViewController.selectedAccountEmailForTesting == "active@example.com") + #expect(store.auth.selectedAccount?.email == "active@example.com") } - @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 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, secondWorkspace], - jobs: [firstJob, secondJob] + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) - let backend = makeWindowHarness(store: store) - let viewController = backend.viewController - let window = backend.window + let uiState = ReviewMonitorUIState(auth: store.auth) + uiState.sidebarSelection = .account + let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) + let window = NSWindow(contentViewController: viewController) defer { window.close() } - let sidebar = viewController.sidebarViewControllerForTesting - sidebar.clickWorkspaceHeaderForTesting(firstWorkspace) - #expect(sidebar.selectedWorkspaceSectionForTesting?.title == "CodexReviewKit") + window.setContentSize(NSSize(width: 900, height: 600)) + viewController.loadViewIfNeeded() - store.loadForTesting( - serverState: .running, - workspaces: [firstWorkspace], - jobs: [firstJob] + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedOtherAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "other@example.com" } ) - try await waitForCondition { - sidebar.selectedWorkspaceSectionForTesting?.workspaceCWDs == [firstWorkspace.cwd] + 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 - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect(window.title == "CodexReviewKit") - #expect(window.subtitle == firstWorkspace.cwd) - } - - @Test func workspaceSectionDropAcrossSectionRootUsesVisibleRootIndexes() async throws { - let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit") - defer { - try? FileManager.default.removeItem(at: fixture.rootURL) + store.auth.selectPersistedAccount(displayedOtherAccount.accountKey) + try await waitForObservedValue( + from: accountsViewController.accountListObservationForTesting, + true + ) { + accountsViewController.selectedAccountEmailForTesting == "other@example.com" } - 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" + + #expect(accountsViewController.selectedAccountEmailForTesting == "other@example.com") + #expect(accountsViewController.displayedAccountEmailsForTesting == displayedEmails) + #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeSelectionChange) + #expect( + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeSelectionChange ) + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeSelectionChange) + } + + @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, standaloneWorkspace], - jobs: [firstJob, secondJob, standaloneJob] + account: activeAccount, + persistedAccounts: [activeAccount], + workspaces: [] ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + let uiState = ReviewMonitorUIState(auth: store.auth) + uiState.sidebarSelection = .account + let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) viewController.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit", "Standalone"]) - - let incrementalMoveCountBeforeDrop = sidebar.sidebarIncrementalMoveCountForTesting - #expect(sidebar.performWorkspaceDropForTesting(standaloneWorkspace, toIndex: 0)) - await Task.yield() - #expect(sidebar.displayedSectionTitlesForTesting == ["Standalone", "CodexReviewKit"]) - #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) - } + 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 - @Test func workspaceSectionDropReordersSectionRootAsBlock() async throws { - let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit") - defer { - try? FileManager.default.removeItem(at: fixture.rootURL) + let updatedAccount = CodexReviewAccount(email: "active@example.com", planType: "team") + store.auth.applyPersistedAccountStates([savedAccountPayload(from: updatedAccount)]) + for _ in 0..<10 { + await Task.yield() } - 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" + + #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 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], + workspaces: [] ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + 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.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 + #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(sidebar.displayedSectionTitlesForTesting == ["Standalone", "CodexReviewKit"]) #expect( - store.orderedWorkspaces.map(\.cwd) == [ - standaloneWorkspace.cwd, - firstWorkspace.cwd, - secondWorkspace.cwd, + accountsViewController.displayedAccountEmailsForTesting == [ + "saved@example.com", + "detached@example.com", ]) - #expect(sidebar.sidebarIncrementalMoveCountForTesting == incrementalMoveCountBeforeDrop + 1) - } + #expect(accountsViewController.selectedAccountEmailForTesting == "detached@example.com") + #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges) + #expect( + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeMembershipChanges + 1 + ) + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) - @Test func workspaceSectionDropBlockifiesNonContiguousGroupedWorkspaces() async throws { - let fixture = try makeLinkedWorktreeFixtureForTesting(repositoryName: "CodexReviewKit") - defer { - try? FileManager.default.removeItem(at: fixture.rootURL) + store.auth.selectPersistedAccount(savedAccount.accountKey) + try await waitForObservedValue( + from: accountsViewController.accountListObservationForTesting, + ["saved@example.com"] + ) { + accountsViewController.displayedAccountEmailsForTesting } - 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" + + #expect(accountsViewController.displayedAccountEmailsForTesting == ["saved@example.com"]) + #expect(accountsViewController.selectedAccountEmailForTesting == "saved@example.com") + #expect(accountsViewController.accountFullReloadCountForTesting == fullReloadCountBeforeMembershipChanges) + #expect( + accountsViewController.accountIncrementalMembershipChangeCountForTesting + == incrementalMembershipChangeCountBeforeMembershipChanges + 2 ) - let standaloneBJob = makeJob( - id: "job-noncontiguous-standalone-b", - cwd: standaloneBWorkspace.cwd, - status: .running, - targetSummary: "Standalone B" + #expect( + accountsViewController.accountIncrementalMoveCountForTesting == incrementalMoveCountBeforeMembershipChanges) + } + + @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, + account: activeAccount, + persistedAccounts: [activeAccount, otherAccount], + workspaces: [] ) - let standaloneCJob = makeJob( - id: "job-noncontiguous-standalone-c", - cwd: standaloneCWorkspace.cwd, - status: .queued, - targetSummary: "Standalone C" + 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.loadViewIfNeeded() + + let accountsViewController = viewController + .sidebarViewControllerForTesting + .accountsViewControllerForTesting + let displayedOtherAccount = try #require( + store.auth.persistedAccounts.first { $0.email == "other@example.com" } ) - let secondJob = makeJob( - id: "job-noncontiguous-group-second", - cwd: secondWorkspace.cwd, - status: .failed, - targetSummary: "Second grouped workspace" + + 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 reviewChatsPresentOnInitialLoadStayUnselected() { + let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") + let recentJob = makeJob(status: .succeeded, targetSummary: "Commit: abc123") let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - workspaces: [firstWorkspace, standaloneBWorkspace, standaloneCWorkspace, secondWorkspace], - jobs: [firstJob, standaloneBJob, standaloneCJob, secondJob] + content: makeSidebarContent(from: [activeJob, recentJob]) ) 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() - - #expect(sidebar.displayedSectionTitlesForTesting == ["StandaloneB", "CodexReviewKit", "StandaloneC"]) - #expect( - store.orderedWorkspaces.map(\.cwd) == [ - standaloneBWorkspace.cwd, - firstWorkspace.cwd, - secondWorkspace.cwd, - standaloneCWorkspace.cwd, - ]) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) + #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) + #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } - @Test func workspaceSectionReviewChatDropUsesRootChildIndexesForLaterWorkspaceReviewChats() 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, + @Test func selectingReviewChatUpdatesDetailPane() async throws { + let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes", logText: "Running review\n") + let recentJob = makeJob( 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" + targetSummary: "Commit: abc123", + summary: "MCP server codex_review ready.", + logText: "Findings ready\n" ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - workspaces: [firstWorkspace, secondWorkspace], - jobs: [firstWorkspaceJob, secondWorkspaceFirstJob, secondWorkspaceSecondJob] + content: makeSidebarContent(from: [activeJob, recentJob]) ) - let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) - viewController.loadViewIfNeeded() - let sidebar = viewController.sidebarViewControllerForTesting - #expect(sidebar.displayedSectionTitlesForTesting == ["CodexReviewKit"]) - #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ - "job-second-workspace-first", - "job-second-workspace-second", - ]) + let backend = makeWindowHarness(store: store) + let viewController = backend.viewController + let window = backend.window + defer { window.close() } + let transport = viewController.transportViewControllerForTesting + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + let selectedSnapshot = try await awaitTimelineRenderForTesting( + recentJob, + in: transport, + allowIncrementalUpdate: false + ) #expect( - sidebar.performReviewChatDropForTesting( - secondWorkspaceFirstJob, - proposedWorkspaceSectionContaining: secondWorkspace, - childIndex: 0 - ) == false) + selectedSnapshot + == .init( + title: nil, + summary: nil, + log: reviewMonitorLogText(for: recentJob), + isShowingEmptyState: false + ) + ) + #expect(window.title == recentJob.targetSummary) + #expect(window.subtitle == recentJob.cwd) + #expect(transport.logUsesFindBarForTesting) + #expect(transport.logIsIncrementalSearchingEnabledForTesting) + #expect(transport.logFindBarVisibleForTesting == false) - #expect( - sidebar.performReviewChatDropForTesting( - secondWorkspaceFirstJob, - proposedJob: firstWorkspaceJob, - hoveringBelowMidpoint: true - ) == false) + let findItem = textFinderMenuItemForTesting(.showFindInterface) + #expect(viewController.validateUserInterfaceItem(findItem)) + viewController.performTextFinderAction(findItem) + #expect(transport.logFindBarVisibleForTesting) - #expect( - sidebar.performReviewChatDropForTesting( - secondWorkspaceFirstJob, - proposedJob: secondWorkspaceSecondJob, - hoveringBelowMidpoint: true - )) - await Task.yield() + activeJob.updateStateForTesting(summary: "Old selection should not render.") + replaceTimelineLogTextForTesting(activeJob, "Old selection log") + appendTimelineEntryForTesting( + recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) + + let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: 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) - #expect(sidebar.displayedReviewChatJobIDsForTesting(in: firstWorkspace) == ["job-first-workspace"]) + _ = try await awaitTransportRender(transport) #expect( - sidebar.displayedReviewChatJobIDsForTesting(in: secondWorkspace) == [ - "job-second-workspace-second", - "job-second-workspace-first", + viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ + workspaceCWD ]) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == 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(window.title == workspace.displayTitle) + #expect(window.subtitle == workspace.cwd) + + let findItem = textFinderMenuItemForTesting(.showFindInterface) + #expect(viewController.validateUserInterfaceItem(findItem)) + viewController.performTextFinderAction(findItem) + #expect(transport.workspaceFindingsFindBarVisibleForTesting) } @Test func workspaceFindingsTextWrapsWithinDetailWidth() async throws { @@ -3247,47 +1113,6 @@ struct ReviewUITests { #expect(window.subtitle == workspace.cwd) } - @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 replacement = CodexReviewWorkspace(cwd: workspace.cwd) - let replacementJob = makeJob( - id: "job-workspace-selection-replacement", - cwd: workspace.cwd, - status: .succeeded, - targetSummary: "Commit: replacement" - ) - 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.selectedReviewChatIDForTesting == nil - && transport.isShowingEmptyStateForTesting - } - - #expect(sidebar.selectedWorkspaceSectionForTesting == nil) - #expect(sidebar.selectedReviewChatIDForTesting == nil) - #expect(transport.isShowingEmptyStateForTesting) - } - @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws { let job = CodexReviewJob.makeForTesting( id: "job-monitor-log", @@ -4972,52 +2797,67 @@ struct ReviewUITests { } @Test func removingSelectedReviewChatClearsSelectionWithoutAutoSelectingReplacement() 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" - ) + 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, workspaces: []) let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + store: store, + uiState: ReviewMonitorUIState(auth: store.auth), + modelContext: context + ) viewController.loadViewIfNeeded() let contentPane = viewController.contentPaneViewControllerForTesting - let transport = viewController.transportViewControllerForTesting let sidebar = viewController.sidebarViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - let activeSnapshot = try await awaitTimelineRenderForTesting( - activeJob, - in: transport, - allowIncrementalUpdate: false - ) - #expect(activeSnapshot.title == nil) - #expect(activeSnapshot.summary == nil) - store.loadForTesting( - serverState: .running, - content: makeSidebarContent(from: [recentJob]) - ) - try await waitForObservedValue( - from: sidebar.sidebarTopologyObservationForTesting, - true - ) { - sidebar.selectedReviewChatIDForTesting == 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 emptySnapshot = try await awaitContentPaneRender(contentPane) + 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) { snapshot in + snapshot.isShowingEmptyState + } #expect(sidebar.selectedReviewChatIDForTesting == nil) #expect(emptySnapshot.isShowingEmptyState) #expect(emptySnapshot.title == nil) From 9de2f18827c5c4c1e0eb42a1b83c2e70e5fb4dce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:30:51 +0900 Subject: [PATCH 202/359] perf(review-ui): apply codex sidebar outline diffs --- .../ReviewMonitorCodexSidebarLibrary.swift | 47 ++++++- .../ReviewMonitorSidebarViewController.swift | 130 +++++++++++++++++- ...eviewMonitorCodexSidebarLibraryTests.swift | 4 + 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 1d73db5b..9e9b13f1 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -422,6 +422,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { final class ReviewMonitorCodexSidebarOutlineTree { struct ApplyResult: Equatable { var topologyChanged: Bool + var topologyChanges: [ReviewMonitorCodexSidebarOutlineTopologyChange] } private var nodesByRowID: [ReviewMonitorCodexSidebarRowID: ReviewMonitorCodexSidebarOutlineNode] = [:] @@ -432,7 +433,11 @@ final class ReviewMonitorCodexSidebarOutlineTree { var activeRowIDs: Set = [] roots = snapshot.outlineItems.map { node(for: $0, activeRowIDs: &activeRowIDs) } nodesByRowID = nodesByRowID.filter { activeRowIDs.contains($0.key) } - return ApplyResult(topologyChanged: topology != oldTopology) + let newTopology = topology + return ApplyResult( + topologyChanged: newTopology != oldTopology, + topologyChanges: Self.topologyChanges(from: oldTopology, to: newTopology) + ) } func node(rowID: ReviewMonitorCodexSidebarRowID) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -448,6 +453,39 @@ final class ReviewMonitorCodexSidebarOutlineTree { ) } + 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 node( for item: ReviewMonitorCodexSidebarOutlineItem, activeRowIDs: inout Set @@ -474,6 +512,13 @@ private struct ReviewMonitorCodexSidebarOutlineTopology: Equatable { var childrenByRowID: [ReviewMonitorCodexSidebarRowID: [ReviewMonitorCodexSidebarRowID]] } +@MainActor +struct ReviewMonitorCodexSidebarOutlineTopologyChange: Equatable { + var parentRowID: ReviewMonitorCodexSidebarRowID? + var oldChildRowIDs: [ReviewMonitorCodexSidebarRowID] + var newChildRowIDs: [ReviewMonitorCodexSidebarRowID] +} + @MainActor @Observable final class ReviewMonitorCodexSidebarOutlineNode { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 2dc43944..8db5c516 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -380,12 +380,140 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return } if applyResult.topologyChanged { - reloadCodexSidebarOutline() + applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) } else if isUsingCodexSidebarOutline { 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) + if isUsingCodexSidebarOutline { + reconcileOutlineSelection() + } else if outlineView.selectedRow != -1 { + outlineView.deselectAll(nil) + } + 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 diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 26921578..4ab206c0 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -564,6 +564,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) let firstSection = try #require(snapshot.sections.first) let secondSection = try #require(snapshot.sections.dropFirst().first) + let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID)) #expect(sidebar.performCodexSectionDropForTesting(id: secondSection.id, toIndex: 0)) @@ -577,6 +578,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { firstSection.id, secondSection.id, ]) + #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } @Test func sidebarViewControllerReordersCodexChatsLocallyWithinContainer() async throws { @@ -619,6 +621,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { } let snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) let container = try #require(snapshot.sections.first?.workspaces.first?.rowID) + let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting #expect(sidebar.displayedCodexChatIDsForTesting(container: container) == [firstThreadID, secondThreadID]) #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: .chat(secondThreadID))) @@ -629,6 +632,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { firstThreadID, secondThreadID, ]) + #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } @Test func sidebarViewControllerDoesNotReloadCodexOutlineWhenSelectionChanges() async throws { From 0533da2092ba6a4f5939983f61359a1291537bd4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:32:57 +0900 Subject: [PATCH 203/359] perf(review-ui): skip unchanged sidebar presentation --- .../Sidebar/Jobs/ReviewMonitorSidebarViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 8db5c516..7d1509de 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -115,6 +115,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarUnfilteredSnapshot = ReviewMonitorCodexSidebarSnapshot(sections: []) private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() + private var appliedSidebarKind: SidebarKind? private var isReconcilingSelection = false #if DEBUG private var fullReloadCountForTesting = 0 @@ -570,6 +571,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applySidebarKind(_ kind: SidebarKind) { + guard appliedSidebarKind != kind else { + return + } + appliedSidebarKind = kind switch kind { case .unavailable: unavailableView.isHidden = false From 95aa95772b0a8c8fc8df43ed44eb21f2d00a8ffe Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:43:28 +0900 Subject: [PATCH 204/359] refactor(review-ui): remove job fallback from sidebar test hooks --- .../ReviewMonitorSidebarViewController.swift | 82 ++++++------------- Tests/ReviewUITests/ReviewUITests.swift | 66 ++++++++++----- 2 files changed, 73 insertions(+), 75 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 7d1509de..4667d9e5 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1448,42 +1448,32 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD emptyStateViewController.view.isHidden == false } - func selectReviewChatForTesting(id chatID: CodexThreadID) { - guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { - uiState.selection = .chat(testingChatSelectionFallback(id: chatID)) + func selectReviewChatForTesting(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { + guard let row = row(forCodexSidebarSelectionID: .chat(chat.id)) else { + uiState.selection = .chat(chat) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } - private func testingChatSelectionFallback( - id chatID: CodexThreadID - ) -> ReviewMonitorCodexSidebarSnapshot.Chat { - if let job = store.orderedJobs.first(where: { job in - let reviewThreadID = job.core.run.reviewThreadID?.nilIfEmpty - let threadID = job.core.run.threadID?.nilIfEmpty - return reviewThreadID == chatID.rawValue || threadID == chatID.rawValue - }) { - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: job.displayTitle, - preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, - model: job.core.run.model, - workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) - ) + func selectReviewChatForTesting(id chatID: CodexThreadID) { + if let chat = currentChatSelection(id: chatID) { + selectReviewChatForTesting(chat: chat) + return } - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: chatID.rawValue, - preview: nil, - workspaceCWD: nil, - updatedAt: nil - ) + guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { + uiState.selection = .chat( + ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: chatID.rawValue, + preview: nil, + workspaceCWD: nil, + updatedAt: nil + )) + return + } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } func selectWorkspaceForTesting(_ workspace: CodexReviewWorkspace) { @@ -2060,33 +2050,13 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { #if DEBUG @MainActor - private func reviewMonitorReviewChatCellChatForTesting( - job: CodexReviewJob - ) -> ReviewMonitorCodexSidebarSnapshot.Chat { - let chatID = CodexThreadID( - rawValue: job.core.run.reviewThreadID?.nilIfEmpty - ?? job.core.run.threadID?.nilIfEmpty - ?? job.id - ) - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: job.displayTitle, - preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, - model: job.core.run.model, - workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) - ) - } - - @MainActor - func makeReviewMonitorReviewChatCellViewForTesting(job: CodexReviewJob) -> NSTableCellView { + func makeReviewMonitorReviewChatCellViewForTesting( + chat: ReviewMonitorCodexSidebarSnapshot.Chat + ) -> NSTableCellView { let cellView = ReviewMonitorReviewChatCellView() cellView.configure( with: ReviewMonitorCodexSidebarOutlineNode( - item: .chat(reviewMonitorReviewChatCellChatForTesting(job: job)) + item: .chat(chat) )) return cellView } @@ -2094,14 +2064,14 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { @MainActor func configureReviewMonitorReviewChatCellViewForTesting( _ cellView: NSTableCellView, - job: CodexReviewJob + chat: ReviewMonitorCodexSidebarSnapshot.Chat ) { guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { fatalError("Expected ReviewMonitorReviewChatCellView.") } cellView.configure( with: ReviewMonitorCodexSidebarOutlineNode( - item: .chat(reviewMonitorReviewChatCellChatForTesting(job: job)) + item: .chat(chat) )) } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 78036655..cecf0984 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -363,40 +363,40 @@ struct ReviewUITests { } @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { - let placeholderJob = makeJob( - id: "job-placeholder", - status: .queued, - targetSummary: "Queued review" + let placeholderChat = reviewChatCellTestChat( + id: "chat-placeholder", + title: "Queued review", + workspaceCWD: "/tmp/placeholder" ) - let loadedJob = makeJob( - id: "job-loaded", - status: .running, - targetSummary: "Uncommitted changes" + let loadedChat = reviewChatCellTestChat( + id: "chat-loaded", + title: "Uncommitted changes", + workspaceCWD: "/tmp/loaded" ) - let cellView = makeReviewMonitorReviewChatCellViewForTesting(job: placeholderJob) + let cellView = makeReviewMonitorReviewChatCellViewForTesting(chat: placeholderChat) let initialHostingViewIdentity = try #require( reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) let initialHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - configureReviewMonitorReviewChatCellViewForTesting(cellView, job: loadedJob) + configureReviewMonitorReviewChatCellViewForTesting(cellView, chat: loadedChat) let updatedHostingViewIdentity = try #require( reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) ) let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) - #expect(initialHostedRowID == placeholderJob.id) - #expect(updatedHostedRowID == loadedJob.legacyReviewChatID?.rawValue) + #expect(initialHostedRowID == placeholderChat.id.rawValue) + #expect(updatedHostedRowID == loadedChat.id.rawValue) #expect(initialHostingViewIdentity == updatedHostingViewIdentity) let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) guard case .chat(let objectChat) = objectNode.item else { Issue.record("Expected cell to bind a Codex chat node.") return } - #expect(objectChat.id == loadedJob.legacyReviewChatID) - #expect(cellView.toolTip == loadedJob.cwd) + #expect(objectChat.id == loadedChat.id) + #expect(cellView.toolTip == loadedChat.workspaceCWD) } @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws { @@ -813,7 +813,9 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( + chat: try #require(recentJob.legacyReviewChatSelection) + ) let selectedSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -1164,7 +1166,9 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( + chat: try #require(job.legacyReviewChatSelection) + ) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -2274,7 +2278,9 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( + chat: try #require(activeJob.legacyReviewChatSelection) + ) let activeSnapshot = try await awaitTimelineRenderForTesting( activeJob, @@ -2285,7 +2291,9 @@ struct ReviewUITests { #expect(activeSnapshot.summary == nil) #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( + chat: try #require(recentJob.legacyReviewChatSelection) + ) let recentSnapshot = try await awaitTimelineRenderForTesting( recentJob, @@ -2883,7 +2891,9 @@ struct ReviewUITests { defer { window.close() } let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( + chat: try #require(job.legacyReviewChatSelection) + ) let selectedSnapshot = try await awaitTimelineRenderForTesting( job, @@ -5494,6 +5504,24 @@ func makeJob( return job } +func reviewChatCellTestChat( + id: String, + title: String, + workspaceCWD: String +) -> ReviewMonitorCodexSidebarSnapshot.Chat { + let chatID = CodexThreadID(rawValue: id) + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: title, + preview: nil, + workspaceCWD: workspaceCWD, + updatedAt: Date(timeIntervalSince1970: 200), + recencyAt: Date(timeIntervalSince1970: 200), + status: .idle + ) +} + @MainActor func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] { var seenCWDs: Set = [] From 106e5440663147d37d78f9cac82ddee2fbcb6f32 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:07:05 +0900 Subject: [PATCH 205/359] perf(review-ui): avoid redundant chat cell rebinding --- .../ReviewMonitorSidebarViewController.swift | 74 ++++++++++++++++++- Tests/ReviewUITests/ReviewUITests.swift | 42 +++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 4667d9e5..512af21d 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -1300,7 +1300,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD as? ReviewMonitorReviewChatCellView) ?? ReviewMonitorReviewChatCellView() view.identifier = Identifier.reviewChatCell - view.objectValue = node view.configure(with: node) return view } @@ -1310,7 +1309,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD as? ReviewMonitorWorkspaceCellView) ?? ReviewMonitorWorkspaceCellView() view.identifier = Identifier.workspaceCell - view.objectValue = node view.configure(with: node) return view } @@ -1972,6 +1970,9 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? private var nodeObservation: PortableObservationTracking.Token? + #if DEBUG + private var bindingGenerationForTesting = 0 + #endif override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -1988,11 +1989,14 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - objectValue = node guard boundNode !== node else { return } + objectValue = node boundNode = node + #if DEBUG + bindingGenerationForTesting += 1 + #endif nodeObservation?.cancel() nodeObservation = withPortableContinuousObservation { [weak self, node] _ in guard let self, @@ -2038,6 +2042,18 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { hostingView.map(ObjectIdentifier.init) } + var hostedNodeIdentityForTesting: ObjectIdentifier? { + hostingView.map { ObjectIdentifier($0.rootView.node) } + } + + var nodeObservationForTesting: PortableObservationTracking.Token? { + nodeObservation + } + + var bindingGenerationValueForTesting: Int { + bindingGenerationForTesting + } + func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? { guard let hostingView else { return nil @@ -2061,6 +2077,15 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { return cellView } + @MainActor + func makeReviewMonitorReviewChatCellViewForTesting( + node: ReviewMonitorCodexSidebarOutlineNode + ) -> NSTableCellView { + let cellView = ReviewMonitorReviewChatCellView() + cellView.configure(with: node) + return cellView + } + @MainActor func configureReviewMonitorReviewChatCellViewForTesting( _ cellView: NSTableCellView, @@ -2075,6 +2100,17 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { )) } + @MainActor + func configureReviewMonitorReviewChatCellViewForTesting( + _ cellView: NSTableCellView, + node: ReviewMonitorCodexSidebarOutlineNode + ) { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + fatalError("Expected ReviewMonitorReviewChatCellView.") + } + cellView.configure(with: node) + } + @MainActor func reviewMonitorReviewChatCellHostedRowIDForTesting(_ cellView: NSTableCellView) -> String? { guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { @@ -2092,6 +2128,36 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } return cellView.hostingViewIdentityForTesting } + + @MainActor + func reviewMonitorReviewChatCellHostedNodeIdentityForTesting( + _ cellView: NSTableCellView + ) -> ObjectIdentifier? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + return nil + } + return cellView.hostedNodeIdentityForTesting + } + + @MainActor + func reviewMonitorReviewChatCellNodeObservationForTesting( + _ cellView: NSTableCellView + ) -> PortableObservationTracking.Token? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + return nil + } + return cellView.nodeObservationForTesting + } + + @MainActor + func reviewMonitorReviewChatCellBindingGenerationForTesting( + _ cellView: NSTableCellView + ) -> Int? { + guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { + return nil + } + return cellView.bindingGenerationValueForTesting + } #endif @MainActor @@ -2122,10 +2188,10 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - objectValue = node guard boundNode !== node else { return } + objectValue = node boundNode = node nodeObservation?.cancel() nodeObservation = withPortableContinuousObservation { [weak self, node] _ in diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index cecf0984..bd12d41a 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -399,6 +399,48 @@ struct ReviewUITests { #expect(cellView.toolTip == loadedChat.workspaceCWD) } + @Test func reviewChatCellViewSkipsSameNodeConfigureWithoutRebindingHostedContent() throws { + let chat = reviewChatCellTestChat( + id: "chat-stable", + title: "Stable review", + workspaceCWD: "/tmp/stable" + ) + let node = ReviewMonitorCodexSidebarOutlineNode(item: .chat(chat)) + + let cellView = makeReviewMonitorReviewChatCellViewForTesting(node: node) + let initialHostingViewIdentity = try #require( + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) + ) + let initialHostedNodeIdentity = try #require( + reviewMonitorReviewChatCellHostedNodeIdentityForTesting(cellView) + ) + let initialObservation = try #require( + reviewMonitorReviewChatCellNodeObservationForTesting(cellView) + ) + let initialBindingGeneration = try #require( + reviewMonitorReviewChatCellBindingGenerationForTesting(cellView) + ) + + configureReviewMonitorReviewChatCellViewForTesting(cellView, node: node) + + let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) + #expect(objectNode === node) + #expect( + reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) + == initialHostingViewIdentity + ) + #expect( + reviewMonitorReviewChatCellHostedNodeIdentityForTesting(cellView) + == initialHostedNodeIdentity + ) + #expect( + reviewMonitorReviewChatCellBindingGenerationForTesting(cellView) + == initialBindingGeneration + ) + #expect(initialObservation.isActive) + #expect(reviewMonitorReviewChatCellNodeObservationForTesting(cellView)?.isActive == true) + } + @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws { let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro") let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus") From f5a2848e9df808ccdba80d32d9b6c297a5d26579 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:04:30 +0900 Subject: [PATCH 206/359] refactor(review-ui): isolate preview review status mapping --- Sources/ReviewUI/ReviewMonitorPreviewContent.swift | 13 ++++++++++++- .../Jobs/ReviewMonitorCodexSidebarLibrary.swift | 11 ----------- ...CodexReviewJob+ReviewChatProjectionTesting.swift | 13 ++++++++++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 2fa073b0..945170d3 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -722,7 +722,7 @@ public enum ReviewMonitorPreviewContent { workspaceCWD: job.cwd, updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobState: job.core.lifecycle.status) + status: CodexThreadStatus(previewReviewJobState: job.core.lifecycle.status) ) let turn = CodexChatTurnStateSnapshot( id: turnID, @@ -1236,6 +1236,17 @@ public enum ReviewMonitorPreviewContent { } } +private extension CodexThreadStatus { + init(previewReviewJobState jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .active(activeFlags: []) + case .succeeded, .failed, .cancelled: + self = .idle + } + } +} + private extension CodexTurnStatus { init(_ jobState: ReviewJobState) { switch jobState { diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift index 9e9b13f1..75d4e0e8 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift @@ -352,17 +352,6 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { } } -extension CodexThreadStatus { - init(reviewJobState: ReviewJobState) { - switch reviewJobState { - case .queued, .running: - self = .active(activeFlags: []) - case .succeeded, .failed, .cancelled: - self = .idle - } - } -} - package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { case section(ReviewMonitorCodexSidebarSnapshot.Section) case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) diff --git a/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift b/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift index 2ef22988..fff7be9b 100644 --- a/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift +++ b/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift @@ -28,11 +28,22 @@ extension CodexReviewJob { workspaceCWD: cwd, updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobState: core.lifecycle.status) + status: CodexThreadStatus(reviewJobStateForTesting: core.lifecycle.status) ) } } +private extension CodexThreadStatus { + init(reviewJobStateForTesting jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .active(activeFlags: []) + case .succeeded, .failed, .cancelled: + self = .idle + } + } +} + private func nonEmptyReviewChatProjectionStringForTesting(_ value: String?) -> String? { guard let value, value.isEmpty == false else { return nil From b4c82897a1983c0afe4cb35e4f713bb38ce912ae Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:16:08 +0900 Subject: [PATCH 207/359] refactor(review-ui): use chat snapshots for preview fixtures --- .../ReviewMonitorPreviewChatLogSource.swift | 171 +------- .../ReviewMonitorPreviewContent.swift | 405 +++++++----------- .../ReviewMonitorSelectedCodexChatTests.swift | 6 - 3 files changed, 164 insertions(+), 418 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 3da4b652..565c2f56 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -171,20 +171,15 @@ private final class PreviewReviewChat { ) switch step.mode { case .update: - upsertItem(step.seed(id: itemID)) + upsertItem(step.itemSnapshot(id: itemID, turnID: currentTurnID)) case .complete: - upsertItem(step.seed(id: itemID)) + upsertItem(step.itemSnapshot(id: itemID, turnID: currentTurnID)) case .textDelta: appendTextDelta( step.deltaText ?? "", - itemID: itemID.rawValue, - kind: CodexThreadItem.Kind(step.kind), - content: CodexThreadItem.Content( - previewContent: step.content, - id: itemID.rawValue, - phase: step.phase, - fallbackRawKind: step.kind.rawValue - ) + itemID: itemID, + kind: step.kind, + content: step.content ) } } @@ -193,11 +188,6 @@ private final class PreviewReviewChat { snapshotStorage.turns.last?.id } - private func upsertItem(_ seed: ReviewTimelineItemSeed) { - let item = CodexChatItemSnapshot(previewSeed: seed, turnID: currentTurnID) - upsertItem(item) - } - func upsertItem( id: String, kind: CodexThreadItem.Kind, @@ -392,17 +382,6 @@ private extension CodexChatChange { } extension CodexChatItemSnapshot { - @MainActor - init(previewItem item: ReviewTimelineItem, turnID: CodexTurnID) { - self.init( - id: item.id.rawValue, - turnID: turnID, - kind: CodexThreadItem.Kind(item.kind), - content: CodexThreadItem.Content(previewItem: item), - rawPayload: nil - ) - } - func textDelta(to item: CodexChatItemSnapshot) -> String? { guard turnID == item.turnID, @@ -437,105 +416,7 @@ extension CodexChatItemSnapshot { } } -private extension CodexThreadItem.Kind { - init(_ kind: ReviewItemKind) { - self.init(rawValue: kind.rawValue) - } -} - private extension CodexThreadItem.Content { - @MainActor - init(previewItem item: ReviewTimelineItem) { - self.init( - previewContent: item.content, - id: item.id.rawValue, - phase: item.phase, - fallbackRawKind: item.kind.rawValue - ) - } - - init( - previewContent content: ReviewTimelineItem.Content, - id: String, - phase: ReviewItemPhase, - fallbackRawKind: String - ) { - switch content { - case .approval(let approval): - self = .diagnostic( - [ - approval.title.nilIfEmpty, - approval.detail?.nilIfEmpty, - ] - .compactMap { $0 } - .joined(separator: "\n") - ) - case .command(let command): - self = .command( - .init( - command: command.command, - cwd: command.cwd, - output: command.output.nilIfEmpty, - exitCode: command.exitCode, - status: command.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) - )) - case .contextCompaction(let compaction): - self = .contextCompaction(compaction.title) - case .diagnostic(let diagnostic): - self = .diagnostic(diagnostic.message) - case .fileChange(let fileChange): - self = .fileChange( - .init( - path: fileChange.paths.first ?? fileChange.title.nilIfEmpty, - output: fileChange.output.nilIfEmpty ?? fileChange.patch?.nilIfEmpty, - status: fileChange.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) - )) - case .message(let message): - self = .message(.init(id: id, role: .assistant, text: message.text)) - case .plan(let plan): - self = .plan(plan.markdown) - case .reasoning(let reasoning): - switch reasoning.style { - case .raw: - self = .reasoning(.init(content: reasoning.text)) - case .summary: - self = .reasoning(.init(summary: reasoning.text)) - } - case .search(let search): - self = .toolCall( - .init( - name: "web_search", - arguments: search.query, - result: search.result, - status: search.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) - )) - case .toolCall(let toolCall): - self = .toolCall( - .init( - namespace: toolCall.namespace, - server: toolCall.server, - name: toolCall.tool, - arguments: toolCall.arguments, - result: toolCall.result, - error: toolCall.error, - status: toolCall.status.map(CodexTurnStatus.init) ?? CodexTurnStatus(phase) - )) - case .unknown(let unknown): - let text = [ - unknown.title.nilIfEmpty, - unknown.detail?.nilIfEmpty, - ] - .compactMap { $0 } - .joined(separator: "\n") - .nilIfEmpty - self = .unknown( - .init( - rawType: unknown.rawKind?.rawValue ?? fallbackRawKind, - text: text - )) - } - } - mutating func appendPreviewText(_ delta: String) { switch self { case .message(var message): @@ -579,45 +460,3 @@ private extension CodexThreadItem.Content { } } } - -private extension CodexChatItemSnapshot { - init(previewSeed seed: ReviewTimelineItemSeed, turnID: CodexTurnID?) { - self.init( - id: seed.id.rawValue, - turnID: turnID, - kind: CodexThreadItem.Kind(seed.kind), - content: CodexThreadItem.Content( - previewContent: seed.content, - id: seed.id.rawValue, - phase: seed.phase, - fallbackRawKind: seed.kind.rawValue - ), - rawPayload: nil - ) - } -} - -private extension CodexTurnStatus { - init(_ phase: ReviewItemPhase) { - switch phase { - case .awaitingApproval, .queued, .running, .waitingForInput: - self = .running - case .cancelled: - self = .cancelled - case .completed, .skipped: - self = .completed - case .failed, .incomplete: - self = .failed - } - } - - init(_ status: some ReviewOpenStringValue) { - self.init(rawValue: status.rawValue) - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 945170d3..4425f0d8 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -16,46 +16,53 @@ public enum ReviewMonitorPreviewContent { let hasFinalReview: Bool } + private struct PreviewReviewFixture { + let id: String + let chatID: CodexThreadID + let turnID: CodexTurnID + let cwd: String + let targetSummary: String + let summary: String + let lastAgentMessage: String + let model: String + let status: ReviewJobState + let startedAt: Date? + let endedAt: Date? + let hasFinalReview: Bool + let reviewResult: ParsedReviewResult? + let chatItems: [PreviewTimelineItemTemplate] + } + + private enum PreviewReasoningStyle { + case raw + case summary + } + private struct PreviewStreamTemplate { let itemName: String? - let kind: ReviewItemKind - let family: ReviewItemFamily - let phase: ReviewItemPhase - let content: ReviewTimelineItem.Content + let kind: CodexThreadItem.Kind + let content: CodexThreadItem.Content let mode: PreviewStreamMode let deltaText: String? - let startedAt: Date? - let completedAt: Date? - let durationMs: Int? let chunkByWord: Bool let delayBeforeFrameCount: Int let chunkIntervalFrameCount: Int init( itemName: String? = nil, - kind: ReviewItemKind, - family: ReviewItemFamily, - phase: ReviewItemPhase = .completed, - content: ReviewTimelineItem.Content, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, mode: PreviewStreamMode = .complete, deltaText: String? = nil, - startedAt: Date? = nil, - completedAt: Date? = nil, - durationMs: Int? = nil, chunkByWord: Bool = false, delayBeforeFrameCount: Int, chunkIntervalFrameCount: Int = 1 ) { self.itemName = itemName self.kind = kind - self.family = family - self.phase = phase self.content = content self.mode = mode self.deltaText = deltaText - self.startedAt = startedAt - self.completedAt = completedAt - self.durationMs = durationMs self.chunkByWord = chunkByWord self.delayBeforeFrameCount = delayBeforeFrameCount self.chunkIntervalFrameCount = chunkIntervalFrameCount @@ -70,70 +77,42 @@ public enum ReviewMonitorPreviewContent { private struct PreviewTimelineItemTemplate { let itemName: String - let kind: ReviewItemKind - let family: ReviewItemFamily - let phase: ReviewItemPhase - let content: ReviewTimelineItem.Content - let startedAt: Date? - let completedAt: Date? - let durationMs: Int? + let kind: CodexThreadItem.Kind + let content: CodexThreadItem.Content init( itemName: String, - kind: ReviewItemKind, - family: ReviewItemFamily, - phase: ReviewItemPhase, - content: ReviewTimelineItem.Content, - startedAt: Date? = nil, - completedAt: Date? = nil, - durationMs: Int? = nil + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content ) { self.itemName = itemName self.kind = kind - self.family = family - self.phase = phase self.content = content - self.startedAt = startedAt - self.completedAt = completedAt - self.durationMs = durationMs } - func seed(id: ReviewTimelineItem.ID) -> ReviewTimelineItemSeed { - .init( + func itemSnapshot(id: String, turnID: CodexTurnID) -> CodexChatItemSnapshot { + CodexChatItemSnapshot( id: id, + turnID: turnID, kind: kind, - family: family, - phase: phase, - content: content, - startedAt: startedAt, - completedAt: completedAt, - durationMs: durationMs + content: content ) } } package struct PreviewTimelineStep { let itemName: String - let kind: ReviewItemKind - let family: ReviewItemFamily - let phase: ReviewItemPhase - let content: ReviewTimelineItem.Content + let kind: CodexThreadItem.Kind + let content: CodexThreadItem.Content let mode: PreviewStreamMode let deltaText: String? - let startedAt: Date? - let completedAt: Date? - let durationMs: Int? - func seed(id: ReviewTimelineItem.ID) -> ReviewTimelineItemSeed { - .init( + func itemSnapshot(id: String, turnID: CodexTurnID?) -> CodexChatItemSnapshot { + CodexChatItemSnapshot( id: id, + turnID: turnID, kind: kind, - family: family, - phase: phase, - content: content, - startedAt: startedAt, - completedAt: completedAt, - durationMs: durationMs + content: content ) } } @@ -207,21 +186,23 @@ public enum ReviewMonitorPreviewContent { let timelineItems = makeCommandOutputPreviewTimelineItems() let chatID = CodexThreadID(rawValue: "preview-command-output-panel") let turnID = CodexTurnID(rawValue: "preview-command-output-turn") - let job = makeJob( + let review = PreviewReviewFixture( id: "preview-command-output-panel", - threadID: chatID.rawValue, - turnID: turnID.rawValue, + chatID: chatID, + turnID: turnID, cwd: cwd, + targetSummary: "Command output panel", + summary: "A command output block is collapsed by default.", + lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.", 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." + chatItems: timelineItems ) + let job = makeJob(from: review) store.loadForTesting( serverState: .running, account: accounts.first, @@ -231,10 +212,7 @@ public enum ReviewMonitorPreviewContent { jobs: [job] ) let fixture = makeChatLogFixture( - chatID: chatID, - turnID: turnID, - for: job, - timelineItems: timelineItems + for: review ) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) @@ -268,8 +246,8 @@ public enum ReviewMonitorPreviewContent { itemName: String, jobID: String, cycle: Int - ) -> ReviewTimelineItem.ID { - .init(rawValue: "preview-\(itemName)-\(jobID)-\(cycle)") + ) -> String { + "preview-\(itemName)-\(jobID)-\(cycle)" } private static func previewTurnID(_ tick: Int) -> String { @@ -300,14 +278,9 @@ public enum ReviewMonitorPreviewContent { PreviewTimelineStep( itemName: itemName, kind: template.kind, - family: template.family, - phase: template.phase, content: template.content, mode: template.mode, - deltaText: template.mode == .textDelta ? chunk : template.deltaText, - startedAt: template.startedAt, - completedAt: template.completedAt, - durationMs: template.durationMs + deltaText: template.mode == .textDelta ? chunk : template.deltaText )) } } @@ -334,34 +307,29 @@ public enum ReviewMonitorPreviewContent { private static let previewStreamTemplates: [PreviewStreamTemplate] = [ .init( - kind: ReviewItemKind(rawValue: "event"), - family: .diagnostic, - content: .diagnostic(.init(message: "Turn started: \(previewTurnID(1))")), + kind: CodexThreadItem.Kind(rawValue: "event"), + content: .diagnostic("Turn started: \(previewTurnID(1))"), delayBeforeFrameCount: 1 ), .init( itemName: "plan", kind: .plan, - family: .plan, content: .plan( - .init( - markdown: """ + """ [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, - family: .command, - phase: .running, content: .command( .init( command: "/bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"", - status: .inProgress + status: .running )), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount @@ -369,8 +337,6 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "command-search-test", kind: .commandExecution, - family: .command, - phase: .completed, content: .command( .init( command: "", @@ -386,19 +352,17 @@ public enum ReviewMonitorPreviewContent { ), .init( kind: .mcpToolCall, - family: .tool, content: .toolCall( .init( result: "MCP codex_review.review_read started.", - status: .started + status: .running )), delayBeforeFrameCount: interItemDelayFrameCount ), .init( itemName: "reasoning-summary", kind: .reasoning, - family: .reasoning, - content: .reasoning(.init(text: "", style: .summary)), + content: .reasoning(.init(summary: "")), mode: .textDelta, deltaText: "Checking whether append-only log updates are notifying NSTextFinder while an incremental search is active.\n", @@ -408,13 +372,11 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "command-open-log-scroll", kind: .commandExecution, - family: .command, - phase: .running, content: .command( .init( command: "/bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"", - status: .inProgress + status: .running )), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount @@ -422,8 +384,6 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "command-open-log-scroll", kind: .commandExecution, - family: .command, - phase: .completed, content: .command( .init( command: "", @@ -444,31 +404,18 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "context-compaction", kind: .contextCompaction, - family: .contextCompaction, - phase: .running, - content: .contextCompaction( - .init( - title: "Automatically compacting context", - status: .inProgress - )), + content: .contextCompaction("Automatically compacting context"), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( itemName: "context-compaction", kind: .contextCompaction, - family: .contextCompaction, - phase: .completed, - content: .contextCompaction( - .init( - title: "Context automatically compacted", - status: .completed - )), + content: .contextCompaction("Context automatically compacted"), delayBeforeFrameCount: compactionCompletionDelayFrameCount ), .init( kind: .mcpToolCall, - family: .tool, content: .toolCall( .init( result: "File changes updated.", @@ -479,8 +426,7 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "raw-reasoning", kind: .reasoning, - family: .reasoning, - content: .reasoning(.init(text: "", style: .raw)), + 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", @@ -490,8 +436,7 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "raw-reasoning-follow-up", kind: .reasoning, - family: .reasoning, - content: .reasoning(.init(text: "", style: .raw)), + 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", @@ -501,9 +446,10 @@ public enum ReviewMonitorPreviewContent { .init( itemName: "agent-message-summary", kind: .agentMessage, - family: .message, 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", )), @@ -531,7 +477,7 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "command-output-event", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Turn started: preview-command-output-panel" ), messageItem( @@ -671,32 +617,30 @@ public enum ReviewMonitorPreviewContent { let timelineItems = makePreviewTimelineItems(for: definition, workspaceName: workspaceName) let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(jobIndex)") let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(jobIndex)") - let job = makeJob( + let review = PreviewReviewFixture( id: "preview-\(workspaceIndex)-\(jobIndex)", - threadID: chatID.rawValue, - turnID: turnID.rawValue, + chatID: chatID, + turnID: turnID, cwd: cwd, + targetSummary: definition.targetSummary, + summary: definition.summary, + lastAgentMessage: definition.lastAgentMessage, 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 + chatItems: timelineItems ) - jobs.append(job) + jobs.append(makeJob(from: review)) chatLogFixtures.append( makeChatLogFixture( - chatID: chatID, - turnID: turnID, - for: job, - timelineItems: timelineItems + for: review )) } } @@ -708,71 +652,82 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( - chatID: CodexThreadID, - turnID: CodexTurnID, - for job: CodexReviewJob, - timelineItems: [PreviewTimelineItemTemplate] + for review: PreviewReviewFixture ) -> ReviewMonitorPreviewChatLogFixture { let chat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: job.displayTitle, - preview: job.core.output.lastAgentMessage?.nilIfEmpty ?? job.core.output.summary.nilIfEmpty, - model: job.core.run.model, - workspaceCWD: job.cwd, - updatedAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - recencyAt: job.core.lifecycle.endedAt ?? job.core.lifecycle.startedAt, - status: CodexThreadStatus(previewReviewJobState: job.core.lifecycle.status) + rowID: .chat(review.chatID), + id: review.chatID, + title: review.targetSummary, + preview: review.lastAgentMessage.nilIfEmpty ?? review.summary.nilIfEmpty, + model: review.model, + workspaceCWD: review.cwd, + updatedAt: review.endedAt ?? review.startedAt, + recencyAt: review.endedAt ?? review.startedAt, + status: CodexThreadStatus(previewReviewJobState: review.status) ) let turn = CodexChatTurnStateSnapshot( - id: turnID, - status: CodexTurnStatus(job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, + id: review.turnID, + status: CodexTurnStatus(review.status), + errorDescription: review.status == .failed ? review.summary : nil, usage: nil ) let initialSnapshot = CodexChatSnapshot( chatID: chat.id, phase: CodexDataPhase( - job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage + review.status, + errorMessage: review.status == .failed ? review.summary : nil ), turns: [turn], items: makeInitialChatItems( - for: job, - timelineItems: timelineItems, + streamID: review.id, + timelineItems: review.chatItems, turnID: turn.id ) ) return ReviewMonitorPreviewChatLogFixture( chat: chat, - cwd: job.cwd, - streamID: job.id, - isRunning: job.core.lifecycle.status == .running, + cwd: review.cwd, + streamID: review.id, + isRunning: review.status == .running, initialSnapshot: initialSnapshot ) } private static func makeInitialChatItems( - for job: CodexReviewJob, + streamID: String, timelineItems: [PreviewTimelineItemTemplate], turnID: CodexTurnID ) -> [CodexChatItemSnapshot] { - let timeline = ReviewTimeline() - for item in timelineItems { - let itemID = previewTimelineItemID( - itemName: item.itemName, - jobID: job.id, - cycle: 0 + timelineItems.map { item in + item.itemSnapshot( + id: previewTimelineItemID( + itemName: item.itemName, + jobID: streamID, + cycle: 0 + ), + turnID: turnID ) - let seed = item.seed(id: itemID) - let event: ReviewDomainEvent = item.phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed) - timeline.apply(event) - } - return timeline.items.map { - CodexChatItemSnapshot(previewItem: $0, turnID: turnID) } } + private static func makeJob(from review: PreviewReviewFixture) -> CodexReviewJob { + makeJob( + id: review.id, + threadID: review.chatID.rawValue, + turnID: review.turnID.rawValue, + cwd: review.cwd, + model: review.model, + status: review.status, + targetSummary: review.targetSummary, + startedAt: review.startedAt, + endedAt: review.endedAt, + summary: review.summary, + hasFinalReview: review.hasFinalReview, + reviewResult: review.reviewResult, + lastAgentMessage: review.lastAgentMessage + ) + } + private static func makeJob( id: String, threadID: String, @@ -801,22 +756,20 @@ public enum ReviewMonitorPreviewContent { summary: summary, hasFinalReview: hasFinalReview, reviewResult: reviewResult, - lastAgentMessage: lastAgentMessage + lastAgentMessage: lastAgentMessage, + errorMessage: status == .failed ? summary : nil ) } private static func diagnosticItem( _ itemName: String, - kind: ReviewItemKind, - message: String, - phase: ReviewItemPhase = .completed + kind: CodexThreadItem.Kind, + message: String ) -> PreviewTimelineItemTemplate { .init( itemName: itemName, kind: kind, - family: .diagnostic, - phase: phase, - content: .diagnostic(.init(message: message)) + content: .diagnostic(message) ) } @@ -824,9 +777,7 @@ public enum ReviewMonitorPreviewContent { .init( itemName: itemName, kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: text)) + content: .message(.init(id: itemName, role: .assistant, text: text)) ) } @@ -834,23 +785,19 @@ public enum ReviewMonitorPreviewContent { .init( itemName: itemName, kind: .plan, - family: .plan, - phase: .completed, - content: .plan(.init(markdown: markdown)) + content: .plan(markdown) ) } private static func reasoningItem( _ itemName: String, text: String, - style: ReviewTimelineItem.Reasoning.Style = .summary + style: PreviewReasoningStyle = .summary ) -> PreviewTimelineItemTemplate { .init( itemName: itemName, kind: .reasoning, - family: .reasoning, - phase: .completed, - content: .reasoning(.init(text: text, style: style)) + content: .reasoning(style == .summary ? .init(summary: text) : .init(content: text)) ) } @@ -862,32 +809,24 @@ public enum ReviewMonitorPreviewContent { .init( itemName: itemName, kind: .contextCompaction, - family: .contextCompaction, - phase: status == .completed ? .completed : .running, - content: .contextCompaction(.init(title: title, status: status)) + content: .contextCompaction(title) ) } private static func commandStartedItem( _ itemName: String, command: String, - cwd: String? = nil, - startedAt: Date? = nil, - actions: [ReviewTimelineItem.CommandAction] = [] + cwd: String? = nil ) -> PreviewTimelineItemTemplate { .init( itemName: itemName, kind: .commandExecution, - family: .command, - phase: .running, content: .command( .init( command: command, cwd: cwd, - status: .inProgress, - actions: actions - )), - startedAt: startedAt + status: .running + )) ) } @@ -895,23 +834,18 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, output: String, exitCode: Int, - status: ReviewCommandStatus, - durationMs: Int? = nil + status: ReviewCommandStatus ) -> PreviewTimelineItemTemplate { .init( itemName: itemName, kind: .commandExecution, - family: .command, - phase: status == .failed ? .failed : .completed, content: .command( .init( command: "", output: output, exitCode: exitCode, - status: status, - durationMs: durationMs - )), - durationMs: durationMs + status: CodexTurnStatus(status) + )) ) } @@ -920,23 +854,10 @@ public enum ReviewMonitorPreviewContent { result: String, status: ReviewToolCallStatus ) -> PreviewTimelineItemTemplate { - let phase: ReviewItemPhase = - switch status { - case .started, .inProgress: - .running - case .failed: - .failed - case .cancelled: - .cancelled - default: - .completed - } return PreviewTimelineItemTemplate( itemName: itemName, kind: .mcpToolCall, - family: .tool, - phase: phase, - content: .toolCall(.init(result: result, status: status)) + content: .toolCall(.init(result: result, status: CodexTurnStatus(status))) ) } @@ -994,12 +915,12 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "queued-event-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Queued review for \(definition.targetSummary)." ), diagnosticItem( "queued-progress-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "progress"), + kind: CodexThreadItem.Kind(rawValue: "progress"), message: definition.summary ), ] @@ -1008,7 +929,7 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "failed-event-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Turn started: preview-failed-\(workspaceName.lowercased())" ), commandStartedItem( @@ -1021,14 +942,13 @@ public enum ReviewMonitorPreviewContent { Building for debugging... Test Suite 'ReviewUITests' started. ReviewMonitorContentPreviewTests.testPreviewStore failed: expected command output panel metadata. - """, + """, exitCode: 1, - status: .failed, - durationMs: 10_000 + status: .failed ), diagnosticItem( "failed-error-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "error"), + kind: .error, message: definition.summary ), messageItem( @@ -1038,12 +958,12 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "cancelled-event-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Turn started: preview-cancelled-\(workspaceName.lowercased())" ), diagnosticItem( "cancelled-progress-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "progress"), + kind: CodexThreadItem.Kind(rawValue: "progress"), message: definition.summary ), messageItem( @@ -1054,7 +974,7 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "complete-event-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Turn started: preview-complete-\(workspaceName.lowercased())" ), commandStartedItem( @@ -1067,10 +987,9 @@ public enum ReviewMonitorPreviewContent { Test Suite 'ReviewUITests' started. Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed. Test Suite 'ReviewUITests' passed. - """, + """, exitCode: 0, - status: .completed, - durationMs: 4_000 + status: .completed ), reasoningItem( "complete-summary-\(workspaceName)-\(definition.targetSummary)", @@ -1093,12 +1012,12 @@ public enum ReviewMonitorPreviewContent { return [ diagnosticItem( "running-event-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "event"), + kind: CodexThreadItem.Kind(rawValue: "event"), message: "Turn started: preview-\(workspaceName.lowercased())" ), diagnosticItem( "running-progress-\(workspaceName)-\(definition.targetSummary)", - kind: ReviewItemKind(rawValue: "progress"), + kind: CodexThreadItem.Kind(rawValue: "progress"), message: "Reviewing \(definition.targetSummary)" ), contextCompactionItem( @@ -1129,21 +1048,11 @@ public enum ReviewMonitorPreviewContent { Tests/ReviewUITests/ReviewUITests.swift:2322: commandOutputRendersCollapsedTextKitPanelAndExpandsInline """, exitCode: 0, - status: .completed, - durationMs: 6_000 + status: .completed ), commandStartedItem( sourceReadItemName, - command: "/bin/zsh -lc \"\(sourceReadCommand)\"", - startedAt: Date().addingTimeInterval(-88), - actions: [ - .init( - kind: .read, - command: sourceReadCommand, - name: "ReviewMonitorCommandOutputDisplayDocument.swift", - path: "Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift" - ) - ] + command: "/bin/zsh -lc \"\(sourceReadCommand)\"" ), toolCallItem( "running-tool-\(workspaceName)-\(definition.targetSummary)", @@ -1260,6 +1169,10 @@ private extension CodexTurnStatus { self = .cancelled } } + + init(_ status: some ReviewOpenStringValue) { + self.init(rawValue: status.rawValue) + } } private extension CodexDataPhase { diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index a22d7bab..f06206b9 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -38,7 +38,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, modelContext: modelContext ) @@ -71,7 +70,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, modelContext: modelContext ) @@ -121,7 +119,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, codexModelSource: modelSource ) @@ -170,7 +167,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, modelContext: modelContext ) @@ -235,7 +231,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, modelContext: modelContext ) @@ -303,7 +298,6 @@ struct ReviewMonitorSelectedCodexChatTests { let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) let transport = ReviewMonitorTransportViewController( - store: store, uiState: uiState, modelContext: modelContext ) From 7f10c6eed8bbd7f4d65763550ecc1c43173abbd7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:28:35 +0900 Subject: [PATCH 208/359] fix(review-ui): preserve preview chat item upserts --- Sources/ReviewUI/ReviewMonitorPreviewContent.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 4425f0d8..7f8a8e8e 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -698,8 +698,9 @@ public enum ReviewMonitorPreviewContent { timelineItems: [PreviewTimelineItemTemplate], turnID: CodexTurnID ) -> [CodexChatItemSnapshot] { - timelineItems.map { item in - item.itemSnapshot( + var snapshots: [CodexChatItemSnapshot] = [] + for item in timelineItems { + let snapshot = item.itemSnapshot( id: previewTimelineItemID( itemName: item.itemName, jobID: streamID, @@ -707,7 +708,15 @@ public enum ReviewMonitorPreviewContent { ), turnID: turnID ) + if let index = snapshots.firstIndex(where: { + $0.id == snapshot.id && $0.turnID == snapshot.turnID + }) { + snapshots[index] = snapshot + } else { + snapshots.append(snapshot) + } } + return snapshots } private static func makeJob(from review: PreviewReviewFixture) -> CodexReviewJob { From 6349a9f491dcb09a772362f7f2db8eda26093719 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:29:31 +0900 Subject: [PATCH 209/359] refactor(review-ui): remove workspace findings detail pane --- ...ReviewMonitorTransportViewController.swift | 220 +---- .../ReviewMonitorWorkspaceFindingsView.swift | 929 ------------------ .../ReviewMonitorSplitViewController.swift | 1 - Tests/ReviewUITests/ReviewUIShellTests.swift | 16 - Tests/ReviewUITests/ReviewUITests.swift | 272 +---- 5 files changed, 9 insertions(+), 1429 deletions(-) delete mode 100644 Sources/ReviewUI/Detail/ReviewMonitorWorkspaceFindingsView.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index cc59ad1f..9b5f0ce9 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,6 +1,5 @@ import AppKit import CodexKit -import CodexReviewKit import ObservationBridge @MainActor @@ -12,18 +11,14 @@ final class ReviewMonitorTransportViewController: NSViewController { private let codexModelSource: ReviewMonitorCodexModelSource? private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let uiState: ReviewMonitorUIState - private let store: CodexReviewStore private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() - private let workspaceFindingsView = ReviewMonitorWorkspaceFindingsView() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? - private var selectedWorkspaceFindingsObservation: PortableObservationTracking.Token? private var selectedChatLogTask: Task? private var boundChatID: CodexThreadID? - private var boundWorkspaceSection: ReviewMonitorWorkspaceSectionSelection? private var displayedSelection: ReviewMonitorSelectionID? private var logScrollTargetsByChatID: [CodexThreadID: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] private var logRenderTask: Task? @@ -32,13 +27,11 @@ final class ReviewMonitorTransportViewController: NSViewController { private var hasAppliedBoundLog = false convenience init( - store: CodexReviewStore, uiState: ReviewMonitorUIState, modelContext: CodexModelContext, previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil ) { self.init( - store: store, uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), previewChatLogSource: previewChatLogSource @@ -46,14 +39,12 @@ final class ReviewMonitorTransportViewController: NSViewController { } init( - store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil ) { self.codexModelSource = codexModelSource self.previewChatLogSource = previewChatLogSource - self.store = store self.uiState = uiState self.selectedCodexChat = ReviewMonitorSelectedCodexChat(modelSource: codexModelSource) super.init(nibName: nil, bundle: nil) @@ -66,7 +57,6 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() - selectedWorkspaceFindingsObservation?.cancel() selectedChatLogTask?.cancel() logRenderTask?.cancel() } @@ -95,7 +85,6 @@ final class ReviewMonitorTransportViewController: NSViewController { placeholderView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(logScrollView) - view.addSubview(workspaceFindingsView) view.addSubview(placeholderView) displayedContentConstraints = [ @@ -108,11 +97,6 @@ final class ReviewMonitorTransportViewController: NSViewController { 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), @@ -137,8 +121,8 @@ final class ReviewMonitorTransportViewController: NSViewController { private func selectionRequiresPresentationUpdate(_ selection: ReviewMonitorSelection?) -> Bool { switch selection { - case .workspaceSection(let selectedSection): - return boundWorkspaceSection != selectedSection || displayedSelection != selection?.id + case .workspaceSection: + return displayedSelection != selection?.id case .workspace: return displayedSelection != selection?.id case .chat(let selectedChat): @@ -151,36 +135,30 @@ final class ReviewMonitorTransportViewController: NSViewController { private func updatePresentation(selection: ReviewMonitorSelection?) { switch selection { - case .workspaceSection(let selectedSection): + case .workspaceSection: clearDisplayedLogSelection() - displayWorkspaceSection(selectedSection) + displayPlaceholder(.noFindings) logScrollView.isHidden = true displayedSelection = selection?.id case .workspace: clearDisplayedLogSelection() - clearDisplayedWorkspace() displayPlaceholder(.noFindings) logScrollView.isHidden = true - workspaceFindingsView.isHidden = true displayedSelection = selection?.id case .chat: - clearDisplayedWorkspace() if case .chat(let selectedChat) = selection { displayChat(selectedChat) } hidePlaceholder() logScrollView.isHidden = false - workspaceFindingsView.isHidden = true displayedSelection = selection?.id case nil: clearDisplayedLogSelection() - clearDisplayedWorkspace() displayPlaceholder(.noSelection) logScrollView.isHidden = true - workspaceFindingsView.isHidden = true displayedSelection = nil } } @@ -223,58 +201,6 @@ final class ReviewMonitorTransportViewController: NSViewController { logScrollView.clear() } - private func displayWorkspaceSection(_ section: ReviewMonitorWorkspaceSectionSelection) { - if boundWorkspaceSection != section { - selectedWorkspaceFindingsObservation?.cancel() - selectedWorkspaceFindingsObservation = nil - boundWorkspaceSection = section - bindWorkspaceSectionObservation(section) - } - } - - 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 = CodexReviewWorkspaceFindingsIndex(store: self.store) - .entries(forWorkspaceCWDs: section.workspaceCWDs) - .map(ReviewMonitorWorkspaceFindingsView.Entry.init) - self.renderWorkspaceFindings(entries: entries) - } - } - - @discardableResult - private func renderWorkspaceFindings(entries: [ReviewMonitorWorkspaceFindingsView.Entry]) -> Bool { - let rendered = workspaceFindingsView.render(entries: entries) - let presentationChanged = updateWorkspaceFindingsPresentation(hasFindings: entries.isEmpty == false) - return rendered || presentationChanged - } - - @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 - } - @discardableResult private func displayPlaceholder(_ content: PlaceholderContent) -> Bool { let contentChanged = placeholderViewController.render(content: content) @@ -453,39 +379,23 @@ final class ReviewMonitorTransportViewController: NSViewController { @discardableResult func performDisplayedTextFinderAction(_ sender: Any?) -> Bool { switch displayedSelection { - case .workspaceSection: - return workspaceFindingsView.performDisplayedTextFinderAction(sender) case .chat: return logScrollView.performDisplayedTextFinderAction(sender) - case .workspace, nil: + case .workspaceSection, .workspace, nil: return false } } func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool { switch displayedSelection { - case .workspaceSection: - return workspaceFindingsView.validateDisplayedTextFinderAction(item) case .chat: return logScrollView.validateDisplayedTextFinderAction(item) - case .workspace, nil: + case .workspaceSection, .workspace, nil: return false } } } -private extension ReviewMonitorWorkspaceFindingsView.Entry { - init(_ entry: CodexReviewWorkspaceFindingEntry) { - self.init( - threadID: entry.threadID, - targetSummary: entry.targetSummary, - priority: entry.priority, - title: entry.title, - body: entry.body, - locationText: entry.locationText - ) - } -} #if DEBUG @MainActor @@ -497,12 +407,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { let isShowingEmptyState: Bool } - struct WorkspaceFindingSnapshotForTesting: Sendable, Equatable { - let text: String - let isShowingNoFindingsState: Bool - let isShowingFindingsList: Bool - } - enum DisplayedSelectionForTesting: Sendable, Equatable { case workspaceSection(String) case workspace(String) @@ -522,10 +426,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { selectedChatLogTask } - var selectedWorkspaceFindingsObservationForTesting: PortableObservationTracking.Token? { - selectedWorkspaceFindingsObservation - } - var selectedCodexChatIDForTesting: String? { selectedCodexChat.chat?.id.rawValue } @@ -544,9 +444,7 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { return selectionObservation } switch expectedSelection { - case .workspaceSection: - return selectedWorkspaceFindingsObservation ?? selectionObservation - case .workspace, .chat: + case .workspaceSection, .workspace, .chat: return selectionObservation case nil: return selectionObservation @@ -561,10 +459,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { logScrollView.displayedTextForTesting } - var displayedWorkspaceFindingsForTesting: String { - workspaceFindingsView.displayedTextForTesting - } - var displayedSummaryForTesting: String? { nil } @@ -581,10 +475,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { placeholderViewController.view.isHidden == false && placeholderViewController.content == .noFindings } - var isShowingWorkspaceFindingsListForTesting: Bool { - workspaceFindingsView.isShowingFindingsListForTesting - } - var logAppendCountForTesting: Int { logScrollView.appendCount } @@ -970,100 +860,6 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { } } - 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 scrollLogToTopForTesting() { logScrollView.scrollToTopForTesting() } @@ -1237,10 +1033,8 @@ private extension ReviewMonitorWorkspaceFindingsView.Entry { resetLogRenderer() boundChatID = chatID } - clearDisplayedWorkspace() hidePlaceholder() logScrollView.isHidden = false - workspaceFindingsView.isHidden = true displayedSelection = .chat(chatID) case .workspaceSection, .workspace: break 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/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 0eca5090..6c275180 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -88,7 +88,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe previewChatLogSource: previewChatLogSource ) let transportViewController = ReviewMonitorTransportViewController( - store: store, uiState: uiState, codexModelSource: codexModelSource, previewChatLogSource: previewChatLogSource diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index f78cc4a8..0605538d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1714,22 +1714,6 @@ extension ReviewUITests { #expect(snapshot.items.last?.kind.rawValue == "commandExecution") } - @Test func previewFirstWorkspaceShowsStructuredFindingsWhenSelected() async throws { - let store = ReviewMonitorPreviewContent.makeStore() - 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) - - _ = try await awaitTransportRender(transport) - let accessibilityValue = try #require(transport.workspaceFindingsAccessibilityValueForTesting) - #expect(transport.workspaceFindingSnapshotForTesting.isShowingFindingsList) - #expect(transport.workspaceFindingSnapshotForTesting.isShowingNoFindingsState == false) - #expect(accessibilityValue.isEmpty == false) - } } @MainActor diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index bd12d41a..1cf7eb93 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -896,267 +896,6 @@ struct ReviewUITests { #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.selectedReviewChatIDForTesting == 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(window.title == workspace.displayTitle) - #expect(window.subtitle == workspace.cwd) - - let findItem = textFinderMenuItemForTesting(.showFindInterface) - #expect(viewController.validateUserInterfaceItem(findItem)) - viewController.performTextFinderAction(findItem) - #expect(transport.workspaceFindingsFindBarVisibleForTesting) - } - - @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) - ) - 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, - 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 - ) - ) - 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 - ) - ) - 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 - - #expect( - transport.workspaceFindingSnapshotForTesting - == .init( - text: "", - isShowingNoFindingsState: true, - isShowingFindingsList: 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) - } - @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws { let job = CodexReviewJob.makeForTesting( id: "job-monitor-log", @@ -2776,7 +2515,7 @@ struct ReviewUITests { #expect(transport.renderSnapshotForTesting == selectedSnapshot) } - @Test func clickingWorkspaceHeaderSelectsWorkspaceAndShowsFindingsPane() async throws { + @Test func clickingWorkspaceHeaderSelectsWorkspacePlaceholder() async throws { let job = makeJob( id: "job-selected", cwd: "/tmp/workspace-alpha", @@ -2815,14 +2554,7 @@ struct ReviewUITests { workspace.cwd ]) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) - #expect( - transport.workspaceFindingSnapshotForTesting - == .init( - text: "", - isShowingNoFindingsState: true, - isShowingFindingsList: false - ) - ) + #expect(transport.isShowingNoFindingsStateForTesting) } @Test func newJobsArrivingWhileUnselectedDoNotAutoSelect() { From e1f304e508298775824b857ea0eccdb9be250a0e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:53:02 +0900 Subject: [PATCH 210/359] refactor(review-ui): seed previews from chat fixtures --- .../ReviewMonitorPreviewContent.swift | 227 +++++------------- Tests/ReviewUITests/ReviewUIShellTests.swift | 33 +-- 2 files changed, 67 insertions(+), 193 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 7f8a8e8e..bcc80383 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -5,15 +5,22 @@ import CodexKit @_spi(PreviewSupport) @MainActor public enum ReviewMonitorPreviewContent { - private struct PreviewJobDefinition { - let status: ReviewJobState + 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 lastAgentMessage: String let model: String let startedOffset: TimeInterval? let endedOffset: TimeInterval? - let hasFinalReview: Bool } private struct PreviewReviewFixture { @@ -25,11 +32,9 @@ public enum ReviewMonitorPreviewContent { let summary: String let lastAgentMessage: String let model: String - let status: ReviewJobState + let lifecycle: PreviewChatLifecycle let startedAt: Date? let endedAt: Date? - let hasFinalReview: Bool - let reviewResult: ParsedReviewResult? let chatItems: [PreviewTimelineItemTemplate] } @@ -123,8 +128,6 @@ public enum ReviewMonitorPreviewContent { } private struct PreviewSidebarContent { - var workspaces: [CodexReviewWorkspace] - var jobs: [CodexReviewJob] var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] } @@ -140,8 +143,7 @@ public enum ReviewMonitorPreviewContent { account: accounts.first, persistedAccounts: accounts, serverURL: URL(string: "http://localhost:9417/mcp"), - workspaces: previewContent.workspaces, - jobs: previewContent.jobs + workspaces: [] ) let chatLogSource = ReviewMonitorPreviewChatLogSource( fixtures: previewContent.chatLogFixtures @@ -195,21 +197,17 @@ public enum ReviewMonitorPreviewContent { summary: "A command output block is collapsed by default.", lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.", model: "gpt-5.4", - status: .running, + lifecycle: .running, startedAt: now.addingTimeInterval(-135), endedAt: nil, - hasFinalReview: false, - reviewResult: nil, chatItems: timelineItems ) - let job = makeJob(from: review) store.loadForTesting( serverState: .running, account: accounts.first, persistedAccounts: accounts, serverURL: URL(string: "http://localhost:9417/mcp"), - workspaces: [CodexReviewWorkspace(cwd: cwd)], - jobs: [job] + workspaces: [] ) let fixture = makeChatLogFixture( for: review @@ -607,18 +605,15 @@ public enum ReviewMonitorPreviewContent { "/path/to/workspace-gamma", ] - var workspaces: [CodexReviewWorkspace] = [] - var jobs: [CodexReviewJob] = [] var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] = [] for (workspaceIndex, cwd) in workspacePaths.enumerated() { let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent - workspaces.append(CodexReviewWorkspace(cwd: cwd)) - for (jobIndex, definition) in makeJobDefinitions(for: workspaceName).enumerated() { + for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() { let timelineItems = makePreviewTimelineItems(for: definition, workspaceName: workspaceName) - let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(jobIndex)") - let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(jobIndex)") + let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(chatIndex)") + let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(chatIndex)") let review = PreviewReviewFixture( - id: "preview-\(workspaceIndex)-\(jobIndex)", + id: "preview-\(workspaceIndex)-\(chatIndex)", chatID: chatID, turnID: turnID, cwd: cwd, @@ -626,18 +621,11 @@ public enum ReviewMonitorPreviewContent { summary: definition.summary, lastAgentMessage: definition.lastAgentMessage, model: definition.model, - status: definition.status, + lifecycle: definition.lifecycle, startedAt: definition.startedOffset.map { now.addingTimeInterval($0) }, endedAt: definition.endedOffset.map { now.addingTimeInterval($0) }, - hasFinalReview: definition.hasFinalReview, - reviewResult: makeReviewResult( - workspaceIndex: workspaceIndex, - jobIndex: jobIndex, - cwd: cwd - ), chatItems: timelineItems ) - jobs.append(makeJob(from: review)) chatLogFixtures.append( makeChatLogFixture( for: review @@ -645,8 +633,6 @@ public enum ReviewMonitorPreviewContent { } } return PreviewSidebarContent( - workspaces: workspaces, - jobs: jobs, chatLogFixtures: chatLogFixtures ) } @@ -663,19 +649,19 @@ public enum ReviewMonitorPreviewContent { workspaceCWD: review.cwd, updatedAt: review.endedAt ?? review.startedAt, recencyAt: review.endedAt ?? review.startedAt, - status: CodexThreadStatus(previewReviewJobState: review.status) + status: CodexThreadStatus(previewLifecycle: review.lifecycle) ) let turn = CodexChatTurnStateSnapshot( id: review.turnID, - status: CodexTurnStatus(review.status), - errorDescription: review.status == .failed ? review.summary : nil, + status: CodexTurnStatus(review.lifecycle), + errorDescription: review.lifecycle == .failed ? review.summary : nil, usage: nil ) let initialSnapshot = CodexChatSnapshot( chatID: chat.id, phase: CodexDataPhase( - review.status, - errorMessage: review.status == .failed ? review.summary : nil + review.lifecycle, + errorMessage: review.lifecycle == .failed ? review.summary : nil ), turns: [turn], items: makeInitialChatItems( @@ -688,7 +674,7 @@ public enum ReviewMonitorPreviewContent { chat: chat, cwd: review.cwd, streamID: review.id, - isRunning: review.status == .running, + isRunning: review.lifecycle == .running, initialSnapshot: initialSnapshot ) } @@ -719,57 +705,6 @@ public enum ReviewMonitorPreviewContent { return snapshots } - private static func makeJob(from review: PreviewReviewFixture) -> CodexReviewJob { - makeJob( - id: review.id, - threadID: review.chatID.rawValue, - turnID: review.turnID.rawValue, - cwd: review.cwd, - model: review.model, - status: review.status, - targetSummary: review.targetSummary, - startedAt: review.startedAt, - endedAt: review.endedAt, - summary: review.summary, - hasFinalReview: review.hasFinalReview, - reviewResult: review.reviewResult, - lastAgentMessage: review.lastAgentMessage - ) - } - - private static func makeJob( - id: String, - threadID: String, - turnID: String, - cwd: String, - model: String, - status: ReviewJobState, - targetSummary: String, - startedAt: Date?, - endedAt: Date?, - summary: String, - hasFinalReview: Bool, - reviewResult: ParsedReviewResult?, - lastAgentMessage: String - ) -> CodexReviewJob { - CodexReviewJob.makeForTesting( - id: id, - cwd: cwd, - targetSummary: targetSummary, - model: model, - threadID: threadID, - turnID: turnID, - status: status, - startedAt: startedAt, - endedAt: endedAt, - summary: summary, - hasFinalReview: hasFinalReview, - reviewResult: reviewResult, - lastAgentMessage: lastAgentMessage, - errorMessage: status == .failed ? summary : nil - ) - } - private static func diagnosticItem( _ itemName: String, kind: CodexThreadItem.Kind, @@ -870,54 +805,11 @@ public enum ReviewMonitorPreviewContent { ) } - 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 makePreviewTimelineItems( - for definition: PreviewJobDefinition, + for definition: PreviewChatDefinition, workspaceName: String ) -> [PreviewTimelineItemTemplate] { - switch definition.status { + switch definition.lifecycle { case .running: return makeRunningPreviewTimelineItems(for: definition, workspaceName: workspaceName) case .queued: @@ -1011,7 +903,7 @@ public enum ReviewMonitorPreviewContent { } private static func makeRunningPreviewTimelineItems( - for definition: PreviewJobDefinition, + for definition: PreviewChatDefinition, workspaceName: String ) -> [PreviewTimelineItemTemplate] { let sourceReadItemName = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)" @@ -1078,85 +970,78 @@ public enum ReviewMonitorPreviewContent { ] } - private static func makeJobDefinitions(for workspaceName: String) -> [PreviewJobDefinition] { + private static func makeChatDefinitions(for workspaceName: String) -> [PreviewChatDefinition] { [ - PreviewJobDefinition( - status: .running, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: nil ), - PreviewJobDefinition( - status: .running, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: nil ), - PreviewJobDefinition( - status: .queued, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: nil ), - PreviewJobDefinition( - status: .succeeded, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: -1_260 ), - PreviewJobDefinition( - status: .failed, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: -2_190 ), - PreviewJobDefinition( - status: .cancelled, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: -840 ), - PreviewJobDefinition( - status: .succeeded, + PreviewChatDefinition( + lifecycle: .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 + endedOffset: -5_040 ), ] } } private extension CodexThreadStatus { - init(previewReviewJobState jobState: ReviewJobState) { - switch jobState { + init(previewLifecycle lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { + switch lifecycle { case .queued, .running: self = .active(activeFlags: []) case .succeeded, .failed, .cancelled: @@ -1166,8 +1051,8 @@ private extension CodexThreadStatus { } private extension CodexTurnStatus { - init(_ jobState: ReviewJobState) { - switch jobState { + init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { + switch lifecycle { case .queued, .running: self = .running case .succeeded: @@ -1185,8 +1070,8 @@ private extension CodexTurnStatus { } private extension CodexDataPhase { - init(_ jobState: ReviewJobState, errorMessage: String?) { - switch jobState { + init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) { + switch lifecycle { case .queued, .running, .succeeded, .cancelled: self = .loaded case .failed: diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 0605538d..b3996219 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -23,11 +23,8 @@ extension ReviewUITests { @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let selectedReviewChatJob = try #require( - store.orderedJobs.first { $0.core.lifecycle.status == .running } - ?? store.orderedJobs.first - ) - let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let selectedChat = try #require(previewChatLogSource.initialChat) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -45,18 +42,14 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) + #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) #expect(snapshot.log.isEmpty == false) } @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let selectedReviewChatJob = try #require( - store.orderedJobs.first { $0.core.lifecycle.status == .running } - ?? store.orderedJobs.first - ) - let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) - let selectedTargetSummary = selectedReviewChatJob.targetSummary + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let selectedChat = try #require(previewChatLogSource.initialChat) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -66,10 +59,10 @@ extension ReviewUITests { let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting let snapshot = try await awaitTransportRender(transport) { snapshot in - snapshot.log.contains(selectedTargetSummary) && snapshot.isShowingEmptyState == false + snapshot.log.contains(selectedChat.title) && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) + #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) #expect(snapshot.log.isEmpty == false) } @@ -794,12 +787,8 @@ extension ReviewUITests { @Test func previewContentViewControllerRendersSelectedChatLog() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let selectedReviewChatJob = try #require( - store.orderedJobs.first { $0.core.lifecycle.status == .running } - ?? store.orderedJobs.first - ) - let selectedChatID = try #require(selectedReviewChatJob.core.run.reviewThreadID) - let selectedTargetSummary = selectedReviewChatJob.targetSummary + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let selectedChat = try #require(previewChatLogSource.initialChat) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -811,10 +800,10 @@ extension ReviewUITests { let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting let snapshot = try await awaitTransportRender(transport) { snapshot in - snapshot.log.contains(selectedTargetSummary) && snapshot.isShowingEmptyState == false + snapshot.log.contains(selectedChat.title) && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID)) + #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) #expect(snapshot.log.isEmpty == false) } From 40b6eb90a03456ae7f9ccb8aa59ad440ecc8459f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:56:01 +0900 Subject: [PATCH 211/359] refactor(review-ui): remove unused workspace findings index --- .../Model/CodexReviewWorkspaceFindings.swift | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift diff --git a/Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift b/Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift deleted file mode 100644 index 4d3c5f44..00000000 --- a/Sources/CodexReviewKit/Model/CodexReviewWorkspaceFindings.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation - -package struct CodexReviewWorkspaceFindingEntry: Equatable, Sendable { - package var threadID: String - package var targetSummary: String? - package var priority: Int? - package var title: String - package var body: String - package var locationText: String? - - package init( - threadID: String, - targetSummary: String? = nil, - priority: Int? = nil, - title: String, - body: String, - locationText: String? = nil - ) { - self.threadID = threadID - self.targetSummary = targetSummary - self.priority = priority - self.title = title - self.body = body - self.locationText = locationText - } -} - -@MainActor -package struct CodexReviewWorkspaceFindingsIndex { - private let workspaces: [CodexReviewWorkspace] - private let jobsByWorkspaceCWD: [String: [CodexReviewJob]] - - package init(store: CodexReviewStore) { - self.workspaces = store.orderedWorkspaces - self.jobsByWorkspaceCWD = Dictionary(grouping: store.orderedJobs, by: \.cwd) - } - - package func entries(forWorkspaceCWDs workspaceCWDs: [String]) -> [CodexReviewWorkspaceFindingEntry] { - let workspacesByCWD = Dictionary( - uniqueKeysWithValues: workspaces.map { ($0.cwd, $0) } - ) - return workspaceCWDs - .compactMap { workspacesByCWD[$0] } - .flatMap(entries(in:)) - } - - private func entries(in workspace: CodexReviewWorkspace) -> [CodexReviewWorkspaceFindingEntry] { - let jobs = jobsByWorkspaceCWD[workspace.cwd, default: []] - return jobs.flatMap { job -> [CodexReviewWorkspaceFindingEntry] in - guard let result = job.core.output.reviewResult, - result.state == .hasFindings - else { - return [] - } - let threadID = workspaceFindingThreadID(for: job) - return result.findings.map { finding in - CodexReviewWorkspaceFindingEntry( - threadID: threadID, - targetSummary: job.targetSummary, - priority: finding.priority, - title: finding.title, - body: finding.body, - locationText: locationText(for: finding.location, in: workspace) - ) - } - } - } - - private func locationText( - for location: ParsedReviewResult.Finding.Location?, - in workspace: CodexReviewWorkspace - ) -> String? { - guard let location else { - return nil - } - - let path = workspaceRelativePath(location.path, in: workspace) ?? 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 { - job.core.run.reviewThreadID?.nilIfEmpty ?? job.core.run.threadID?.nilIfEmpty ?? job.id - } -} From 4b5a8af8e56142a5b263d78e331a3d5a6d6921e5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:32:47 +0900 Subject: [PATCH 212/359] refactor(review-ui): migrate log fixtures to chat streams --- .../Detail/ReviewMonitorLogScrollView.swift | 3 + ...ReviewMonitorTransportViewController.swift | 18 +- .../ReviewMonitorSplitViewController.swift | 1 + ...eviewJob+ReviewChatProjectionTesting.swift | 52 - .../ReviewMonitorTimelineLogProjection.swift | 413 ------ .../ReviewMonitorTimelineTesting.swift | 763 ++++++----- .../ReviewTimelineDocumentForTesting.swift | 437 ------- ...ewTimelineDocumentRendererForTesting.swift | 238 ---- Tests/ReviewUITests/ReviewUIShellTests.swift | 42 +- Tests/ReviewUITests/ReviewUITests.swift | 1118 ++++++----------- 10 files changed, 865 insertions(+), 2220 deletions(-) delete mode 100644 Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift delete mode 100644 Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift delete mode 100644 Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift delete mode 100644 Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift index efcaeafd..f49c8d56 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift @@ -491,6 +491,7 @@ final class ReviewMonitorLogScrollView: NSScrollView { ) let presentationChanged = displayedPresentationSignature != presentationSignature let finderSupplementChanged = displayedFinderSupplementSignature != finderSupplementSignature + let shouldRestoreScroll = currentScrollRestorationTarget != restorationTarget let shouldRefreshFindSession = shouldRefreshFindSessionForFinderSupplementChange( to: finderSupplementSignature ) @@ -498,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) } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 9b5f0ce9..ba1b980d 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -267,7 +267,6 @@ final class ReviewMonitorTransportViewController: NSViewController { ) { selectedChatLogTask?.cancel() selectedChatLogTask = Task { @MainActor [weak self] in - var didRenderInitialDocument = false var projection = ReviewMonitorSelectedCodexChatLogProjection() for await change in stream { guard let self, @@ -284,17 +283,15 @@ final class ReviewMonitorTransportViewController: NSViewController { else { continue } + let hasAppliedInitialDocument = self.hasAppliedBoundLog self.applySelectedCodexChatLogChange( logChange, target: target, - restorationTarget: didRenderInitialDocument + restorationTarget: hasAppliedInitialDocument ? self.logScrollView.currentScrollRestorationTarget : initialRestorationTarget, - allowIncrementalUpdate: didRenderInitialDocument && logChange.allowsIncrementalRender + allowIncrementalUpdate: hasAppliedInitialDocument && logChange.allowsIncrementalRender ) - if logChange.sourceDocument != nil { - didRenderInitialDocument = true - } } } } @@ -306,24 +303,21 @@ final class ReviewMonitorTransportViewController: NSViewController { ) { selectedChatLogTask?.cancel() selectedChatLogTask = Task { @MainActor [weak self] in - var didRenderInitialDocument = false for await change in stream { guard let self, self.isCurrentLogRenderTarget(target) else { return } + let hasAppliedInitialDocument = self.hasAppliedBoundLog self.applySelectedCodexChatLogChange( change, target: target, - restorationTarget: didRenderInitialDocument + restorationTarget: hasAppliedInitialDocument ? self.logScrollView.currentScrollRestorationTarget : initialRestorationTarget, - allowIncrementalUpdate: didRenderInitialDocument && change.allowsIncrementalRender + allowIncrementalUpdate: hasAppliedInitialDocument && change.allowsIncrementalRender ) - if change.sourceDocument != nil { - didRenderInitialDocument = true - } } } } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 6c275180..119e6be0 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -65,6 +65,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe self.uiState = uiState self.codexModelSource = codexModelSource self.previewChatLogSource = previewChatLogSource + ?? (store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport)?.chatLogSource self.showSettings = showSettings super.init(nibName: nil, bundle: nil) } diff --git a/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift b/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift deleted file mode 100644 index fff7be9b..00000000 --- a/Tests/ReviewUITests/CodexReviewJob+ReviewChatProjectionTesting.swift +++ /dev/null @@ -1,52 +0,0 @@ -import CodexKit -@_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI - -extension CodexReviewJob { - @MainActor - var legacyReviewChatID: CodexThreadID? { - if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { - return CodexThreadID(rawValue: reviewThreadID) - } - if let threadID = nonEmptyReviewChatProjectionStringForTesting(core.run.threadID) { - return CodexThreadID(rawValue: threadID) - } - return nil - } - - @MainActor - var legacyReviewChatSelection: ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let chatID = legacyReviewChatID else { - return nil - } - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: displayTitle, - preview: nonEmptyReviewChatProjectionStringForTesting(core.output.lastAgentMessage) - ?? nonEmptyReviewChatProjectionStringForTesting(core.output.summary), - workspaceCWD: cwd, - updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobStateForTesting: core.lifecycle.status) - ) - } -} - -private extension CodexThreadStatus { - init(reviewJobStateForTesting jobState: ReviewJobState) { - switch jobState { - case .queued, .running: - self = .active(activeFlags: []) - case .succeeded, .failed, .cancelled: - self = .idle - } - } -} - -private func nonEmptyReviewChatProjectionStringForTesting(_ value: String?) -> String? { - guard let value, value.isEmpty == false else { - return nil - } - return value -} diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift b/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift deleted file mode 100644 index 5cdbb68c..00000000 --- a/Tests/ReviewUITests/ReviewMonitorTimelineLogProjection.swift +++ /dev/null @@ -1,413 +0,0 @@ -import Foundation -@_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI - -struct ReviewMonitorTimelineLogProjection: Sendable { - private var documentProjection = ReviewMonitorLogDocumentProjection() - - var currentDocument: ReviewMonitorLog.Document { - documentProjection.currentDocument - } - - mutating func render(timelineDocument: ReviewTimelineDocument) -> ReviewMonitorLog.Document { - documentProjection.render(projectedBlocks: Self.projectedBlocks(from: timelineDocument)) - } - - private static func projectedBlocks( - from timelineDocument: ReviewTimelineDocument - ) -> [ReviewMonitorLogProjectedBlock] { - var blocks: [ReviewMonitorLogProjectedBlock] = [] - let blocksByID = Dictionary(uniqueKeysWithValues: timelineDocument.blocks.map { ($0.id, $0) }) - for blockID in timelineDocument.orderedBlockIDs { - guard let block = blocksByID[blockID] else { - continue - } - blocks.append(contentsOf: projectedBlocks(for: block)) - } - return blocks - } - - private static func projectedBlocks( - for block: ReviewTimelineDocument.Block - ) -> [ReviewMonitorLogProjectedBlock] { - 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 [ - ReviewMonitorLogProjectedBlock( - 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 = [ - ReviewMonitorLogProjectedBlock( - id: derivedBlockID(prefix: "command", from: block), - kind: .command, - groupID: groupID, - text: "$ \(commandLine)", - metadata: metadata - ) - ] - if command.output.isEmpty == false { - blocks.append( - ReviewMonitorLogProjectedBlock( - 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: logKind(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: logKind(for: block, fallback: .plan), text: plan.markdown)] - case .reasoning(let reasoning): - return [ - projectedBlock( - block, - kind: logKind( - 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: logKind(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: ReviewMonitorLog.Kind, - groupID: String? = nil, - text: String, - metadata: ReviewMonitorLog.Metadata? = nil - ) -> ReviewMonitorLogProjectedBlock { - ReviewMonitorLogProjectedBlock( - 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 logKind( - for block: ReviewTimelineDocument.Block, - fallback: ReviewMonitorLog.Kind - ) -> ReviewMonitorLog.Kind { - ReviewMonitorLog.Kind(rawValue: block.kind.rawValue) ?? fallback - } - - private static func commandMetadata( - for block: ReviewTimelineDocument.Block, - command: ReviewTimelineDocument.Command - ) -> ReviewMonitorLog.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 - ) -> ReviewMonitorLog.Metadata { - guard hasStructuredOutputOnlyCommandMetadata(for: block, command: command) else { - return genericCommandOutputMetadata(for: block) - } - - let normalizedStatus = commandStatus(for: block, command: command) - return ReviewMonitorLog.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 - ) -> ReviewMonitorLog.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? - ) -> ReviewMonitorLog.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 - ) -> ReviewMonitorLog.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 - ) -> ReviewMonitorLog.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 - ) -> ReviewMonitorLog.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 extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index eb57c469..d2b47415 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -4,10 +4,11 @@ import CodexKit @_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI -struct ReviewTimelineEntryForTesting: Sendable, Hashable { +struct ReviewChatLogEntryForTesting: Sendable, Hashable { enum Kind: String, Sendable, Hashable { case command case commandOutput + case fileChange case agentMessage case plan case todoList @@ -105,7 +106,7 @@ extension CodexReviewJob { hasFinalReview: Bool = false, reviewResult: ParsedReviewResult? = nil, lastAgentMessage: String? = "", - timelineEntries: [ReviewTimelineEntryForTesting], + chatEntries: [ReviewChatLogEntryForTesting] = [], errorMessage: String? = nil, exitCode: Int? = nil ) -> CodexReviewJob { @@ -129,142 +130,117 @@ extension CodexReviewJob { errorMessage: errorMessage, exitCode: exitCode ) - seedTimelineForTesting(job, entries: timelineEntries) + ReviewChatLogFixtureStore.setEntries(chatEntries, for: job) return job } } @MainActor -func seedTimelineForTesting( +func seedChatLogForTesting( _ job: CodexReviewJob, logText: String = "", rawLogText: String = "" ) { + var entries: [ReviewChatLogEntryForTesting] = [] let trimmedLogText = logText.trimmingCharacters(in: .newlines) if trimmedLogText.isEmpty == false { - job.timeline.apply( - .itemCompleted( - .init( - id: .init(rawValue: "fixture-log-\(job.id)"), - kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: trimmedLogText)) - ))) + entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(job.id)", text: trimmedLogText)) } - for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { - job.timeline.apply( - .itemCompleted( - .init( - id: .init(rawValue: "fixture-diagnostic-\(job.id)-\(index)"), - kind: .dynamicToolCall, - family: .diagnostic, - phase: .completed, - content: .diagnostic(.init(message: String(line))) - ))) + entries.append( + .init( + kind: .diagnostic, + groupID: "fixture-diagnostic-\(job.id)-\(index)", + text: String(line) + )) } + ReviewChatLogFixtureStore.setEntries(entries, for: job) } @MainActor -func seedTimelineForTesting(_ job: CodexReviewJob, entries: [ReviewTimelineEntryForTesting]) { - guard job.timeline.items.isEmpty else { - return - } - for entry in entries { - appendTimelineEntryForTesting(job, entry) - } +func appendChatLogEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewChatLogEntryForTesting) { + ReviewChatLogFixtureStore.append(entry, to: job) } @MainActor -func appendTimelineEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewTimelineEntryForTesting) { - let itemID = ReviewTimelineItem.ID(rawValue: entry.groupID ?? entry.id.uuidString) - let existingContent = entry.replacesGroup ? nil : job.timeline.item(for: itemID)?.content - let phase = timelinePhase(for: entry) - let seed = ReviewTimelineItemSeed( - id: itemID, - kind: timelineKind(for: entry), - family: timelineFamily(for: entry), - phase: phase, - content: timelineContent(for: entry, existing: existingContent), - startedAt: entry.metadata?.startedAt, - completedAt: entry.metadata?.completedAt, - durationMs: entry.metadata?.durationMs +func replaceChatLogTextForTesting(_ job: CodexReviewJob, _ text: String) { + ReviewChatLogFixtureStore.replaceEntries( + [.init(kind: .agentMessage, groupID: "fixture-log-\(job.id)", text: text.trimmingCharacters(in: .newlines))], + for: job ) - job.timeline.apply(phase.isTerminal ? .itemCompleted(seed) : .itemUpdated(seed)) -} - -@MainActor -func replaceTimelineLogTextForTesting(_ job: CodexReviewJob, _ text: String) { - job.timeline.apply( - .itemUpdated( - .init( - id: .init(rawValue: "fixture-log-\(job.id)"), - kind: .agentMessage, - family: .message, - phase: .completed, - content: .message(.init(text: text.trimmingCharacters(in: .newlines))) - ))) } @MainActor -func reviewMonitorLogText(for job: CodexReviewJob) -> String { - var projection = ReviewMonitorTimelineLogProjection() - let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - let sourceDocument = projection.render(timelineDocument: timelineDocument) - let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(from: sourceDocument) - return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) +func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, jobs: [CodexReviewJob]) { + let fixtures = jobs.compactMap { try? makePreviewChatLogFixtureForTesting(job: $0) } + let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) + store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) + jobStorePreviewSupportRetainers.append( + ReviewChatLogFixtureRetainer( + source: source, + jobIDs: Set(jobs.map(\.id)) + )) } -// Direct timeline rendering helpers bypass the production CodexChatChange stream. -// Keep them for timeline projection tests; new selected-chat detail tests should -// use ReviewMonitorPreviewChatLogSource or CodexAppServerTestRuntime instead. @MainActor -@discardableResult -func renderTimelineForTesting( - _ job: CodexReviewJob, - in transport: ReviewMonitorTransportViewController, - restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, - allowIncrementalUpdate: Bool -) throws -> Bool { - let timelineDocument = ReviewTimelineDocumentRenderer().document(from: job.timeline) - let target = try timelineRenderTarget(for: job) - transport.bindLogRenderTargetForTesting(target) - let sourceDocument = TimelineLogProjectionStore.document( - from: timelineDocument, - transport: transport, - target: target - ) - return transport.renderLogDocumentForTesting( - sourceDocument, - target: target, - restoring: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate - ) +func reviewChatLogText(for job: CodexReviewJob) -> String { + ReviewChatLogFixtureStore.logText(for: job) } @MainActor -func awaitTimelineRenderForTesting( +func awaitChatRenderForTesting( _ job: CodexReviewJob, in transport: ReviewMonitorTransportViewController, - restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, allowIncrementalUpdate: Bool = true, timeout: Duration = .seconds(2), matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil ) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { - let expectedLog = reviewMonitorLogText(for: job) - _ = try renderTimelineForTesting( - job, - in: transport, - restoring: restorationTarget, - allowIncrementalUpdate: allowIncrementalUpdate - ) - return try await awaitTransportRender(transport, timeout: timeout) { snapshot in + _ = allowIncrementalUpdate + let expectedLog = reviewChatLogText(for: job) + let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = + .chat(chatIDForTesting(job).rawValue) + let clock = ContinuousClock() + let deadline = clock.now + timeout + repeat { + let state = transport.renderedStateForTesting + let matchesSnapshot: Bool if let predicate { - return predicate(snapshot) + matchesSnapshot = predicate(state.snapshot) + } else { + matchesSnapshot = reviewChatRenderedLogMatches(state.snapshot.log, expectedLog) } - return 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 @@ -291,12 +267,27 @@ func makePreviewMessageItemForTesting( ) } +@MainActor +func chatCommandOutputBlockIDForTesting( + _ job: CodexReviewJob, + itemID: String +) -> ReviewMonitorLog.BlockID { + ReviewMonitorLog.BlockID("commandOutput:\(job.previewTurnIDForTesting.rawValue):\(itemID)") +} + +@MainActor +private func makePreviewChatLogFixtureForTesting(job: CodexReviewJob) throws -> ReviewMonitorPreviewChatLogFixture { + try makePreviewChatLogFixtureForTesting(job: job) { _ in + ReviewChatLogFixtureStore.items(for: job) + } +} + @MainActor private func makePreviewChatLogFixtureForTesting( job: CodexReviewJob, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogFixture { - let chat = try #require(job.legacyReviewChatSelection) + let chat = try #require(job.reviewChatSelectionForTesting) let turn = CodexChatTurnStateSnapshot( id: job.previewTurnIDForTesting, status: CodexTurnStatus(reviewJobStateForTesting: job.core.lifecycle.status), @@ -322,54 +313,313 @@ private func makePreviewChatLogFixtureForTesting( } @MainActor -private func timelineRenderTarget(for job: CodexReviewJob) throws - -> ReviewMonitorTransportViewController.DisplayedSelectionForTesting -{ - let chatID = try #require(job.legacyReviewChatID) - return .chat(chatID.rawValue) +private enum ReviewChatLogFixtureStore { + private static var entriesByJobID: [String: [ReviewChatLogEntryForTesting]] = [:] + + static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for job: CodexReviewJob) { + entriesByJobID[job.id] = entries + } + + static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for job: CodexReviewJob) { + setEntries(entries, for: job) + updateRetainedPreviewSource(for: job) + } + + static func append(_ entry: ReviewChatLogEntryForTesting, to job: CodexReviewJob) { + entriesByJobID[job.id, default: []].append(entry) + updateRetainedPreviewSource(for: job) + } + + static func items(for job: CodexReviewJob) -> [CodexChatItemSnapshot] { + makeChatItems(from: entriesByJobID[job.id, default: []], turnID: job.previewTurnIDForTesting) + } + + static func logText(for job: CodexReviewJob) -> String { + var projection = ReviewMonitorSelectedCodexChatLogProjection() + var document: ReviewMonitorLog.Document? + let snapshot = CodexChatSnapshot( + chatID: chatIDForTesting(job), + phase: .loaded, + turns: [ + .init( + id: job.previewTurnIDForTesting, + status: CodexTurnStatus(reviewJobStateForTesting: job.core.lifecycle.status) + ) + ], + items: items(for: job) + ) + for change in CodexChatChange.previewChangesForTesting(from: nil, to: snapshot) { + document = projection.apply( + change, + chatCreatedAt: nil, + chatUpdatedAt: job.core.lifecycle.endedAt + )?.sourceDocument ?? document + } + guard let document else { + return "" + } + let displayDocument = ReviewMonitorCommandOutputDisplayDocument.make(from: document) + return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) + } + + private static func updateRetainedPreviewSource(for job: CodexReviewJob) { + for support in jobStorePreviewSupportRetainers where support.contains(jobID: job.id) { + support.upsert(job: job, items: items(for: job)) + } + } } @MainActor -private enum TimelineLogProjectionStore { - private struct Key: Hashable { - var transportID: ObjectIdentifier - var targetID: String +private final class ReviewChatLogFixtureRetainer { + let source: ReviewMonitorPreviewChatLogSource + private var jobIDs: Set + + init(source: ReviewMonitorPreviewChatLogSource, jobIDs: Set) { + self.source = source + self.jobIDs = jobIDs } - private static var projectionsByKey: [Key: ReviewMonitorTimelineLogProjection] = [:] + func contains(jobID: String) -> Bool { + jobIDs.contains(jobID) + } - static func document( - from timelineDocument: ReviewTimelineDocument, - transport: ReviewMonitorTransportViewController, - target: ReviewMonitorTransportViewController.DisplayedSelectionForTesting - ) -> ReviewMonitorLog.Document { - let key = Key(transportID: ObjectIdentifier(transport), targetID: target.projectionStoreID) - var projection = projectionsByKey[key] ?? ReviewMonitorTimelineLogProjection() - let document = projection.render(timelineDocument: timelineDocument) - projectionsByKey[key] = projection - return document + func upsert(job: CodexReviewJob, items: [CodexChatItemSnapshot]) { + guard let chatID = job.reviewChatIDForTesting else { + return + } + for item in items { + source.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + } } } -private extension ReviewMonitorTransportViewController.DisplayedSelectionForTesting { - var projectionStoreID: String { - switch self { - case .workspaceSection(let id): - "workspaceSection:\(id)" - case .workspace(let id): - "workspace:\(id)" - case .chat(let id): - "chat:\(id)" +@MainActor +private var jobStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] + +@MainActor +private func makeChatItems( + from entries: [ReviewChatLogEntryForTesting], + turnID: CodexTurnID +) -> [CodexChatItemSnapshot] { + 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 extension CodexReviewJob { +private struct ReviewChatLogAccumulatedItem { + var snapshot: CodexChatItemSnapshot + + init( + entry: ReviewChatLogEntryForTesting, + existing: ReviewChatLogAccumulatedItem?, + itemID: String, + turnID: CodexTurnID + ) { + let status = codexTurnStatus(for: entry) + switch entry.kind { + case .command: + snapshot = .init( + id: itemID, + turnID: turnID, + 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, + turnID: turnID, + 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, + turnID: turnID, + kind: .fileChange, + content: .fileChange( + .init( + path: entry.metadata?.title, + output: (existing?.plainText ?? "") + entry.text, + status: status + )) + ) + case .agentMessage: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: .agentMessage, + content: .message( + .init( + id: itemID, + role: .assistant, + text: (existing?.messageText ?? "") + entry.text + )) + ) + case .plan, .todoList: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: entry.kind == .todoList ? .init(rawValue: "todoList") : .plan, + content: .plan((existing?.plainText ?? "") + entry.text) + ) + case .reasoning, .rawReasoning: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: entry.kind == .rawReasoning ? .init(rawValue: "rawReasoning") : .reasoning, + content: .reasoning(.init(content: (existing?.reasoningText ?? "") + entry.text)) + ) + case .reasoningSummary: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: .init(rawValue: "reasoningSummary"), + content: .reasoning(.init(summary: (existing?.reasoningText ?? "") + entry.text)) + ) + case .contextCompaction: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: .contextCompaction, + content: .contextCompaction(entry.text) + ) + case .toolCall: + snapshot = .init( + id: itemID, + turnID: turnID, + kind: .mcpToolCall, + content: .toolCall(.init(result: (existing?.toolResult ?? "") + entry.text, status: status)) + ) + case .diagnostic, .error, .progress, .event: + snapshot = .init( + id: itemID, + turnID: turnID, + 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 + } +} + +extension CodexReviewJob { + var reviewChatIDForTesting: CodexThreadID? { + if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { + return CodexThreadID(rawValue: reviewThreadID) + } + if let threadID = nonEmptyReviewChatProjectionStringForTesting(core.run.threadID) { + return CodexThreadID(rawValue: threadID) + } + return nil + } + + var reviewChatSelectionForTesting: ReviewMonitorCodexSidebarSnapshot.Chat? { + guard let chatID = reviewChatIDForTesting else { + return nil + } + return ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(chatID), + id: chatID, + title: displayTitle, + preview: nonEmptyReviewChatProjectionStringForTesting(core.output.lastAgentMessage) + ?? nonEmptyReviewChatProjectionStringForTesting(core.output.summary), + workspaceCWD: cwd, + updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, + recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, + status: CodexThreadStatus(reviewJobStateForTesting: core.lifecycle.status) + ) + } + var previewTurnIDForTesting: CodexTurnID { core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") } } +private extension CodexThreadStatus { + init(reviewJobStateForTesting jobState: ReviewJobState) { + switch jobState { + case .queued, .running: + self = .active(activeFlags: []) + case .succeeded, .failed, .cancelled: + self = .idle + } + } +} + private extension CodexTurnStatus { init(reviewJobStateForTesting jobState: ReviewJobState) { switch jobState { @@ -396,134 +646,7 @@ private extension CodexDataPhase { } } -private func timelineKind(for entry: ReviewTimelineEntryForTesting) -> ReviewItemKind { - switch entry.kind { - case .command, .commandOutput: - .commandExecution - case .agentMessage: - .agentMessage - case .plan, .todoList: - .plan - case .reasoning, .reasoningSummary, .rawReasoning: - .reasoning - case .contextCompaction: - .contextCompaction - case .toolCall: - .mcpToolCall - case .diagnostic, .error, .progress, .event: - ReviewItemKind(rawValue: entry.kind.rawValue) - } -} - -private func timelineFamily(for entry: ReviewTimelineEntryForTesting) -> ReviewItemFamily { - switch entry.kind { - case .command, .commandOutput: - .command - case .agentMessage: - .message - case .plan, .todoList: - .plan - case .reasoning, .reasoningSummary, .rawReasoning: - .reasoning - case .contextCompaction: - .contextCompaction - case .toolCall: - .tool - case .diagnostic, .error, .progress, .event: - .diagnostic - } -} - -private func timelineContent( - for entry: ReviewTimelineEntryForTesting, - existing: ReviewTimelineItem.Content? -) -> ReviewTimelineItem.Content { - switch entry.kind { - case .command: - return .command( - .init( - command: commandText(for: entry), - cwd: entry.metadata?.cwd, - output: "", - exitCode: entry.metadata?.exitCode, - status: commandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) - case .commandOutput: - let existingOutput: String - let existingCommand: String? - if case .command(let command) = existing { - existingOutput = command.output - existingCommand = command.command - } else { - existingOutput = "" - existingCommand = nil - } - return .command( - .init( - command: entry.metadata?.command ?? existingCommand ?? "Command", - cwd: entry.metadata?.cwd, - output: existingOutput + entry.text, - exitCode: entry.metadata?.exitCode, - status: commandStatus(for: entry), - durationMs: entry.metadata?.durationMs - )) - case .agentMessage: - return .message(.init(text: existingText(existing, message: "") + entry.text)) - case .plan, .todoList: - return .plan(.init(markdown: existingText(existing, plan: "") + entry.text)) - case .reasoning: - return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .raw)) - case .reasoningSummary: - return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .summary)) - case .rawReasoning: - return .reasoning(.init(text: existingText(existing, reasoning: "") + entry.text, style: .raw)) - case .contextCompaction: - return .contextCompaction( - .init( - title: entry.text, - status: contextCompactionStatus(for: entry) - )) - case .toolCall: - return .toolCall( - .init( - result: entry.text, - status: toolCallStatus(for: entry) - )) - case .diagnostic, .error, .progress, .event: - return .diagnostic(.init(message: existingText(existing, diagnostic: "") + entry.text)) - } -} - -private func existingText(_ content: ReviewTimelineItem.Content?, message defaultValue: String) -> String { - if case .message(let message) = content { - return message.text - } - return defaultValue -} - -private func existingText(_ content: ReviewTimelineItem.Content?, plan defaultValue: String) -> String { - if case .plan(let plan) = content { - return plan.markdown - } - return defaultValue -} - -private func existingText(_ content: ReviewTimelineItem.Content?, reasoning defaultValue: String) -> String { - if case .reasoning(let reasoning) = content { - return reasoning.text - } - return defaultValue -} - -private func existingText(_ content: ReviewTimelineItem.Content?, diagnostic defaultValue: String) -> String { - if case .diagnostic(let diagnostic) = content { - return diagnostic.message - } - return defaultValue -} - -private func commandText(for entry: ReviewTimelineEntryForTesting) -> String { +private func chatLogCommandText(for entry: ReviewChatLogEntryForTesting) -> String { if let command = entry.metadata?.command, command.isEmpty == false { return command } @@ -533,62 +656,90 @@ private func commandText(for entry: ReviewTimelineEntryForTesting) -> String { return entry.text } -private func timelinePhase(for entry: ReviewTimelineEntryForTesting) -> ReviewItemPhase { - let status = entry.metadata?.commandStatus ?? entry.metadata?.status - let normalizedStatus = ReviewItemPhase.normalized(status) - if normalizedStatus.isTerminal { - return normalizedStatus - } - switch status { - case "inProgress", "running", "started": - return .running - case "failed": - return .failed - case "cancelled": - return .cancelled - default: - return entry.kind == .command ? .running : .completed +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) } -private func commandStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewCommandStatus? { - guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { +private func nonEmptyReviewChatProjectionStringForTesting(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { return nil } - switch rawValue { - case "succeeded", "success": - return .completed - default: - return .init(rawValue: rawValue) - } + return value } -private func contextCompactionStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewContextCompactionStatus? { - guard let rawValue = entry.metadata?.status else { - return nil - } - switch rawValue { - case "inProgress", "running": - return .inProgress - case "completed", "succeeded", "success": - return .completed - default: - return .init(rawValue: rawValue) +private extension CodexChatChange { + static func previewChangesForTesting(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { + guard let previous else { + return [.snapshot(current)] + } + + var changes: [Self] = [] + let previousTurnsByID = Dictionary(uniqueKeysWithValues: previous.turns.map { ($0.id, $0) }) + for turn in current.turns where previousTurnsByID[turn.id] != turn { + changes.append(.turnUpdated(turn)) + } + + let previousItemsByID = Dictionary(uniqueKeysWithValues: previous.items.map { ($0.id, $0) }) + let currentItemIDs = Set(current.items.map(\.id)) + for removedItem in previous.items where currentItemIDs.contains(removedItem.id) == false { + changes.append(.itemRemoved(id: removedItem.id, turnID: removedItem.turnID)) + } + for item in current.items { + guard let previousItem = previousItemsByID[item.id] else { + changes.append(.itemInserted(item)) + continue + } + guard previousItem != item else { + continue + } + if let delta = previousItem.textDeltaForTesting(to: item) { + changes.append(.itemTextAppended(id: item.id, turnID: item.turnID, delta: delta, item: item)) + } else { + changes.append(.itemUpdated(item)) + } + } + + if previous.phase != current.phase { + changes.append(.phaseChanged(current.phase)) + } + return changes } } -private func toolCallStatus(for entry: ReviewTimelineEntryForTesting) -> ReviewToolCallStatus? { - guard let rawValue = entry.metadata?.status else { - return nil - } - switch rawValue { - case "inProgress", "running": - return .inProgress - case "completed", "succeeded", "success": - return .completed - case "failed": - return .failed - default: - return .init(rawValue: rawValue) +private extension CodexChatItemSnapshot { + func textDeltaForTesting(to item: CodexChatItemSnapshot) -> String? { + guard + turnID == item.turnID, + kind == item.kind, + sameContentShapeForTesting(as: 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 sameContentShapeForTesting(as item: CodexChatItemSnapshot) -> 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/ReviewTimelineDocumentForTesting.swift b/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift deleted file mode 100644 index 42b9dca9..00000000 --- a/Tests/ReviewUITests/ReviewTimelineDocumentForTesting.swift +++ /dev/null @@ -1,437 +0,0 @@ -import Foundation -import CodexReviewKit - -// Test-only bridge for legacy ReviewTimeline fixtures. Production detail rendering -// uses CodexChatChange streams through ReviewMonitorSelectedCodexChat. -struct ReviewTimelineDocument: Codable, Equatable, Sendable { - struct Block: Identifiable, Codable, Equatable, Sendable { - struct ID: RawRepresentable, Codable, Hashable, Sendable, ExpressibleByStringLiteral, CustomStringConvertible { - var rawValue: String - - init(rawValue: String) { - self.rawValue = rawValue - } - - init(stringLiteral value: String) { - self.init(rawValue: value) - } - - init(itemID: ReviewTimelineItem.ID) { - self.init(rawValue: itemID.rawValue) - } - - var description: String { - rawValue - } - } - - var id: ID - var sourceItemID: ReviewTimelineItem.ID - var kind: ReviewItemKind - var family: ReviewItemFamily - var phase: ReviewItemPhase - var isActive: Bool - var primaryText: String - var rawTranscriptText: String - var content: Content - var createdAt: Date - var updatedAt: Date - var startedAt: Date? - var completedAt: Date? - var durationMs: Int? - - 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 - } - } - - 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) - - 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" - } - } - } - - struct Approval: Codable, Equatable, Sendable { - var title: String - var detail: String? - var decision: ReviewApprovalDecision? - var scope: ReviewApprovalScope? - var risk: ReviewApprovalRisk? - var status: ReviewApprovalStatus? - - 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 - } - } - - struct Command: Codable, Equatable, Sendable { - struct Action: Codable, Equatable, Sendable { - var kind: ReviewCommandActionKind - var command: String? - var name: String? - var path: String? - var query: String? - - 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 - } - } - - var title: String - var command: String - var cwd: String? - var output: String - var exitCode: Int? - var status: ReviewCommandStatus? - var source: ReviewCommandSource? - var processID: String? - var actions: [Action] - var durationMs: Int? - - 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 - } - } - - struct ContextCompaction: Codable, Equatable, Sendable { - var title: String - var status: ReviewContextCompactionStatus? - var inputTokens: Int? - var outputTokens: Int? - - init( - title: String, - status: ReviewContextCompactionStatus? = nil, - inputTokens: Int? = nil, - outputTokens: Int? = nil - ) { - self.title = title - self.status = status - self.inputTokens = inputTokens - self.outputTokens = outputTokens - } - } - - struct Diagnostic: Codable, Equatable, Sendable { - struct Retry: Codable, Equatable, Sendable { - var state: ReviewDiagnosticRetryState - var attempt: Int? - var maxAttempts: Int? - var delayMs: Int? - - init( - state: ReviewDiagnosticRetryState, - attempt: Int? = nil, - maxAttempts: Int? = nil, - delayMs: Int? = nil - ) { - self.state = state - self.attempt = attempt - self.maxAttempts = maxAttempts - self.delayMs = delayMs - } - } - - var message: String - var severity: ReviewDiagnosticSeverity? - var retry: Retry? - - init( - message: String, - severity: ReviewDiagnosticSeverity? = nil, - retry: Retry? = nil - ) { - self.message = message - self.severity = severity - self.retry = retry - } - } - - struct FileChange: Codable, Equatable, Sendable { - var title: String - var output: String - var paths: [String] - var patch: String? - var status: ReviewFileChangeStatus? - - 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 - } - } - - struct Message: Codable, Equatable, Sendable { - var text: String - - init(text: String) { - self.text = text - } - } - - struct Plan: Codable, Equatable, Sendable { - var markdown: String - - init(markdown: String) { - self.markdown = markdown - } - } - - struct Reasoning: Codable, Equatable, Sendable { - var text: String - var style: ReviewTimelineItem.Reasoning.Style - - init(text: String, style: ReviewTimelineItem.Reasoning.Style) { - self.text = text - self.style = style - } - } - - struct Search: Codable, Equatable, Sendable { - var query: String - var result: String? - var status: ReviewSearchStatus? - var resultCount: Int? - var durationMs: Int? - - 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 - } - } - - struct ToolCall: Codable, Equatable, Sendable { - var namespace: String? - var server: String? - var name: String? - var arguments: String? - var result: String? - var error: String? - var status: ReviewToolCallStatus? - var durationMs: Int? - var appContext: ReviewAppContext? - var pluginID: ReviewPluginID? - var callID: ReviewToolCall.ID? - var progress: String? - - 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 - } - } - - struct Unknown: Codable, Equatable, Sendable { - struct RawReference: Codable, Equatable, Sendable { - var kind: ReviewRawReferenceKind - var value: String - var label: String? - - init(kind: ReviewRawReferenceKind, value: String, label: String? = nil) { - self.kind = kind - self.value = value - self.label = label - } - } - - var title: String - var detail: String? - var rawKind: ReviewItemKind? - var rawStatus: String? - var references: [RawReference] - - 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 - } - } - - var timelineRevision: ReviewTimeline.Revision - var orderedBlockIDs: [Block.ID] - var activeBlockIDs: [Block.ID] - var activeBlockCount: Int - var latestActivityBlockID: Block.ID? - var terminalStatus: ReviewLifecycleStatus? - var terminalSummary: String? - var terminalResult: String? - var blocks: [Block] - - 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 - } - - var plainText: String { - blocks.map(\.rawTranscriptText).filter { $0.isEmpty == false }.joined(separator: "\n\n") - } -} diff --git a/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift b/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift deleted file mode 100644 index ded05b4d..00000000 --- a/Tests/ReviewUITests/ReviewTimelineDocumentRendererForTesting.swift +++ /dev/null @@ -1,238 +0,0 @@ -import Foundation -import CodexReviewKit - -// Kept in ReviewUITests while legacy job timeline fixtures still seed UI tests. -// Runtime UI code should not depend on this renderer. -struct ReviewTimelineDocumentRenderer: Sendable { - init() {} - - @MainActor - 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 - 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/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index b3996219..c6cd6090 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -50,6 +50,8 @@ extension ReviewUITests { let store = ReviewMonitorPreviewContent.makeStore() let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) let selectedChat = try #require(previewChatLogSource.initialChat) + let selectedSnapshot = try #require(previewChatLogSource.snapshotForTesting(chatID: selectedChat.id)) + let expectedLogText = try #require(selectedSnapshot.items.compactMap(\.text).first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -59,7 +61,7 @@ extension ReviewUITests { let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting let snapshot = try await awaitTransportRender(transport) { snapshot in - snapshot.log.contains(selectedChat.title) && snapshot.isShowingEmptyState == false + snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) @@ -789,6 +791,8 @@ extension ReviewUITests { let store = ReviewMonitorPreviewContent.makeStore() let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) let selectedChat = try #require(previewChatLogSource.initialChat) + let selectedSnapshot = try #require(previewChatLogSource.snapshotForTesting(chatID: selectedChat.id)) + let expectedLogText = try #require(selectedSnapshot.items.compactMap(\.text).first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -800,7 +804,7 @@ extension ReviewUITests { let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting let snapshot = try await awaitTransportRender(transport) { snapshot in - snapshot.log.contains(selectedChat.title) && snapshot.isShowingEmptyState == false + snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) @@ -1705,14 +1709,6 @@ extension ReviewUITests { } -@MainActor -private func diagnosticMessage(_ item: ReviewTimelineItem) -> String { - if case .diagnostic(let diagnostic) = item.content { - return diagnostic.message - } - return "" -} - @MainActor private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { if case .diagnostic(let message) = item.content { @@ -1721,14 +1717,6 @@ private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { return "" } -@MainActor -private func reasoningText(_ item: ReviewTimelineItem) -> String { - if case .reasoning(let reasoning) = item.content { - return reasoning.text - } - return "" -} - @MainActor private func reasoningText(_ item: CodexChatItemSnapshot) -> String { if case .reasoning(let reasoning) = item.content { @@ -1737,14 +1725,6 @@ private func reasoningText(_ item: CodexChatItemSnapshot) -> String { return "" } -@MainActor -private func contextCompactionTitle(_ item: ReviewTimelineItem) -> String { - if case .contextCompaction(let contextCompaction) = item.content { - return contextCompaction.title - } - return "" -} - @MainActor private func contextCompactionTitle(_ item: CodexChatItemSnapshot) -> String { if case .contextCompaction(let title) = item.content { @@ -1753,14 +1733,6 @@ private func contextCompactionTitle(_ item: CodexChatItemSnapshot) -> String { return "" } -@MainActor -private func contextCompactionStatus(_ item: ReviewTimelineItem) -> ReviewContextCompactionStatus? { - if case .contextCompaction(let contextCompaction) = item.content { - return contextCompaction.status - } - return nil -} - @MainActor private func renderDetailLogForShellLayoutTesting( _ text: String, @@ -1769,7 +1741,7 @@ private func renderDetailLogForShellLayoutTesting( job: CodexReviewJob ) async throws { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let chatID = try #require(job.legacyReviewChatID) + let chatID = try #require(job.reviewChatIDForTesting) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { transport.renderedStateForTesting.selection == expectedSelection diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 1cf7eb93..ae100b19 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -23,7 +23,7 @@ private extension CodexReviewAuthModel { @MainActor func chatIDForTesting(_ job: CodexReviewJob) -> CodexThreadID { - guard let chatID = job.legacyReviewChatID else { + guard let chatID = job.reviewChatIDForTesting else { Issue.record("Expected review job \(job.id) to have a chat id.") return CodexThreadID(rawValue: job.id) } @@ -856,10 +856,10 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(recentJob.legacyReviewChatSelection) + chat: try #require(recentJob.reviewChatSelectionForTesting) ) - let selectedSnapshot = try await awaitTimelineRenderForTesting( + let selectedSnapshot = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false @@ -869,7 +869,7 @@ struct ReviewUITests { == .init( title: nil, summary: nil, - log: reviewMonitorLogText(for: recentJob), + log: reviewChatLogText(for: recentJob), isShowingEmptyState: false ) ) @@ -885,11 +885,11 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) activeJob.updateStateForTesting(summary: "Old selection should not render.") - replaceTimelineLogTextForTesting(activeJob, "Old selection log") - appendTimelineEntryForTesting( + replaceChatLogTextForTesting(activeJob, "Old selection log") + appendChatLogEntryForTesting( recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) - let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: transport) { snapshot in + let updatedSnapshot = try await awaitChatRenderForTesting(recentJob, in: transport) { snapshot in snapshot.log.contains("Current selection log after stale mutation") } #expect(updatedSnapshot.log.contains("Old selection log") == false) @@ -909,7 +909,7 @@ struct ReviewUITests { summary: "Review completed.", hasFinalReview: true, lastAgentMessage: "No correctness issues found.", - timelineEntries: [ + chatEntries: [ .init( kind: .command, groupID: "cmd_1", @@ -948,10 +948,10 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(job.legacyReviewChatSelection) + chat: try #require(job.reviewChatSelectionForTesting) ) - let selectedSnapshot = try await awaitTimelineRenderForTesting( + let selectedSnapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -974,9 +974,9 @@ struct ReviewUITests { #expect(transport.logCommandOutputPanelUsesTextKit2ForTesting == false) } - @Test func detailPaneRendersDirectTimelineUpdatesWithoutLogEntryChanges() async throws { + @Test func detailPaneRendersSelectedChatStreamUpdates() async throws { let job = CodexReviewJob.makeForTesting( - id: "job-direct-timeline-detail", + id: "job-selected-chat-stream-detail", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", threadID: UUID().uuidString, @@ -984,7 +984,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [] + chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -1000,56 +1000,46 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, 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 awaitTimelineRenderForTesting(job, in: transport) - #expect(snapshot.log == "Timeline-only detail update") - - 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 - ))) + appendChatLogEntryForTesting( + job, + .init(kind: .agentMessage, groupID: "message-direct", text: "Selected chat detail update")) + + var snapshot = try await awaitChatRenderForTesting(job, in: transport) + #expect(snapshot.log == "Selected chat detail update") - snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { - $0.log.contains("Ran swift test for 2s") + appendChatLogEntryForTesting( + job, + .init( + kind: .command, + groupID: "cmd-direct", + text: "$ swift test", + metadata: .init(command: "swift test", commandStatus: "completed") + )) + appendChatLogEntryForTesting( + job, + .init( + kind: .commandOutput, + groupID: "cmd-direct", + text: "Tests passed", + metadata: .init(command: "swift test", exitCode: 0, commandStatus: "completed") + )) + + snapshot = try await awaitChatRenderForTesting(job, 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(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect( @@ -1060,9 +1050,9 @@ struct ReviewUITests { == true) } - @Test func directTimelineFailedCommandPreservesFailedPanelStatus() async throws { + @Test func selectedChatFailedCommandPreservesFailedPanelStatus() async throws { let job = CodexReviewJob.makeForTesting( - id: "job-direct-timeline-failed-command", + id: "job-selected-chat-failed-command", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", threadID: UUID().uuidString, @@ -1070,7 +1060,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [] + chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -1086,37 +1076,28 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - 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 - ))) + appendChatLogEntryForTesting( + job, + .init( + kind: .commandOutput, + groupID: "cmd-failed-direct", + text: "Tests failed", + metadata: .init(command: "swift test", commandStatus: "failed") + )) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { - $0.log.contains("Ran swift test for 4s") + let snapshot = try await awaitChatRenderForTesting(job, 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(job, itemID: "cmd-failed-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Failed") @@ -1125,9 +1106,9 @@ struct ReviewUITests { == true) } - @Test func directTimelineRunningCommandOutputStaysActive() async throws { + @Test func selectedChatRunningCommandOutputStaysActive() async throws { let job = CodexReviewJob.makeForTesting( - id: "job-direct-timeline-running-command", + id: "job-selected-chat-running-command", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", threadID: UUID().uuidString, @@ -1135,7 +1116,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [] + chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -1151,28 +1132,22 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - 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 awaitTimelineRenderForTesting(job, in: transport) { + appendChatLogEntryForTesting( + job, + .init( + kind: .commandOutput, + groupID: "cmd-running-direct", + text: "Building...", + metadata: .init(command: "swift test", commandStatus: "running") + )) + + let snapshot = try await awaitChatRenderForTesting(job, in: transport) { $0.log.contains("Running swift test") } #expect(snapshot.log.contains("Running swift test")) @@ -1180,7 +1155,7 @@ struct ReviewUITests { #expect(snapshot.log.contains("Building...") == false) #expect(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd-running-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd-running-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "running") @@ -1189,9 +1164,9 @@ struct ReviewUITests { == true) } - @Test func directTimelineTerminalPhaseOverridesStaleCommandStatus() async throws { + @Test func selectedChatFileChangePreservesPanelTitle() async throws { let job = CodexReviewJob.makeForTesting( - id: "job-direct-timeline-stale-command-status", + id: "job-selected-chat-file-change", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", threadID: UUID().uuidString, @@ -1199,7 +1174,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [] + chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( @@ -1215,375 +1190,25 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - 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 awaitTimelineRenderForTesting(job, in: 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) - ) - ] - ) - - 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) - ) - ] - ) - - 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(metadata.status == "completed") - #expect(metadata.commandStatus == "completed") - } - - @Test func directTimelineFileChangePreservesPanelTitle() async throws { - let job = CodexReviewJob.makeForTesting( - id: "job-direct-timeline-file-change", - cwd: "/tmp/workspace-alpha", - targetSummary: "Uncommitted changes", - threadID: UUID().uuidString, - turnID: UUID().uuidString, - status: .running, - startedAt: Date(timeIntervalSince1970: 200), - summary: "Running review.", - timelineEntries: [] - ) - 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.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + appendChatLogEntryForTesting( job, - in: transport, - allowIncrementalUpdate: false - ) + .init( + kind: .fileChange, + groupID: "file-change-direct", + text: "Sources/App.swift | 12 ++++++------", + metadata: .init( + title: "Updated Sources/App.swift", + commandStatus: "completed" + ) + )) - 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 - )) - ))) - - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) { + let snapshot = try await awaitChatRenderForTesting(job, in: transport) { $0.log.contains("Updated Sources/App.swift") } #expect(snapshot.log.contains("Updated Sources/App.swift")) @@ -1591,7 +1216,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(job, itemID: "file-change-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Success") @@ -1611,7 +1236,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init( kind: .contextCompaction, groupID: "compact_1", @@ -1640,7 +1265,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -1649,7 +1274,7 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("Automatically compacting context")) #expect(transport.logCommandOutputPanelCountForTesting == 0) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .contextCompaction, @@ -1662,7 +1287,7 @@ struct ReviewUITests { itemID: "compact_1" ) )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.displayedLogForTesting == "Context automatically compacted") #expect(transport.displayedLogForTesting.contains("Automatically compacting context") == false) @@ -1674,7 +1299,7 @@ struct ReviewUITests { let outputText = (1...9) .map { "output line \($0)" } .joined(separator: "\n") - let commandMetadata = ReviewTimelineEntryForTesting.Metadata( + let commandMetadata = ReviewChatLogEntryForTesting.Metadata( sourceType: "command", title: "Ran command for 17s", status: "succeeded", @@ -1691,7 +1316,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( kind: .commandOutput, @@ -1717,7 +1342,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -1779,7 +1404,7 @@ struct ReviewUITests { transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) #expect(scrolledOutputScrollOffset < initialOutputScrollMaximumOffset) let expandedOutputAppendReloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .commandOutput, @@ -1787,7 +1412,7 @@ struct ReviewUITests { text: "\noutput line 10", metadata: commandMetadata )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logReloadCountForTesting == expandedOutputAppendReloadCount) let offsetAfterOutputAppend = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) @@ -1815,7 +1440,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("output line 9") == false) #expect(transport.logFindStringForTesting.contains("output line 9") == false) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .commandOutput, @@ -1823,8 +1448,8 @@ struct ReviewUITests { text: "\noutput line 11", metadata: commandMetadata )) - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) + _ = try await awaitChatRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelTerminalTextForTesting?.contains("output line 11") == true) #expect(transport.logFindStringForTesting.contains("output line 11") == false) @@ -1847,7 +1472,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( kind: .commandOutput, @@ -1879,14 +1504,14 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let firstBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_1") - let secondBlockID = ReviewMonitorLog.BlockID("commandOutput:cmd_2") + let firstBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd_1") + let secondBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd_2") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: firstBlockID)) await awaitNativeLayoutTurn() @@ -1915,7 +1540,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test") ] ) @@ -1934,7 +1559,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -1943,7 +1568,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("Running swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .commandOutput, @@ -1958,7 +1583,7 @@ struct ReviewUITests { commandStatus: "completed" ) )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logCommandOutputPanelCountForTesting == 1) #expect(transport.displayedLogForTesting.contains("Ran swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) @@ -1978,7 +1603,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init(kind: .commandOutput, groupID: "cmd_1", text: outputText), ] @@ -1997,7 +1622,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2006,10 +1631,10 @@ struct ReviewUITests { try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) #expect(transport.logFindBarVisibleForTesting) - #expect(transport.setLogVisibleFindBarSearchStringForTesting("Ran swift test")) + #expect(transport.setLogVisibleFindBarSearchStringForTesting("Running swift test")) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Ran swift test")) + #expect(transport.logFindStringForTesting.contains("Running swift test")) #expect(transport.logFindStringForTesting.contains("$ swift test") == false) #expect(transport.logFindStringForTesting.contains("output line 3") == false) @@ -2018,18 +1643,18 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Ran swift test")) + #expect(transport.logFindStringForTesting.contains("Running swift test")) #expect(transport.logFindStringForTesting.contains("$ swift test") == false) #expect(transport.logFindStringForTesting.contains("output line 3") == false) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) await awaitNativeLayoutTurn() #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringForTesting.contains("Ran swift test")) + #expect(transport.logFindStringForTesting.contains("Running swift test")) #expect(transport.logFindStringForTesting.contains("output line 6") == false) } } @@ -2060,10 +1685,10 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(activeJob.legacyReviewChatSelection) + chat: try #require(activeJob.reviewChatSelectionForTesting) ) - let activeSnapshot = try await awaitTimelineRenderForTesting( + let activeSnapshot = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false @@ -2073,10 +1698,10 @@ struct ReviewUITests { #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(recentJob.legacyReviewChatSelection) + chat: try #require(recentJob.reviewChatSelectionForTesting) ) - let recentSnapshot = try await awaitTimelineRenderForTesting( + let recentSnapshot = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false @@ -2086,7 +1711,7 @@ struct ReviewUITests { == .init( title: nil, summary: nil, - log: reviewMonitorLogText(for: recentJob), + log: reviewChatLogText(for: recentJob), isShowingEmptyState: false ) ) @@ -2113,14 +1738,14 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) transport.view.layoutSubtreeIfNeeded() - #expect(transport.isLogPinnedToBottomForTesting) + try await waitForLogPinnedToBottom(in: transport) } @Test func switchingSelectedReviewChatStartsUnvisitedReviewChatAtBottomAndRestoresPreviousOffset() async throws { @@ -2154,7 +1779,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false @@ -2165,7 +1790,7 @@ struct ReviewUITests { #expect(activeOffset > 0) #expect(transport.isLogPinnedToBottomForTesting == false) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false @@ -2173,7 +1798,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false @@ -2214,7 +1839,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false @@ -2223,7 +1848,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false @@ -2231,9 +1856,9 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) - appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) + appendChatLogEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - let snapshot = try await awaitTimelineRenderForTesting( + let snapshot = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false @@ -2263,7 +1888,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2282,7 +1907,7 @@ struct ReviewUITests { ) store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [replacement])) - #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: replacement)) + #expect(transport.displayedLogForTesting == reviewChatLogText(for: replacement)) #expect(transport.logVerticalScrollOffsetForTesting == preservedOffset) } @@ -2313,7 +1938,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false @@ -2322,13 +1947,13 @@ struct ReviewUITests { transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false ) - #expect(transport.isLogPinnedToBottomForTesting) + try await waitForLogPinnedToBottom(in: transport) } @Test func switchingFromShortToLongJobMaterializesVisibleTextKit2Fragments() async throws { @@ -2358,25 +1983,25 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(longJob)) - let longSnapshot = try await awaitTimelineRenderForTesting( + let longSnapshot = try await awaitChatRenderForTesting( longJob, in: transport, allowIncrementalUpdate: false ) - #expect(longSnapshot.log == reviewMonitorLogText(for: longJob)) + #expect(longSnapshot.log == reviewChatLogText(for: longJob)) #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( @@ -2404,33 +2029,28 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false ) expectLogVisibleFragmentsWithoutForcingLayout(transport) - replaceTimelineLogTextForTesting(shortJob, longLog) + replaceChatLogTextForTesting(shortJob, longLog) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - #expect( - abs( - transport.logVerticalScrollOffsetForTesting - - transport.logMinimumVerticalScrollOffsetForTesting - ) < 0.5) - #expect(transport.isLogPinnedToBottomForTesting == false) + #expect(transport.isLogPinnedToBottomForTesting) expectLogVisibleFragmentsWithoutForcingLayout(transport) } @@ -2460,21 +2080,21 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( recentJob, in: transport, allowIncrementalUpdate: false ) - appendTimelineEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) - appendTimelineEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) + appendChatLogEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) + appendChatLogEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) - let updatedSnapshot = try await awaitTimelineRenderForTesting(recentJob, in: transport) { snapshot in + let updatedSnapshot = try await awaitChatRenderForTesting(recentJob, in: transport) { snapshot in snapshot.log.contains("fresh update") } #expect(updatedSnapshot.log.contains("stale update") == false) @@ -2504,14 +2124,14 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let selectedSnapshot = try await awaitTimelineRenderForTesting( + let selectedSnapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.legacyReviewChatID) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatIDForTesting) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @@ -2541,7 +2161,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2666,10 +2286,10 @@ struct ReviewUITests { let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(job.legacyReviewChatSelection) + chat: try #require(job.reviewChatSelectionForTesting) ) - let selectedSnapshot = try await awaitTimelineRenderForTesting( + let selectedSnapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2687,7 +2307,7 @@ struct ReviewUITests { #expect(window.title == "") #expect(window.subtitle == "") job.updateStateForTesting(summary: "Deselected summary") - replaceTimelineLogTextForTesting(job, "Deselected log") + replaceChatLogTextForTesting(job, "Deselected log") #expect(contentPane.selectedChatLogTaskForTesting == nil) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) @@ -2712,7 +2332,7 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let selectedSnapshot = try await awaitTimelineRenderForTesting( + let selectedSnapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2723,12 +2343,12 @@ struct ReviewUITests { status: .succeeded, summary: "Review completed successfully." ) - replaceTimelineLogTextForTesting(job, "Updated log") + replaceChatLogTextForTesting(job, "Updated log") - let updatedSnapshot = try await awaitTimelineRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.legacyReviewChatID) + let updatedSnapshot = try await awaitChatRenderForTesting(job, in: transport) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatIDForTesting) #expect(updatedSnapshot.summary == nil) - #expect(updatedSnapshot.log == reviewMonitorLogText(for: job)) + #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: job))) } @Test func selectedReviewChatLogAppendUsesAppendPath() async throws { @@ -2758,7 +2378,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.legacyReviewChatID) + let chatID = try #require(job.reviewChatIDForTesting) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) @@ -2806,7 +2426,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.legacyReviewChatID) + let chatID = try #require(job.reviewChatIDForTesting) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting @@ -2834,7 +2454,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: decomposedPrefix) ] ) @@ -2845,7 +2465,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2870,7 +2490,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -2881,15 +2501,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) + appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) + appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log == "Initial one two") } @@ -2903,7 +2523,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) @@ -2914,15 +2534,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) - appendTimelineEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) + appendChatLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) + appendChatLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log.hasSuffix("stream.tick 002")) } @@ -2936,7 +2556,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -2947,15 +2567,15 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) let wordGlowCount = transport.logWordGlowCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + appendChatLogEntryForTesting( job, .init( kind: .progress, @@ -2963,7 +2583,7 @@ struct ReviewUITests { text: String(repeating: "progress ", count: 20) )) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log.contains("progress progress")) #expect(transport.logAppendCountForTesting > 0) #expect(transport.logWordGlowCountForTesting == wordGlowCount + 1) @@ -2985,7 +2605,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -2997,14 +2617,14 @@ struct ReviewUITests { #expect( abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .progress, text: "stream.tick 001 delta/layout +2 -0 while the short log remains below the scrollable viewport height" )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) transport.view.layoutSubtreeIfNeeded() let appendedDocumentFrame = transport.logDocumentViewFrameForTesting @@ -3024,7 +2644,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) @@ -3035,7 +2655,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3043,10 +2663,10 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log == "- updated") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) @@ -3064,7 +2684,7 @@ struct ReviewUITests { status: .running, startedAt: startedAt, summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init( kind: .rawReasoning, groupID: "reasoning_1", @@ -3080,7 +2700,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3088,7 +2708,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .command, @@ -3103,7 +2723,7 @@ struct ReviewUITests { commandStatus: "inProgress" ) )) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .rawReasoning, @@ -3111,7 +2731,7 @@ struct ReviewUITests { text: "Inspecting details after the command starts." )) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log.contains("Need to inspect files.")) #expect(snapshot.log.contains("Running git diff")) #expect(snapshot.log.contains("Inspecting details after the command starts.")) @@ -3129,7 +2749,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "**bo") ] ) @@ -3140,7 +2760,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3148,9 +2768,9 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) + appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) - let snapshot = try await awaitTimelineRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(job, in: transport) #expect(snapshot.log == "bold") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) @@ -3167,7 +2787,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) @@ -3178,12 +2798,12 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .plan, @@ -3191,17 +2811,17 @@ struct ReviewUITests { replacesGroup: true, text: "- updated with longer replacement text" )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) { snapshot in - snapshot.log.contains("Command output") + appendChatLogEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) + _ = try await awaitChatRenderForTesting(job, in: transport) { snapshot in + snapshot.log.contains("Running Command") } #expect(transport.displayedLogForTesting.contains("- updated with longer replacement text")) - #expect(transport.displayedLogForTesting.contains("Command output")) + #expect(transport.displayedLogForTesting.contains("Running Command")) #expect(transport.displayedLogForTesting.contains("Command output - 1 line") == false) #expect(transport.displayedLogForTesting.contains("hidden output") == false) #expect(transport.logAppendCountForTesting == appendCount + 1) @@ -3224,7 +2844,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3233,7 +2853,7 @@ struct ReviewUITests { let reloadCount = transport.logReloadCountForTesting job.updateStateForTesting(summary: "Updated summary.") - #expect(transport.displayedLogForTesting == reviewMonitorLogText(for: job)) + #expect(transport.displayedLogForTesting == reviewChatLogText(for: job)) #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReloadCountForTesting == reloadCount) } @@ -3248,7 +2868,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -3259,30 +2879,30 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 2) transport.completeLogWordGlowAnimationsForTesting() #expect(transport.logWordGlowCountForTesting == 0) - appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 1) transport.setLogReduceMotionForTesting(true) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting == 0) } @@ -3297,7 +2917,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -3310,7 +2930,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 201), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Other job") ] ) @@ -3323,30 +2943,30 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false ) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) #expect(transport.logWordGlowCountForTesting == 0) - appendTimelineEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) - _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) + appendChatLogEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) + _ = try await awaitChatRenderForTesting(firstJob, in: transport) #expect(transport.logWordGlowCountForTesting > 0) } @@ -3360,7 +2980,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -3372,7 +2992,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3380,9 +3000,9 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting > 0) #expect(transport.logWordFadeRenderingAttributeRangeCountForTesting > 0) @@ -3406,7 +3026,7 @@ struct ReviewUITests { status: .running, startedAt: Date(timeIntervalSince1970: 200), summary: "Running review.", - timelineEntries: [ + chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) @@ -3418,15 +3038,15 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) - appendTimelineEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logWordGlowCountForTesting > 0) transport.advanceLogWordGlowAnimationsAfterInitialDelayForTesting(5) @@ -3453,7 +3073,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3466,16 +3086,16 @@ struct ReviewUITests { transport.scrollLogToTopForTesting() #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == unpinnedAutoFollow) #expect(transport.isLogPinnedToBottomForTesting == false) transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) let pinnedAutoFollow = transport.logAutoFollowCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) } @@ -3497,7 +3117,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3509,8 +3129,8 @@ struct ReviewUITests { let wrappedLine = (0..<140) .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") - appendTimelineEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == pinnedAutoFollow + 1) #expect(transport.isLogPinnedToBottomForTesting) @@ -3540,7 +3160,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3552,13 +3172,13 @@ struct ReviewUITests { let offsetBeforeAppend = transport.logVerticalScrollOffsetForTesting let autoFollowBeforeAppend = transport.logAutoFollowCountForTesting let programmaticScrollsBeforeAppend = transport.logProgrammaticScrollCountForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init( kind: .progress, text: "Near-bottom append should not snap inertial or manual scrolling to the document end" )) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logAutoFollowCountForTesting == autoFollowBeforeAppend) #expect(transport.logProgrammaticScrollCountForTesting == programmaticScrollsBeforeAppend) @@ -3586,7 +3206,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3596,8 +3216,8 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.isLogPinnedToBottomForTesting) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend + 1) @@ -3623,7 +3243,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3633,8 +3253,8 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -3658,7 +3278,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3667,8 +3287,8 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "short update")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "short update")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -3687,7 +3307,7 @@ struct ReviewUITests { 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])) @@ -3700,7 +3320,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false @@ -3710,22 +3330,25 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false ) let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting + transport.setLogOverlayScrollersShownForTesting(true) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, - restoring: .top, allowIncrementalUpdate: false ) - #expect(transport.logOverlayScrollerHideRequestCountForTesting > hideCountBeforeRestore) + try await waitForOverlayScrollerHideRequest( + in: transport, + exceeding: hideCountBeforeRestore + ) } @Test func privateOverlayBridgeNoOpsWhenScrollerImpPairIsUnavailable() async throws { @@ -3748,7 +3371,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3758,8 +3381,8 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -3784,7 +3407,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3794,8 +3417,8 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logOverlayScrollerHideRequestCountForTesting == hideCountBeforeAppend) } @@ -3815,7 +3438,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3847,7 +3470,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3890,14 +3513,14 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) let appendCount = transport.logAppendCountForTesting - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logVisibleFragmentViewCountForTesting > 0) @@ -3919,7 +3542,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -3928,7 +3551,7 @@ struct ReviewUITests { #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.showFindInterface))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.nextMatch))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.replace)) == false) - #expect(transport.logAccessibilityValueForTesting == reviewMonitorLogText(for: job)) + #expect(transport.logAccessibilityValueForTesting == reviewChatLogText(for: job)) #expect(transport.logDocumentViewExportsUserInterfaceValidationForTesting) let copyItem = commandMenuItemForTesting("copy:") @@ -3943,7 +3566,7 @@ struct ReviewUITests { #expect(transport.validateLogDocumentUserInterfaceItemForTesting(deleteItem) == false) transport.selectAllLogForTesting() - #expect(transport.logSelectedTextForTesting == reviewMonitorLogText(for: job)) + #expect(transport.logSelectedTextForTesting == reviewChatLogText(for: job)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(cutItem) == false) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(pasteItem) == false) @@ -3951,7 +3574,7 @@ struct ReviewUITests { NSPasteboard.general.clearContents() transport.copyLogSelectionForTesting() - #expect(NSPasteboard.general.string(forType: .string) == reviewMonitorLogText(for: job)) + #expect(NSPasteboard.general.string(forType: .string) == reviewChatLogText(for: job)) transport.clearLogFinderSelectedRangesForTesting() #expect(transport.logSelectedTextForTesting == nil) @@ -4004,7 +3627,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -4053,13 +3676,13 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let renderedInitialLog = reviewMonitorLogText(for: job) + let renderedInitialLog = reviewChatLogText(for: job) let renderedInitialLength = (renderedInitialLog as NSString).length let visibleRanges = transport.logFindVisibleCharacterRangesForTesting #expect(transport.logFindIncrementalSearchUsesSystemHighlightingForTesting) @@ -4080,10 +3703,10 @@ struct ReviewUITests { } #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) + _ = try await awaitChatRenderForTesting(job, in: transport) - let appendedLength = (reviewMonitorLogText(for: job) as NSString).length + let appendedLength = (reviewChatLogText(for: job) as NSString).length let appendedVisibleRanges = transport.logFindVisibleCharacterRangesForTesting #expect(transport.logFindStringLengthForTesting == renderedInitialLength) #expect(transport.logSelectedTextForTesting == "needle") @@ -4106,9 +3729,9 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting == false) let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .progress, text: "needle appended while the log is not following bottom")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(abs(transport.logVerticalScrollOffsetForTesting - offsetBeforeMiddleAppend) < 0.5) #expect(transport.logSelectedTextForTesting == "needle") @@ -4116,7 +3739,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - var burstText = reviewMonitorLogText(for: job) + var burstText = reviewChatLogText(for: job) for index in 0..<8 { burstText += "\nneedle burst \(index)" #expect(transport.renderLogForTesting(text: burstText, allowIncrementalUpdate: true)) @@ -4203,19 +3826,19 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) + appendChatLogEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) + _ = try await awaitChatRenderForTesting(firstJob, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) viewController.sidebarViewControllerForTesting.clearSelectionForTesting() @@ -4223,7 +3846,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false @@ -4231,7 +3854,7 @@ struct ReviewUITests { #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 { @@ -4263,28 +3886,28 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) - _ = try await awaitTimelineRenderForTesting(firstJob, in: transport) + appendChatLogEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) + _ = try await awaitChatRenderForTesting(firstJob, in: transport) #expect( transport.displayedLogForTesting.trimmingCharacters(in: .newlines) - == reviewMonitorLogText(for: secondJob).trimmingCharacters(in: .newlines) + == reviewChatLogText(for: secondJob).trimmingCharacters(in: .newlines) ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false @@ -4292,7 +3915,7 @@ struct ReviewUITests { #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 { @@ -4322,13 +3945,13 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: firstJob) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: firstJob) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) @@ -4339,7 +3962,7 @@ struct ReviewUITests { let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( secondJob, in: transport, allowIncrementalUpdate: false @@ -4348,7 +3971,7 @@ struct ReviewUITests { #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: secondJob) as NSString).length) } @Test func logFindHidingVisibleSnapshotReturnsClientToLiveString() async throws { @@ -4370,26 +3993,26 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) + _ = try await awaitChatRenderForTesting(job, 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: job) as NSString).length) } @Test func logFindClearedSelectionReturnsVisibleUpdatesToLiveString() async throws { @@ -4411,19 +4034,19 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -4432,12 +4055,12 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logSelectedTextForTesting == nil) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) } @Test func logFindClearedQueryReturnsVisibleUpdatesToLiveString() async throws { @@ -4459,19 +4082,19 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewMonitorLogText(for: job) as NSString).range(of: "needle") + let firstNeedleRange = (reviewChatLogText(for: job) as NSString).range(of: "needle") #expect(firstNeedleRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -4481,12 +4104,12 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logHasActiveFindQueryForTesting == false) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) } } @@ -4509,7 +4132,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -4521,12 +4144,12 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) } } @@ -4549,21 +4172,21 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let initialLength = (reviewMonitorLogText(for: job) as NSString).length + let initialLength = (reviewChatLogText(for: job) 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) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logVisibleFindBarSearchStringForTesting == "core") @@ -4592,21 +4215,21 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) - let initialLength = (reviewMonitorLogText(for: job) as NSString).length + let initialLength = (reviewChatLogText(for: job) as NSString).length try await withFindPasteboardString(nil) { viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("alpha")) #expect(transport.logFindStringLengthForTesting == initialLength) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindStringLengthForTesting == initialLength) @@ -4614,7 +4237,7 @@ 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: job) as NSString).length) } } @@ -4637,7 +4260,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -4648,18 +4271,18 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") - let normalSelectionRange = (reviewMonitorLogText(for: job) as NSString).range(of: "copyable") + let normalSelectionRange = (reviewChatLogText(for: job) as NSString).range(of: "copyable") #expect(normalSelectionRange.location != NSNotFound) transport.setSelectedLogRangeForTesting(normalSelectionRange) #expect(transport.logSelectedTextForTesting == "copyable") #expect(transport.logHasActiveFindQueryForTesting == false) - appendTimelineEntryForTesting( + appendChatLogEntryForTesting( job, .init(kind: .progress, text: "needle appended after normal selection")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) } } @@ -4682,7 +4305,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -4696,10 +4319,10 @@ struct ReviewUITests { transport.simulateLogFinderEmptySelectedRangesForTesting() #expect(transport.logHasActiveFindQueryForTesting) - let initialLength = (reviewMonitorLogText(for: job) as NSString).length - appendTimelineEntryForTesting( + let initialLength = (reviewChatLogText(for: job) as NSString).length + appendChatLogEntryForTesting( job, .init(kind: .progress, text: "active query appears after no-result search")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) @@ -4749,7 +4372,7 @@ struct ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - _ = try await awaitTimelineRenderForTesting( + _ = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false @@ -4759,12 +4382,12 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) - appendTimelineEntryForTesting(job, .init(kind: .progress, text: "needle first content")) - _ = try await awaitTimelineRenderForTesting(job, in: transport) + appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle first content")) + _ = try await awaitChatRenderForTesting(job, in: transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewMonitorLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) } @Test func authFailedJobShowsNormalFailureDetails() async throws { @@ -4787,13 +4410,13 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let snapshot = try await awaitTimelineRenderForTesting( + let snapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) #expect(snapshot.summary == nil) - #expect(snapshot.log == reviewMonitorLogText(for: job)) + #expect(snapshot.log == reviewChatLogText(for: job)) } @Test func authenticatedAuthFailedJobStillShowsNormalFailureDetails() async throws { @@ -4816,13 +4439,13 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let snapshot = try await awaitTimelineRenderForTesting( + let snapshot = try await awaitChatRenderForTesting( job, in: transport, allowIncrementalUpdate: false ) #expect(snapshot.summary == nil) - #expect(snapshot.log == reviewMonitorLogText(for: job)) + #expect(snapshot.log == reviewChatLogText(for: job)) } } @@ -4859,8 +4482,11 @@ func makeWindowHarness( .defaultContentTransitionAnimator ) -> ReviewMonitorWindowHarness { applyTestAuthState(auth: store.auth, state: authState) + let previewChatLogSource = + (store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport)?.chatLogSource let windowController = ReviewMonitorWindowController( store: store, + previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) @@ -5239,6 +4865,43 @@ func awaitContentPaneRender( ) } +@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 @@ -5271,10 +4934,10 @@ func makeJob( summary: summary ?? status.displayText, reviewResult: reviewResult, lastAgentMessage: "", - timelineEntries: [], + chatEntries: [], errorMessage: status == .failed ? summary ?? status.displayText : nil ) - seedTimelineForTesting(job, logText: logText, rawLogText: rawLogText) + seedChatLogForTesting(job, logText: logText, rawLogText: rawLogText) return job } @@ -5493,6 +5156,7 @@ extension CodexReviewStore { jobs: [CodexReviewJob] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { + installPreviewChatLogSourceForTesting(on: self, jobs: jobs) loadForTesting( serverState: serverState, authPhase: authState.phase, From 30912d1261467485f647ed1fe4fc103c35e0efc3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:35:22 +0900 Subject: [PATCH 213/359] feat(review-ui): add chat cancellation context menu --- .../ReviewMonitorSidebarViewController.swift | 104 +++++++++++++++++- Tests/ReviewUITests/ReviewUITests.swift | 20 ++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 512af21d..a69a154a 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -696,8 +696,66 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func makeContextMenu(at point: NSPoint) -> NSMenu? { - _ = point - return nil + let row = outlineView.row(at: point) + guard row >= 0, + let node = codexSidebarNode(from: outlineView.item(atRow: row)), + case .chat(let chat) = node.item + else { + return nil + } + + let menu = NSMenu() + if let job = cancellableReviewJob(for: chat) { + let item = NSMenuItem( + title: "Cancel Review", + action: #selector(cancelReviewFromContextMenu(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = job.id + menu.addItem(item) + } + return menu.items.isEmpty ? nil : menu + } + + private func cancellableReviewJob( + for chat: ReviewMonitorCodexSidebarSnapshot.Chat + ) -> CodexReviewJob? { + store.orderedJobs.first { job in + guard job.isTerminal == false, + let chatID = job.sidebarChatID + else { + return false + } + return chatID == chat.id + } + } + + @objc + private func cancelReviewFromContextMenu(_ sender: NSMenuItem) { + guard let jobID = sender.representedObject as? String else { + return + } + Task { @MainActor [weak self] in + guard let self else { + return + } + do { + _ = try await self.store.cancelReview( + jobID: jobID, + cancellation: .userInterface() + ) + } catch { + guard let job = self.store.job(id: jobID) else { + return + } + try? self.store.recordCancellationFailure( + jobID: jobID, + sessionID: job.sessionID, + message: error.localizedDescription + ) + } + } } private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -1595,6 +1653,28 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return cellView.isHostingReviewMonitorChatRowViewForTesting } + func reviewChatContextMenuTitlesForTesting(_ chatID: CodexThreadID) -> [String] { + var titles: [String] = [] + presentReviewChatContextMenuForTesting(chatID) { menu in + titles = menu.items.map(\.title) + } + return titles + } + + private func presentReviewChatContextMenuForTesting( + _ chatID: CodexThreadID, + presenter: @escaping (NSMenu) -> Void + ) { + guard let row = row(for: chatID) else { + return + } + view.layoutSubtreeIfNeeded() + outlineView.layoutSubtreeIfNeeded() + let rowRect = outlineView.rect(ofRow: row) + let point = NSPoint(x: rowRect.midX, y: rowRect.midY) + outlineView.presentContextMenuForTesting(at: point, presenter: presenter) + } + func clickBlankAreaForTesting() { view.layoutSubtreeIfNeeded() guard outlineView.numberOfRows > 0 else { @@ -1949,6 +2029,26 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { #endif } +private extension CodexReviewJob { + var sidebarChatID: CodexThreadID? { + if let reviewThreadID = nonEmptySidebarChatID(core.run.reviewThreadID) { + return CodexThreadID(rawValue: reviewThreadID) + } + if let threadID = nonEmptySidebarChatID(core.run.threadID) { + return CodexThreadID(rawValue: threadID) + } + return nil + } + + private func nonEmptySidebarChatID(_ value: String?) -> String? { + guard let value else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + @MainActor private final class ReviewMonitorWorkspaceRowView: NSTableRowView { override var isEmphasized: Bool { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index ae100b19..472b4f0f 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -837,6 +837,26 @@ struct ReviewUITests { #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } + @Test func reviewChatContextMenuOffersCancellationForRunningChatOnly() throws { + 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 backend = makeWindowHarness(store: store) + let viewController = backend.viewController + defer { backend.window.close() } + let sidebar = viewController.sidebarViewControllerForTesting + + let activeChatID = try #require(activeJob.reviewChatIDForTesting) + let recentChatID = try #require(recentJob.reviewChatIDForTesting) + + #expect(sidebar.reviewChatContextMenuTitlesForTesting(activeChatID) == ["Cancel Review"]) + #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChatID).isEmpty) + } + @Test func selectingReviewChatUpdatesDetailPane() async throws { let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes", logText: "Running review\n") let recentJob = makeJob( From bc521522e45aa24f8f6e68cbb815072f498835d5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:45:21 +0900 Subject: [PATCH 214/359] refactor(review): rename jobs to run records --- .../ReviewObservationAwaiter.swift | 4 +- ...xReviewJob.swift => ReviewRunRecord.swift} | 4 +- ...ing.swift => ReviewRunRecordTesting.swift} | 6 +- .../Store/CodexReviewStore.swift | 2 +- .../Store/CodexReviewStoreOrderQueries.swift | 12 ++-- .../Store/CodexReviewStoreReviews.swift | 58 ++++++++--------- .../Store/CodexReviewStoreTesting.swift | 4 +- .../ReviewMonitorSidebarViewController.swift | 4 +- .../ReviewObservationAwaiterTests.swift | 4 +- .../CodexReviewStoreCommandTests.swift | 10 +-- ...ReviewStoreRateLimitAutoRefreshTests.swift | 4 +- ...Tests.swift => ReviewRunRecordTests.swift} | 12 ++-- .../CodexReviewMCPHTTPServerTests.swift | 32 +++++----- ...eviewMonitorCodexSidebarLibraryTests.swift | 2 +- .../ReviewMonitorTimelineTesting.swift | 42 ++++++------ Tests/ReviewUITests/ReviewUIShellTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 64 +++++++++---------- 17 files changed, 133 insertions(+), 133 deletions(-) rename Sources/CodexReviewKit/Model/{CodexReviewJob.swift => ReviewRunRecord.swift} (95%) rename Sources/CodexReviewKit/Model/{CodexReviewJobTesting.swift => ReviewRunRecordTesting.swift} (96%) rename Tests/CodexReviewKitTests/{CodexReviewJobTests.swift => ReviewRunRecordTests.swift} (87%) diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index b348ef03..3446bbe3 100644 --- a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -4,7 +4,7 @@ import ObservationBridge @MainActor public enum ReviewObservationAwaiter { public static func waitUntilTerminal( - job: CodexReviewJob, + job: ReviewRunRecord, timeout: Duration? = nil ) async -> Bool { if job.isTerminal { @@ -36,7 +36,7 @@ private final class ReviewTerminalObservationWaiter { private var isResolved = false func begin( - job: CodexReviewJob, + job: ReviewRunRecord, timeout: Duration?, continuation: CheckedContinuation ) { diff --git a/Sources/CodexReviewKit/Model/CodexReviewJob.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift similarity index 95% rename from Sources/CodexReviewKit/Model/CodexReviewJob.swift rename to Sources/CodexReviewKit/Model/ReviewRunRecord.swift index 82004044..0c64f493 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJob.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -3,7 +3,7 @@ import Observation @MainActor @Observable -public final class CodexReviewJob: Identifiable, Hashable { +public final class ReviewRunRecord: Identifiable, Hashable { public nonisolated let id: String package let timeline: ReviewTimeline public let sessionID: String @@ -55,7 +55,7 @@ public final class CodexReviewJob: Identifiable, Hashable { syncTimelineTerminalStateFromCore() } - public nonisolated static func == (lhs: CodexReviewJob, rhs: CodexReviewJob) -> Bool { + public nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { lhs.id == rhs.id } diff --git a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift similarity index 96% rename from Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift rename to Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index f28b8373..5ae531a7 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewJobTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -1,6 +1,6 @@ import Foundation -extension CodexReviewJob { +extension ReviewRunRecord { package static func makeForTesting( id: String = UUID().uuidString, sessionID: String = "session-1", @@ -20,8 +20,8 @@ extension CodexReviewJob { lastAgentMessage: String? = "", errorMessage: String? = nil, exitCode: Int? = nil - ) -> CodexReviewJob { - CodexReviewJob( + ) -> ReviewRunRecord { + ReviewRunRecord( id: id, sessionID: sessionID, cwd: cwd, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index ae50fe3c..20e6c635 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -9,7 +9,7 @@ public final class CodexReviewStore { package let settings: SettingsStore public package(set) var serverURL: URL? public package(set) var workspaces: Set = [] - public package(set) var jobs: Set = [] + public package(set) var jobs: Set = [] package var shouldAutoStartEmbeddedServer: Bool { backend.seed.shouldAutoStartEmbeddedServer } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index a5422805..826afefe 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -8,7 +8,7 @@ extension CodexReviewStore { } } - package var orderedJobs: [CodexReviewJob] { + package var orderedJobs: [ReviewRunRecord] { orderedWorkspaces.flatMap { orderedJobs(in: $0) } } @@ -20,23 +20,23 @@ extension CodexReviewStore { workspaces.first(where: { $0.cwd == cwd }) } - package func workspace(containing job: CodexReviewJob) -> CodexReviewWorkspace? { + package func workspace(containing job: ReviewRunRecord) -> CodexReviewWorkspace? { workspace(cwd: job.cwd) } - package func job(id: String) -> CodexReviewJob? { + package func job(id: String) -> ReviewRunRecord? { jobs.first(where: { $0.id == id }) } - package func jobs(inWorkspace cwd: String) -> [CodexReviewJob] { + package func jobs(inWorkspace cwd: String) -> [ReviewRunRecord] { jobs.filter { $0.cwd == cwd } } - package func orderedJobs(in workspace: CodexReviewWorkspace) -> [CodexReviewJob] { + package func orderedJobs(in workspace: CodexReviewWorkspace) -> [ReviewRunRecord] { orderedJobs(inWorkspace: workspace.cwd) } - package func orderedJobs(inWorkspace cwd: String) -> [CodexReviewJob] { + package func orderedJobs(inWorkspace cwd: String) -> [ReviewRunRecord] { jobs(inWorkspace: cwd).sorted { if $0.sortOrder == $1.sortOrder { return $0.id < $1.id diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index d5809b1c..2bd1ba1b 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -64,7 +64,7 @@ extension CodexReviewStore { let validatedRequest = try request.validated() let jobID = idGenerator.next() let createdAt = clock.now() - let job = CodexReviewJob( + let job = ReviewRunRecord( id: jobID, sessionID: sessionID, cwd: validatedRequest.cwd, @@ -192,7 +192,7 @@ extension CodexReviewStore { runtimeState.removeDetachedWorker(for: jobID) } - private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: CodexReviewJob) + private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: ReviewRunRecord) async { guard job.isTerminal == false else { @@ -222,7 +222,7 @@ extension CodexReviewStore { } } - private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: CodexReviewJob) { + private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: ReviewRunRecord) { runtimeState.setActiveRun(backendRun, for: job.id) job.core.run = .init( attemptID: backendRun.attemptID, @@ -234,21 +234,21 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func appendRecoveryProgress(_ message: String, to job: CodexReviewJob) { + private func appendRecoveryProgress(_ message: String, to job: ReviewRunRecord) { let now = clock.now() job.core.output.summary = message appendDiagnostic(message, to: job, at: now) writeDiagnosticsIfNeeded() } - private func markReviewWaitingForNetworkRecovery(_ job: CodexReviewJob) { + private func markReviewWaitingForNetworkRecovery(_ job: ReviewRunRecord) { job.resetReviewAttemptOutputForRecovery() appendRecoveryProgress(networkRecoveryUnavailableMessage, to: job) } private func applyDomainEvents( _ events: [ReviewDomainEvent], - to job: CodexReviewJob, + to job: ReviewRunRecord, at timestamp: Date ) { for event in events { @@ -260,7 +260,7 @@ extension CodexReviewStore { } } - private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to job: CodexReviewJob) -> Bool { + private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to job: ReviewRunRecord) -> Bool { switch event { case .textDelta(let itemID, _, let family, _, _) where family == .message && job.completedAgentMessageItemIDs.contains(itemID.rawValue): @@ -270,7 +270,7 @@ extension CodexReviewStore { } } - private func updateCoreOutput(from event: ReviewDomainEvent, for job: CodexReviewJob) { + private func updateCoreOutput(from event: ReviewDomainEvent, for job: ReviewRunRecord) { switch event { case .itemStarted(let seed), .itemUpdated(let seed): @@ -295,7 +295,7 @@ extension CodexReviewStore { private func updateCoreOutput( from seed: ReviewTimelineItemSeed, isCompleted: Bool, - for job: CodexReviewJob + for job: ReviewRunRecord ) { guard seed.family == .message, case .message(let message) = seed.content @@ -318,7 +318,7 @@ extension CodexReviewStore { _ text: String, itemID: String?, isCompleted: Bool, - to job: CodexReviewJob, + to job: ReviewRunRecord, at timestamp: Date ) { let resolvedItemID = @@ -342,7 +342,7 @@ extension CodexReviewStore { private func applyMessageDelta( _ text: String, itemID: String, - to job: CodexReviewJob, + to job: ReviewRunRecord, at timestamp: Date ) { guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID, delta: text) else { @@ -362,7 +362,7 @@ extension CodexReviewStore { private func appendDiagnostic( _ message: String, - to job: CodexReviewJob, + to job: ReviewRunRecord, at timestamp: Date, severity: ReviewDiagnosticSeverity? = nil ) { @@ -460,11 +460,11 @@ extension CodexReviewStore { return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem)) } - package func resolveJob(selector: CodexReviewAPI.Job.Selector) throws -> CodexReviewJob { + package func resolveJob(selector: CodexReviewAPI.Job.Selector) throws -> ReviewRunRecord { try resolveJob(sessionID: nil, selector: selector) } - package func resolveJob(sessionID: String?, selector: CodexReviewAPI.Job.Selector) throws -> CodexReviewJob { + package func resolveJob(sessionID: String?, selector: CodexReviewAPI.Job.Selector) throws -> ReviewRunRecord { let statusSet = selector.statuses.map(Set.init) let matches = orderedJobs.filter { job in if let sessionID, job.sessionID != sessionID { @@ -613,14 +613,14 @@ extension CodexReviewStore { } } - private func job(jobID: String) throws -> CodexReviewJob { + private func job(jobID: String) throws -> ReviewRunRecord { guard let job = job(id: jobID) else { throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") } return job } - private func filteredJobs(cwd: String?, statuses: [ReviewJobState]?) -> [CodexReviewJob] { + private func filteredJobs(cwd: String?, statuses: [ReviewJobState]?) -> [ReviewRunRecord] { let statusSet = statuses.map(Set.init) return orderedJobs.filter { job in if let cwd, job.cwd != cwd { @@ -633,7 +633,7 @@ extension CodexReviewStore { } } - private func makeListItem(_ job: CodexReviewJob) -> CodexReviewAPI.Job.ListItem { + private func makeListItem(_ job: ReviewRunRecord) -> CodexReviewAPI.Job.ListItem { CodexReviewAPI.Job.ListItem( jobID: job.id, cwd: job.cwd, @@ -644,7 +644,7 @@ extension CodexReviewStore { ) } - private func elapsedSeconds(for job: CodexReviewJob) -> Int? { + private func elapsedSeconds(for job: ReviewRunRecord) -> Int? { guard let startedAt = job.core.lifecycle.startedAt else { return nil } @@ -652,7 +652,7 @@ extension CodexReviewStore { return max(0, Int(end.timeIntervalSince(startedAt))) } - private func insertReviewJob(_ job: CodexReviewJob) { + private func insertReviewJob(_ job: ReviewRunRecord) { if workspace(cwd: job.cwd) == nil { let workspace = CodexReviewWorkspace( cwd: job.cwd, @@ -664,7 +664,7 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func markReviewRunning(_ job: CodexReviewJob, startedAt: Date) { + private func markReviewRunning(_ job: ReviewRunRecord, startedAt: Date) { job.core.lifecycle.status = .running job.core.lifecycle.startedAt = startedAt job.core.output.summary = "Review started." @@ -677,7 +677,7 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func markReviewFailed(_ job: CodexReviewJob, message: String) { + private func markReviewFailed(_ job: ReviewRunRecord, message: String) { guard job.isTerminal == false else { return } @@ -693,7 +693,7 @@ extension CodexReviewStore { private func consumeReviewEvents( for initialAttempt: BackendReviewAttempt, - job: CodexReviewJob, + job: ReviewRunRecord, startRequest: CodexReviewBackendModel.Review.Start ) async throws -> CodexReviewBackendModel.Review.Run { let inputs = await reviewWorkerInputs(for: initialAttempt) @@ -824,7 +824,7 @@ extension CodexReviewStore { } private func handleReviewEventsFinished( - job: CodexReviewJob, + job: ReviewRunRecord, isWaitingForNetworkRecovery: Bool ) throws -> Bool { if Task.isCancelled { @@ -858,7 +858,7 @@ extension CodexReviewStore { } private func restartReviewAfterNetworkRestore( - job: CodexReviewJob, + job: ReviewRunRecord, startRequest: CodexReviewBackendModel.Review.Start, inputs: ReviewWorkerInputs, preparedRestartToken: CodexReviewBackendModel.Review.RestartToken? @@ -891,7 +891,7 @@ extension CodexReviewStore { private func stopRecoveredRunIfJobShouldNotResume( _ recoveredRun: CodexReviewBackendModel.Review.Run, - job: CodexReviewJob + job: ReviewRunRecord ) async throws -> Bool { if Task.isCancelled { try? await backend.interruptReview( @@ -940,7 +940,7 @@ extension CodexReviewStore { private func handleReviewEvent( _ event: CodexReviewBackendModel.Review.Event, - job: CodexReviewJob, + job: ReviewRunRecord, currentRun: CodexReviewBackendModel.Review.Run ) -> CodexReviewBackendModel.Review.Run { guard job.isTerminal == false else { @@ -995,7 +995,7 @@ extension CodexReviewStore { return updatedRun } - private func completePendingCancellationIfNeeded(for job: CodexReviewJob) -> Bool { + private func completePendingCancellationIfNeeded(for job: ReviewRunRecord) -> Bool { guard job.cancellationRequested else { return false } @@ -1009,7 +1009,7 @@ extension CodexReviewStore { } private func completeReview( - _ job: CodexReviewJob, + _ job: ReviewRunRecord, summary: String, result: String? ) { @@ -1072,7 +1072,7 @@ private extension CodexReviewBackendModel.Review.Event { } } -private extension CodexReviewJob { +private extension ReviewRunRecord { var backendRun: CodexReviewBackendModel.Review.Run? { guard let threadID = core.run.threadID else { return nil diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index dcc3ff55..8268ea04 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -20,7 +20,7 @@ extension CodexReviewStore { persistedAccounts: [CodexReviewAccount]? = nil, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], - jobs: [CodexReviewJob] = [], + jobs: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { precondition( @@ -61,7 +61,7 @@ extension CodexReviewStore { } self.workspaces = Set(resolvedWorkspaces) - var jobsByCWD: [String: [CodexReviewJob]] = [:] + var jobsByCWD: [String: [ReviewRunRecord]] = [:] let resolvedJobs = jobs.filter { job in resolvedWorkspaces.contains(where: { $0.cwd == job.cwd }) } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index a69a154a..d736fb73 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -720,7 +720,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func cancellableReviewJob( for chat: ReviewMonitorCodexSidebarSnapshot.Chat - ) -> CodexReviewJob? { + ) -> ReviewRunRecord? { store.orderedJobs.first { job in guard job.isTerminal == false, let chatID = job.sidebarChatID @@ -2029,7 +2029,7 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { #endif } -private extension CodexReviewJob { +private extension ReviewRunRecord { var sidebarChatID: CodexThreadID? { if let reviewThreadID = nonEmptySidebarChatID(core.run.reviewThreadID) { return CodexThreadID(rawValue: reviewThreadID) diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index 05de141b..7211ee6f 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -50,8 +50,8 @@ struct ReviewObservationAwaiterTests { #expect(result == false) } - private func makeRunningJob() -> CodexReviewJob { - CodexReviewJob.makeForTesting( + private func makeRunningJob() -> ReviewRunRecord { + ReviewRunRecord.makeForTesting( id: "job-awaiter", targetSummary: "Uncommitted changes", status: .running, diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index df4cc82f..c792ec5f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -385,7 +385,7 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { - let existing = CodexReviewJob.makeForTesting( + let existing = ReviewRunRecord.makeForTesting( id: "job-existing", cwd: "/tmp/project", targetSummary: "Existing", @@ -445,21 +445,21 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) ) let workspace = CodexReviewWorkspace(cwd: "/tmp/project") - let firstJob = CodexReviewJob.makeForTesting( + let firstJob = ReviewRunRecord.makeForTesting( id: "job-first", cwd: workspace.cwd, targetSummary: "First", status: .running, summary: "Running" ) - let secondJob = CodexReviewJob.makeForTesting( + let secondJob = ReviewRunRecord.makeForTesting( id: "job-second", cwd: workspace.cwd, targetSummary: "Second", status: .running, summary: "Running" ) - let thirdJob = CodexReviewJob.makeForTesting( + let thirdJob = ReviewRunRecord.makeForTesting( id: "job-third", cwd: workspace.cwd, targetSummary: "Third", @@ -1680,7 +1680,7 @@ struct CodexReviewStoreCommandTests { idGenerator: .init(next: { "job-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { - let running = CodexReviewJob.makeForTesting( + let running = ReviewRunRecord.makeForTesting( id: "running-job", sessionID: "session-1", cwd: "/tmp/project", diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 8f614102..291cab6e 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -164,7 +164,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { @Test func storeTargetsFollowRunningJobState() { let account = makeAccount(lastFetchAt: now) - let runningJob = CodexReviewJob.makeForTesting( + let runningJob = ReviewRunRecord.makeForTesting( targetSummary: "Review changes", status: .running, startedAt: now, @@ -304,7 +304,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { private func loadStore( _ store: CodexReviewStore, account: CodexReviewAccount, - jobs: [CodexReviewJob] + jobs: [ReviewRunRecord] ) { store.loadForTesting( serverState: .running, diff --git a/Tests/CodexReviewKitTests/CodexReviewJobTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift similarity index 87% rename from Tests/CodexReviewKitTests/CodexReviewJobTests.swift rename to Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index ec845beb..438be1c7 100644 --- a/Tests/CodexReviewKitTests/CodexReviewJobTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -2,11 +2,11 @@ import Foundation import Testing @testable import CodexReviewKit -@Suite("Codex review job") +@Suite("Review run record") @MainActor -struct CodexReviewJobTests { +struct ReviewRunRecordTests { @Test func prebuiltTerminalJobsInitializeTimelineTerminalState() { - let succeeded = CodexReviewJob.makeForTesting( + let succeeded = ReviewRunRecord.makeForTesting( id: "job-terminal-succeeded", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", @@ -19,7 +19,7 @@ struct CodexReviewJobTests { #expect(succeeded.timeline.terminalSummary == "Succeeded.") #expect(succeeded.timeline.terminalResult == "No findings.") - let succeededWithoutFinalReview = CodexReviewJob.makeForTesting( + let succeededWithoutFinalReview = ReviewRunRecord.makeForTesting( id: "job-terminal-succeeded-no-review", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", @@ -32,7 +32,7 @@ struct CodexReviewJobTests { #expect(succeededWithoutFinalReview.timeline.terminalSummary == "Succeeded.") #expect(succeededWithoutFinalReview.timeline.terminalResult == nil) - let failed = CodexReviewJob.makeForTesting( + let failed = ReviewRunRecord.makeForTesting( id: "job-terminal-failed", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", @@ -44,7 +44,7 @@ struct CodexReviewJobTests { #expect(failed.timeline.terminalSummary == "Backend failed.") #expect(failed.timeline.terminalResult == nil) - let cancelled = CodexReviewJob.makeForTesting( + let cancelled = ReviewRunRecord.makeForTesting( id: "job-terminal-cancelled", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index ba83aaba..2f6770af 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -347,7 +347,7 @@ struct CodexReviewMCPHTTPServerTests { ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let included = CodexReviewJob.makeForTesting( + let included = ReviewRunRecord.makeForTesting( id: "job-included", sessionID: sessionID, cwd: "/tmp/project", @@ -355,7 +355,7 @@ struct CodexReviewMCPHTTPServerTests { status: .succeeded, summary: "Done" ) - let otherSession = CodexReviewJob.makeForTesting( + let otherSession = ReviewRunRecord.makeForTesting( id: "job-other-session", sessionID: "other-session", cwd: "/tmp/project", @@ -363,7 +363,7 @@ struct CodexReviewMCPHTTPServerTests { status: .succeeded, summary: "Done" ) - let otherWorkspace = CodexReviewJob.makeForTesting( + let otherWorkspace = ReviewRunRecord.makeForTesting( id: "job-other-workspace", sessionID: sessionID, cwd: "/tmp/other", @@ -410,7 +410,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let includedJob = CodexReviewJob.makeForTesting( + let includedJob = ReviewRunRecord.makeForTesting( id: "job-in-session", sessionID: sessionID, cwd: "/tmp/project", @@ -436,7 +436,7 @@ struct CodexReviewMCPHTTPServerTests { workspaces: [.init(cwd: "/tmp/project")], jobs: [ includedJob, - CodexReviewJob.makeForTesting( + ReviewRunRecord.makeForTesting( id: "job-other-session", sessionID: "other-session", cwd: "/tmp/project", @@ -498,7 +498,7 @@ struct CodexReviewMCPHTTPServerTests { let startedAt = Date(timeIntervalSince1970: 20) let completedAt = Date(timeIntervalSince1970: 23) let longOutput = "Tests passed\n" + String(repeating: "x", count: 4500) - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-semantic", sessionID: sessionID, cwd: "/tmp/project", @@ -596,7 +596,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-tool-progress", sessionID: sessionID, cwd: "/tmp/project", @@ -666,7 +666,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = CodexReviewJob.makeForTesting( + let running = ReviewRunRecord.makeForTesting( id: "job-running", sessionID: sessionID, cwd: "/tmp/project", @@ -676,7 +676,7 @@ struct CodexReviewMCPHTTPServerTests { status: .running, summary: "Running" ) - let otherSession = CodexReviewJob.makeForTesting( + let otherSession = ReviewRunRecord.makeForTesting( id: "job-other-session", sessionID: "other-session", cwd: "/tmp/project", @@ -733,7 +733,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let completed = CodexReviewJob.makeForTesting( + let completed = ReviewRunRecord.makeForTesting( id: "job-completed", sessionID: sessionID, cwd: "/tmp/project", @@ -743,7 +743,7 @@ struct CodexReviewMCPHTTPServerTests { status: .succeeded, summary: "Done" ) - let running = CodexReviewJob.makeForTesting( + let running = ReviewRunRecord.makeForTesting( id: "job-running", sessionID: sessionID, cwd: "/tmp/project", @@ -795,7 +795,7 @@ struct CodexReviewMCPHTTPServerTests { serverState: .running, workspaces: [.init(cwd: "/tmp/project")], jobs: [ - CodexReviewJob.makeForTesting( + ReviewRunRecord.makeForTesting( id: "job-running-1", sessionID: sessionID, cwd: "/tmp/project", @@ -805,7 +805,7 @@ struct CodexReviewMCPHTTPServerTests { status: .running, summary: "Running" ), - CodexReviewJob.makeForTesting( + ReviewRunRecord.makeForTesting( id: "job-running-2", sessionID: sessionID, cwd: "/tmp/project", @@ -851,7 +851,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = CodexReviewJob.makeForTesting( + let running = ReviewRunRecord.makeForTesting( id: "job-running", sessionID: sessionID, cwd: "/tmp/project", @@ -1021,7 +1021,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let other = CodexReviewJob.makeForTesting( + let other = ReviewRunRecord.makeForTesting( id: "job-other-session", sessionID: "other-session", cwd: "/tmp/project", @@ -1067,7 +1067,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = CodexReviewJob.makeForTesting( + let running = ReviewRunRecord.makeForTesting( id: "job-running", sessionID: sessionID, cwd: "/tmp/project", diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 4ab206c0..055c3247 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -394,7 +394,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let legacyJob = CodexReviewJob.makeForTesting( + let legacyJob = ReviewRunRecord.makeForTesting( id: "legacy-job", cwd: repo.path, targetSummary: "Legacy review row", diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index d2b47415..ab6b61c8 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -87,7 +87,7 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { } } -extension CodexReviewJob { +extension ReviewRunRecord { @MainActor static func makeForTesting( id: String = UUID().uuidString, @@ -109,8 +109,8 @@ extension CodexReviewJob { chatEntries: [ReviewChatLogEntryForTesting] = [], errorMessage: String? = nil, exitCode: Int? = nil - ) -> CodexReviewJob { - let job = CodexReviewJob.makeForTesting( + ) -> ReviewRunRecord { + let job = ReviewRunRecord.makeForTesting( id: id, sessionID: sessionID, cwd: cwd, @@ -137,7 +137,7 @@ extension CodexReviewJob { @MainActor func seedChatLogForTesting( - _ job: CodexReviewJob, + _ job: ReviewRunRecord, logText: String = "", rawLogText: String = "" ) { @@ -158,12 +158,12 @@ func seedChatLogForTesting( } @MainActor -func appendChatLogEntryForTesting(_ job: CodexReviewJob, _ entry: ReviewChatLogEntryForTesting) { +func appendChatLogEntryForTesting(_ job: ReviewRunRecord, _ entry: ReviewChatLogEntryForTesting) { ReviewChatLogFixtureStore.append(entry, to: job) } @MainActor -func replaceChatLogTextForTesting(_ job: CodexReviewJob, _ text: String) { +func replaceChatLogTextForTesting(_ job: ReviewRunRecord, _ text: String) { ReviewChatLogFixtureStore.replaceEntries( [.init(kind: .agentMessage, groupID: "fixture-log-\(job.id)", text: text.trimmingCharacters(in: .newlines))], for: job @@ -171,7 +171,7 @@ func replaceChatLogTextForTesting(_ job: CodexReviewJob, _ text: String) { } @MainActor -func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, jobs: [CodexReviewJob]) { +func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, jobs: [ReviewRunRecord]) { let fixtures = jobs.compactMap { try? makePreviewChatLogFixtureForTesting(job: $0) } let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) @@ -183,13 +183,13 @@ func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, jobs: [Co } @MainActor -func reviewChatLogText(for job: CodexReviewJob) -> String { +func reviewChatLogText(for job: ReviewRunRecord) -> String { ReviewChatLogFixtureStore.logText(for: job) } @MainActor func awaitChatRenderForTesting( - _ job: CodexReviewJob, + _ job: ReviewRunRecord, in transport: ReviewMonitorTransportViewController, allowIncrementalUpdate: Bool = true, timeout: Duration = .seconds(2), @@ -245,7 +245,7 @@ func reviewChatRenderedLogMatches(_ actual: String, _ expected: String) -> Bool @MainActor func makePreviewChatLogSourceForTesting( - job: CodexReviewJob, + job: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogSource { ReviewMonitorPreviewChatLogSource( @@ -269,14 +269,14 @@ func makePreviewMessageItemForTesting( @MainActor func chatCommandOutputBlockIDForTesting( - _ job: CodexReviewJob, + _ job: ReviewRunRecord, itemID: String ) -> ReviewMonitorLog.BlockID { ReviewMonitorLog.BlockID("commandOutput:\(job.previewTurnIDForTesting.rawValue):\(itemID)") } @MainActor -private func makePreviewChatLogFixtureForTesting(job: CodexReviewJob) throws -> ReviewMonitorPreviewChatLogFixture { +private func makePreviewChatLogFixtureForTesting(job: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { try makePreviewChatLogFixtureForTesting(job: job) { _ in ReviewChatLogFixtureStore.items(for: job) } @@ -284,7 +284,7 @@ private func makePreviewChatLogFixtureForTesting(job: CodexReviewJob) throws -> @MainActor private func makePreviewChatLogFixtureForTesting( - job: CodexReviewJob, + job: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogFixture { let chat = try #require(job.reviewChatSelectionForTesting) @@ -316,25 +316,25 @@ private func makePreviewChatLogFixtureForTesting( private enum ReviewChatLogFixtureStore { private static var entriesByJobID: [String: [ReviewChatLogEntryForTesting]] = [:] - static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for job: CodexReviewJob) { + static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for job: ReviewRunRecord) { entriesByJobID[job.id] = entries } - static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for job: CodexReviewJob) { + static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for job: ReviewRunRecord) { setEntries(entries, for: job) updateRetainedPreviewSource(for: job) } - static func append(_ entry: ReviewChatLogEntryForTesting, to job: CodexReviewJob) { + static func append(_ entry: ReviewChatLogEntryForTesting, to job: ReviewRunRecord) { entriesByJobID[job.id, default: []].append(entry) updateRetainedPreviewSource(for: job) } - static func items(for job: CodexReviewJob) -> [CodexChatItemSnapshot] { + static func items(for job: ReviewRunRecord) -> [CodexChatItemSnapshot] { makeChatItems(from: entriesByJobID[job.id, default: []], turnID: job.previewTurnIDForTesting) } - static func logText(for job: CodexReviewJob) -> String { + static func logText(for job: ReviewRunRecord) -> String { var projection = ReviewMonitorSelectedCodexChatLogProjection() var document: ReviewMonitorLog.Document? let snapshot = CodexChatSnapshot( @@ -362,7 +362,7 @@ private enum ReviewChatLogFixtureStore { return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) } - private static func updateRetainedPreviewSource(for job: CodexReviewJob) { + private static func updateRetainedPreviewSource(for job: ReviewRunRecord) { for support in jobStorePreviewSupportRetainers where support.contains(jobID: job.id) { support.upsert(job: job, items: items(for: job)) } @@ -383,7 +383,7 @@ private final class ReviewChatLogFixtureRetainer { jobIDs.contains(jobID) } - func upsert(job: CodexReviewJob, items: [CodexChatItemSnapshot]) { + func upsert(job: ReviewRunRecord, items: [CodexChatItemSnapshot]) { guard let chatID = job.reviewChatIDForTesting else { return } @@ -576,7 +576,7 @@ private struct ReviewChatLogAccumulatedItem { } } -extension CodexReviewJob { +extension ReviewRunRecord { var reviewChatIDForTesting: CodexThreadID? { if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { return CodexThreadID(rawValue: reviewThreadID) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index c6cd6090..07c7dd1f 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1738,7 +1738,7 @@ private func renderDetailLogForShellLayoutTesting( _ text: String, in transport: ReviewMonitorTransportViewController, viewController: ReviewMonitorSplitViewController, - job: CodexReviewJob + job: ReviewRunRecord ) async throws { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) let chatID = try #require(job.reviewChatIDForTesting) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 472b4f0f..9a927564 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -22,7 +22,7 @@ private extension CodexReviewAuthModel { } @MainActor -func chatIDForTesting(_ job: CodexReviewJob) -> CodexThreadID { +func chatIDForTesting(_ job: ReviewRunRecord) -> CodexThreadID { guard let chatID = job.reviewChatIDForTesting else { Issue.record("Expected review job \(job.id) to have a chat id.") return CodexThreadID(rawValue: job.id) @@ -917,7 +917,7 @@ struct ReviewUITests { } @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-monitor-log", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -995,7 +995,7 @@ struct ReviewUITests { } @Test func detailPaneRendersSelectedChatStreamUpdates() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-selected-chat-stream-detail", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1071,7 +1071,7 @@ struct ReviewUITests { } @Test func selectedChatFailedCommandPreservesFailedPanelStatus() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-selected-chat-failed-command", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1127,7 +1127,7 @@ struct ReviewUITests { } @Test func selectedChatRunningCommandOutputStaysActive() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-selected-chat-running-command", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1185,7 +1185,7 @@ struct ReviewUITests { } @Test func selectedChatFileChangePreservesPanelTitle() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-selected-chat-file-change", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1247,7 +1247,7 @@ struct ReviewUITests { } @Test func contextCompactionMarkerRendersAsVisibleLogTextWithoutCommandPanel() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-context-compaction-marker", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1327,7 +1327,7 @@ struct ReviewUITests { exitCode: 0, commandStatus: "completed" ) - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-command-output-panel", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1483,7 +1483,7 @@ struct ReviewUITests { let secondOutput = (1...80) .map { "second output line \($0)" } .joined(separator: "\n") - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-command-output-panel-isolation", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1551,7 +1551,7 @@ struct ReviewUITests { } @Test func startedCommandRendersAsCollapsedPanelBeforeOutputArrives() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-command-start-panel", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -1614,7 +1614,7 @@ struct ReviewUITests { let outputText = (1...5) .map { "output line \($0)" } .joined(separator: "\n") - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-command-output-find-refresh", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2372,7 +2372,7 @@ struct ReviewUITests { } @Test func selectedReviewChatLogAppendUsesAppendPath() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-append", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2420,7 +2420,7 @@ struct ReviewUITests { } @Test func separatorPrefixedProgressAppendDoesNotUseGenericWordGlow() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-progress-separator-append", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2465,7 +2465,7 @@ struct ReviewUITests { @Test func logCanonicalEquivalentPrefixReloadsWhenUTF16LengthChanges() async throws { let decomposedPrefix = "Caf\u{0065}\u{0301}" let precomposedUpdate = "Caf\u{00E9} appended" - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-canonical-append", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2501,7 +2501,7 @@ struct ReviewUITests { } @Test func coalescedLogTextUpdateDisplaysCombinedSuffix() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-coalesced", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2534,7 +2534,7 @@ struct ReviewUITests { } @Test func coalescedProgressSuffixDisplaysLatestProgress() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-coalesced-progress", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2567,7 +2567,7 @@ struct ReviewUITests { } @Test func coalescedMixedReasoningAndProgressSuffixAnimatesOnlyReasoningRange() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-coalesced-mixed-reasoning-progress", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2655,7 +2655,7 @@ struct ReviewUITests { } @Test func selectedReviewChatGroupedReplacementUsesReplacementPath() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-reload", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2695,7 +2695,7 @@ struct ReviewUITests { @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws { let startedAt = Date(timeIntervalSince1970: 200) - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-reasoning-command-append", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2760,7 +2760,7 @@ struct ReviewUITests { } @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-markdown-append-fallback", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2798,7 +2798,7 @@ struct ReviewUITests { } @Test func staleGroupedReplacementIsNotReplayedAfterHiddenCommandOutput() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-stale-replacement", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2879,7 +2879,7 @@ struct ReviewUITests { } @Test func reasoningAppendUsesWordGlowAndReduceMotionDisablesGlow() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-reasoning-glow", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2928,7 +2928,7 @@ struct ReviewUITests { } @Test func screenSwitchBacklogDoesNotAnimateButNextVisibleReasoningAppendDoes() async throws { - let firstJob = CodexReviewJob.makeForTesting( + let firstJob = ReviewRunRecord.makeForTesting( id: "job-reasoning-switch-backlog", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -2941,7 +2941,7 @@ struct ReviewUITests { .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) - let secondJob = CodexReviewJob.makeForTesting( + let secondJob = ReviewRunRecord.makeForTesting( id: "job-other-selected", cwd: "/tmp/workspace-beta", targetSummary: "Uncommitted changes", @@ -2991,7 +2991,7 @@ struct ReviewUITests { } @Test func reasoningWordGlowCompletesAndClearsRenderingAttributes() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-reasoning-glow-completion", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -3037,7 +3037,7 @@ struct ReviewUITests { } @Test func delayedFirstWordGlowTickDoesNotImmediatelyClearAnimation() async throws { - let job = CodexReviewJob.makeForTesting( + let job = ReviewRunRecord.makeForTesting( id: "job-reasoning-glow-delayed-first-tick", cwd: "/tmp/workspace-alpha", targetSummary: "Uncommitted changes", @@ -4941,8 +4941,8 @@ func makeJob( reviewResult: ParsedReviewResult? = nil, logText: String = "", rawLogText: String = "" -) -> CodexReviewJob { - let job = CodexReviewJob.makeForTesting( +) -> ReviewRunRecord { + let job = ReviewRunRecord.makeForTesting( id: id, cwd: cwd, targetSummary: targetSummary, @@ -4980,7 +4980,7 @@ func reviewChatCellTestChat( } @MainActor -func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] { +func makeWorkspaces(from jobs: [ReviewRunRecord]) -> [CodexReviewWorkspace] { var seenCWDs: Set = [] var order: [String] = [] for job in jobs { @@ -4993,7 +4993,7 @@ func makeWorkspaces(from jobs: [CodexReviewJob]) -> [CodexReviewWorkspace] { } @MainActor -func makeSidebarContent(from jobs: [CodexReviewJob]) -> (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]) { +func makeSidebarContent(from jobs: [ReviewRunRecord]) -> (workspaces: [CodexReviewWorkspace], jobs: [ReviewRunRecord]) { return (makeWorkspaces(from: jobs), Array(jobs.reversed())) } @@ -5173,7 +5173,7 @@ extension CodexReviewStore { authState: TestAuthState = .signedOut, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], - jobs: [CodexReviewJob] = [], + jobs: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { installPreviewChatLogSourceForTesting(on: self, jobs: jobs) @@ -5205,7 +5205,7 @@ extension CodexReviewStore { serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, serverURL: URL? = nil, - content: (workspaces: [CodexReviewWorkspace], jobs: [CodexReviewJob]), + content: (workspaces: [CodexReviewWorkspace], jobs: [ReviewRunRecord]), settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { loadForTesting( From c14dfb0b45b9d06b2683c711bd66a53825661918 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:49:14 +0900 Subject: [PATCH 215/359] refactor(review): name store records as review runs --- .../ReviewAPI/ReviewResults.swift | 6 +- .../Store/CodexReviewStore.swift | 22 +++---- .../Store/CodexReviewStoreCancellation.swift | 34 +++++------ .../Store/CodexReviewStoreDiagnostics.swift | 2 +- .../Store/CodexReviewStoreOrderQueries.swift | 40 ++++++------- .../Store/CodexReviewStoreOrdering.swift | 8 +-- .../Store/CodexReviewStoreReviews.swift | 38 ++++++------ .../Store/CodexReviewStoreRuntimeState.swift | 2 +- .../Store/CodexReviewStoreTesting.swift | 6 +- .../CodexReviewMCPServer.swift | 2 +- Sources/CodexReviewTesting/TestSupport.swift | 12 ++-- .../ReviewMonitorSidebarViewController.swift | 4 +- .../CodexReviewHostTests.swift | 10 ++-- .../CodexReviewStoreCommandTests.swift | 60 +++++++++---------- ...ReviewStoreRateLimitAutoRefreshTests.swift | 10 ++-- .../CodexReviewMCPHTTPServerTests.swift | 22 +++---- ...eviewMonitorCodexSidebarLibraryTests.swift | 2 +- .../ReviewMonitorTimelineTesting.swift | 6 +- Tests/ReviewUITests/ReviewUITests.swift | 20 +++---- 19 files changed, 153 insertions(+), 153 deletions(-) diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift index cbe7bba0..ff4c0ac9 100644 --- a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift +++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift @@ -90,12 +90,12 @@ enum SelectionError: Swift.Error, Sendable { extension CodexReviewAPI.Job.SelectionError: LocalizedError { package var errorDescription: String? { switch self { - case .ambiguous(let jobs): - let candidates = jobs + case .ambiguous(let reviewRuns): + let candidates = reviewRuns .map { "- \($0.jobID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" } .joined(separator: "\n") return """ - Review job selector matched multiple jobs: + Review job selector matched multiple review jobs: \(candidates) Specify jobID or narrow cwd/statuses. """ diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 20e6c635..35c4391e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -9,7 +9,7 @@ public final class CodexReviewStore { package let settings: SettingsStore public package(set) var serverURL: URL? public package(set) var workspaces: Set = [] - public package(set) var jobs: Set = [] + public package(set) var reviewRuns: Set = [] package var shouldAutoStartEmbeddedServer: Bool { backend.seed.shouldAutoStartEmbeddedServer } @@ -122,16 +122,16 @@ public final class CodexReviewStore { } public func stop() async { - let locallyCancelledJobIDs: [String] + let locallyCancelledReviewRunIDs: [String] if backend.invokesRuntimeStopReviewCleanupDuringStop { - locallyCancelledJobIDs = [] + 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)) + jobIDs: Array(Set(locallyCancelledReviewRunIDs + remainingLocallyCancelledReviewRunIDs)) ) transitionToStopped() } @@ -363,7 +363,7 @@ public final class CodexReviewStore { guard let diagnosticsURL else { return } - let jobs: [CodexReviewStoreDiagnosticsSnapshot.Job] = orderedJobs.map { job in + let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Job] = orderedReviewRuns.map { job in let timelineTextSnapshot = job.timeline.textSnapshot return CodexReviewStoreDiagnosticsSnapshot.Job( status: job.core.lifecycle.status.rawValue, @@ -377,7 +377,7 @@ public final class CodexReviewStore { failureMessage: serverState.failureMessage, serverURL: serverURL?.absoluteString, childRuntimePath: nil, - jobs: jobs + reviewRuns: reviewRuns ) do { try FileManager.default.createDirectory( @@ -396,11 +396,11 @@ public final class CodexReviewStore { } public var hasRunningJobs: Bool { - jobs.contains(where: { $0.isTerminal == false }) + reviewRuns.contains(where: { $0.isTerminal == false }) } public var runningJobCount: Int { - jobs.filter { $0.isTerminal == false }.count + reviewRuns.filter { $0.isTerminal == false }.count } public var canPerformPrimaryAuthenticationAction: Bool { @@ -420,7 +420,7 @@ public final class CodexReviewStore { private func resetReviews() { workspaces = [] - jobs = [] + reviewRuns = [] } private func executePendingAccountAction(_ action: CodexReviewAuthModel.PendingAccountAction) async throws { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 2d9e770f..68677754 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -33,7 +33,7 @@ extension CodexReviewStore { sessionID: String, cancellation: ReviewCancellation = .system() ) throws { - guard let job = job(id: jobID) + guard let job = reviewRun(id: jobID) else { throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") } @@ -65,7 +65,7 @@ extension CodexReviewStore { sessionID: String, message: String ) throws { - guard let job = job(id: jobID) + guard let job = reviewRun(id: jobID) else { throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") } @@ -95,9 +95,9 @@ extension CodexReviewStore { let cancellation = ReviewCancellation.system( message: reason.nilIfEmpty ?? "Cancellation requested." ) - let cancellableJobs = orderedJobs.filter { $0.isTerminal == false } + let cancellableReviewRuns = orderedReviewRuns.filter { $0.isTerminal == false } var firstError: (any Error)? - for job in cancellableJobs { + for job in cancellableReviewRuns { do { _ = try await cancelReview( jobID: job.id, @@ -124,14 +124,14 @@ extension CodexReviewStore { package func requestActiveReviewCancellationsForRuntimeStop( reason: ReviewCancellation = .system(message: "Review runtime stopped.") ) async -> [String] { - let activeJobIDs = - orderedJobs + let activeReviewRunIDs = + orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) - for jobID in activeJobIDs { + for jobID in activeReviewRunIDs { _ = try? await cancelReview(jobID: jobID, cancellation: reason) } - return activeJobIDs + return activeReviewRunIDs } package func cleanupActiveReviewsForRuntimeStop( @@ -144,11 +144,11 @@ extension CodexReviewStore { ) async -> CodexReviewRuntimeStopReviewCleanupResult { let request = runtimeStopReviewCleanupRequest(reason: reason) let didCompleteBackendCleanup = await cleanupBackendReviews(request) - let locallyCancelledJobIDs = cancelActiveReviewsLocallyForRuntimeStop( + let locallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop( reason: reason, cancelWorkers: false ) - cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop( timeout: workerDrainTimeout ) @@ -172,16 +172,16 @@ extension CodexReviewStore { reason: ReviewCancellation = .system(message: "Review runtime stopped."), cancelWorkers: Bool = true ) -> [String] { - let activeJobIDs = - orderedJobs + let activeReviewRunIDs = + orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) - guard activeJobIDs.isEmpty == false else { + guard activeReviewRunIDs.isEmpty == false else { return [] } - for jobID in activeJobIDs { - if let job = job(id: jobID), job.isTerminal == false { + for jobID in activeReviewRunIDs { + if let job = reviewRun(id: jobID), job.isTerminal == false { try? completeCancellationLocally( jobID: job.id, sessionID: job.sessionID, @@ -192,7 +192,7 @@ extension CodexReviewStore { runtimeState.cancelActiveWorker(for: jobID) } } - return activeJobIDs + return activeReviewRunIDs } package func cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: [String]) { @@ -250,7 +250,7 @@ extension CodexReviewStore { failureMessage: String ) { let resolvedError = failureMessage.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - for job in orderedJobs where job.isTerminal == false { + for job in orderedReviewRuns where job.isTerminal == false { job.cancellationRequested = false job.core.lifecycle.cancellation = nil job.core.lifecycle.status = .failed diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index 671f8cc5..6caf3cc9 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -25,5 +25,5 @@ struct CodexReviewStoreDiagnosticsSnapshot: Encodable { var failureMessage: String? var serverURL: String? var childRuntimePath: String? - var jobs: [Job] + var reviewRuns: [Job] } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 826afefe..804739b5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -8,12 +8,12 @@ extension CodexReviewStore { } } - package var orderedJobs: [ReviewRunRecord] { - orderedWorkspaces.flatMap { orderedJobs(in: $0) } + package var orderedReviewRuns: [ReviewRunRecord] { + orderedWorkspaces.flatMap { orderedReviewRuns(in: $0) } } - package var hasReviewJobs: Bool { - jobs.isEmpty == false + package var hasReviewRuns: Bool { + reviewRuns.isEmpty == false } package func workspace(cwd: String) -> CodexReviewWorkspace? { @@ -24,20 +24,20 @@ extension CodexReviewStore { workspace(cwd: job.cwd) } - package func job(id: String) -> ReviewRunRecord? { - jobs.first(where: { $0.id == id }) + package func reviewRun(id: String) -> ReviewRunRecord? { + reviewRuns.first(where: { $0.id == id }) } - package func jobs(inWorkspace cwd: String) -> [ReviewRunRecord] { - jobs.filter { $0.cwd == cwd } + package func reviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { + reviewRuns.filter { $0.cwd == cwd } } - package func orderedJobs(in workspace: CodexReviewWorkspace) -> [ReviewRunRecord] { - orderedJobs(inWorkspace: workspace.cwd) + package func orderedReviewRuns(in workspace: CodexReviewWorkspace) -> [ReviewRunRecord] { + orderedReviewRuns(inWorkspace: workspace.cwd) } - package func orderedJobs(inWorkspace cwd: String) -> [ReviewRunRecord] { - jobs(inWorkspace: cwd).sorted { + package func orderedReviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { + reviewRuns(inWorkspace: cwd).sorted { if $0.sortOrder == $1.sortOrder { return $0.id < $1.id } @@ -45,16 +45,16 @@ extension CodexReviewStore { } } - package func jobCount(in workspace: CodexReviewWorkspace) -> Int { - jobs(inWorkspace: workspace.cwd).count + package func reviewRunCount(in workspace: CodexReviewWorkspace) -> Int { + reviewRuns(inWorkspace: workspace.cwd).count } - package func totalJobCount() -> Int { - jobs.count + package func totalReviewRunCount() -> Int { + reviewRuns.count } - package func normalizeJobSortOrders(inWorkspace cwd: String) { - let ordered = orderedJobs(inWorkspace: cwd) + package func normalizeReviewRunSortOrders(inWorkspace cwd: String) { + let ordered = orderedReviewRuns(inWorkspace: cwd) for (index, job) in ordered.enumerated() { job.sortOrder = Double(ordered.count - index - 1) } @@ -67,9 +67,9 @@ extension CodexReviewStore { } } - package func normalizeAllJobSortOrders() { + package func normalizeAllReviewRunSortOrders() { for workspace in workspaces { - normalizeJobSortOrders(inWorkspace: workspace.cwd) + normalizeReviewRunSortOrders(inWorkspace: workspace.cwd) } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift index 8b8c3aa5..986dac0e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift @@ -63,7 +63,7 @@ extension CodexReviewStore { return false } - let ordered = orderedJobs(inWorkspace: cwd) + let ordered = orderedReviewRuns(inWorkspace: cwd) guard let job = ordered.first(where: { $0.id == id }) else { return false } @@ -91,7 +91,7 @@ extension CodexReviewStore { return false } - let ordered = orderedJobs(inWorkspace: cwd) + let ordered = orderedReviewRuns(inWorkspace: cwd) guard let job = ordered.first(where: { $0.id == id }), let sourceIndex = ordered.firstIndex(where: { $0 === job }) else { @@ -110,11 +110,11 @@ extension CodexReviewStore { sortOrder: \.sortOrder ) if sortOrder == nil { - normalizeJobSortOrders(inWorkspace: cwd) + normalizeReviewRunSortOrders(inWorkspace: cwd) sortOrder = reorderedSortOrder( moving: job, toIndex: destinationIndex, - in: orderedJobs(inWorkspace: cwd), + in: orderedReviewRuns(inWorkspace: cwd), sortOrder: \.sortOrder ) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 2bd1ba1b..2040ca60 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -4,8 +4,8 @@ private let networkRecoveryUnavailableMessage = "Network unavailable; waiting to 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) } @@ -68,14 +68,14 @@ extension CodexReviewStore { id: jobID, sessionID: sessionID, cwd: validatedRequest.cwd, - sortOrder: nextJobSortOrder(inWorkspace: validatedRequest.cwd), + sortOrder: nextReviewRunSortOrder(inWorkspace: validatedRequest.cwd), targetSummary: validatedRequest.target.displaySummary, core: .init( lifecycle: .init(status: .queued), output: .init(summary: "Queued.") ) ) - insertReviewJob(job) + insertReviewRun(job) markReviewRunning(job, startedAt: createdAt) runtimeState.markStarting(jobID) launchReviewWorker(jobID: jobID, sessionID: sessionID, request: validatedRequest) @@ -99,7 +99,7 @@ extension CodexReviewStore { sessionID: String, request validatedRequest: CodexReviewAPI.Start.Request ) async { - guard let job = job(id: jobID) else { + guard let job = reviewRun(id: jobID) else { runtimeState.clearStarting(jobID) runtimeState.removeActiveWorker(for: jobID) return @@ -432,7 +432,7 @@ extension CodexReviewStore { statuses: [ReviewJobState]? = nil, limit: Int? = nil ) -> 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)) } @@ -444,7 +444,7 @@ extension CodexReviewStore { limit: Int? = nil ) -> CodexReviewAPI.List.Result { let statusSet = statuses.map(Set.init) - let filtered = orderedJobs.filter { job in + let filtered = orderedReviewRuns.filter { job in if let sessionID, job.sessionID != sessionID { return false } @@ -466,7 +466,7 @@ extension CodexReviewStore { package func resolveJob(sessionID: String?, selector: CodexReviewAPI.Job.Selector) throws -> ReviewRunRecord { let statusSet = selector.statuses.map(Set.init) - let matches = orderedJobs.filter { job in + let matches = orderedReviewRuns.filter { job in if let sessionID, job.sessionID != sessionID { return false } @@ -495,7 +495,7 @@ extension CodexReviewStore { sessionID: String, cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { - guard let job = job(id: jobID), job.sessionID == sessionID else { + guard let job = reviewRun(id: jobID), job.sessionID == sessionID else { throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") } return try await cancelReview(jobID: jobID, cancellation: cancellation) @@ -598,14 +598,14 @@ extension CodexReviewStore { reason: ReviewCancellation = .sessionClosed() ) async { closedSessions.insert(sessionID) - for jobID in activeJobIDs(for: sessionID) { + for jobID in activeReviewRunIDs(for: sessionID) { _ = try? await cancelReview(jobID: jobID, cancellation: reason) } } package func closeActiveReviewSessions(reason: ReviewCancellation) async { let jobIDs = - orderedJobs + orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) for jobID in jobIDs { @@ -614,15 +614,15 @@ extension CodexReviewStore { } private func job(jobID: String) throws -> ReviewRunRecord { - guard let job = job(id: jobID) else { + guard let job = reviewRun(id: jobID) else { throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") } return job } - private func filteredJobs(cwd: String?, statuses: [ReviewJobState]?) -> [ReviewRunRecord] { + private func filteredReviewRuns(cwd: String?, statuses: [ReviewJobState]?) -> [ReviewRunRecord] { let statusSet = statuses.map(Set.init) - return orderedJobs.filter { job in + return orderedReviewRuns.filter { job in if let cwd, job.cwd != cwd { return false } @@ -652,7 +652,7 @@ extension CodexReviewStore { return max(0, Int(end.timeIntervalSince(startedAt))) } - private func insertReviewJob(_ job: ReviewRunRecord) { + private func insertReviewRun(_ job: ReviewRunRecord) { if workspace(cwd: job.cwd) == nil { let workspace = CodexReviewWorkspace( cwd: job.cwd, @@ -660,7 +660,7 @@ extension CodexReviewStore { ) workspaces.insert(workspace) } - jobs.insert(job) + reviewRuns.insert(job) writeDiagnosticsIfNeeded() } @@ -1037,7 +1037,7 @@ extension CodexReviewStore { } private func waitForReviewTerminal(jobID: String, timeout: Duration?) async { - guard let job = job(id: jobID), + guard let job = reviewRun(id: jobID), job.isTerminal == false else { return @@ -1048,8 +1048,8 @@ extension CodexReviewStore { ) } - private func nextJobSortOrder(inWorkspace cwd: String) -> Double { - (jobs(inWorkspace: cwd).map(\.sortOrder).max() ?? -1) + 1 + private func nextReviewRunSortOrder(inWorkspace cwd: String) -> Double { + (reviewRuns(inWorkspace: cwd).map(\.sortOrder).max() ?? -1) + 1 } private func nextWorkspaceSortOrder() -> Double { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift index 655e7945..d147230f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -156,7 +156,7 @@ final class CodexReviewStoreRuntimeState { } extension CodexReviewStore { - package func runtimeJobState(jobID: String) -> CodexReviewRuntimeJobState { + package func runtimeReviewRunState(jobID: String) -> CodexReviewRuntimeJobState { runtimeState.jobState(for: jobID) } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index 8268ea04..4062e34b 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -20,7 +20,7 @@ extension CodexReviewStore { persistedAccounts: [CodexReviewAccount]? = nil, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], - jobs: [ReviewRunRecord] = [], + reviewRuns: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { precondition( @@ -62,7 +62,7 @@ extension CodexReviewStore { self.workspaces = Set(resolvedWorkspaces) var jobsByCWD: [String: [ReviewRunRecord]] = [:] - let resolvedJobs = jobs.filter { job in + let resolvedJobs = reviewRuns.filter { job in resolvedWorkspaces.contains(where: { $0.cwd == job.cwd }) } for job in resolvedJobs { @@ -76,7 +76,7 @@ extension CodexReviewStore { } job.sortOrder = Double(workspaceJobs.count - index - 1) } - self.jobs = Set(resolvedJobs) + self.reviewRuns = Set(resolvedJobs) if let settingsSnapshot { settings.loadForTesting(snapshot: settingsSnapshot) } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 4134381f..6ba36bc7 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -149,7 +149,7 @@ package final class CodexReviewMCPServer { } package func hasActiveReviews(in sessionID: String) -> Bool { - store.activeJobIDs(for: sessionID).isEmpty == false + store.activeReviewRunIDs(for: sessionID).isEmpty == false } } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index a29aaa0a..682695c1 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -535,7 +535,7 @@ 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 @@ -543,7 +543,7 @@ package final class StoreSnapshotProbe { return lhs.sortOrder > rhs.sortOrder } .map { job in - let runtimeState = store.runtimeJobState(jobID: job.id) + let runtimeState = store.runtimeReviewRunState(jobID: job.id) return StoreJobSnapshot( jobID: job.id, status: job.core.lifecycle.status, @@ -554,7 +554,7 @@ package final class StoreSnapshotProbe { cancellationRequested: job.cancellationRequested ) } - return StoreSnapshot(jobs: jobs) + return StoreSnapshot(reviewRuns: reviewRuns) } package func waitUntilJobStatus( @@ -597,13 +597,13 @@ package final class StoreSnapshotProbe { } package struct StoreSnapshot: Sendable { - package var jobs: [StoreJobSnapshot] + package var reviewRuns: [StoreJobSnapshot] package func job(_ jobID: String? = nil) -> StoreJobSnapshot? { guard let jobID else { - return jobs.first + return reviewRuns.first } - return jobs.first { $0.jobID == jobID } + return reviewRuns.first { $0.jobID == jobID } } } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index d736fb73..0ac1eef2 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -721,7 +721,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func cancellableReviewJob( for chat: ReviewMonitorCodexSidebarSnapshot.Chat ) -> ReviewRunRecord? { - store.orderedJobs.first { job in + store.orderedReviewRuns.first { job in guard job.isTerminal == false, let chatID = job.sidebarChatID else { @@ -746,7 +746,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD cancellation: .userInterface() ) } catch { - guard let job = self.store.job(id: jobID) else { + guard let job = self.store.reviewRun(id: jobID) else { return } try? self.store.recordCancellationFailure( diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 89dfa4b0..00f390f9 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1147,7 +1147,7 @@ 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(CodexReviewKit.CodexReviewAccount(email: "second@example.com")) let result = try await reviewRead @@ -1218,7 +1218,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 @@ -1321,7 +1321,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() @@ -1372,7 +1372,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() @@ -1426,7 +1426,7 @@ struct CodexReviewHostTests { 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)) { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index c792ec5f..c769ce25 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -240,7 +240,7 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "second") #expect(read.core.reviewText == "second") - let job = try #require(store.job(id: "job-1")) + let job = try #require(store.reviewRun(id: "job-1")) let messages = job.timeline.items.compactMap { item -> String? in guard case .message(let message) = item.content else { return nil @@ -349,7 +349,7 @@ struct CodexReviewStoreCommandTests { _ = try await second #expect( - store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary) == [ + store.orderedReviewRuns(inWorkspace: "/tmp/project").map(\.targetSummary) == [ "Uncommitted changes", "Base branch: main", ]) @@ -395,9 +395,9 @@ struct CodexReviewStoreCommandTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [existing] + reviewRuns: [existing] ) - store.job(id: "job-existing")?.sortOrder = 10 + store.reviewRun(id: "job-existing")?.sortOrder = 10 async let result = store.startReview( sessionID: "session-1", @@ -406,7 +406,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "new")) _ = try await result - #expect(store.orderedJobs(inWorkspace: "/tmp/project").map(\.targetSummary).first == "Uncommitted changes") + #expect(store.orderedReviewRuns(inWorkspace: "/tmp/project").map(\.targetSummary).first == "Uncommitted changes") } } @@ -469,11 +469,11 @@ struct CodexReviewStoreCommandTests { store.loadForTesting( serverState: .running, workspaces: [workspace], - jobs: [firstJob, secondJob, thirdJob] + reviewRuns: [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.orderedReviewRuns(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) } @@ -628,7 +628,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) #expect( await waitUntil { - store.job(id: "job-1")?.core.output.summary == "Network restored; restarting review." + store.reviewRun(id: "job-1")?.core.output.summary == "Network restored; restarting review." }) networkMonitor.yield(.satisfied()) await settleGate.open() @@ -681,7 +681,7 @@ struct CodexReviewStoreCommandTests { ), for: initialRun) #expect( await waitUntil { - store.job(id: "job-1")?.core.run.turnID == "turn-actual" + store.reviewRun(id: "job-1")?.core.run.turnID == "turn-actual" }) networkMonitor.yield(.init(status: .unsatisfied)) @@ -962,7 +962,7 @@ struct CodexReviewStoreCommandTests { } try #require( await waitUntil { - let state = store.runtimeJobState(jobID: "job-1") + let state = store.runtimeReviewRunState(jobID: "job-1") return state.hasActiveWorker == false && state.activeRun == nil }) await recoverGate.open() @@ -1015,7 +1015,7 @@ struct CodexReviewStoreCommandTests { let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) let cleanedUp = await waitUntil { - let runtimeState = store.runtimeJobState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(jobID: "job-1") return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil } let read = try store.readReview(jobID: "job-1") @@ -1047,21 +1047,21 @@ struct CodexReviewStoreCommandTests { ) _ = try await running - let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( + let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) let cancelled = try store.readReview(jobID: "job-1") - #expect(locallyCancelledJobIDs == ["job-1"]) + #expect(locallyCancelledReviewRunIDs == ["job-1"]) #expect(cancelled.core.lifecycle.status == .cancelled) - let runtimeStateBeforeDetach = store.runtimeJobState(jobID: "job-1") + let runtimeStateBeforeDetach = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeStateBeforeDetach.hasActiveWorker) #expect(runtimeStateBeforeDetach.activeRun == run) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) - let runtimeStateAfterDetach = store.runtimeJobState(jobID: "job-1") + let runtimeStateAfterDetach = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeStateAfterDetach.hasActiveWorker == false) #expect(runtimeStateAfterDetach.activeRun == nil) } @@ -1104,7 +1104,7 @@ struct CodexReviewStoreCommandTests { let commands = await backend.recordedCommands() #expect(commands.contains(.interruptReview(run, .init(message: "Review runtime stopped.")))) #expect(stopped.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeJobState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeState.activeRun == nil) #expect(runtimeState.hasActiveWorker == false) } @@ -1136,13 +1136,13 @@ struct CodexReviewStoreCommandTests { try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) _ = try await running - let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( + let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) - let runtimeState = store.runtimeJobState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1190,7 +1190,7 @@ struct CodexReviewStoreCommandTests { #expect(request.reason.message == "Review runtime stopped.") #expect(request.recoveryWaitingRuns == [run]) #expect(read.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeJobState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1217,14 +1217,14 @@ struct CodexReviewStoreCommandTests { ) _ = try await running - let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( + let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) - #expect(store.runtimeJobState(jobID: "job-1").hasDetachedWorker == false) + #expect(store.runtimeReviewRunState(jobID: "job-1").hasDetachedWorker == false) #expect(await backend.recordedCommands().contains(.cleanupReview(run))) } } @@ -1246,18 +1246,18 @@ struct CodexReviewStoreCommandTests { } try await backend.waitForStartReview(timeout: .seconds(2)) - let locallyCancelledJobIDs = store.cancelActiveReviewsLocallyForRuntimeStop( + let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledJobIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) let resultBeforeStartReviewUnblocked = try await waitForTaskValue(running, timeout: .seconds(1)) await startReviewGate.open() let result = try #require(resultBeforeStartReviewUnblocked) - #expect(locallyCancelledJobIDs == ["job-1"]) + #expect(locallyCancelledReviewRunIDs == ["job-1"]) #expect(result.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeJobState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(jobID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) } @@ -1630,7 +1630,7 @@ struct CodexReviewStoreCommandTests { ) 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) + let cancelledDuringStartup = try #require(store.reviewRuns.first) #expect(cancel.core.lifecycle.status == .cancelled) #expect(cancelledDuringStartup.core.lifecycle.status == .cancelled) await gate.open() @@ -1691,7 +1691,7 @@ struct CodexReviewStoreCommandTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [running] + reviewRuns: [running] ) await store.closeActiveReviewSessions(reason: .system(message: "Account switched.")) diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 291cab6e..ada250b5 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -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: [runningJob]) #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)), ]) @@ -304,7 +304,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { private func loadStore( _ store: CodexReviewStore, account: CodexReviewAccount, - jobs: [ReviewRunRecord] + reviewRuns: [ReviewRunRecord] ) { store.loadForTesting( serverState: .running, @@ -312,7 +312,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { account: account, persistedAccounts: [account], workspaces: [CodexReviewWorkspace(cwd: "/tmp/repo")], - jobs: jobs + reviewRuns: reviewRuns ) } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 2f6770af..dd95fa30 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -377,7 +377,7 @@ struct CodexReviewMCPHTTPServerTests { .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, @@ -434,7 +434,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [ + reviewRuns: [ includedJob, ReviewRunRecord.makeForTesting( id: "job-other-session", @@ -527,7 +527,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [job] + reviewRuns: [job] ) let defaultResponse = try await postJSONRPC( @@ -622,7 +622,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [job] + reviewRuns: [job] ) let response = try await postJSONRPC( @@ -689,7 +689,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, @@ -756,7 +756,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [completed, running] + reviewRuns: [completed, running] ) let response = try await postJSONRPC( @@ -794,7 +794,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [ + reviewRuns: [ ReviewRunRecord.makeForTesting( id: "job-running-1", sessionID: sessionID, @@ -837,7 +837,7 @@ 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("matched multiple review jobs") == true) #expect(text?.contains("job-running-1") == true) #expect(text?.contains("job-running-2") == true) } @@ -864,7 +864,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [running] + reviewRuns: [running] ) let response = try await postJSONRPC( @@ -1034,7 +1034,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - jobs: [other] + reviewRuns: [other] ) let response = try await postJSONRPC( @@ -1080,7 +1080,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) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 055c3247..1bb52b1f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -416,7 +416,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { store.loadForTesting( serverState: .running, workspaces: [CodexReviewWorkspace(cwd: repo.path)], - jobs: [legacyJob] + reviewRuns: [legacyJob] ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index ab6b61c8..0e1481f9 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -171,14 +171,14 @@ func replaceChatLogTextForTesting(_ job: ReviewRunRecord, _ text: String) { } @MainActor -func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, jobs: [ReviewRunRecord]) { - let fixtures = jobs.compactMap { try? makePreviewChatLogFixtureForTesting(job: $0) } +func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, reviewRuns: [ReviewRunRecord]) { + let fixtures = reviewRuns.compactMap { try? makePreviewChatLogFixtureForTesting(job: $0) } let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) jobStorePreviewSupportRetainers.append( ReviewChatLogFixtureRetainer( source: source, - jobIDs: Set(jobs.map(\.id)) + jobIDs: Set(reviewRuns.map(\.id)) )) } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 9a927564..012e3792 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2169,7 +2169,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, workspaces: [workspace], - jobs: [job] + reviewRuns: [job] ) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) @@ -4980,10 +4980,10 @@ func reviewChatCellTestChat( } @MainActor -func makeWorkspaces(from jobs: [ReviewRunRecord]) -> [CodexReviewWorkspace] { +func makeWorkspaces(from reviewRuns: [ReviewRunRecord]) -> [CodexReviewWorkspace] { var seenCWDs: Set = [] var order: [String] = [] - for job in jobs { + for job in reviewRuns { if seenCWDs.contains(job.cwd) == false { order.insert(job.cwd, at: 0) seenCWDs.insert(job.cwd) @@ -4993,8 +4993,8 @@ func makeWorkspaces(from jobs: [ReviewRunRecord]) -> [CodexReviewWorkspace] { } @MainActor -func makeSidebarContent(from jobs: [ReviewRunRecord]) -> (workspaces: [CodexReviewWorkspace], jobs: [ReviewRunRecord]) { - return (makeWorkspaces(from: jobs), Array(jobs.reversed())) +func makeSidebarContent(from reviewRuns: [ReviewRunRecord]) -> (workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord]) { + return (makeWorkspaces(from: reviewRuns), Array(reviewRuns.reversed())) } struct LinkedWorktreeFixtureForTesting { @@ -5173,10 +5173,10 @@ extension CodexReviewStore { authState: TestAuthState = .signedOut, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], - jobs: [ReviewRunRecord] = [], + reviewRuns: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { - installPreviewChatLogSourceForTesting(on: self, jobs: jobs) + installPreviewChatLogSourceForTesting(on: self, reviewRuns: reviewRuns) loadForTesting( serverState: serverState, authPhase: authState.phase, @@ -5196,7 +5196,7 @@ extension CodexReviewStore { } ?? [], serverURL: serverURL, workspaces: workspaces, - jobs: jobs, + reviewRuns: reviewRuns, settingsSnapshot: settingsSnapshot ) } @@ -5205,7 +5205,7 @@ extension CodexReviewStore { serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, serverURL: URL? = nil, - content: (workspaces: [CodexReviewWorkspace], jobs: [ReviewRunRecord]), + content: (workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord]), settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { loadForTesting( @@ -5213,7 +5213,7 @@ extension CodexReviewStore { authState: authState, serverURL: serverURL, workspaces: content.workspaces, - jobs: content.jobs, + reviewRuns: content.reviewRuns, settingsSnapshot: settingsSnapshot ) } From df5bd79080c9d5a10659b8d480edf58779bef67c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:05:04 +0900 Subject: [PATCH 216/359] refactor(review): decouple run records from timelines --- .../Model/ReviewRunRecord.swift | 43 +--- .../Store/CodexReviewStore.swift | 5 +- .../Store/CodexReviewStoreCancellation.swift | 3 - .../Store/CodexReviewStoreReviews.swift | 77 ++----- .../CodexReviewMCPServer.swift | 9 +- .../CodexReviewMCPToolResults.swift | 206 ++---------------- .../ReviewMCPProjection.swift | 205 +++-------------- .../CodexReviewStoreCommandTests.swift | 9 +- .../ReviewRunRecordTests.swift | 26 +-- .../CodexReviewMCPHTTPServerTests.swift | 93 ++------ .../ReviewMCPProjectionTests.swift | 200 ++++------------- 11 files changed, 148 insertions(+), 728 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index 0c64f493..d0e87728 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -5,7 +5,6 @@ import Observation @Observable public final class ReviewRunRecord: Identifiable, Hashable { public nonisolated let id: String - package let timeline: ReviewTimeline public let sessionID: String public let cwd: String public internal(set) var sortOrder: Double @@ -18,7 +17,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { @ObservationIgnored package var completedAgentMessageItemIDs: Set @ObservationIgnored - private var syntheticTimelineItemCounter: UInt64 + private var syntheticMessageItemCounter: UInt64 public var isTerminal: Bool { core.isTerminal @@ -42,7 +41,6 @@ public final class ReviewRunRecord: Identifiable, Hashable { cancellationRequested: Bool = false ) { self.id = id - self.timeline = ReviewTimeline() self.sessionID = sessionID self.cwd = cwd self.sortOrder = sortOrder @@ -51,8 +49,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { self.cancellationRequested = cancellationRequested self.agentMessagesByItemID = [:] self.completedAgentMessageItemIDs = [] - self.syntheticTimelineItemCounter = 0 - syncTimelineTerminalStateFromCore() + self.syntheticMessageItemCounter = 0 } public nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { @@ -63,38 +60,8 @@ public final class ReviewRunRecord: Identifiable, Hashable { hasher.combine(id) } - package func nextSyntheticTimelineItemID(prefix: String) -> ReviewTimelineItem.ID { - syntheticTimelineItemCounter &+= 1 - return .init(rawValue: "\(id):\(prefix):\(syntheticTimelineItemCounter)") - } - - package func syncTimelineTerminalStateFromCore() { - guard core.lifecycle.status.isTerminal else { - return - } - let timestamp = core.lifecycle.endedAt ?? 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 - } + package func nextSyntheticMessageItemID(prefix: String) -> String { + syntheticMessageItemCounter &+= 1 + return "\(id):\(prefix):\(syntheticMessageItemCounter)" } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 35c4391e..e73606f9 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -364,12 +364,11 @@ public final class CodexReviewStore { return } let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Job] = orderedReviewRuns.map { job in - let timelineTextSnapshot = job.timeline.textSnapshot return CodexReviewStoreDiagnosticsSnapshot.Job( status: job.core.lifecycle.status.rawValue, summary: job.core.output.summary, - timelineText: timelineTextSnapshot.timelineText, - rawTimelineText: timelineTextSnapshot.rawTimelineText + timelineText: job.core.reviewText, + rawTimelineText: job.core.reviewText ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 68677754..061d1933 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -46,7 +46,6 @@ extension CodexReviewStore { } let endedAt = clock.now() - job.timeline.closeActiveItems(family: .command, phase: .cancelled, timestamp: endedAt) job.cancellationRequested = false job.core.lifecycle.cancellation = cancellation job.core.lifecycle.status = .cancelled @@ -56,7 +55,6 @@ extension CodexReviewStore { cancellation.message.nilIfEmpty ?? job.core.lifecycle.errorMessage job.core.lifecycle.endedAt = endedAt - job.timeline.apply(.reviewCancelled(cancellation.message), at: endedAt) noteJobMutation() } @@ -265,7 +263,6 @@ extension CodexReviewStore { ?? 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()) } noteJobMutation() } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 2040ca60..e419226f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -235,9 +235,7 @@ extension CodexReviewStore { } private func appendRecoveryProgress(_ message: String, to job: ReviewRunRecord) { - let now = clock.now() job.core.output.summary = message - appendDiagnostic(message, to: job, at: now) writeDiagnosticsIfNeeded() } @@ -256,7 +254,6 @@ extension CodexReviewStore { continue } updateCoreOutput(from: event, for: job) - job.timeline.apply(event, at: timestamp) } } @@ -322,21 +319,17 @@ extension CodexReviewStore { at timestamp: Date ) { let resolvedItemID = - itemID?.nilIfEmpty.map(ReviewTimelineItem.ID.init(rawValue:)) - ?? job.nextSyntheticTimelineItemID(prefix: "message") - let seed = ReviewTimelineItemSeed( - id: resolvedItemID, - kind: .agentMessage, - family: .message, - phase: isCompleted ? .completed : .running, - content: .message(.init(text: text)) - ) + itemID?.nilIfEmpty + ?? job.nextSyntheticMessageItemID(prefix: "message") job.noteAgentMessageSnapshot( - itemID: resolvedItemID.rawValue, + itemID: resolvedItemID, text: text, isCompleted: isCompleted ) - job.timeline.apply(isCompleted ? .itemCompleted(seed) : .itemUpdated(seed), at: timestamp) + if let text = text.nilIfEmpty { + job.core.output.lastAgentMessage = text + job.core.output.summary = text + } } private func applyMessageDelta( @@ -350,33 +343,6 @@ extension CodexReviewStore { } job.core.output.lastAgentMessage = updatedMessage job.core.output.summary = updatedMessage - job.timeline.apply( - .textDelta( - itemID: .init(rawValue: itemID), - kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: text - ), at: timestamp) - } - - private func appendDiagnostic( - _ message: String, - to job: ReviewRunRecord, - at timestamp: Date, - severity: ReviewDiagnosticSeverity? = nil - ) { - guard let message = message.nilIfEmpty else { - return - } - let seed = ReviewTimelineItemSeed( - id: job.nextSyntheticTimelineItemID(prefix: "diagnostic"), - kind: .init(rawValue: "reviewDiagnostic"), - family: .diagnostic, - phase: .completed, - content: .diagnostic(.init(message: message, severity: severity)) - ) - job.timeline.apply(.itemCompleted(seed), at: timestamp) } private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { @@ -668,12 +634,6 @@ extension CodexReviewStore { 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) writeDiagnosticsIfNeeded() } @@ -682,12 +642,10 @@ extension CodexReviewStore { return } let endedAt = clock.now() - job.timeline.closeActiveItems(family: .command, phase: .failed, timestamp: endedAt) job.core.lifecycle.status = .failed job.core.lifecycle.endedAt = endedAt job.core.lifecycle.errorMessage = message job.core.output.summary = message - job.timeline.apply(.reviewFailed(message), at: endedAt) writeDiagnosticsIfNeeded() } @@ -963,13 +921,6 @@ extension CodexReviewStore { updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: job.id) 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): let now = clock.now() job.core.output.lastAgentMessage = text @@ -978,7 +929,9 @@ extension CodexReviewStore { case .messageDelta(let text, let itemID): applyMessageDelta(text, itemID: itemID, to: job, at: clock.now()) case .log(let text): - appendDiagnostic(text, to: job, at: clock.now()) + if job.core.output.summary.isEmpty || job.core.output.summary == "Review started." { + job.core.output.summary = text + } case .completed(let summary, let result): completeReview(job, summary: summary, result: result) case .failed(let message): @@ -1020,7 +973,6 @@ extension CodexReviewStore { let previousAgentMessage = job.core.output.lastAgentMessage?.nilIfEmpty let resultText = result?.nilIfEmpty let finalReviewText = resultText ?? previousAgentMessage - job.timeline.closeActiveItems(family: .command, phase: .completed, timestamp: endedAt) job.core.lifecycle.status = .succeeded job.core.lifecycle.endedAt = endedAt job.core.output.summary = summary @@ -1030,9 +982,12 @@ extension CodexReviewStore { if let result = resultText, result != previousAgentMessage { - applyMessageSnapshot(result, itemID: nil, isCompleted: true, to: job, at: endedAt) + job.noteAgentMessageSnapshot( + itemID: job.nextSyntheticMessageItemID(prefix: "message"), + text: result, + isCompleted: true + ) } - job.timeline.apply(.reviewCompleted(summary: summary, result: finalReviewText), at: endedAt) writeDiagnosticsIfNeeded() } @@ -1110,8 +1065,6 @@ private extension ReviewRunRecord { core.output.reviewResult = nil agentMessagesByItemID.removeAll(keepingCapacity: true) completedAgentMessageItemIDs.removeAll(keepingCapacity: true) - timeline.reset(keepingTerminal: core.lifecycle.status.isTerminal) - syncTimelineTerminalStateFromCore() } } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 6ba36bc7..062bbf20 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -137,11 +137,10 @@ package final class CodexReviewMCPServer { _ result: CodexReviewAPI.Read.Result, sessionID: String? ) throws -> CodexReviewMCP.Tool.ReviewSnapshot { - let job = try store.resolveJob( - sessionID: sessionID, - selector: .init(jobID: result.jobID) - ) - return .init(result: result, timeline: ReviewMCPProjection(timeline: job.timeline)) + if let sessionID { + _ = try store.resolveJob(sessionID: sessionID, selector: .init(jobID: result.jobID)) + } + return .init(result: result, timeline: ReviewMCPProjection(result: result)) } package func closeSession(_ sessionID: String) async { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index c1a80c9b..5a87969d 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -109,11 +109,11 @@ 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) }), + "revision": .string(revision), + "orderedItemIds": .array(orderedItemIDs.map(Value.string)), + "activeItemIds": .array(activeItemIDs.map(Value.string)), "activeItemCount": .int(activeItemCount), - "latestActivityId": latestActivityID.map { .string($0.rawValue) } ?? .null, + "latestActivityId": latestActivityID.map(Value.string) ?? .null, "terminalSummary": boundedTimelineString( terminalSummary, field: "terminalSummary", @@ -135,11 +135,11 @@ private extension ReviewMCPProjection { func structuredContentWithItems() -> 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) }), + "revision": .string(revision), + "orderedItemIds": .array(orderedItemIDs.map(Value.string)), + "activeItemIds": .array(activeItemIDs.map(Value.string)), "activeItemCount": .int(activeItemCount), - "latestActivityId": latestActivityID.map { .string($0.rawValue) } ?? .null, + "latestActivityId": latestActivityID.map(Value.string) ?? .null, "terminalSummary": boundedTimelineString( terminalSummary, field: "terminalSummary", @@ -171,17 +171,9 @@ private extension ReviewMCPProjection { 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), + "id": .string(id), + "kind": .string(kind), "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, ]) } } @@ -189,189 +181,28 @@ private extension ReviewMCPProjection.Item { 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): + case .diagnostic(let message): var truncatedFields: [String] = [] return .object([ "type": .string("diagnostic"), "message": boundedTimelineString( - diagnostic.message, + 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): + case .message(let text): var truncatedFields: [String] = [] return .object([ "type": .string("message"), "text": boundedTimelineString( - message.text, + 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)), - ]) } } } @@ -471,15 +302,6 @@ private extension String { } } -private extension UInt64 { - func structuredRevisionValue() -> Value { - if self <= UInt64(Int.max) { - return .int(Int(self)) - } - return .string(String(self)) - } -} - private extension CodexReviewAPI.Job.ListItem { func structuredContent() -> Value { .object([ diff --git a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift index 051398e1..92f41070 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift @@ -3,194 +3,59 @@ import CodexReviewKit struct ReviewMCPProjection: Sendable, Equatable { struct Item: Sendable, Equatable { - var id: ReviewTimelineItem.ID - var kind: ReviewItemKind - var family: ReviewItemFamily - var phase: ReviewItemPhase - var isActive: Bool + var id: String + var kind: String var content: Content - var createdAt: Date - var updatedAt: Date - var startedAt: Date? - var completedAt: Date? - var durationMs: Int? - - @MainActor - 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 - } } 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) + case message(String) + case diagnostic(String) 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" - } - } - - 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)) + case .diagnostic: + "diagnostic" } } } - struct Approval: Sendable, Equatable { - var title: String - var detail: String? - } - - struct Command: Sendable, Equatable { - var command: String - var cwd: String? - var output: String - var exitCode: Int? - } - - struct ContextCompaction: Sendable, Equatable { - var title: String - } - - struct Diagnostic: Sendable, Equatable { - var message: String - } - - struct FileChange: Sendable, Equatable { - var title: String - var output: String - } - - struct Message: Sendable, Equatable { - var text: String - } - - struct Plan: Sendable, Equatable { - var markdown: String - } - - struct Reasoning: Sendable, Equatable { - var text: String - var style: ReviewTimelineItem.Reasoning.Style - } - - struct Search: Sendable, Equatable { - var query: String - var result: String? - } - - struct ToolCall: Sendable, Equatable { - var namespace: String? - var server: String? - var tool: String? - var arguments: String? - var progress: String? - var result: String? - var error: String? - } - - struct Unknown: Sendable, Equatable { - var title: String - var detail: String? - } - - var timelineRevision: ReviewTimeline.Revision - var orderedItemIDs: [ReviewTimelineItem.ID] - var activeItemIDs: [ReviewTimelineItem.ID] + var revision: String + var orderedItemIDs: [String] + var activeItemIDs: [String] var activeItemCount: Int - var latestActivityID: ReviewTimelineItem.ID? + var latestActivityID: String? var terminalSummary: String? var terminalResult: String? var items: [Item] - @MainActor - 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)) + init(result: CodexReviewAPI.Read.Result) { + let lifecycle = result.core.lifecycle + let output = result.core.output + let status = lifecycle.status + self.revision = [ + result.jobID, + status.rawValue, + lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", + output.summary, + output.lastAgentMessage ?? "", + ].joined(separator: ":") + + var items: [Item] = [] + if let text = output.lastAgentMessage?.nilIfEmpty { + items.append(.init(id: "\(result.jobID):message", kind: "agentMessage", content: .message(text))) + } else if let summary = output.summary.nilIfEmpty { + items.append(.init(id: "\(result.jobID):summary", kind: "diagnostic", content: .diagnostic(summary))) } + + self.items = items + self.orderedItemIDs = items.map(\.id) + self.activeItemIDs = status.isTerminal ? [] : items.map(\.id) + self.activeItemCount = activeItemIDs.count + self.latestActivityID = orderedItemIDs.last + self.terminalSummary = status.isTerminal ? output.summary : nil + self.terminalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index c769ce25..527de5d9 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -240,14 +240,7 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "second") #expect(read.core.reviewText == "second") - let job = try #require(store.reviewRun(id: "job-1")) - let messages = job.timeline.items.compactMap { item -> String? in - guard case .message(let message) = item.content else { - return nil - } - return message.text - } - #expect(messages == ["first", "second"]) + #expect(store.reviewRun(id: "job-1")?.core.output.lastAgentMessage == "second") } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 438be1c7..8c15b6bd 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("Review run record") @MainActor struct ReviewRunRecordTests { - @Test func prebuiltTerminalJobsInitializeTimelineTerminalState() { + @Test func prebuiltTerminalRunsKeepCoreTerminalState() { let succeeded = ReviewRunRecord.makeForTesting( id: "job-terminal-succeeded", cwd: "/tmp/workspace", @@ -15,9 +15,9 @@ struct ReviewRunRecordTests { hasFinalReview: true, lastAgentMessage: "No findings." ) - #expect(succeeded.timeline.terminalStatus == .succeeded) - #expect(succeeded.timeline.terminalSummary == "Succeeded.") - #expect(succeeded.timeline.terminalResult == "No findings.") + #expect(succeeded.core.lifecycle.status == .succeeded) + #expect(succeeded.core.output.summary == "Succeeded.") + #expect(succeeded.core.reviewText == "No findings.") let succeededWithoutFinalReview = ReviewRunRecord.makeForTesting( id: "job-terminal-succeeded-no-review", @@ -28,9 +28,9 @@ struct ReviewRunRecordTests { hasFinalReview: false, lastAgentMessage: "Succeeded." ) - #expect(succeededWithoutFinalReview.timeline.terminalStatus == .succeeded) - #expect(succeededWithoutFinalReview.timeline.terminalSummary == "Succeeded.") - #expect(succeededWithoutFinalReview.timeline.terminalResult == nil) + #expect(succeededWithoutFinalReview.core.lifecycle.status == .succeeded) + #expect(succeededWithoutFinalReview.core.output.summary == "Succeeded.") + #expect(succeededWithoutFinalReview.core.reviewText == "Succeeded.") let failed = ReviewRunRecord.makeForTesting( id: "job-terminal-failed", @@ -40,9 +40,9 @@ struct ReviewRunRecordTests { summary: "Failed.", errorMessage: "Backend failed." ) - #expect(failed.timeline.terminalStatus == .failed) - #expect(failed.timeline.terminalSummary == "Backend failed.") - #expect(failed.timeline.terminalResult == nil) + #expect(failed.core.lifecycle.status == .failed) + #expect(failed.core.lifecycle.errorMessage == "Backend failed.") + #expect(failed.core.reviewText == "Backend failed.") let cancelled = ReviewRunRecord.makeForTesting( id: "job-terminal-cancelled", @@ -52,8 +52,8 @@ struct ReviewRunRecordTests { cancellation: .mcpClient(message: "Session closed."), summary: "Cancelled." ) - #expect(cancelled.timeline.terminalStatus == .cancelled) - #expect(cancelled.timeline.terminalSummary == "Session closed.") - #expect(cancelled.timeline.terminalResult == nil) + #expect(cancelled.core.lifecycle.status == .cancelled) + #expect(cancelled.core.lifecycle.cancellation?.message == "Session closed.") + #expect(cancelled.core.reviewText == "Cancelled.") } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index dd95fa30..ee15112d 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -249,7 +249,8 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text") #expect( - awaited.value(for: ["result", "structuredContent", "timeline", "terminalSummary"]) as? String == "Done") + awaited.value(for: ["result", "structuredContent", "timeline", "terminalSummary"]) as? String + == "Done") #expect( awaited.value(for: ["result", "structuredContent", "timeline", "terminalResult"]) as? String == "review text") @@ -420,17 +421,6 @@ struct CodexReviewMCPHTTPServerTests { hasFinalReview: true, lastAgentMessage: "No correctness issues found." ) - includedJob.timeline.apply( - .itemCompleted( - .init( - id: "cmd-1", - kind: .commandExecution, - family: .command, - phase: .completed, - content: .command(.init(command: "swift test", output: "Tests passed", status: .completed)) - )), - at: Date() - ) store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], @@ -495,8 +485,6 @@ 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 job = ReviewRunRecord.makeForTesting( id: "job-semantic", @@ -504,26 +492,10 @@ struct CodexReviewMCPHTTPServerTests { cwd: "/tmp/project", targetSummary: "Included", status: .succeeded, - summary: "Done" + summary: "Done", + hasFinalReview: true, + lastAgentMessage: longOutput ) - job.timeline.apply( - .itemCompleted( - .init( - id: "cmd-1", - kind: .commandExecution, - family: .command, - phase: .completed, - content: .command( - .init( - command: "swift test", - cwd: "/tmp/project", - output: longOutput, - exitCode: 0 - )), - startedAt: startedAt, - completedAt: completedAt, - durationMs: 3000 - ))) store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], @@ -559,36 +531,29 @@ struct CodexReviewMCPHTTPServerTests { let timeline = try #require( defaultResponse.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) - #expect(timeline["orderedItemIds"] as? [String] == ["cmd-1"]) + #expect(timeline["orderedItemIds"] as? [String] == ["job-semantic:message"]) #expect(timeline["activeItemIds"] as? [String] == []) #expect(timeline["activeItemCount"] as? Int == 0) - #expect(timeline["latestActivityId"] as? String == "cmd-1") + #expect(timeline["latestActivityId"] as? String == "job-semantic:message") let itemsPage = try #require(timeline["itemsPage"] as? [String: Any]) #expect(itemsPage["total"] as? Int == 1) #expect(itemsPage["limit"] as? Int == 1) #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) + let messageItem = try #require(items.first) + #expect(messageItem["id"] as? String == "job-semantic:message") + #expect(messageItem["kind"] as? String == "agentMessage") + let content = try #require(messageItem["content"] as? [String: Any]) + #expect(content["type"] as? String == "message") + let timelineOutput = try #require(content["text"] as? String) #expect(timelineOutput.hasPrefix("Tests passed")) #expect(timelineOutput.hasSuffix("...")) #expect(timelineOutput.count < longOutput.count) - #expect(content["truncatedFields"] as? [String] == ["output"]) + #expect(content["truncatedFields"] as? [String] == ["text"]) } } - @Test func streamableHTTPReviewReadIncludesToolProgressInTimelineContent() async throws { + @Test func streamableHTTPReviewReadIncludesRunningSummaryInTimelineContent() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -604,21 +569,6 @@ struct CodexReviewMCPHTTPServerTests { 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")], @@ -643,18 +593,15 @@ struct CodexReviewMCPHTTPServerTests { let timeline = try #require( response.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) - #expect(timeline["activeItemIds"] as? [String] == ["tool-1:progress"]) + #expect(timeline["activeItemIds"] as? [String] == ["job-tool-progress:summary"]) #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) + #expect(item["id"] as? String == "job-tool-progress:summary") + #expect(item["kind"] as? String == "diagnostic") 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") + #expect(content["type"] as? String == "diagnostic") + #expect(content["message"] as? String == "Running") } } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift index 9e65a61e..91f2ccc8 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift @@ -1,172 +1,50 @@ import Foundation import Testing +@testable import CodexReviewKit @testable import CodexReviewMCPServer -import CodexReviewKit -@MainActor -@Suite("review MCP projection") +@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) + @Test func runningResultProjectsSummaryAsActiveDiagnosticItem() throws { + let projection = ReviewMCPProjection(result: .init( + jobID: "job-1", + core: .init( + lifecycle: .init(status: .running), + output: .init(summary: "Review started.") + ), + cancellable: true + )) + + #expect(projection.orderedItemIDs == ["job-1:summary"]) + #expect(projection.activeItemIDs == ["job-1:summary"]) + #expect(projection.activeItemCount == 1) + #expect(projection.latestActivityID == "job-1:summary") 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") + #expect(item.kind == "diagnostic") + #expect(item.content.type == "diagnostic") } - @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) + @Test func terminalResultProjectsFinalReviewText() throws { + let projection = ReviewMCPProjection(result: .init( + jobID: "job-2", + core: .init( + lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), + output: .init( + summary: "Done.", + hasFinalReview: true, + lastAgentMessage: "No findings." + ) + ), + cancellable: false + )) + + #expect(projection.activeItemIDs == []) #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") + #expect(projection.terminalSummary == "Done.") + #expect(projection.terminalResult == "No findings.") + let item = try #require(projection.items.first) + #expect(item.id == "job-2:message") + #expect(item.kind == "agentMessage") + #expect(item.content.type == "message") } } From ad6641e6a8c5a9c928bae316badd0fd93c1929e7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:06:53 +0900 Subject: [PATCH 217/359] refactor(review): remove legacy review timeline store --- .../Domain/ReviewTimeline.swift | 244 ----------- .../Domain/ReviewTimelineTextSnapshot.swift | 123 ------ .../Domain/ReviewTimelineTests.swift | 385 ------------------ 3 files changed, 752 deletions(-) delete mode 100644 Sources/CodexReviewKit/Domain/ReviewTimeline.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift delete mode 100644 Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift diff --git a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift b/Sources/CodexReviewKit/Domain/ReviewTimeline.swift deleted file mode 100644 index 2defe967..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewTimeline.swift +++ /dev/null @@ -1,244 +0,0 @@ -import Foundation -import Observation - -@MainActor -@Observable -package 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 closeActiveItems( - family: ReviewItemFamily? = nil, - phase: ReviewItemPhase, - timestamp: Date - ) -> Bool { - let ids = activeItemIDs.filter { id in - guard let family else { - return true - } - return itemsByID[id]?.family == family - } - guard ids.isEmpty == false else { - return false - } - for id in ids { - guard let item = itemsByID[id] else { - continue - } - item.update( - phase: phase, - content: item.content.closingActiveContent(phase: phase), - updatedAt: timestamp, - completedAt: timestamp - ) - activeItemIDs.remove(id) - } - bumpRevision() - return true - } - - @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/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift deleted file mode 100644 index 1f9ecc8d..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewTimelineTextSnapshot.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation - -package struct ReviewTimelineTextSnapshot: Sendable, Equatable { - package var timelineText: String - package var rawTimelineText: String -} - -package extension ReviewTimeline { - @MainActor - var textSnapshot: ReviewTimelineTextSnapshot { - var timelineSegments: [String] = [] - var rawTimelineSegments: [String] = [] - - for item in items { - if let text = item.timelineTextSegment?.nilIfEmpty { - timelineSegments.append(text) - } - if let text = item.rawTimelineTextSegment?.nilIfEmpty { - rawTimelineSegments.append(text) - } - } - - if let terminalSummary = terminalSummary?.nilIfEmpty { - timelineSegments.append(terminalSummary) - rawTimelineSegments.append(terminalSummary) - } - if let terminalResult = terminalResult?.nilIfEmpty, - terminalResult != terminalSummary - { - timelineSegments.append(terminalResult) - rawTimelineSegments.append(terminalResult) - } - - return .init( - timelineText: timelineSegments.joined(separator: "\n\n"), - rawTimelineText: rawTimelineSegments.joined(separator: "\n\n") - ) - } -} - -private extension ReviewTimelineItem { - var timelineTextSegment: String? { - switch content { - case .approval(let approval): - return [approval.title, approval.detail].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") - case .command(let command): - return command.command.nilIfEmpty.map { command.phaseTitle(command: $0, phase: phase) } - 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.result?.nilIfEmpty ?? search.query - case .toolCall(let toolCall): - return toolCall.error?.nilIfEmpty - ?? toolCall.result?.nilIfEmpty - ?? toolCall.progress?.nilIfEmpty - ?? [toolCall.namespace, toolCall.server, toolCall.tool] - .compactMap { $0?.nilIfEmpty } - .joined(separator: ".") - .nilIfEmpty - case .unknown(let unknown): - return [unknown.title, unknown.detail].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") - } - } - - var rawTimelineTextSegment: String? { - switch content { - 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 .fileChange(let fileChange): - return fileChange.output.isEmpty ? fileChange.title : "\(fileChange.title)\n\(fileChange.output)" - case .search(let search): - return [search.query, search.result].compactMap { $0?.nilIfEmpty }.joined(separator: "\n") - case .toolCall(let toolCall): - return [ - [toolCall.namespace, toolCall.server, toolCall.tool] - .compactMap { $0?.nilIfEmpty } - .joined(separator: ".") - .nilIfEmpty, - toolCall.progress?.nilIfEmpty, - toolCall.result?.nilIfEmpty, - toolCall.error?.nilIfEmpty, - ] - .compactMap { $0 } - .joined(separator: "\n") - case .approval, - .contextCompaction, - .diagnostic, - .message, - .plan, - .reasoning, - .unknown: - return timelineTextSegment - } - } -} - -private extension ReviewTimelineItem.Command { - func phaseTitle(command: String, phase: ReviewItemPhase) -> String { - switch status { - case .some(.completed): - return "Ran \(command)" - case .some(.failed): - return "Command failed: \(command)" - case .some(.cancelled): - return "Command cancelled: \(command)" - default: - return phase.isTerminal ? "Ran \(command)" : "Running \(command)" - } - } -} diff --git a/Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift b/Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift deleted file mode 100644 index f6fb839b..00000000 --- a/Tests/CodexReviewKitTests/Domain/ReviewTimelineTests.swift +++ /dev/null @@ -1,385 +0,0 @@ -import Foundation -import Testing -@testable import CodexReviewKit - -@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") - - 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) - } -} From ee1e3f245a3283ff13da6a496fd188f6b627ac6a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:13:29 +0900 Subject: [PATCH 218/359] refactor(review): replace timeline items with event payloads --- .../AppServerCodexReviewBackend.swift | 22 +- .../ReviewBackendEventSession.swift | 2 +- .../Domain/ReviewDomainEvent.swift | 18 +- .../Domain/ReviewEventItem.swift | 306 ++++++++ .../Domain/ReviewIdentifiers.swift | 2 +- .../Domain/ReviewTimelineItem.swift | 667 ------------------ .../Store/CodexReviewStoreReviews.swift | 2 +- 7 files changed, 329 insertions(+), 690 deletions(-) create mode 100644 Sources/CodexReviewKit/Domain/ReviewEventItem.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 97af170a..a020e4fe 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -543,7 +543,7 @@ private enum AppServerTypedReviewEventAdapter { _ status: String, turnID: String ) -> [CodexReviewBackendModel.Review.Event] { - let seed = ReviewTimelineItemSeed( + let seed = ReviewEventItemSeed( id: .init(rawValue: "\(turnID):status"), kind: .init(rawValue: "reviewThreadStatus"), family: .diagnostic, @@ -581,7 +581,7 @@ private enum AppServerTypedReviewEventAdapter { ] } guard item.kind != .userMessage, - let seed = timelineSeed(for: item, phase: phase) + let seed = eventItemSeed(for: item, phase: phase) else { return [] } @@ -637,14 +637,14 @@ private enum AppServerTypedReviewEventAdapter { private static func reasoningPartEvents( _ part: CodexReasoningPart ) -> [CodexReviewBackendModel.Review.Event] { - let style: ReviewTimelineItem.Reasoning.Style = + let style: ReviewEventItem.Reasoning.Style = switch part.kind { case .summary: .summary case .text: .raw } - let seed = ReviewTimelineItemSeed( + let seed = ReviewEventItemSeed( id: .init(rawValue: part.id), kind: .reasoning, family: .reasoning, @@ -660,7 +660,7 @@ private enum AppServerTypedReviewEventAdapter { guard delta.delta.isEmpty == false else { return [] } - let style: ReviewTimelineItem.Reasoning.Style = + let style: ReviewEventItem.Reasoning.Style = switch delta.part.kind { case .summary: .summary @@ -685,7 +685,7 @@ private enum AppServerTypedReviewEventAdapter { ?? raw.threadID?.rawValue ?? raw.method let detail = String(data: raw.params, encoding: .utf8) - let seed = ReviewTimelineItemSeed( + let seed = ReviewEventItemSeed( id: .init(rawValue: "\(itemID):\(raw.method)"), kind: .init(rawValue: raw.method), family: .unknown, @@ -700,16 +700,16 @@ private enum AppServerTypedReviewEventAdapter { return [.domainEvents([.itemUpdated(seed)])] } - private static func timelineSeed( + private static func eventItemSeed( for item: CodexThreadItem, phase: AppServerTypedItemPhase - ) -> ReviewTimelineItemSeed? { - ReviewTimelineItemSeed( + ) -> ReviewEventItemSeed? { + ReviewEventItemSeed( id: .init(rawValue: item.id), kind: .init(rawValue: item.kind.rawValue), family: item.reviewItemFamily, phase: item.reviewItemPhase(defaultingTo: phase), - content: item.reviewTimelineContent + content: item.reviewEventContent ) } @@ -763,7 +763,7 @@ private extension CodexThreadItem { } } - var reviewTimelineContent: ReviewTimelineItem.Content { + var reviewEventContent: ReviewEventItem.Content { switch content { case .message(let message): .message(.init(text: message.text)) diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 7b6214de..251c171e 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -205,7 +205,7 @@ package actor ReviewBackendEventSession { } } - private func recordTypedMessageText(from seed: ReviewTimelineItemSeed) { + private func recordTypedMessageText(from seed: ReviewEventItemSeed) { guard seed.family == .message, case .message(let message) = seed.content else { diff --git a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift index c176c4dc..34b77f0e 100644 --- a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift +++ b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift @@ -2,31 +2,31 @@ import Foundation package 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 itemStarted(ReviewEventItemSeed) + case itemUpdated(ReviewEventItemSeed) + case itemCompleted(ReviewEventItemSeed) + case textDelta(itemID: ReviewEventItem.ID, kind: ReviewItemKind, family: ReviewItemFamily, content: ReviewEventItem.Content, delta: String) case reviewCompleted(summary: String, result: String?) case reviewFailed(String) case reviewCancelled(String) } -package struct ReviewTimelineItemSeed: Equatable, Sendable { - public var id: ReviewTimelineItem.ID +package struct ReviewEventItemSeed: Equatable, Sendable { + public var id: ReviewEventItem.ID public var kind: ReviewItemKind public var family: ReviewItemFamily public var phase: ReviewItemPhase - public var content: ReviewTimelineItem.Content + public var content: ReviewEventItem.Content public var startedAt: Date? public var completedAt: Date? public var durationMs: Int? public init( - id: ReviewTimelineItem.ID, + id: ReviewEventItem.ID, kind: ReviewItemKind, family: ReviewItemFamily, phase: ReviewItemPhase, - content: ReviewTimelineItem.Content, + content: ReviewEventItem.Content, startedAt: Date? = nil, completedAt: Date? = nil, durationMs: Int? = nil diff --git a/Sources/CodexReviewKit/Domain/ReviewEventItem.swift b/Sources/CodexReviewKit/Domain/ReviewEventItem.swift new file mode 100644 index 00000000..87078f8f --- /dev/null +++ b/Sources/CodexReviewKit/Domain/ReviewEventItem.swift @@ -0,0 +1,306 @@ +import Foundation + +package enum ReviewEventItem { + public typealias ID = ReviewEventItemID + + 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 + } + } +} diff --git a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift index ae2eb291..aa375e7f 100644 --- a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift +++ b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift @@ -34,7 +34,7 @@ public struct ReviewTurnID: ReviewStringIdentifier, CustomStringConvertible { } } -package struct ReviewTimelineItemID: ReviewStringIdentifier, CustomStringConvertible { +package struct ReviewEventItemID: ReviewStringIdentifier, CustomStringConvertible { public var rawValue: String public init(rawValue: String) { diff --git a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift b/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift deleted file mode 100644 index bf417798..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewTimelineItem.swift +++ /dev/null @@ -1,667 +0,0 @@ -import Foundation -import Observation - -@MainActor -@Observable -package 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 - } - } - - func closingActiveContent(phase: ReviewItemPhase) -> Self { - switch self { - case .command(var command): - command.status = phase.commandStatus - return .command(command) - case .fileChange(var fileChange): - fileChange.status = phase.fileChangeStatus - return .fileChange(fileChange) - case .toolCall(var toolCall): - toolCall.status = phase.toolCallStatus - return .toolCall(toolCall) - case .approval(var approval): - approval.status = phase == .cancelled ? .cancelled : approval.status - return .approval(approval) - case .contextCompaction(var contextCompaction): - contextCompaction.status = phase.contextCompactionStatus - return .contextCompaction(contextCompaction) - case .diagnostic, - .message, - .plan, - .reasoning, - .search, - .unknown: - return self - } - } -} - -private extension ReviewItemPhase { - var commandStatus: ReviewCommandStatus? { - switch self { - case .completed: - .completed - case .failed, .incomplete: - .failed - case .cancelled, .skipped: - .cancelled - case .awaitingApproval, .queued, .running, .waitingForInput: - nil - } - } - - var fileChangeStatus: ReviewFileChangeStatus? { - switch self { - case .completed: - .completed - case .failed, .incomplete: - .failed - case .cancelled, .skipped: - nil - case .awaitingApproval, .queued, .running, .waitingForInput: - nil - } - } - - var toolCallStatus: ReviewToolCallStatus? { - switch self { - case .completed: - .completed - case .failed, .incomplete: - .failed - case .cancelled, .skipped: - .cancelled - case .awaitingApproval, .queued, .running, .waitingForInput: - nil - } - } - - var contextCompactionStatus: ReviewContextCompactionStatus? { - switch self { - case .completed: - .completed - case .failed, .incomplete: - .failed - case .cancelled, .skipped: - nil - case .awaitingApproval, .queued, .running, .waitingForInput: - nil - } - } -} - -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/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index e419226f..dcaac976 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -290,7 +290,7 @@ extension CodexReviewStore { } private func updateCoreOutput( - from seed: ReviewTimelineItemSeed, + from seed: ReviewEventItemSeed, isCompleted: Bool, for job: ReviewRunRecord ) { From 102675b7fb00033790e75e2371a41024078c9a78 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:17:06 +0900 Subject: [PATCH 219/359] refactor(review): rename job core state to runs --- ...eviewJobCore.swift => ReviewRunCore.swift} | 6 +- .../Model/ReviewRunRecord.swift | 4 +- .../Model/ReviewRunRecordTesting.swift | 6 +- ...iewJobState.swift => ReviewRunState.swift} | 2 +- .../ReviewAPI/ReviewResults.swift | 16 +-- .../Store/CodexReviewStoreCancellation.swift | 2 +- .../Store/CodexReviewStoreReviews.swift | 6 +- .../Store/CodexReviewStoreRuntimeState.swift | 114 +++++++++--------- .../CodexReviewMCPServer.swift | 2 +- .../CodexReviewMCPToolRequests.swift | 4 +- .../CodexReviewMCPToolResults.swift | 6 +- Sources/CodexReviewTesting/TestSupport.swift | 8 +- .../CodexReviewStoreCommandTests.swift | 18 +-- .../ReviewMonitorTimelineTesting.swift | 16 +-- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 15 files changed, 106 insertions(+), 106 deletions(-) rename Sources/CodexReviewKit/Model/{ReviewJobCore.swift => ReviewRunCore.swift} (95%) rename Sources/CodexReviewKit/Model/{ReviewJobState.swift => ReviewRunState.swift} (90%) diff --git a/Sources/CodexReviewKit/Model/ReviewJobCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift similarity index 95% rename from Sources/CodexReviewKit/Model/ReviewJobCore.swift rename to Sources/CodexReviewKit/Model/ReviewRunCore.swift index b67b7c1a..e2c01d8e 100644 --- a/Sources/CodexReviewKit/Model/ReviewJobCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -1,6 +1,6 @@ import Foundation -public struct ReviewJobCore: Codable, Sendable, Hashable { +public struct ReviewRunCore: Codable, Sendable, Hashable { public struct Run: Codable, Sendable, Hashable { public internal(set) var attemptID: String? public internal(set) var reviewThreadID: String? @@ -24,7 +24,7 @@ public struct ReviewJobCore: Codable, Sendable, Hashable { } public struct Lifecycle: Codable, Sendable, Hashable { - public internal(set) var status: ReviewJobState + public internal(set) var status: ReviewRunState public internal(set) var exitCode: Int? public internal(set) var startedAt: Date? public internal(set) var endedAt: Date? @@ -32,7 +32,7 @@ public struct ReviewJobCore: Codable, Sendable, Hashable { public internal(set) var errorMessage: String? public init( - status: ReviewJobState, + status: ReviewRunState, exitCode: Int? = nil, startedAt: Date? = nil, endedAt: Date? = nil, diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index d0e87728..961db985 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -9,7 +9,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { 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 core: ReviewRunCore public internal(set) var cancellationRequested: Bool @ObservationIgnored @@ -37,7 +37,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { cwd: String, sortOrder: Double = 0, targetSummary: String, - core: ReviewJobCore, + core: ReviewRunCore, cancellationRequested: Bool = false ) { self.id = id diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index 5ae531a7..ebfc8cca 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -9,7 +9,7 @@ extension ReviewRunRecord { model: String? = "gpt-5", threadID: String? = nil, turnID: String? = nil, - status: ReviewJobState, + status: ReviewRunState, cancellationRequested: Bool = false, cancellation: ReviewCancellation? = nil, startedAt: Date? = nil, @@ -26,7 +26,7 @@ extension ReviewRunRecord { sessionID: sessionID, cwd: cwd, targetSummary: targetSummary, - core: ReviewJobCore( + core: ReviewRunCore( run: .init( reviewThreadID: threadID, threadID: threadID, @@ -54,7 +54,7 @@ extension ReviewRunRecord { package func updateStateForTesting( targetSummary: String? = nil, - status: ReviewJobState? = nil, + status: ReviewRunState? = nil, endedAt: Date? = nil, clearEndedAt: Bool = false, summary: String? = nil diff --git a/Sources/CodexReviewKit/Model/ReviewJobState.swift b/Sources/CodexReviewKit/Model/ReviewRunState.swift similarity index 90% rename from Sources/CodexReviewKit/Model/ReviewJobState.swift rename to Sources/CodexReviewKit/Model/ReviewRunState.swift index d0bf4d07..402760ee 100644 --- a/Sources/CodexReviewKit/Model/ReviewJobState.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunState.swift @@ -1,4 +1,4 @@ -public enum ReviewJobState: String, Codable, Sendable, Hashable { +public enum ReviewRunState: String, Codable, Sendable, Hashable { case queued case running case succeeded diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift index ff4c0ac9..d6260d76 100644 --- a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift +++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift @@ -3,13 +3,13 @@ import Foundation package extension CodexReviewAPI.Read { struct Result: Codable, Sendable, Hashable { package var jobID: String - package var core: ReviewJobCore + package var core: ReviewRunCore package var elapsedSeconds: Int? package var cancellable: Bool package init( jobID: String, - core: ReviewJobCore, + core: ReviewRunCore, elapsedSeconds: Int? = nil, cancellable: Bool ) { @@ -27,7 +27,7 @@ struct ListItem: Codable, Sendable, Hashable { package var jobID: String package var cwd: String package var targetSummary: String - package var core: ReviewJobCore + package var core: ReviewRunCore package var elapsedSeconds: Int? package var cancellable: Bool @@ -35,7 +35,7 @@ struct ListItem: Codable, Sendable, Hashable { jobID: String, cwd: String, targetSummary: String, - core: ReviewJobCore, + core: ReviewRunCore, elapsedSeconds: Int?, cancellable: Bool ) { @@ -65,12 +65,12 @@ package extension CodexReviewAPI.Job { struct Selector: Sendable, Hashable { package var jobID: String? package var cwd: String? - package var statuses: [ReviewJobState]? + package var statuses: [ReviewRunState]? package init( jobID: String? = nil, cwd: String? = nil, - statuses: [ReviewJobState]? = nil + statuses: [ReviewRunState]? = nil ) { self.jobID = jobID self.cwd = cwd?.nilIfEmpty @@ -108,12 +108,12 @@ package extension CodexReviewAPI.Cancel { struct Outcome: Codable, Sendable, Hashable { package var jobID: String package var cancelled: Bool - package var core: ReviewJobCore + package var core: ReviewRunCore package init( jobID: String, cancelled: Bool, - core: ReviewJobCore + core: ReviewRunCore ) { self.jobID = jobID self.cancelled = cancelled diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 061d1933..02abd6e0 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -195,7 +195,7 @@ extension CodexReviewStore { package func cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: [String]) { for jobID in jobIDs { - runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(jobID: jobID) + runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(runID: jobID) runtimeState.clearRuntimeStopState(for: jobID) } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index dcaac976..923b4776 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -395,7 +395,7 @@ extension CodexReviewStore { package func listReviews( cwd: String? = nil, - statuses: [ReviewJobState]? = nil, + statuses: [ReviewRunState]? = nil, limit: Int? = nil ) -> CodexReviewAPI.List.Result { let filtered = filteredReviewRuns(cwd: cwd, statuses: statuses) @@ -406,7 +406,7 @@ 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) @@ -586,7 +586,7 @@ extension CodexReviewStore { return job } - private func filteredReviewRuns(cwd: String?, statuses: [ReviewJobState]?) -> [ReviewRunRecord] { + private func filteredReviewRuns(cwd: String?, statuses: [ReviewRunState]?) -> [ReviewRunRecord] { let statusSet = statuses.map(Set.init) return orderedReviewRuns.filter { job in if let cwd, job.cwd != cwd { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift index d147230f..ae64d2cb 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -1,6 +1,6 @@ import Foundation -package struct CodexReviewRuntimeJobState: Sendable { +package struct CodexReviewRuntimeRunState: Sendable { package let activeRun: CodexReviewBackendModel.Review.Run? package let hasActiveWorker: Bool package let hasDetachedWorker: Bool @@ -12,97 +12,97 @@ final class CodexReviewStoreRuntimeState { typealias BackendReviewRun = CodexReviewBackendModel.Review.Run private var activeRuns: [String: BackendReviewRun] = [:] - private var reviewRecoveryWaitingJobIDs: Set = [] - private var startingJobIDs: Set = [] + private var reviewRecoveryWaitingRunIDs: Set = [] + private var startingRunIDs: Set = [] private var startupCancellations: [String: ReviewCancellation] = [:] private var reviewWorkerTasks: [String: Task] = [:] private var runtimeStopDetachedReviewWorkerTasks: [String: Task] = [:] - func jobState(for jobID: String) -> CodexReviewRuntimeJobState { - CodexReviewRuntimeJobState( - activeRun: activeRuns[jobID], - hasActiveWorker: reviewWorkerTasks[jobID] != nil, - hasDetachedWorker: runtimeStopDetachedReviewWorkerTasks[jobID] != nil, - isWaitingForNetworkRecovery: reviewRecoveryWaitingJobIDs.contains(jobID) + 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 jobID: String) -> BackendReviewRun? { - activeRuns[jobID] + func activeRun(for runID: String) -> BackendReviewRun? { + activeRuns[runID] } - func setActiveRun(_ run: BackendReviewRun, for jobID: String) { - activeRuns[jobID] = run + func setActiveRun(_ run: BackendReviewRun, for runID: String) { + activeRuns[runID] = run } - func removeActiveRun(for jobID: String) { - activeRuns.removeValue(forKey: jobID) + func removeActiveRun(for runID: String) { + activeRuns.removeValue(forKey: runID) } func recoveryWaitingRuns() -> [BackendReviewRun] { - reviewRecoveryWaitingJobIDs + reviewRecoveryWaitingRunIDs .sorted() .compactMap { activeRuns[$0] } } - func isWaitingForNetworkRecovery(_ jobID: String) -> Bool { - reviewRecoveryWaitingJobIDs.contains(jobID) + func isWaitingForNetworkRecovery(_ runID: String) -> Bool { + reviewRecoveryWaitingRunIDs.contains(runID) } - func markWaitingForNetworkRecovery(_ jobID: String) { - reviewRecoveryWaitingJobIDs.insert(jobID) + func markWaitingForNetworkRecovery(_ runID: String) { + reviewRecoveryWaitingRunIDs.insert(runID) } - func clearWaitingForNetworkRecovery(_ jobID: String) { - reviewRecoveryWaitingJobIDs.remove(jobID) + func clearWaitingForNetworkRecovery(_ runID: String) { + reviewRecoveryWaitingRunIDs.remove(runID) } - func markStarting(_ jobID: String) { - startingJobIDs.insert(jobID) + func markStarting(_ runID: String) { + startingRunIDs.insert(runID) } - func clearStarting(_ jobID: String) { - startingJobIDs.remove(jobID) + func clearStarting(_ runID: String) { + startingRunIDs.remove(runID) } - func isStarting(_ jobID: String) -> Bool { - startingJobIDs.contains(jobID) + func isStarting(_ runID: String) -> Bool { + startingRunIDs.contains(runID) } - func setStartupCancellation(_ cancellation: ReviewCancellation, for jobID: String) { - startupCancellations[jobID] = cancellation + func setStartupCancellation(_ cancellation: ReviewCancellation, for runID: String) { + startupCancellations[runID] = cancellation } - func takeStartupCancellation(for jobID: String) -> ReviewCancellation? { - startupCancellations.removeValue(forKey: jobID) + func takeStartupCancellation(for runID: String) -> ReviewCancellation? { + startupCancellations.removeValue(forKey: runID) } - func setActiveWorker(_ task: Task, for jobID: String) { - reviewWorkerTasks[jobID] = task + func setActiveWorker(_ task: Task, for runID: String) { + reviewWorkerTasks[runID] = task } - func cancelActiveWorker(for jobID: String) { - reviewWorkerTasks[jobID]?.cancel() + func cancelActiveWorker(for runID: String) { + reviewWorkerTasks[runID]?.cancel() } - func removeActiveWorker(for jobID: String) { - reviewWorkerTasks.removeValue(forKey: jobID) + func removeActiveWorker(for runID: String) { + reviewWorkerTasks.removeValue(forKey: runID) } - func awaitActiveWorker(for jobID: String) async { - let task = reviewWorkerTasks[jobID] + func awaitActiveWorker(for runID: String) async { + let task = reviewWorkerTasks[runID] await task?.value } - func cancelAndDetachActiveWorkerForRuntimeStop(jobID: String) { - if let task = reviewWorkerTasks.removeValue(forKey: jobID) { + func cancelAndDetachActiveWorkerForRuntimeStop(runID: String) { + if let task = reviewWorkerTasks.removeValue(forKey: runID) { task.cancel() - runtimeStopDetachedReviewWorkerTasks[jobID] = task + runtimeStopDetachedReviewWorkerTasks[runID] = task } } - func removeDetachedWorker(for jobID: String) { - runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: jobID) + func removeDetachedWorker(for runID: String) { + runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: runID) } func activeWorkerTasks() -> [Task] { @@ -117,16 +117,16 @@ final class CodexReviewStoreRuntimeState { activeWorkerTasks() + detachedWorkerTasks() } - func clearRuntimeStopState(for jobID: String) { - removeActiveRun(for: jobID) - clearWaitingForNetworkRecovery(jobID) - clearStarting(jobID) - _ = takeStartupCancellation(for: jobID) + func clearRuntimeStopState(for runID: String) { + removeActiveRun(for: runID) + clearWaitingForNetworkRecovery(runID) + clearStarting(runID) + _ = takeStartupCancellation(for: runID) } - func clearReviewRunState(for jobID: String) { - removeActiveRun(for: jobID) - clearWaitingForNetworkRecovery(jobID) + func clearReviewRunState(for runID: String) { + removeActiveRun(for: runID) + clearWaitingForNetworkRecovery(runID) } func cancelAllWorkers() { @@ -148,15 +148,15 @@ final class CodexReviewStoreRuntimeState { func clearForTesting() { reviewWorkerTasks.removeAll(keepingCapacity: false) runtimeStopDetachedReviewWorkerTasks.removeAll(keepingCapacity: false) - startingJobIDs.removeAll(keepingCapacity: false) + startingRunIDs.removeAll(keepingCapacity: false) startupCancellations.removeAll(keepingCapacity: false) activeRuns.removeAll(keepingCapacity: false) - reviewRecoveryWaitingJobIDs.removeAll(keepingCapacity: false) + reviewRecoveryWaitingRunIDs.removeAll(keepingCapacity: false) } } extension CodexReviewStore { - package func runtimeReviewRunState(jobID: String) -> CodexReviewRuntimeJobState { - runtimeState.jobState(for: jobID) + package func runtimeReviewRunState(runID: String) -> CodexReviewRuntimeRunState { + runtimeState.runState(for: runID) } } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 062bbf20..d109b442 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -32,7 +32,7 @@ package extension CodexReviewMCP.Tool { case reviewStart(sessionID: String, request: CodexReviewAPI.Start.Request, waitTimeout: Duration?) case reviewAwait(sessionID: String?, jobID: String, waitTimeout: Duration) case reviewRead(sessionID: String?, jobID: String) - case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewJobState]?, limit: Int?) + case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewRunState]?, limit: Int?) case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Job.Selector, reason: ReviewCancellation) } } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift index a30ceb98..cfc3a15e 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -92,7 +92,7 @@ func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target } } -func statuses(from value: Value?) throws -> [ReviewJobState]? { +func statuses(from value: Value?) throws -> [ReviewRunState]? { guard let value else { return nil } @@ -100,7 +100,7 @@ func statuses(from value: Value?) throws -> [ReviewJobState]? { 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 { + guard let raw = item.stringValue, let status = ReviewRunState(rawValue: raw) else { throw MCPProtocolServerError.invalidArgument("Invalid review status.") } return status diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 5a87969d..f322ee42 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -349,7 +349,7 @@ private extension CodexReviewAPI.Cancel.Outcome { } } -private extension ReviewJobCore.Run { +private extension ReviewRunCore.Run { func structuredContent() -> Value { .object([ "reviewThreadId": reviewThreadID.map(Value.string) ?? .null, @@ -360,7 +360,7 @@ private extension ReviewJobCore.Run { } } -private extension ReviewJobCore.Lifecycle { +private extension ReviewRunCore.Lifecycle { func structuredContent( elapsedSeconds: Int?, cancellable: Bool @@ -378,7 +378,7 @@ private extension ReviewJobCore.Lifecycle { } } -private extension ReviewJobCore.Output { +private extension ReviewRunCore.Output { func structuredContent(review: String) -> Value { .object([ "summary": .string(summary), diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 682695c1..afa6486d 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -543,7 +543,7 @@ package final class StoreSnapshotProbe { return lhs.sortOrder > rhs.sortOrder } .map { job in - let runtimeState = store.runtimeReviewRunState(jobID: job.id) + let runtimeState = store.runtimeReviewRunState(runID: job.id) return StoreJobSnapshot( jobID: job.id, status: job.core.lifecycle.status, @@ -558,7 +558,7 @@ package final class StoreSnapshotProbe { } package func waitUntilJobStatus( - _ status: ReviewJobState, + _ status: ReviewRunState, jobID: String? = nil, timeout: Duration = .seconds(2) ) async -> StoreSnapshot? { @@ -609,10 +609,10 @@ package struct StoreSnapshot: Sendable { package struct StoreJobSnapshot: Sendable { package var jobID: String - package var status: ReviewJobState + package var status: ReviewRunState package var summary: String package var lastAgentMessage: String? - package var run: ReviewJobCore.Run + package var run: ReviewRunCore.Run package var activeRun: CodexReviewBackendModel.Review.Run? package var cancellationRequested: Bool } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 527de5d9..26b67d10 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -955,7 +955,7 @@ struct CodexReviewStoreCommandTests { } try #require( await waitUntil { - let state = store.runtimeReviewRunState(jobID: "job-1") + let state = store.runtimeReviewRunState(runID: "job-1") return state.hasActiveWorker == false && state.activeRun == nil }) await recoverGate.open() @@ -1008,7 +1008,7 @@ struct CodexReviewStoreCommandTests { let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) let cleanedUp = await waitUntil { - let runtimeState = store.runtimeReviewRunState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "job-1") return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil } let read = try store.readReview(jobID: "job-1") @@ -1048,13 +1048,13 @@ struct CodexReviewStoreCommandTests { #expect(locallyCancelledReviewRunIDs == ["job-1"]) #expect(cancelled.core.lifecycle.status == .cancelled) - let runtimeStateBeforeDetach = store.runtimeReviewRunState(jobID: "job-1") + let runtimeStateBeforeDetach = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeStateBeforeDetach.hasActiveWorker) #expect(runtimeStateBeforeDetach.activeRun == run) store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) - let runtimeStateAfterDetach = store.runtimeReviewRunState(jobID: "job-1") + let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeStateAfterDetach.hasActiveWorker == false) #expect(runtimeStateAfterDetach.activeRun == nil) } @@ -1097,7 +1097,7 @@ struct CodexReviewStoreCommandTests { 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(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeState.activeRun == nil) #expect(runtimeState.hasActiveWorker == false) } @@ -1135,7 +1135,7 @@ struct CodexReviewStoreCommandTests { ) store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) - let runtimeState = store.runtimeReviewRunState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1183,7 +1183,7 @@ struct CodexReviewStoreCommandTests { #expect(request.reason.message == "Review runtime stopped.") #expect(request.recoveryWaitingRuns == [run]) #expect(read.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1217,7 +1217,7 @@ struct CodexReviewStoreCommandTests { store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) - #expect(store.runtimeReviewRunState(jobID: "job-1").hasDetachedWorker == false) + #expect(store.runtimeReviewRunState(runID: "job-1").hasDetachedWorker == false) #expect(await backend.recordedCommands().contains(.cleanupReview(run))) } } @@ -1250,7 +1250,7 @@ struct CodexReviewStoreCommandTests { #expect(locallyCancelledReviewRunIDs == ["job-1"]) #expect(result.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(jobID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) } diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift index 0e1481f9..72a9164e 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift @@ -97,7 +97,7 @@ extension ReviewRunRecord { model: String? = "gpt-5", threadID: String? = nil, turnID: String? = nil, - status: ReviewJobState, + status: ReviewRunState, cancellationRequested: Bool = false, cancellation: ReviewCancellation? = nil, startedAt: Date? = nil, @@ -290,14 +290,14 @@ private func makePreviewChatLogFixtureForTesting( let chat = try #require(job.reviewChatSelectionForTesting) let turn = CodexChatTurnStateSnapshot( id: job.previewTurnIDForTesting, - status: CodexTurnStatus(reviewJobStateForTesting: job.core.lifecycle.status), + status: CodexTurnStatus(reviewRunStateForTesting: job.core.lifecycle.status), errorDescription: job.core.lifecycle.errorMessage, usage: nil ) let initialSnapshot = CodexChatSnapshot( chatID: chat.id, phase: CodexDataPhase( - reviewJobStateForTesting: job.core.lifecycle.status, + reviewRunStateForTesting: job.core.lifecycle.status, errorMessage: job.core.lifecycle.errorMessage ), turns: [turn], @@ -343,7 +343,7 @@ private enum ReviewChatLogFixtureStore { turns: [ .init( id: job.previewTurnIDForTesting, - status: CodexTurnStatus(reviewJobStateForTesting: job.core.lifecycle.status) + status: CodexTurnStatus(reviewRunStateForTesting: job.core.lifecycle.status) ) ], items: items(for: job) @@ -600,7 +600,7 @@ extension ReviewRunRecord { workspaceCWD: cwd, updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - status: CodexThreadStatus(reviewJobStateForTesting: core.lifecycle.status) + status: CodexThreadStatus(reviewRunStateForTesting: core.lifecycle.status) ) } @@ -610,7 +610,7 @@ extension ReviewRunRecord { } private extension CodexThreadStatus { - init(reviewJobStateForTesting jobState: ReviewJobState) { + init(reviewRunStateForTesting jobState: ReviewRunState) { switch jobState { case .queued, .running: self = .active(activeFlags: []) @@ -621,7 +621,7 @@ private extension CodexThreadStatus { } private extension CodexTurnStatus { - init(reviewJobStateForTesting jobState: ReviewJobState) { + init(reviewRunStateForTesting jobState: ReviewRunState) { switch jobState { case .queued, .running: self = .running @@ -636,7 +636,7 @@ private extension CodexTurnStatus { } private extension CodexDataPhase { - init(reviewJobStateForTesting jobState: ReviewJobState, errorMessage: String?) { + init(reviewRunStateForTesting jobState: ReviewRunState, errorMessage: String?) { switch jobState { case .queued, .running, .succeeded, .cancelled: self = .loaded diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 012e3792..b6c5a96b 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -4935,7 +4935,7 @@ func makeJob( id: String = UUID().uuidString, cwd: String = "/tmp/repo", startedAt: Date = Date(timeIntervalSince1970: 200), - status: ReviewJobState, + status: ReviewRunState, targetSummary: String, summary: String? = nil, reviewResult: ParsedReviewResult? = nil, From 5eaa19513c7b3d9a36ec379091e9576bd3523dba Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:21:44 +0900 Subject: [PATCH 220/359] refactor(review-ui): rename preview fixtures around chat logs --- .../ReviewMonitorPreviewChatLogSource.swift | 6 +- .../ReviewMonitorPreviewContent.swift | 96 ++++++------- ...wift => ReviewMonitorChatLogTesting.swift} | 130 +++++++++--------- Tests/ReviewUITests/ReviewUITests.swift | 4 +- 4 files changed, 118 insertions(+), 118 deletions(-) rename Tests/ReviewUITests/{ReviewMonitorTimelineTesting.swift => ReviewMonitorChatLogTesting.swift} (86%) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 565c2f56..5acccd58 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -161,12 +161,12 @@ private final class PreviewReviewChat { } func applyPreviewStreamStep( - _ step: ReviewMonitorPreviewContent.PreviewTimelineStep, + _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, cycle: Int ) { - let itemID = ReviewMonitorPreviewContent.previewTimelineItemID( + let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( itemName: step.itemName, - jobID: streamID, + runID: streamID, cycle: cycle ) switch step.mode { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index bcc80383..e39f0139 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -23,7 +23,7 @@ public enum ReviewMonitorPreviewContent { let endedOffset: TimeInterval? } - private struct PreviewReviewFixture { + private struct PreviewRunFixture { let id: String let chatID: CodexThreadID let turnID: CodexTurnID @@ -35,7 +35,7 @@ public enum ReviewMonitorPreviewContent { let lifecycle: PreviewChatLifecycle let startedAt: Date? let endedAt: Date? - let chatItems: [PreviewTimelineItemTemplate] + let chatItems: [PreviewChatLogItemTemplate] } private enum PreviewReasoningStyle { @@ -80,7 +80,7 @@ public enum ReviewMonitorPreviewContent { case textDelta } - private struct PreviewTimelineItemTemplate { + private struct PreviewChatLogItemTemplate { let itemName: String let kind: CodexThreadItem.Kind let content: CodexThreadItem.Content @@ -105,7 +105,7 @@ public enum ReviewMonitorPreviewContent { } } - package struct PreviewTimelineStep { + package struct PreviewChatLogStreamStep { let itemName: String let kind: CodexThreadItem.Kind let content: CodexThreadItem.Content @@ -123,7 +123,7 @@ public enum ReviewMonitorPreviewContent { } package struct PreviewStreamFrame { - let step: PreviewTimelineStep + let step: PreviewChatLogStreamStep let cycle: Int } @@ -185,10 +185,10 @@ public enum ReviewMonitorPreviewContent { let accounts = makePreviewAccounts() let cwd = "/path/to/workspace-alpha" let now = Date() - let timelineItems = makeCommandOutputPreviewTimelineItems() + let chatItems = makeCommandOutputPreviewChatLogItems() let chatID = CodexThreadID(rawValue: "preview-command-output-panel") let turnID = CodexTurnID(rawValue: "preview-command-output-turn") - let review = PreviewReviewFixture( + let review = PreviewRunFixture( id: "preview-command-output-panel", chatID: chatID, turnID: turnID, @@ -200,7 +200,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .running, startedAt: now.addingTimeInterval(-135), endedAt: nil, - chatItems: timelineItems + chatItems: chatItems ) store.loadForTesting( serverState: .running, @@ -222,7 +222,7 @@ public enum ReviewMonitorPreviewContent { forRunningChatAt runningChatIndex: Int, tick: Int ) -> PreviewStreamFrame? { - guard previewStreamTimeline.isEmpty == false else { + guard previewChatLogStreamSchedule.isEmpty == false else { return nil } let chatTick = tick - runningChatIndex * previewChatTickOffset @@ -230,22 +230,22 @@ public enum ReviewMonitorPreviewContent { return nil } - let offset = (chatTick - 1) % previewStreamTimeline.count - guard let step = previewStreamTimeline[offset] else { + let offset = (chatTick - 1) % previewChatLogStreamSchedule.count + guard let step = previewChatLogStreamSchedule[offset] else { return nil } return PreviewStreamFrame( step: step, - cycle: (chatTick - 1) / previewStreamTimeline.count + cycle: (chatTick - 1) / previewChatLogStreamSchedule.count ) } - package static func previewTimelineItemID( + package static func previewChatLogItemID( itemName: String, - jobID: String, + runID: String, cycle: Int ) -> String { - "preview-\(itemName)-\(jobID)-\(cycle)" + "preview-\(itemName)-\(runID)-\(cycle)" } private static func previewTurnID(_ tick: Int) -> String { @@ -258,22 +258,22 @@ public enum ReviewMonitorPreviewContent { return "preview-turn-\(tick)" } - private static func streamTimeline(from templates: [PreviewStreamTemplate]) -> [PreviewTimelineStep?] { - var timeline: [PreviewTimelineStep?] = [] + private static func chatLogStreamSchedule(from templates: [PreviewStreamTemplate]) -> [PreviewChatLogStreamStep?] { + var schedule: [PreviewChatLogStreamStep?] = [] for (templateIndex, template) in templates.enumerated() { - let delay = timeline.isEmpty ? 1 : max(1, template.delayBeforeFrameCount) + let delay = schedule.isEmpty ? 1 : max(1, template.delayBeforeFrameCount) if delay > 1 { - timeline.append(contentsOf: Array(repeating: nil, count: 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 { - timeline.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1)) + schedule.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1)) } - timeline.append( - PreviewTimelineStep( + schedule.append( + PreviewChatLogStreamStep( itemName: itemName, kind: template.kind, content: template.content, @@ -282,7 +282,7 @@ public enum ReviewMonitorPreviewContent { )) } } - return timeline + return schedule } private static func wordChunks(in text: String) -> [String] { @@ -455,9 +455,9 @@ public enum ReviewMonitorPreviewContent { ), ] - private static let previewStreamTimeline = streamTimeline(from: previewStreamTemplates) + private static let previewChatLogStreamSchedule = chatLogStreamSchedule(from: previewStreamTemplates) - private static func makeCommandOutputPreviewTimelineItems() -> [PreviewTimelineItemTemplate] { + 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 @@ -609,10 +609,10 @@ public enum ReviewMonitorPreviewContent { for (workspaceIndex, cwd) in workspacePaths.enumerated() { let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() { - let timelineItems = makePreviewTimelineItems(for: definition, workspaceName: workspaceName) + let chatItems = makePreviewChatLogItems(for: definition, workspaceName: workspaceName) let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(chatIndex)") let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(chatIndex)") - let review = PreviewReviewFixture( + let review = PreviewRunFixture( id: "preview-\(workspaceIndex)-\(chatIndex)", chatID: chatID, turnID: turnID, @@ -624,7 +624,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: definition.lifecycle, startedAt: definition.startedOffset.map { now.addingTimeInterval($0) }, endedAt: definition.endedOffset.map { now.addingTimeInterval($0) }, - chatItems: timelineItems + chatItems: chatItems ) chatLogFixtures.append( makeChatLogFixture( @@ -638,7 +638,7 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( - for review: PreviewReviewFixture + for review: PreviewRunFixture ) -> ReviewMonitorPreviewChatLogFixture { let chat = ReviewMonitorCodexSidebarSnapshot.Chat( rowID: .chat(review.chatID), @@ -666,7 +666,7 @@ public enum ReviewMonitorPreviewContent { turns: [turn], items: makeInitialChatItems( streamID: review.id, - timelineItems: review.chatItems, + chatItems: review.chatItems, turnID: turn.id ) ) @@ -681,15 +681,15 @@ public enum ReviewMonitorPreviewContent { private static func makeInitialChatItems( streamID: String, - timelineItems: [PreviewTimelineItemTemplate], + chatItems: [PreviewChatLogItemTemplate], turnID: CodexTurnID ) -> [CodexChatItemSnapshot] { var snapshots: [CodexChatItemSnapshot] = [] - for item in timelineItems { + for item in chatItems { let snapshot = item.itemSnapshot( - id: previewTimelineItemID( + id: previewChatLogItemID( itemName: item.itemName, - jobID: streamID, + runID: streamID, cycle: 0 ), turnID: turnID @@ -709,7 +709,7 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, kind: CodexThreadItem.Kind, message: String - ) -> PreviewTimelineItemTemplate { + ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: kind, @@ -717,7 +717,7 @@ public enum ReviewMonitorPreviewContent { ) } - private static func messageItem(_ itemName: String, text: String) -> PreviewTimelineItemTemplate { + private static func messageItem(_ itemName: String, text: String) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .agentMessage, @@ -725,7 +725,7 @@ public enum ReviewMonitorPreviewContent { ) } - private static func planItem(_ itemName: String, markdown: String) -> PreviewTimelineItemTemplate { + private static func planItem(_ itemName: String, markdown: String) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .plan, @@ -737,7 +737,7 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, text: String, style: PreviewReasoningStyle = .summary - ) -> PreviewTimelineItemTemplate { + ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .reasoning, @@ -749,7 +749,7 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, title: String, status: ReviewContextCompactionStatus - ) -> PreviewTimelineItemTemplate { + ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .contextCompaction, @@ -761,7 +761,7 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, command: String, cwd: String? = nil - ) -> PreviewTimelineItemTemplate { + ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .commandExecution, @@ -779,7 +779,7 @@ public enum ReviewMonitorPreviewContent { output: String, exitCode: Int, status: ReviewCommandStatus - ) -> PreviewTimelineItemTemplate { + ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .commandExecution, @@ -797,21 +797,21 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, result: String, status: ReviewToolCallStatus - ) -> PreviewTimelineItemTemplate { - return PreviewTimelineItemTemplate( + ) -> PreviewChatLogItemTemplate { + return PreviewChatLogItemTemplate( itemName: itemName, kind: .mcpToolCall, content: .toolCall(.init(result: result, status: CodexTurnStatus(status))) ) } - private static func makePreviewTimelineItems( + private static func makePreviewChatLogItems( for definition: PreviewChatDefinition, workspaceName: String - ) -> [PreviewTimelineItemTemplate] { + ) -> [PreviewChatLogItemTemplate] { switch definition.lifecycle { case .running: - return makeRunningPreviewTimelineItems(for: definition, workspaceName: workspaceName) + return makeRunningPreviewChatLogItems(for: definition, workspaceName: workspaceName) case .queued: return [ diagnosticItem( @@ -902,10 +902,10 @@ public enum ReviewMonitorPreviewContent { } } - private static func makeRunningPreviewTimelineItems( + private static func makeRunningPreviewChatLogItems( for definition: PreviewChatDefinition, workspaceName: String - ) -> [PreviewTimelineItemTemplate] { + ) -> [PreviewChatLogItemTemplate] { let sourceReadItemName = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)" let sourceReadCommand = "sed -n '1,260p' Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift" diff --git a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift similarity index 86% rename from Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift rename to Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 72a9164e..f5c52c36 100644 --- a/Tests/ReviewUITests/ReviewMonitorTimelineTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -110,7 +110,7 @@ extension ReviewRunRecord { errorMessage: String? = nil, exitCode: Int? = nil ) -> ReviewRunRecord { - let job = ReviewRunRecord.makeForTesting( + let run = ReviewRunRecord.makeForTesting( id: id, sessionID: sessionID, cwd: cwd, @@ -130,75 +130,75 @@ extension ReviewRunRecord { errorMessage: errorMessage, exitCode: exitCode ) - ReviewChatLogFixtureStore.setEntries(chatEntries, for: job) - return job + ReviewChatLogFixtureStore.setEntries(chatEntries, for: run) + return run } } @MainActor func seedChatLogForTesting( - _ job: ReviewRunRecord, + _ run: ReviewRunRecord, logText: String = "", rawLogText: String = "" ) { var entries: [ReviewChatLogEntryForTesting] = [] let trimmedLogText = logText.trimmingCharacters(in: .newlines) if trimmedLogText.isEmpty == false { - entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(job.id)", text: trimmedLogText)) + entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(run.id)", text: trimmedLogText)) } for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { entries.append( .init( kind: .diagnostic, - groupID: "fixture-diagnostic-\(job.id)-\(index)", + groupID: "fixture-diagnostic-\(run.id)-\(index)", text: String(line) )) } - ReviewChatLogFixtureStore.setEntries(entries, for: job) + ReviewChatLogFixtureStore.setEntries(entries, for: run) } @MainActor -func appendChatLogEntryForTesting(_ job: ReviewRunRecord, _ entry: ReviewChatLogEntryForTesting) { - ReviewChatLogFixtureStore.append(entry, to: job) +func appendChatLogEntryForTesting(_ run: ReviewRunRecord, _ entry: ReviewChatLogEntryForTesting) { + ReviewChatLogFixtureStore.append(entry, to: run) } @MainActor -func replaceChatLogTextForTesting(_ job: ReviewRunRecord, _ text: String) { +func replaceChatLogTextForTesting(_ run: ReviewRunRecord, _ text: String) { ReviewChatLogFixtureStore.replaceEntries( - [.init(kind: .agentMessage, groupID: "fixture-log-\(job.id)", text: text.trimmingCharacters(in: .newlines))], - for: job + [.init(kind: .agentMessage, groupID: "fixture-log-\(run.id)", text: text.trimmingCharacters(in: .newlines))], + for: run ) } @MainActor func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, reviewRuns: [ReviewRunRecord]) { - let fixtures = reviewRuns.compactMap { try? makePreviewChatLogFixtureForTesting(job: $0) } + let fixtures = reviewRuns.compactMap { try? makePreviewChatLogFixtureForTesting(run: $0) } let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) - jobStorePreviewSupportRetainers.append( + runStorePreviewSupportRetainers.append( ReviewChatLogFixtureRetainer( source: source, - jobIDs: Set(reviewRuns.map(\.id)) + runIDs: Set(reviewRuns.map(\.id)) )) } @MainActor -func reviewChatLogText(for job: ReviewRunRecord) -> String { - ReviewChatLogFixtureStore.logText(for: job) +func reviewChatLogText(for run: ReviewRunRecord) -> String { + ReviewChatLogFixtureStore.logText(for: run) } @MainActor func awaitChatRenderForTesting( - _ job: ReviewRunRecord, + _ run: ReviewRunRecord, in transport: ReviewMonitorTransportViewController, allowIncrementalUpdate: Bool = true, timeout: Duration = .seconds(2), matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil ) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { _ = allowIncrementalUpdate - let expectedLog = reviewChatLogText(for: job) + let expectedLog = reviewChatLogText(for: run) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = - .chat(chatIDForTesting(job).rawValue) + .chat(chatIDForTesting(run).rawValue) let clock = ContinuousClock() let deadline = clock.now + timeout repeat { @@ -245,11 +245,11 @@ func reviewChatRenderedLogMatches(_ actual: String, _ expected: String) -> Bool @MainActor func makePreviewChatLogSourceForTesting( - job: ReviewRunRecord, + run: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogSource { ReviewMonitorPreviewChatLogSource( - fixtures: [try makePreviewChatLogFixtureForTesting(job: job, items: makeItems)] + fixtures: [try makePreviewChatLogFixtureForTesting(run: run, items: makeItems)] ) } @@ -269,90 +269,90 @@ func makePreviewMessageItemForTesting( @MainActor func chatCommandOutputBlockIDForTesting( - _ job: ReviewRunRecord, + _ run: ReviewRunRecord, itemID: String ) -> ReviewMonitorLog.BlockID { - ReviewMonitorLog.BlockID("commandOutput:\(job.previewTurnIDForTesting.rawValue):\(itemID)") + ReviewMonitorLog.BlockID("commandOutput:\(run.previewTurnIDForTesting.rawValue):\(itemID)") } @MainActor -private func makePreviewChatLogFixtureForTesting(job: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { - try makePreviewChatLogFixtureForTesting(job: job) { _ in - ReviewChatLogFixtureStore.items(for: job) +private func makePreviewChatLogFixtureForTesting(run: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { + try makePreviewChatLogFixtureForTesting(run: run) { _ in + ReviewChatLogFixtureStore.items(for: run) } } @MainActor private func makePreviewChatLogFixtureForTesting( - job: ReviewRunRecord, + run: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogFixture { - let chat = try #require(job.reviewChatSelectionForTesting) + let chat = try #require(run.reviewChatSelectionForTesting) let turn = CodexChatTurnStateSnapshot( - id: job.previewTurnIDForTesting, - status: CodexTurnStatus(reviewRunStateForTesting: job.core.lifecycle.status), - errorDescription: job.core.lifecycle.errorMessage, + id: run.previewTurnIDForTesting, + status: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status), + errorDescription: run.core.lifecycle.errorMessage, usage: nil ) let initialSnapshot = CodexChatSnapshot( chatID: chat.id, phase: CodexDataPhase( - reviewRunStateForTesting: job.core.lifecycle.status, - errorMessage: job.core.lifecycle.errorMessage + reviewRunStateForTesting: run.core.lifecycle.status, + errorMessage: run.core.lifecycle.errorMessage ), turns: [turn], items: makeItems(turn.id) ) return ReviewMonitorPreviewChatLogFixture( chat: chat, - cwd: job.cwd, - streamID: job.id, - isRunning: job.core.lifecycle.status == .running, + cwd: run.cwd, + streamID: run.id, + isRunning: run.core.lifecycle.status == .running, initialSnapshot: initialSnapshot ) } @MainActor private enum ReviewChatLogFixtureStore { - private static var entriesByJobID: [String: [ReviewChatLogEntryForTesting]] = [:] + private static var entriesByRunID: [String: [ReviewChatLogEntryForTesting]] = [:] - static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for job: ReviewRunRecord) { - entriesByJobID[job.id] = entries + static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { + entriesByRunID[run.id] = entries } - static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for job: ReviewRunRecord) { - setEntries(entries, for: job) - updateRetainedPreviewSource(for: job) + static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { + setEntries(entries, for: run) + updateRetainedPreviewSource(for: run) } - static func append(_ entry: ReviewChatLogEntryForTesting, to job: ReviewRunRecord) { - entriesByJobID[job.id, default: []].append(entry) - updateRetainedPreviewSource(for: job) + static func append(_ entry: ReviewChatLogEntryForTesting, to run: ReviewRunRecord) { + entriesByRunID[run.id, default: []].append(entry) + updateRetainedPreviewSource(for: run) } - static func items(for job: ReviewRunRecord) -> [CodexChatItemSnapshot] { - makeChatItems(from: entriesByJobID[job.id, default: []], turnID: job.previewTurnIDForTesting) + static func items(for run: ReviewRunRecord) -> [CodexChatItemSnapshot] { + makeChatItems(from: entriesByRunID[run.id, default: []], turnID: run.previewTurnIDForTesting) } - static func logText(for job: ReviewRunRecord) -> String { + static func logText(for run: ReviewRunRecord) -> String { var projection = ReviewMonitorSelectedCodexChatLogProjection() var document: ReviewMonitorLog.Document? let snapshot = CodexChatSnapshot( - chatID: chatIDForTesting(job), + chatID: chatIDForTesting(run), phase: .loaded, turns: [ .init( - id: job.previewTurnIDForTesting, - status: CodexTurnStatus(reviewRunStateForTesting: job.core.lifecycle.status) + id: run.previewTurnIDForTesting, + status: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status) ) ], - items: items(for: job) + items: items(for: run) ) for change in CodexChatChange.previewChangesForTesting(from: nil, to: snapshot) { document = projection.apply( change, chatCreatedAt: nil, - chatUpdatedAt: job.core.lifecycle.endedAt + chatUpdatedAt: run.core.lifecycle.endedAt )?.sourceDocument ?? document } guard let document else { @@ -362,9 +362,9 @@ private enum ReviewChatLogFixtureStore { return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) } - private static func updateRetainedPreviewSource(for job: ReviewRunRecord) { - for support in jobStorePreviewSupportRetainers where support.contains(jobID: job.id) { - support.upsert(job: job, items: items(for: job)) + private static func updateRetainedPreviewSource(for run: ReviewRunRecord) { + for support in runStorePreviewSupportRetainers where support.contains(runID: run.id) { + support.upsert(run: run, items: items(for: run)) } } } @@ -372,19 +372,19 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { let source: ReviewMonitorPreviewChatLogSource - private var jobIDs: Set + private var runIDs: Set - init(source: ReviewMonitorPreviewChatLogSource, jobIDs: Set) { + init(source: ReviewMonitorPreviewChatLogSource, runIDs: Set) { self.source = source - self.jobIDs = jobIDs + self.runIDs = runIDs } - func contains(jobID: String) -> Bool { - jobIDs.contains(jobID) + func contains(runID: String) -> Bool { + runIDs.contains(runID) } - func upsert(job: ReviewRunRecord, items: [CodexChatItemSnapshot]) { - guard let chatID = job.reviewChatIDForTesting else { + func upsert(run: ReviewRunRecord, items: [CodexChatItemSnapshot]) { + guard let chatID = run.reviewChatIDForTesting else { return } for item in items { @@ -394,7 +394,7 @@ private final class ReviewChatLogFixtureRetainer { } @MainActor -private var jobStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] +private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] @MainActor private func makeChatItems( diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index b6c5a96b..604eb7b0 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2384,7 +2384,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = try makePreviewChatLogSourceForTesting(job: job) { turnID in + let previewChatLogSource = try makePreviewChatLogSourceForTesting(run: job) { turnID in [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] } let viewController = ReviewMonitorSplitViewController( @@ -2432,7 +2432,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = try makePreviewChatLogSourceForTesting(job: job) { turnID in + let previewChatLogSource = try makePreviewChatLogSourceForTesting(run: job) { turnID in [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] } let viewController = ReviewMonitorSplitViewController( From 3dc10fe8e3521d0d86cdcb1fc6e81b6ec38b2450 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:22:40 +0900 Subject: [PATCH 221/359] refactor(review-ui): finish run wording in tests --- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 604eb7b0..c8c67f52 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -24,7 +24,7 @@ private extension CodexReviewAuthModel { @MainActor func chatIDForTesting(_ job: ReviewRunRecord) -> CodexThreadID { guard let chatID = job.reviewChatIDForTesting else { - Issue.record("Expected review job \(job.id) to have a chat id.") + Issue.record("Expected review run \(job.id) to have a chat id.") return CodexThreadID(rawValue: job.id) } return chatID From ed9551f26c3744a612b6cc6f23c5b5398028121e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:23:49 +0900 Subject: [PATCH 222/359] refactor(review): expose review runs through api --- Sources/CodexReviewKit/CodexReviewTypes.swift | 10 +- .../Model/CodexReviewAuthModel.swift | 2 +- .../ReviewAPI/CodexReviewAPI.swift | 2 +- .../ReviewAPI/ReviewError.swift | 4 +- .../ReviewAPI/ReviewResults.swift | 44 ++-- .../Store/CodexReviewStore.swift | 22 +- .../Store/CodexReviewStoreCancellation.swift | 50 ++--- .../Store/CodexReviewStoreDiagnostics.swift | 4 +- .../Store/CodexReviewStoreOrdering.swift | 14 +- ...CodexReviewStoreRateLimitAutoRefresh.swift | 10 +- .../Store/CodexReviewStoreReviews.swift | 202 +++++++++--------- .../Store/CodexReviewStoreTesting.swift | 18 +- .../CodexReviewMCPHelpResources.swift | 8 +- .../CodexReviewMCPServer.swift | 36 ++-- .../CodexReviewMCPToolRequests.swift | 22 +- .../CodexReviewMCPToolResults.swift | 14 +- .../CodexReviewMCPToolSchemas.swift | 16 +- .../ReviewMCPProjection.swift | 6 +- Sources/CodexReviewTesting/TestSupport.swift | 26 +-- .../Accounts/AccountContextMenuView.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 12 +- .../AppServerClientTests.swift | 8 +- .../CodexReviewStoreCommandTests.swift | 104 ++++----- ...ReviewStoreRateLimitAutoRefreshTests.swift | 22 +- .../CodexReviewMCPHTTPServerTests.swift | 56 ++--- .../CodexReviewMCPServerTests.swift | 2 +- .../ReviewMCPProjectionTests.swift | 4 +- ...eviewMonitorCodexSidebarLibraryTests.swift | 2 +- .../CodexReviewMonitorApp.swift | 8 +- .../CodexReviewMonitorCITests.swift | 8 +- 30 files changed, 369 insertions(+), 369 deletions(-) diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 62787887..087fe2e5 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -196,22 +196,22 @@ package extension CodexReviewBackendModel.Login { package extension CodexReviewBackendModel.Review { struct Start: Equatable, Sendable { - package var jobID: String + package var runID: 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(runID: String, sessionID: String, request: CodexReviewAPI.Start.Request) { + self.init(runID: runID, sessionID: sessionID, request: request, model: nil) } package init( - jobID: String, + runID: String, sessionID: String, request: CodexReviewAPI.Start.Request, model: String? ) { - self.jobID = jobID + self.runID = runID self.sessionID = sessionID self.request = request self.model = model diff --git a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift index 4e6a6266..506f73fd 100644 --- a/Sources/CodexReviewKit/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 { diff --git a/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift index be135de9..084db9aa 100644 --- a/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift +++ b/Sources/CodexReviewKit/ReviewAPI/CodexReviewAPI.swift @@ -1,7 +1,7 @@ package enum CodexReviewAPI { package enum Start {} package enum Read {} - package enum Job {} + package enum Run {} package enum List {} package enum Cancel {} } diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewError.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewError.swift index 9855e4b5..45c3c306 100644 --- a/Sources/CodexReviewKit/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 index d6260d76..dc3cd9be 100644 --- a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift +++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift @@ -2,18 +2,18 @@ import Foundation package extension CodexReviewAPI.Read { struct Result: Codable, Sendable, Hashable { - package var jobID: String + package var runID: String package var core: ReviewRunCore package var elapsedSeconds: Int? package var cancellable: Bool package init( - jobID: String, + runID: String, core: ReviewRunCore, elapsedSeconds: Int? = nil, cancellable: Bool ) { - self.jobID = jobID + self.runID = runID self.core = core self.elapsedSeconds = elapsedSeconds self.cancellable = cancellable @@ -22,9 +22,9 @@ struct Result: Codable, Sendable, Hashable { } -package extension CodexReviewAPI.Job { +package extension CodexReviewAPI.Run { struct ListItem: Codable, Sendable, Hashable { - package var jobID: String + package var runID: String package var cwd: String package var targetSummary: String package var core: ReviewRunCore @@ -32,14 +32,14 @@ struct ListItem: Codable, Sendable, Hashable { package var cancellable: Bool package init( - jobID: String, + runID: String, cwd: String, targetSummary: String, core: ReviewRunCore, elapsedSeconds: Int?, cancellable: Bool ) { - self.jobID = jobID + self.runID = runID self.cwd = cwd self.targetSummary = targetSummary self.core = core @@ -52,27 +52,27 @@ struct ListItem: Codable, Sendable, Hashable { package extension CodexReviewAPI.List { struct Result: Codable, Sendable, Hashable { - package var items: [CodexReviewAPI.Job.ListItem] + package var items: [CodexReviewAPI.Run.ListItem] - package init(items: [CodexReviewAPI.Job.ListItem]) { + package init(items: [CodexReviewAPI.Run.ListItem]) { self.items = items } } } -package extension CodexReviewAPI.Job { +package extension CodexReviewAPI.Run { struct Selector: Sendable, Hashable { - package var jobID: String? + package var runID: String? package var cwd: String? package var statuses: [ReviewRunState]? package init( - jobID: String? = nil, + runID: String? = nil, cwd: String? = nil, statuses: [ReviewRunState]? = nil ) { - self.jobID = jobID + self.runID = runID self.cwd = cwd?.nilIfEmpty self.statuses = statuses } @@ -80,24 +80,24 @@ struct Selector: Sendable, Hashable { } -package extension CodexReviewAPI.Job { +package extension CodexReviewAPI.Run { enum SelectionError: Swift.Error, Sendable { - case ambiguous([CodexReviewAPI.Job.ListItem]) + case ambiguous([CodexReviewAPI.Run.ListItem]) } } -extension CodexReviewAPI.Job.SelectionError: LocalizedError { +extension CodexReviewAPI.Run.SelectionError: LocalizedError { package var errorDescription: String? { switch self { case .ambiguous(let reviewRuns): let candidates = reviewRuns - .map { "- \($0.jobID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" } + .map { "- \($0.runID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" } .joined(separator: "\n") return """ - Review job selector matched multiple review jobs: + Review run selector matched multiple review runs: \(candidates) - Specify jobID or narrow cwd/statuses. + Specify runID or narrow cwd/statuses. """ } } @@ -106,16 +106,16 @@ extension CodexReviewAPI.Job.SelectionError: LocalizedError { package extension CodexReviewAPI.Cancel { struct Outcome: Codable, Sendable, Hashable { - package var jobID: String + package var runID: String package var cancelled: Bool package var core: ReviewRunCore package init( - jobID: String, + runID: String, cancelled: Bool, core: ReviewRunCore ) { - self.jobID = jobID + self.runID = runID self.cancelled = cancelled self.core = core } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index e73606f9..2249fb02 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -131,7 +131,7 @@ public final class CodexReviewStore { await backend.stop(store: self) let remainingLocallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop(cancelWorkers: false) cancelAndDetachReviewWorkersForRuntimeStop( - jobIDs: Array(Set(locallyCancelledReviewRunIDs + remainingLocallyCancelledReviewRunIDs)) + runIDs: Array(Set(locallyCancelledReviewRunIDs + remainingLocallyCancelledReviewRunIDs)) ) transitionToStopped() } @@ -224,8 +224,8 @@ public final class CodexReviewStore { package func requestSwitchAccountFromUserAction(_ account: CodexReviewAccount) { requestSwitchAccount( account, - requiresConfirmation: hasRunningJobs - && switchActionRequiresRunningJobsConfirmation(for: account) + requiresConfirmation: hasRunningReviewRuns + && switchActionRequiresRunningReviewRunsConfirmation(for: account) ) } @@ -304,7 +304,7 @@ public final class CodexReviewStore { canSwitchAccount(account) == false } - package func switchActionRequiresRunningJobsConfirmation(for account: CodexReviewAccount) -> Bool { + package func switchActionRequiresRunningReviewRunsConfirmation(for account: CodexReviewAccount) -> Bool { guard canSwitchAccount(account) else { return false } @@ -341,18 +341,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 @@ -390,15 +390,15 @@ public final class CodexReviewStore { } catch {} } - package func noteJobMutation() { + package func noteReviewRunMutation() { writeDiagnosticsIfNeeded() } - public var hasRunningJobs: Bool { + public var hasRunningReviewRuns: Bool { reviewRuns.contains(where: { $0.isTerminal == false }) } - public var runningJobCount: Int { + public var runningReviewRunCount: Int { reviewRuns.filter { $0.isTerminal == false }.count } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 02abd6e0..ed97adc7 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -29,17 +29,17 @@ private actor RuntimeStopDetachedReviewWorkerDrainRace { extension CodexReviewStore { package func completeCancellationLocally( - jobID: String, + runID: String, sessionID: String, cancellation: ReviewCancellation = .system() ) throws { - guard let job = reviewRun(id: jobID) + guard let job = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } guard job.sessionID == sessionID else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } guard job.isTerminal == false else { return @@ -55,21 +55,21 @@ extension CodexReviewStore { cancellation.message.nilIfEmpty ?? job.core.lifecycle.errorMessage job.core.lifecycle.endedAt = endedAt - noteJobMutation() + noteReviewRunMutation() } package func recordCancellationFailure( - jobID: String, + runID: String, sessionID: String, message: String ) throws { - guard let job = reviewRun(id: jobID) + guard let job = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } guard job.sessionID == sessionID else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } job.cancellationRequested = false @@ -87,7 +87,7 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - public func cancelAllRunningJobs( + public func cancelAllRunningReviewRuns( reason: String = "Cancellation requested." ) async throws { let cancellation = ReviewCancellation.system( @@ -98,14 +98,14 @@ extension CodexReviewStore { for job in cancellableReviewRuns { do { _ = try await cancelReview( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) } catch { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) try? recordCancellationFailure( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, message: message.isEmpty ? "Failed to cancel review." : message ) @@ -126,8 +126,8 @@ extension CodexReviewStore { orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) - for jobID in activeReviewRunIDs { - _ = try? await cancelReview(jobID: jobID, cancellation: reason) + for runID in activeReviewRunIDs { + _ = try? await cancelReview(runID: runID, cancellation: reason) } return activeReviewRunIDs } @@ -146,7 +146,7 @@ extension CodexReviewStore { reason: reason, cancelWorkers: false ) - cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) + cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop( timeout: workerDrainTimeout ) @@ -178,25 +178,25 @@ extension CodexReviewStore { return [] } - for jobID in activeReviewRunIDs { - if let job = reviewRun(id: jobID), job.isTerminal == false { + for runID in activeReviewRunIDs { + if let job = reviewRun(id: runID), job.isTerminal == false { try? completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: reason ) } if cancelWorkers { - runtimeState.cancelActiveWorker(for: jobID) + runtimeState.cancelActiveWorker(for: runID) } } return activeReviewRunIDs } - package func cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: [String]) { - for jobID in jobIDs { - runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(runID: jobID) - runtimeState.clearRuntimeStopState(for: jobID) + package func cancelAndDetachReviewWorkersForRuntimeStop(runIDs: [String]) { + for runID in runIDs { + runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(runID: runID) + runtimeState.clearRuntimeStopState(for: runID) } } @@ -243,7 +243,7 @@ extension CodexReviewStore { return didDrain } - package func terminateAllRunningJobsLocally( + package func terminateAllRunningReviewRunsLocally( reason: String = "Cancellation requested.", failureMessage: String ) { @@ -264,6 +264,6 @@ extension CodexReviewStore { ?? job.core.lifecycle.errorMessage job.core.lifecycle.endedAt = clock.now() } - noteJobMutation() + noteReviewRunMutation() } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index 6caf3cc9..93b125e4 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -2,12 +2,12 @@ import Foundation public enum CodexReviewStoreTestEnvironment { public static let reviewModeKey = "REVIEW_MONITOR_REVIEW_MODE" - public static let mockJobsKey = "REVIEW_MONITOR_MOCK_JOBS" + public static let mockReviewRunsKey = "REVIEW_MONITOR_MOCK_REVIEW_RUNS" 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 mockReviewRunsArgument = "--review-monitor-mock-review-runs" 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" diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift index 986dac0e..522a6c5e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift @@ -52,12 +52,12 @@ extension CodexReviewStore { } @discardableResult - package func reorderJob( + package func reorderReviewRun( id: String, inWorkspace cwd: String, - beforeJobID: String? + beforeRunID: String? ) -> Bool { - guard beforeJobID != id, + guard beforeRunID != id, workspace(cwd: cwd) != nil else { return false @@ -70,19 +70,19 @@ extension CodexReviewStore { let remaining = ordered.filter { $0 !== job } let destinationIndex: Int - if let beforeJobID { - guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeJobID }) else { + if let beforeRunID { + guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeRunID }) else { return false } destinationIndex = beforeIndex } else { destinationIndex = remaining.count } - return reorderJob(id: id, inWorkspace: cwd, toIndex: destinationIndex) + return reorderReviewRun(id: id, inWorkspace: cwd, toIndex: destinationIndex) } @discardableResult - package func reorderJob( + package func reorderReviewRun( id: String, inWorkspace cwd: String, toIndex: Int diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRateLimitAutoRefresh.swift index 5cee08e5..a20add47 100644 --- a/Sources/CodexReviewKit/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 } @@ -92,7 +92,7 @@ private struct CodexReviewStoreRateLimitAutoRefreshPolicy { let interval: TimeInterval switch role(for: account, context: context) { case .selected: - if context.hasRunningJobs { + if context.hasRunningReviewRuns { kind = .selectedRunningInterval interval = selectedRunningInterval } else { @@ -233,7 +233,7 @@ extension CodexReviewStore { CodexReviewStoreRateLimitAutoRefreshDriver.targets( accounts: auth.accounts, selectedAccountKey: auth.selectedAccount?.accountKey, - hasRunningJobs: hasRunningJobs, + hasRunningReviewRuns: hasRunningReviewRuns, serverState: serverState, now: now ) @@ -275,7 +275,7 @@ package final class CodexReviewStoreRateLimitAutoRefreshDriver { package static func targets( 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/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 923b4776..ab0e9f6e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -15,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 runtimeState.awaitActiveWorker(for: jobID) - 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?.runtimeState.cancelActiveWorker(for: jobID) + self?.runtimeState.cancelActiveWorker(for: runID) } } } @@ -33,23 +33,23 @@ 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) + return try await awaitReview(sessionID: sessionID, runID: runID, timeout: waitTimeout) } package func awaitReview( sessionID: String?, - jobID: String, + runID: String, timeout: Duration? = nil ) async throws -> CodexReviewAPI.Read.Result { - let job = try job(jobID: jobID) + let job = try job(runID: runID) if let sessionID, job.sessionID != sessionID { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } if job.isTerminal == false { - await waitForReviewTerminal(jobID: jobID, timeout: timeout) + await waitForReviewTerminal(runID: runID, timeout: timeout) } - return try readReview(sessionID: sessionID, jobID: jobID) + return try readReview(sessionID: sessionID, runID: runID) } @discardableResult @@ -62,10 +62,10 @@ extension CodexReviewStore { } let validatedRequest = try request.validated() - let jobID = idGenerator.next() + let runID = idGenerator.next() let createdAt = clock.now() let job = ReviewRunRecord( - id: jobID, + id: runID, sessionID: sessionID, cwd: validatedRequest.cwd, sortOrder: nextReviewRunSortOrder(inWorkspace: validatedRequest.cwd), @@ -77,35 +77,35 @@ extension CodexReviewStore { ) insertReviewRun(job) markReviewRunning(job, startedAt: createdAt) - runtimeState.markStarting(jobID) - launchReviewWorker(jobID: jobID, sessionID: sessionID, request: validatedRequest) - return jobID + 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 ) { - runtimeState.cancelActiveWorker(for: jobID) + runtimeState.cancelActiveWorker(for: runID) runtimeState.setActiveWorker( Task { [weak self] in - await self?.runReviewWorker(jobID: jobID, sessionID: sessionID, request: request) - }, for: jobID) + 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 = reviewRun(id: jobID) else { - runtimeState.clearStarting(jobID) - runtimeState.removeActiveWorker(for: jobID) + guard let job = 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 @@ -114,20 +114,20 @@ extension CodexReviewStore { do { let backendAttempt = try await backend.startReview(startRequest) let backendRun = backendAttempt.run - runtimeState.clearStarting(jobID) + runtimeState.clearStarting(runID) run = backendRun if Task.isCancelled { throw CancellationError() } applyBackendRun(backendRun, to: job) - if let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) { + if let startupCancellation = runtimeState.takeStartupCancellation(for: runID) { try? await backend.interruptReview( backendRun, reason: .init(message: startupCancellation.message) ) if job.isTerminal == false { try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: startupCancellation ) @@ -138,7 +138,7 @@ extension CodexReviewStore { reason: .init(message: job.core.lifecycle.cancellation?.message ?? "Cancellation requested.") ) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: job.core.lifecycle.cancellation ?? .system() ) @@ -146,7 +146,7 @@ extension CodexReviewStore { if job.isTerminal { await backend.cleanupReview(backendRun) - runtimeState.clearReviewRunState(for: jobID) + runtimeState.clearReviewRunState(for: runID) } else { let currentRun = try await consumeReviewEvents( for: backendAttempt, @@ -155,32 +155,32 @@ extension CodexReviewStore { ) run = currentRun await backend.cleanupReview(currentRun) - runtimeState.clearReviewRunState(for: jobID) + runtimeState.clearReviewRunState(for: runID) } } catch let error where error is CancellationError || Task.isCancelled { - runtimeState.clearStarting(jobID) - let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) - if let cleanupRun = runtimeState.activeRun(for: jobID) ?? run { + runtimeState.clearStarting(runID) + let startupCancellation = runtimeState.takeStartupCancellation(for: runID) + if let cleanupRun = runtimeState.activeRun(for: runID) ?? run { await interruptReviewAfterTaskCancellation(cleanupRun, job: job) await backend.cleanupReview(cleanupRun) } else if job.isTerminal == false || startupCancellation != nil { try? completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: startupCancellation ?? job.core.lifecycle.cancellation ?? .system() ) } - runtimeState.clearReviewRunState(for: jobID) + runtimeState.clearReviewRunState(for: runID) } catch { - runtimeState.clearStarting(jobID) - let startupCancellation = runtimeState.takeStartupCancellation(for: jobID) - if let cleanupRun = runtimeState.activeRun(for: jobID) ?? run { + runtimeState.clearStarting(runID) + let startupCancellation = runtimeState.takeStartupCancellation(for: runID) + if let cleanupRun = runtimeState.activeRun(for: runID) ?? run { await backend.cleanupReview(cleanupRun) } - runtimeState.clearReviewRunState(for: jobID) + runtimeState.clearReviewRunState(for: runID) if job.isTerminal == false, let startupCancellation { try? completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: startupCancellation ) @@ -188,8 +188,8 @@ extension CodexReviewStore { markReviewFailed(job, message: error.localizedDescription) } } - runtimeState.removeActiveWorker(for: jobID) - runtimeState.removeDetachedWorker(for: jobID) + runtimeState.removeActiveWorker(for: runID) + runtimeState.removeDetachedWorker(for: runID) } private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: ReviewRunRecord) @@ -209,13 +209,13 @@ extension CodexReviewStore { reason: .init(message: cancellation.message) ) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) } catch { try? recordCancellationFailure( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, message: error.localizedDescription ) @@ -373,20 +373,20 @@ extension CodexReviewStore { ) } - package func readReview(jobID: String) throws -> CodexReviewAPI.Read.Result { - try readReview(sessionID: nil, jobID: jobID) + package func readReview(runID: String) throws -> CodexReviewAPI.Read.Result { + try readReview(sessionID: nil, runID: runID) } package func readReview( sessionID: String?, - jobID: String + runID: String ) throws -> CodexReviewAPI.Read.Result { - let job = try job(jobID: jobID) + let job = try job(runID: runID) if let sessionID, job.sessionID != sessionID { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } return CodexReviewAPI.Read.Result( - jobID: job.id, + runID: job.id, core: job.core, elapsedSeconds: elapsedSeconds(for: job), cancellable: job.isTerminal == false && job.cancellationRequested == false @@ -426,11 +426,11 @@ extension CodexReviewStore { return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem)) } - package func resolveJob(selector: CodexReviewAPI.Job.Selector) throws -> ReviewRunRecord { - 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 -> ReviewRunRecord { + package func resolveRun(sessionID: String?, selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord { let statusSet = selector.statuses.map(Set.init) let matches = orderedReviewRuns.filter { job in if let sessionID, job.sessionID != sessionID { @@ -442,7 +442,7 @@ extension CodexReviewStore { if let statusSet, statusSet.contains(job.core.lifecycle.status) == false { return false } - if let jobID = selector.jobID, jobID != job.id { + if let runID = selector.runID, runID != job.id { return false } return true @@ -451,30 +451,30 @@ extension CodexReviewStore { return job } 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 = reviewRun(id: jobID), job.sessionID == sessionID else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + guard let job = reviewRun(id: runID), job.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) + let job = try job(runID: runID) guard job.isTerminal == false else { - return .init(jobID: job.id, cancelled: false, core: job.core) + return .init(runID: job.id, cancelled: false, core: job.core) } job.cancellationRequested = true @@ -484,38 +484,38 @@ extension CodexReviewStore { if job.core.lifecycle.status == .queued { try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - return .init(jobID: job.id, cancelled: true, core: job.core) + return .init(runID: job.id, cancelled: true, core: job.core) } - if runtimeState.isWaitingForNetworkRecovery(jobID) { + if runtimeState.isWaitingForNetworkRecovery(runID) { try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - runtimeState.cancelActiveWorker(for: jobID) - return .init(jobID: job.id, cancelled: true, core: job.core) + runtimeState.cancelActiveWorker(for: runID) + return .init(runID: job.id, cancelled: true, core: job.core) } - if let run = runtimeState.activeRun(for: jobID) { + if let run = runtimeState.activeRun(for: runID) { do { try await backend.interruptReview( run, reason: .init(message: cancellation.message) ) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - runtimeState.cancelActiveWorker(for: jobID) + runtimeState.cancelActiveWorker(for: runID) } catch { try recordCancellationFailure( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, message: error.localizedDescription ) @@ -528,35 +528,35 @@ extension CodexReviewStore { reason: .init(message: cancellation.message) ) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - runtimeState.cancelActiveWorker(for: jobID) + runtimeState.cancelActiveWorker(for: runID) } catch { try recordCancellationFailure( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, message: error.localizedDescription ) throw error } - } else if runtimeState.isStarting(jobID) { - runtimeState.setStartupCancellation(cancellation, for: jobID) + } else if runtimeState.isStarting(runID) { + runtimeState.setStartupCancellation(cancellation, for: runID) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) - return .init(jobID: job.id, cancelled: true, core: job.core) + return .init(runID: job.id, cancelled: true, core: job.core) } else { try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) } - return .init(jobID: job.id, cancelled: true, core: job.core) + return .init(runID: job.id, cancelled: true, core: job.core) } package func closeSession( @@ -564,24 +564,24 @@ extension CodexReviewStore { reason: ReviewCancellation = .sessionClosed() ) async { closedSessions.insert(sessionID) - for jobID in activeReviewRunIDs(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 = + 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 -> ReviewRunRecord { - guard let job = reviewRun(id: jobID) else { - throw CodexReviewAPI.Error.jobNotFound("Job \(jobID) was not found.") + private func job(runID: String) throws -> ReviewRunRecord { + guard let job = reviewRun(id: runID) else { + throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } return job } @@ -599,9 +599,9 @@ extension CodexReviewStore { } } - private func makeListItem(_ job: ReviewRunRecord) -> CodexReviewAPI.Job.ListItem { - CodexReviewAPI.Job.ListItem( - jobID: job.id, + private func makeListItem(_ job: ReviewRunRecord) -> CodexReviewAPI.Run.ListItem { + CodexReviewAPI.Run.ListItem( + runID: job.id, cwd: job.cwd, targetSummary: job.targetSummary, core: job.core, @@ -879,14 +879,14 @@ extension CodexReviewStore { do { try await backend.interruptReview(recoveredRun, reason: .init(message: cancellation.message)) try completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) } catch { await backend.cleanupReview(recoveredRun) try? recordCancellationFailure( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, message: error.localizedDescription ) @@ -939,7 +939,7 @@ extension CodexReviewStore { case .cancelled(let message): let cancellation = job.core.lifecycle.cancellation ?? .system(message: message) try? completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) @@ -954,7 +954,7 @@ extension CodexReviewStore { } let cancellation = job.core.lifecycle.cancellation ?? .system() try? completeCancellationLocally( - jobID: job.id, + runID: job.id, sessionID: job.sessionID, cancellation: cancellation ) @@ -991,8 +991,8 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func waitForReviewTerminal(jobID: String, timeout: Duration?) async { - guard let job = reviewRun(id: jobID), + private func waitForReviewTerminal(runID: String, timeout: Duration?) async { + guard let job = reviewRun(id: runID), job.isTerminal == false else { return diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index 4062e34b..b9a9d029 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -61,22 +61,22 @@ extension CodexReviewStore { } self.workspaces = Set(resolvedWorkspaces) - var jobsByCWD: [String: [ReviewRunRecord]] = [:] - let resolvedJobs = reviewRuns.filter { job in + var runsByCWD: [String: [ReviewRunRecord]] = [:] + let resolvedRuns = reviewRuns.filter { job in resolvedWorkspaces.contains(where: { $0.cwd == job.cwd }) } - for job in resolvedJobs { - jobsByCWD[job.cwd, default: []].append(job) + for job in resolvedRuns { + runsByCWD[job.cwd, default: []].append(job) } - for job in resolvedJobs { - guard let workspaceJobs = jobsByCWD[job.cwd], - let index = workspaceJobs.firstIndex(where: { $0 === job }) + for job in resolvedRuns { + guard let workspaceRuns = runsByCWD[job.cwd], + let index = workspaceRuns.firstIndex(where: { $0 === job }) else { continue } - job.sortOrder = Double(workspaceJobs.count - index - 1) + job.sortOrder = Double(workspaceRuns.count - index - 1) } - self.reviewRuns = Set(resolvedJobs) + self.reviewRuns = Set(resolvedRuns) if let settingsSnapshot { settings.loadForTesting(snapshot: settingsSnapshot) } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift index 60c629fc..e3a353c0 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPHelpResources.swift @@ -24,7 +24,7 @@ let helpResources: [HelpResource] = [ 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. + 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( @@ -42,13 +42,13 @@ let helpResources: [HelpResource] = [ .init( uri: "codex-review://help/tools/review_await", name: "review_await", - description: "Wait for a running Codex review job.", + description: "Wait for a running Codex review run.", content: """ # review_await - Required argument: `jobId`. + Required argument: `runId`. - Use this after `review_start` returns a running job. The tool waits for the job to finish and returns the final review when available. + 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( diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index d109b442..303d9bb2 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -30,10 +30,10 @@ package extension CodexReviewMCP.Tool { 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) + 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.Job.Selector, reason: ReviewCancellation) + case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Run.Selector, reason: ReviewCancellation) } } @@ -70,10 +70,10 @@ package final class CodexReviewMCPServer { 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."), ] } @@ -95,21 +95,21 @@ package final class CodexReviewMCPServer { sessionID: sessionID ) return .reviewStart(snapshot) - case .reviewAwait(let sessionID, let jobID, let waitTimeout): + case .reviewAwait(let sessionID, let runID, let waitTimeout): let snapshot = try reviewSnapshot( try await store.awaitReview( sessionID: sessionID, - jobID: jobID, + runID: runID, timeout: waitTimeout ), sessionID: sessionID ) return .reviewAwait(snapshot) - case .reviewRead(let sessionID, let jobID): + case .reviewRead(let sessionID, let runID): let snapshot = try reviewSnapshot( try store.readReview( sessionID: sessionID, - jobID: jobID + runID: runID ), sessionID: sessionID ) @@ -122,12 +122,12 @@ package final class CodexReviewMCPServer { limit: limit )) case .reviewCancel(let sessionID, let selector, let reason): - let job = try store.resolveJob( + let job = try store.resolveRun( sessionID: sessionID, selector: selector.defaultingToActiveStatusesForCancellation() ) return .reviewCancel(try await store.cancelReview( - jobID: job.id, + runID: job.id, cancellation: reason )) } @@ -138,7 +138,7 @@ package final class CodexReviewMCPServer { sessionID: String? ) throws -> CodexReviewMCP.Tool.ReviewSnapshot { if let sessionID { - _ = try store.resolveJob(sessionID: sessionID, selector: .init(jobID: result.jobID)) + _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID)) } return .init(result: result, timeline: ReviewMCPProjection(result: result)) } @@ -152,11 +152,11 @@ package final class CodexReviewMCPServer { } } -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 index cfc3a15e..f3c45428 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -21,13 +21,13 @@ func toolRequest( case .reviewAwait: return .reviewAwait( sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID), - jobID: try requiredJobID(in: arguments), + runID: try requiredRunID(in: arguments), waitTimeout: boundedReviewWaitDuration ) case .reviewRead: return .reviewRead( sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID), - jobID: try requiredJobID(in: arguments) + runID: try requiredRunID(in: arguments) ) case .reviewList: return .reviewList( @@ -37,16 +37,16 @@ func toolRequest( limit: arguments["limit"]?.intValue ) case .reviewCancel: - let jobID = optionalJobID(in: arguments) + let runID = optionalRunID(in: arguments) let sessionID = sessionID( in: arguments, defaultSessionID: defaultSessionID, - fallback: jobID == nil ? "default" : nil + fallback: runID == nil ? "default" : nil ) return .reviewCancel( sessionID: sessionID, selector: .init( - jobID: jobID, + runID: runID, cwd: arguments["cwd"]?.stringValue, statuses: try statuses(from: arguments["statuses"]) ), @@ -63,15 +63,15 @@ func sessionID( defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback } -func optionalJobID(in arguments: [String: Value]) -> String? { - arguments["jobID"]?.stringValue?.nilIfEmpty ?? arguments["jobId"]?.stringValue?.nilIfEmpty +func optionalRunID(in arguments: [String: Value]) -> String? { + arguments["runID"]?.stringValue?.nilIfEmpty ?? arguments["runId"]?.stringValue?.nilIfEmpty } -func requiredJobID(in arguments: [String: Value]) throws -> String { - guard let jobID = optionalJobID(in: arguments) else { - throw MCPProtocolServerError.missingArgument("jobID/jobId") +func requiredRunID(in arguments: [String: Value]) throws -> String { + guard let runID = optionalRunID(in: arguments) else { + throw MCPProtocolServerError.missingArgument("runID/runId") } - return jobID + return runID } func reviewTarget(from object: [String: Value]) throws -> CodexReviewAPI.Target { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index f322ee42..2cf60c8b 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -18,7 +18,7 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul isError = snapshot.result.core.lifecycle.status == .failed case .reviewList(let result): value = result.structuredContent() - text = "Listed \(result.items.count) review job(s)." + text = "Listed \(result.items.count) review run(s)." isError = false case .reviewCancel(let result): value = result.structuredContent() @@ -46,7 +46,7 @@ private extension CodexReviewAPI.Read.Result { if let elapsedSeconds { status += " for \(elapsedSeconds)s" } - return "\(status). jobId: \(jobID). Call `review_await` with this jobId to continue waiting." + return "\(status). runId: \(runID). Call `review_await` with this runId to continue waiting." } func textContentForRead() -> String { @@ -83,7 +83,7 @@ private extension CodexReviewAPI.Read.Result { timeline: ReviewMCPProjection ) -> Value { var object: [String: Value] = [ - "jobId": .string(jobID), + "runId": .string(runID), "run": core.run.structuredContent(), "lifecycle": core.lifecycle.structuredContent( elapsedSeconds: elapsedSeconds, @@ -98,7 +98,7 @@ private extension CodexReviewAPI.Read.Result { if includeNextAction { object["nextAction"] = .object([ "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue), - "jobId": .string(jobID), + "runId": .string(runID), ]) } return .object(object) @@ -302,10 +302,10 @@ private extension String { } } -private extension CodexReviewAPI.Job.ListItem { +private extension CodexReviewAPI.Run.ListItem { func structuredContent() -> Value { .object([ - "jobId": .string(jobID), + "runId": .string(runID), "cwd": .string(cwd), "targetSummary": .string(targetSummary), "run": core.run.structuredContent(), @@ -337,7 +337,7 @@ private extension CodexReviewAPI.Cancel.Outcome { func structuredContent() -> Value { .object([ - "jobId": .string(jobID), + "runId": .string(runID), "cancelled": .bool(cancelled), "run": core.run.structuredContent(), "lifecycle": core.lifecycle.structuredContent( diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift index 3b0c1806..2f7a3992 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift @@ -28,12 +28,12 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { "type": .string("object"), "properties": .object([ "sessionID": .object(["type": .string("string")]), - "jobID": .object(["type": .string("string")]), - "jobId": .object(["type": .string("string")]), + "runID": .object(["type": .string("string")]), + "runId": .object(["type": .string("string")]), ]), "anyOf": .array([ - .object(["required": .array([.string("jobId")])]), - .object(["required": .array([.string("jobID")])]), + .object(["required": .array([.string("runId")])]), + .object(["required": .array([.string("runID")])]), ]), ]) case .reviewRead: @@ -41,8 +41,8 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { "type": .string("object"), "properties": .object([ "sessionID": .object(["type": .string("string")]), - "jobID": .object(["type": .string("string")]), - "jobId": .object(["type": .string("string")]), + "runID": .object(["type": .string("string")]), + "runId": .object(["type": .string("string")]), ]), ]) case .reviewList: @@ -60,8 +60,8 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { "type": .string("object"), "properties": .object([ "sessionID": .object(["type": .string("string")]), - "jobID": .object(["type": .string("string")]), - "jobId": .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/ReviewMCPProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift index 92f41070..930b6841 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift @@ -36,7 +36,7 @@ struct ReviewMCPProjection: Sendable, Equatable { let output = result.core.output let status = lifecycle.status self.revision = [ - result.jobID, + result.runID, status.rawValue, lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", output.summary, @@ -45,9 +45,9 @@ struct ReviewMCPProjection: Sendable, Equatable { var items: [Item] = [] if let text = output.lastAgentMessage?.nilIfEmpty { - items.append(.init(id: "\(result.jobID):message", kind: "agentMessage", content: .message(text))) + items.append(.init(id: "\(result.runID):message", kind: "agentMessage", content: .message(text))) } else if let summary = output.summary.nilIfEmpty { - items.append(.init(id: "\(result.jobID):summary", kind: "diagnostic", content: .diagnostic(summary))) + items.append(.init(id: "\(result.runID):summary", kind: "diagnostic", content: .diagnostic(summary))) } self.items = items diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index afa6486d..82290286 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -544,8 +544,8 @@ package final class StoreSnapshotProbe { } .map { job in let runtimeState = store.runtimeReviewRunState(runID: job.id) - return StoreJobSnapshot( - jobID: job.id, + return StoreRunSnapshot( + runID: job.id, status: job.core.lifecycle.status, summary: job.core.output.summary, lastAgentMessage: job.core.output.lastAgentMessage, @@ -557,23 +557,23 @@ package final class StoreSnapshotProbe { return StoreSnapshot(reviewRuns: reviewRuns) } - package func waitUntilJobStatus( + package func waitUntilRunStatus( _ status: ReviewRunState, - jobID: String? = nil, + runID: String? = nil, timeout: Duration = .seconds(2) ) async -> StoreSnapshot? { await waitUntil(timeout: timeout) { snapshot in - snapshot.job(jobID)?.status == status + snapshot.job(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.job(runID)?.activeRun?.attemptID == attemptID } } @@ -597,18 +597,18 @@ package final class StoreSnapshotProbe { } package struct StoreSnapshot: Sendable { - package var reviewRuns: [StoreJobSnapshot] + package var reviewRuns: [StoreRunSnapshot] - package func job(_ jobID: String? = nil) -> StoreJobSnapshot? { - guard let jobID else { + package func job(_ runID: String? = nil) -> StoreRunSnapshot? { + guard let runID else { return reviewRuns.first } - return reviewRuns.first { $0.jobID == jobID } + return reviewRuns.first { $0.runID == runID } } } -package struct StoreJobSnapshot: Sendable { - package var jobID: String +package struct StoreRunSnapshot: Sendable { + package var runID: String package var status: ReviewRunState package var summary: String package var lastAgentMessage: String? diff --git a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift index 6917e0b1..a05525c3 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift @@ -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) } diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 0ac1eef2..9304cd03 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -705,7 +705,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let menu = NSMenu() - if let job = cancellableReviewJob(for: chat) { + if let job = cancellableReviewRun(for: chat) { let item = NSMenuItem( title: "Cancel Review", action: #selector(cancelReviewFromContextMenu(_:)), @@ -718,7 +718,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return menu.items.isEmpty ? nil : menu } - private func cancellableReviewJob( + private func cancellableReviewRun( for chat: ReviewMonitorCodexSidebarSnapshot.Chat ) -> ReviewRunRecord? { store.orderedReviewRuns.first { job in @@ -733,7 +733,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD @objc private func cancelReviewFromContextMenu(_ sender: NSMenuItem) { - guard let jobID = sender.representedObject as? String else { + guard let runID = sender.representedObject as? String else { return } Task { @MainActor [weak self] in @@ -742,15 +742,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } do { _ = try await self.store.cancelReview( - jobID: jobID, + runID: runID, cancellation: .userInterface() ) } catch { - guard let job = self.store.reviewRun(id: jobID) else { + guard let job = self.store.reviewRun(id: runID) else { return } try? self.store.recordCancellationFailure( - jobID: jobID, + runID: runID, sessionID: job.sessionID, message: error.localizedDescription ) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 2c6990fe..dafc6206 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -159,10 +159,10 @@ struct AppServerClientTests { await runtime.transport.waitForNotificationStreamCount(1) let backend = AppServerCodexReviewBackend(appServer: runtime.server) - let firstAttempt = try await backend.startReview(makeReviewStart(jobID: "job-1", sessionID: "session-1")) + let firstAttempt = try await backend.startReview(makeReviewStart(runID: "job-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(jobID: "job-2", sessionID: "session-2")) + let secondAttempt = try await backend.startReview(makeReviewStart(runID: "job-2", sessionID: "session-2")) try await runtime.transport.emitServerNotification( method: "item/commandExecution/outputDelta", @@ -562,12 +562,12 @@ private func eventSequence( } private func makeReviewStart( - jobID: String = "job-1", + runID: String = "job-1", sessionID: String = "session-1", target: CodexReviewAPI.Target = .uncommittedChanges ) -> CodexReviewBackendModel.Review.Start { .init( - jobID: jobID, + runID: runID, sessionID: sessionID, request: .init(cwd: "/tmp/project", target: target), model: "gpt-5" diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 26b67d10..4d5d10b4 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -22,10 +22,10 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let read = try await result - #expect(read.jobID == "job-1") + #expect(read.runID == "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"]) + #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["job-1"]) let commands = await backend.recordedCommands() #expect( @@ -53,14 +53,14 @@ struct CodexReviewStoreCommandTests { ) let running = try await result - #expect(running.jobID == "job-1") + #expect(running.runID == "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", + runID: "job-1", timeout: .seconds(1) ) @@ -85,7 +85,7 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - jobID: "job-1", + runID: "job-1", timeout: .seconds(1) ) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) @@ -112,11 +112,11 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - jobID: "job-1", + runID: "job-1", timeout: .seconds(1) ) _ = try await store.cancelReview( - jobID: "job-1", + runID: "job-1", cancellation: .mcpClient(message: "Stop") ) let final = try await awaited @@ -142,7 +142,7 @@ struct CodexReviewStoreCommandTests { let snapshot = try await store.awaitReview( sessionID: "session-1", - jobID: "job-1", + runID: "job-1", timeout: .milliseconds(10) ) @@ -167,11 +167,11 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - jobID: "job-1", + runID: "job-1", timeout: .seconds(1) ) await Task.yield() - store.terminateAllRunningJobsLocally( + store.terminateAllRunningReviewRunsLocally( failureMessage: "Review runtime stopped." ) let final = try await awaited @@ -362,10 +362,10 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) clock.current = Date(timeIntervalSince1970: 13) - #expect(try store.readReview(jobID: "job-1").elapsedSeconds == 12) + #expect(try store.readReview(runID: "job-1").elapsedSeconds == 12) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -465,10 +465,10 @@ struct CodexReviewStoreCommandTests { reviewRuns: [firstJob, secondJob, thirdJob] ) - #expect(store.reorderJob(id: firstJob.id, inWorkspace: workspace.cwd, beforeJobID: thirdJob.id)) + #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: thirdJob.id)) #expect(store.orderedReviewRuns(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) + #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: firstJob.id) == false) + #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: "job-missing") == false) } @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() async throws { @@ -482,16 +482,16 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) let cancel = try await store.cancelReview( - jobID: "job-1", + runID: "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) + #expect(try store.readReview(runID: "job-1").core.lifecycle.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains( @@ -571,10 +571,10 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - let running = try store.readReview(jobID: "job-1") + let running = try store.readReview(runID: "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")) + _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) await backend.yield(.cancelled("Stop")) _ = try await result } @@ -882,7 +882,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) - let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) #expect(cancel.cancelled) await recoverGate.open() @@ -1006,12 +1006,12 @@ struct CodexReviewStoreCommandTests { _ = try await running await backend.finishEvents(for: initialRun) - let cancel = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) let cleanedUp = await waitUntil { let runtimeState = store.runtimeReviewRunState(runID: "job-1") return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil } - let read = try store.readReview(jobID: "job-1") + let read = try store.readReview(runID: "job-1") #expect(cancel.cancelled) #expect(cleanedUp) @@ -1044,7 +1044,7 @@ struct CodexReviewStoreCommandTests { reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - let cancelled = try store.readReview(jobID: "job-1") + let cancelled = try store.readReview(runID: "job-1") #expect(locallyCancelledReviewRunIDs == ["job-1"]) #expect(cancelled.core.lifecycle.status == .cancelled) @@ -1052,7 +1052,7 @@ struct CodexReviewStoreCommandTests { #expect(runtimeStateBeforeDetach.hasActiveWorker) #expect(runtimeStateBeforeDetach.activeRun == run) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeStateAfterDetach.hasActiveWorker == false) @@ -1087,13 +1087,13 @@ struct CodexReviewStoreCommandTests { await store.stop() } try await backend.waitForInterruptReview(timeout: .seconds(2)) - let inFlight = try store.readReview(jobID: "job-1") + let inFlight = try store.readReview(runID: "job-1") #expect(inFlight.core.lifecycle.status == .running) await interruptGate.open() await stopTask.value - let stopped = try store.readReview(jobID: "job-1") + let stopped = try store.readReview(runID: "job-1") let commands = await backend.recordedCommands() #expect(commands.contains(.interruptReview(run, .init(message: "Review runtime stopped.")))) #expect(stopped.core.lifecycle.status == .cancelled) @@ -1133,7 +1133,7 @@ struct CodexReviewStoreCommandTests { reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) let runtimeState = store.runtimeReviewRunState(runID: "job-1") #expect(runtimeState.hasActiveWorker == false) @@ -1177,7 +1177,7 @@ struct CodexReviewStoreCommandTests { return true } let request = try #require(await recorder.onlyRequest()) - let read = try store.readReview(jobID: "job-1") + let read = try store.readReview(runID: "job-1") #expect(result.didComplete) #expect(request.reason.message == "Review runtime stopped.") @@ -1214,7 +1214,7 @@ struct CodexReviewStoreCommandTests { reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) #expect(store.runtimeReviewRunState(runID: "job-1").hasDetachedWorker == false) @@ -1243,7 +1243,7 @@ struct CodexReviewStoreCommandTests { reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - store.cancelAndDetachReviewWorkersForRuntimeStop(jobIDs: locallyCancelledReviewRunIDs) + store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) let resultBeforeStartReviewUnblocked = try await waitForTaskValue(running, timeout: .seconds(1)) await startReviewGate.open() let result = try #require(resultBeforeStartReviewUnblocked) @@ -1279,7 +1279,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) await backend.finishEvents(for: initialRun) let read = try await result @@ -1303,10 +1303,10 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) networkMonitor.yield(.init(status: .unsatisfied)) - _ = try await store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) await debounceGate.open() await backend.yield(.cancelled("Stop")) let read = try await result @@ -1346,12 +1346,12 @@ struct CodexReviewStoreCommandTests { await #expect(throws: (any Error).self) { try await store.cancelReview( - jobID: "job-1", + runID: "job-1", sessionID: "session-2", cancellation: .mcpClient(message: "Stop") ) } - #expect(try store.readReview(jobID: "job-1").cancellable) + #expect(try store.readReview(runID: "job-1").cancellable) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -1378,9 +1378,9 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) _ = try await store.cancelReview( - jobID: "job-1", + runID: "job-1", cancellation: .mcpClient(message: "Stop") ) await backend.finishEvents(throwing: StreamClosedError()) @@ -1402,7 +1402,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) await backend.yield(.message("partial review")) await backend.finishEvents(throwing: StreamClosedError()) let read = try await result @@ -1449,7 +1449,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) networkMonitor.yield(.init(status: .unsatisfied)) await outageSleepStarted.wait() @@ -1457,7 +1457,7 @@ struct CodexReviewStoreCommandTests { let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store) - .waitUntilJobStatus(.failed, jobID: "job-1", timeout: .milliseconds(100)) != nil + .waitUntilRunStatus(.failed, runID: "job-1", timeout: .milliseconds(100)) != nil #expect(failedBeforeOutageConfirmed == false) await debounceGate.open() @@ -1485,7 +1485,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) await backend.finishEvents(throwing: CancellationError()) let read = try await result @@ -1539,14 +1539,14 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) await #expect(throws: FakeCodexReviewBackendError.self) { try await store.cancelReview( - jobID: "job-1", + runID: "job-1", cancellation: .mcpClient(message: "Stop") ) } - let readAfterFailure = try store.readReview(jobID: "job-1") + let readAfterFailure = try store.readReview(runID: "job-1") #expect(readAfterFailure.cancellable) #expect(readAfterFailure.core.lifecycle.cancellation == nil) @@ -1568,9 +1568,9 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilJobStatus(.running, jobID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) _ = try await store.cancelReview( - jobID: "job-1", + runID: "job-1", cancellation: .mcpClient(message: "Stop") ) await backend.yield(.completed(summary: "Succeeded.", result: "late result")) @@ -1595,7 +1595,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - async let cancel = store.cancelReview(jobID: "job-1", cancellation: .mcpClient(message: "Stop")) + async let cancel = store.cancelReview(runID: "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() @@ -1622,7 +1622,7 @@ struct CodexReviewStoreCommandTests { 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 cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) let cancelledDuringStartup = try #require(store.reviewRuns.first) #expect(cancel.core.lifecycle.status == .cancelled) #expect(cancelledDuringStartup.core.lifecycle.status == .cancelled) @@ -1697,7 +1697,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let read = try await result - #expect(read.jobID == "job-1") + #expect(read.runID == "job-1") #expect(read.core.lifecycle.status == .succeeded) } } @@ -1754,9 +1754,9 @@ struct CodexReviewStoreCommandTests { ) #expect(store.switchActionIsDisabled(for: displayedSelectedAccount)) - #expect(store.switchActionRequiresRunningJobsConfirmation(for: displayedSelectedAccount) == false) + #expect(store.switchActionRequiresRunningReviewRunsConfirmation(for: displayedSelectedAccount) == false) #expect(store.switchActionIsDisabled(for: displayedOtherAccount) == false) - #expect(store.switchActionRequiresRunningJobsConfirmation(for: displayedOtherAccount)) + #expect(store.switchActionRequiresRunningReviewRunsConfirmation(for: displayedOtherAccount)) store.requestSwitchAccountFromUserAction(displayedSelectedAccount) await Task.yield() diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index ada250b5..3e56de96 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -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 == [ @@ -82,7 +82,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { 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,7 +155,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)), @@ -289,13 +289,13 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { private func targets( accounts: [CodexReviewAccount], selectedAccount: CodexReviewAccount?, - hasRunningJobs: Bool, + hasRunningReviewRuns: Bool, serverState: CodexReviewServerState = .running ) -> [CodexReviewStoreRateLimitAutoRefreshTarget] { CodexReviewStoreRateLimitAutoRefreshDriver.targets( accounts: accounts, selectedAccountKey: selectedAccount?.accountKey, - hasRunningJobs: hasRunningJobs, + hasRunningReviewRuns: hasRunningReviewRuns, serverState: serverState, now: now ) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index ee15112d..5f7fadac 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -49,12 +49,12 @@ struct CodexReviewMCPHTTPServerTests { 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"])) } } @@ -164,8 +164,8 @@ struct CodexReviewMCPHTTPServerTests { 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 == "job-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") @@ -176,7 +176,7 @@ struct CodexReviewMCPHTTPServerTests { commands.contains( .startReview( .init( - jobID: "job-1", + runID: "job-1", sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -219,7 +219,7 @@ 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 == "job-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) @@ -237,7 +237,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_await", "arguments": [ - "jobId": "job-1" + "runId": "job-1" ], ], ] @@ -291,13 +291,13 @@ struct CodexReviewMCPHTTPServerTests { await backend.yield(.completed(summary: "Done", result: "review text")) 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 == "job-1") let commands = await backend.recordedCommands() #expect( commands.contains( .startReview( .init( - jobID: "job-1", + runID: "job-1", sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -336,7 +336,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 == "job-1") #expect(resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "failed") } } @@ -399,7 +399,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 } == ["job-included"]) } } @@ -446,7 +446,7 @@ struct CodexReviewMCPHTTPServerTests { "method": "tools/call", "params": [ "name": "review_read", - "arguments": ["jobId": "job-in-session"], + "arguments": ["runId": "job-in-session"], ], ] ) @@ -459,12 +459,12 @@ struct CodexReviewMCPHTTPServerTests { "method": "tools/call", "params": [ "name": "review_read", - "arguments": ["jobID": "job-other-session"], + "arguments": ["runID": "job-other-session"], ], ] ) #expect(allowed.value(for: ["result", "isError"]) as? Bool == false) - #expect(allowed.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-in-session") + #expect(allowed.value(for: ["result", "structuredContent", "runId"]) as? String == "job-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 @@ -473,7 +473,7 @@ struct CodexReviewMCPHTTPServerTests { #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.") + == "Run job-other-session was not found.") } } @@ -512,13 +512,13 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_read", "arguments": [ - "jobId": "job-semantic" + "runId": "job-semantic" ], ], ] ) - #expect(defaultResponse.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-semantic") + #expect(defaultResponse.value(for: ["result", "structuredContent", "runId"]) as? String == "job-semantic") #expect(defaultResponse.value(for: ["result", "structuredContent", "run"]) != nil) #expect( defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String @@ -585,7 +585,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_read", "arguments": [ - "jobId": "job-tool-progress" + "runId": "job-tool-progress" ], ], ] @@ -657,7 +657,7 @@ struct CodexReviewMCPHTTPServerTests { ] ) - #expect(response.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-running") + #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "job-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") @@ -723,7 +723,7 @@ struct CodexReviewMCPHTTPServerTests { ] ) - #expect(response.value(for: ["result", "structuredContent", "jobId"]) as? String == "job-running") + #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "job-running") #expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == true) #expect(completed.core.lifecycle.status == .succeeded) #expect(running.core.lifecycle.status == .cancelled) @@ -784,13 +784,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 review jobs") == true) + #expect(text?.contains("matched multiple review runs") == true) #expect(text?.contains("job-running-1") == true) #expect(text?.contains("job-running-2") == true) } } - @Test func streamableHTTPCancelsDocumentedJobId() async throws { + @Test func streamableHTTPCancelsDocumentedRunId() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -824,14 +824,14 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "jobId": "job-running", + "runId": "job-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 == "job-running") #expect(running.core.lifecycle.status == .cancelled) } } @@ -875,7 +875,7 @@ struct CodexReviewMCPHTTPServerTests { await backend.yield(.completed(summary: "Done", result: "review text")) 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 == "job-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 @@ -960,7 +960,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPKeepsJobIDCancellationInTransportSession() async throws { + @Test func streamableHTTPKeepsRunIDCancellationInTransportSession() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -994,7 +994,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "jobID": "job-other-session", + "runID": "job-other-session", "reason": "Stop from MCP", ], ], diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 5bf6c7e7..a70540c4 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -44,7 +44,7 @@ struct CodexReviewMCPServerTests { } let read = snapshot.result let timeline = snapshot.timeline - #expect(read.jobID == "job-1") + #expect(read.runID == "job-1") #expect(read.core.lifecycle.status == .succeeded) #expect(timeline.terminalSummary == "Done") #expect(timeline.terminalResult == "review") diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift index 91f2ccc8..3a229ecd 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift @@ -7,7 +7,7 @@ import Testing struct ReviewMCPProjectionTests { @Test func runningResultProjectsSummaryAsActiveDiagnosticItem() throws { let projection = ReviewMCPProjection(result: .init( - jobID: "job-1", + runID: "job-1", core: .init( lifecycle: .init(status: .running), output: .init(summary: "Review started.") @@ -26,7 +26,7 @@ struct ReviewMCPProjectionTests { @Test func terminalResultProjectsFinalReviewText() throws { let projection = ReviewMCPProjection(result: .init( - jobID: "job-2", + runID: "job-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), output: .init( diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 1bb52b1f..a0cd0b18 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -375,7 +375,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(selectedChat.title == "App review") } - @Test func codexSidebarSelectionDoesNotFallBackToReviewJobRows() async throws { + @Test func codexSidebarSelectionDoesNotFallBackToReviewRunRows() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index f74063fa..12bbaae9 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -47,7 +47,7 @@ struct ReviewMonitorLaunchContext: Sendable { enum ReviewMonitorLaunchEnvironment { static let reviewModeKey = CodexReviewStoreTestEnvironment.reviewModeKey - static let mockJobsKey = CodexReviewStoreTestEnvironment.mockJobsKey + static let mockReviewRunsKey = CodexReviewStoreTestEnvironment.mockReviewRunsKey static let xctestConfigurationKey = "XCTestConfigurationFilePath" static let xctestBundlePathKey = "XCTestBundlePath" static let xcInjectBundleIntoKey = "XCInjectBundleInto" @@ -58,7 +58,7 @@ enum ReviewMonitorLaunchEnvironment { static let testCodexCommandKey = CodexReviewStoreTestEnvironment.codexCommandKey static let testDiagnosticsPathKey = CodexReviewStoreTestEnvironment.diagnosticsPathKey static let reviewModeArgument = CodexReviewStoreTestEnvironment.reviewModeArgument - static let mockJobsArgument = CodexReviewStoreTestEnvironment.mockJobsArgument + static let mockReviewRunsArgument = CodexReviewStoreTestEnvironment.mockReviewRunsArgument static let testPortArgument = CodexReviewStoreTestEnvironment.portArgument static let testCodexCommandArgument = CodexReviewStoreTestEnvironment.codexCommandArgument static let testDiagnosticsPathArgument = CodexReviewStoreTestEnvironment.diagnosticsPathArgument @@ -103,8 +103,8 @@ enum ReviewMonitorLaunchEnvironment { } return isEnabledFlag(environment[reviewModeKey]) || arguments.contains(reviewModeArgument) - || isEnabledFlag(environment[mockJobsKey]) - || arguments.contains(mockJobsArgument) + || isEnabledFlag(environment[mockReviewRunsKey]) + || arguments.contains(mockReviewRunsArgument) } static func isRunningUnderXCTest( diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index da9de831..fb6ba819 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -16,7 +16,7 @@ struct CodexReviewMonitorCITests { arguments: [] ) - #expect(environment[ReviewMonitorLaunchEnvironment.mockJobsKey] == "1") + #expect(environment[ReviewMonitorLaunchEnvironment.mockReviewRunsKey] == "1") #expect(context.requestsPreviewContent) #expect(context.shouldStartEmbeddedServer == false) } @@ -104,7 +104,7 @@ struct CodexReviewMonitorCITests { launchContextProvider: { ReviewMonitorLaunchContext( environment: [ - ReviewMonitorLaunchEnvironment.mockJobsKey: "1", + ReviewMonitorLaunchEnvironment.mockReviewRunsKey: "1", ], arguments: [], launchMode: .application @@ -231,7 +231,7 @@ struct CodexReviewMonitorCITests { ) let context = ReviewMonitorLaunchContext( environment: [ - ReviewMonitorLaunchEnvironment.mockJobsKey: "1", + ReviewMonitorLaunchEnvironment.mockReviewRunsKey: "1", ], arguments: [], launchMode: .application @@ -262,7 +262,7 @@ struct CodexReviewMonitorCITests { ) let context = ReviewMonitorLaunchContext( environment: [ - ReviewMonitorLaunchEnvironment.mockJobsKey: "1", + ReviewMonitorLaunchEnvironment.mockReviewRunsKey: "1", ], arguments: [], launchMode: .application From 41ebef0c7d6b27fdb730b69e9ed5e247ffd1745a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:29:49 +0900 Subject: [PATCH 223/359] refactor(review): expose mcp log projection --- .../Store/CodexReviewStore.swift | 8 +- .../Store/CodexReviewStoreDiagnostics.swift | 8 +- .../CodexReviewMCPServer.swift | 8 +- .../CodexReviewMCPToolResults.swift | 90 +++++++++---------- ...ion.swift => ReviewMCPLogProjection.swift} | 26 +++--- .../AppServerClientTests.swift | 2 +- .../CodexReviewStoreCommandTests.swift | 6 +- .../CodexReviewMCPHTTPServerTests.swift | 44 ++++----- .../CodexReviewMCPServerTests.swift | 6 +- ...wift => ReviewMCPLogProjectionTests.swift} | 26 +++--- .../ReviewMonitorSelectedCodexChatTests.swift | 2 +- 11 files changed, 113 insertions(+), 113 deletions(-) rename Sources/CodexReviewMCPServer/{ReviewMCPProjection.swift => ReviewMCPLogProjection.swift} (68%) rename Tests/CodexReviewMCPServerTests/{ReviewMCPProjectionTests.swift => ReviewMCPLogProjectionTests.swift} (61%) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 2249fb02..4334b9f1 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -363,12 +363,12 @@ public final class CodexReviewStore { guard let diagnosticsURL else { return } - let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Job] = orderedReviewRuns.map { job in - return CodexReviewStoreDiagnosticsSnapshot.Job( + let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { job in + return CodexReviewStoreDiagnosticsSnapshot.Run( status: job.core.lifecycle.status.rawValue, summary: job.core.output.summary, - timelineText: job.core.reviewText, - rawTimelineText: job.core.reviewText + logText: job.core.reviewText, + rawLogText: job.core.reviewText ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index 93b125e4..db2079ef 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -14,16 +14,16 @@ public enum CodexReviewStoreTestEnvironment { } struct CodexReviewStoreDiagnosticsSnapshot: Encodable { - struct Job: Encodable { + struct Run: Encodable { var status: String var summary: String - var timelineText: String - var rawTimelineText: String + var logText: String + var rawLogText: String } var serverState: String var failureMessage: String? var serverURL: String? var childRuntimePath: String? - var reviewRuns: [Job] + var reviewRuns: [Run] } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 303d9bb2..a8596c69 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -40,11 +40,11 @@ package extension CodexReviewMCP.Tool { package extension CodexReviewMCP.Tool { internal struct ReviewSnapshot: Equatable, Sendable { var result: CodexReviewAPI.Read.Result - var timeline: ReviewMCPProjection + var log: ReviewMCPLogProjection - init(result: CodexReviewAPI.Read.Result, timeline: ReviewMCPProjection) { + init(result: CodexReviewAPI.Read.Result, log: ReviewMCPLogProjection) { self.result = result - self.timeline = timeline + self.log = log } } } @@ -140,7 +140,7 @@ package final class CodexReviewMCPServer { if let sessionID { _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID)) } - return .init(result: result, timeline: ReviewMCPProjection(result: result)) + return .init(result: result, log: ReviewMCPLogProjection(result: result)) } package func closeSession(_ sessionID: String) async { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 2cf60c8b..79948770 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -9,11 +9,11 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul switch response { case .reviewStart(let snapshot), .reviewAwait(let snapshot): - value = snapshot.result.structuredContentForStartOrAwait(timeline: snapshot.timeline) + value = snapshot.result.structuredContentForStartOrAwait(log: snapshot.log) text = snapshot.result.textContentForStartOrAwait() isError = snapshot.result.core.lifecycle.status == .failed case .reviewRead(let snapshot): - value = snapshot.result.structuredContentForRead(timeline: snapshot.timeline) + value = snapshot.result.structuredContentForRead(log: snapshot.log) text = snapshot.result.textContentForRead() isError = snapshot.result.core.lifecycle.status == .failed case .reviewList(let result): @@ -61,26 +61,26 @@ private extension CodexReviewAPI.Read.Result { return "\(status)." } - func structuredContentForStartOrAwait(timeline: ReviewMCPProjection) -> Value { + func structuredContentForStartOrAwait(log: ReviewMCPLogProjection) -> Value { structuredContent( includeDetails: false, includeNextAction: core.lifecycle.status.isTerminal == false, - timeline: timeline + log: log ) } - func structuredContentForRead(timeline: ReviewMCPProjection) -> Value { + func structuredContentForRead(log: ReviewMCPLogProjection) -> Value { structuredContent( includeDetails: true, includeNextAction: false, - timeline: timeline + log: log ) } func structuredContent( includeDetails: Bool, includeNextAction: Bool, - timeline: ReviewMCPProjection + log: ReviewMCPLogProjection ) -> Value { var object: [String: Value] = [ "runId": .string(runID), @@ -91,10 +91,10 @@ private extension CodexReviewAPI.Read.Result { ), "output": core.output.structuredContent(review: core.reviewText), ] - object["timeline"] = + object["log"] = includeDetails - ? timeline.structuredContentWithItems() - : timeline.structuredContent() + ? log.structuredContentWithItems() + : log.structuredContent() if includeNextAction { object["nextAction"] = .object([ "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue), @@ -105,27 +105,27 @@ private extension CodexReviewAPI.Read.Result { } } -private extension ReviewMCPProjection { +private extension ReviewMCPLogProjection { func structuredContent() -> Value { var truncatedFields: [String] = [] var object: [String: Value] = [ "revision": .string(revision), - "orderedItemIds": .array(orderedItemIDs.map(Value.string)), - "activeItemIds": .array(activeItemIDs.map(Value.string)), - "activeItemCount": .int(activeItemCount), - "latestActivityId": latestActivityID.map(Value.string) ?? .null, - "terminalSummary": boundedTimelineString( - terminalSummary, - field: "terminalSummary", + "orderedEntryIds": .array(orderedEntryIDs.map(Value.string)), + "activeEntryIds": .array(activeEntryIDs.map(Value.string)), + "activeEntryCount": .int(activeEntryCount), + "latestEntryId": latestEntryID.map(Value.string) ?? .null, + "finalSummary": boundedLogString( + finalSummary, + field: "finalSummary", truncatedFields: &truncatedFields ), - "terminalResult": boundedTimelineString( - terminalResult, - field: "terminalResult", + "finalResult": boundedLogString( + finalResult, + field: "finalResult", truncatedFields: &truncatedFields ), ] - let page = TimelineItemPage.unreturned(total: items.count) + let page = LogEntryPage.unreturned(total: items.count) object["items"] = .array([]) object["itemsPage"] = page.structuredContent() object["truncatedFields"] = .array(truncatedFields.map(Value.string)) @@ -136,22 +136,22 @@ private extension ReviewMCPProjection { var truncatedFields: [String] = [] var object: [String: Value] = [ "revision": .string(revision), - "orderedItemIds": .array(orderedItemIDs.map(Value.string)), - "activeItemIds": .array(activeItemIDs.map(Value.string)), - "activeItemCount": .int(activeItemCount), - "latestActivityId": latestActivityID.map(Value.string) ?? .null, - "terminalSummary": boundedTimelineString( - terminalSummary, - field: "terminalSummary", + "orderedEntryIds": .array(orderedEntryIDs.map(Value.string)), + "activeEntryIds": .array(activeEntryIDs.map(Value.string)), + "activeEntryCount": .int(activeEntryCount), + "latestEntryId": latestEntryID.map(Value.string) ?? .null, + "finalSummary": boundedLogString( + finalSummary, + field: "finalSummary", truncatedFields: &truncatedFields ), - "terminalResult": boundedTimelineString( - terminalResult, - field: "terminalResult", + "finalResult": boundedLogString( + finalResult, + field: "finalResult", truncatedFields: &truncatedFields ), ] - let page = TimelineItemPage( + let page = LogEntryPage( total: items.count, offset: 0, limit: items.count, @@ -168,7 +168,7 @@ private extension ReviewMCPProjection { } } -private extension ReviewMCPProjection.Item { +private extension ReviewMCPLogProjection.Item { func structuredContent() -> Value { .object([ "id": .string(id), @@ -178,14 +178,14 @@ private extension ReviewMCPProjection.Item { } } -private extension ReviewMCPProjection.Content { +private extension ReviewMCPLogProjection.Content { func structuredContent() -> Value { switch self { case .diagnostic(let message): var truncatedFields: [String] = [] return .object([ "type": .string("diagnostic"), - "message": boundedTimelineString( + "message": boundedLogString( message, field: "message", truncatedFields: &truncatedFields @@ -196,7 +196,7 @@ private extension ReviewMCPProjection.Content { var truncatedFields: [String] = [] return .object([ "type": .string("message"), - "text": boundedTimelineString( + "text": boundedLogString( text, field: "text", truncatedFields: &truncatedFields @@ -207,7 +207,7 @@ private extension ReviewMCPProjection.Content { } } -private struct TimelineItemPage { +private struct LogEntryPage { var total: Int var offset: Int var limit: Int @@ -241,8 +241,8 @@ private struct TimelineItemPage { self.nextOffset = nextOffset } - static func unreturned(total: Int) -> TimelineItemPage { - TimelineItemPage( + static func unreturned(total: Int) -> LogEntryPage { + LogEntryPage( total: total, offset: 0, limit: 0, @@ -268,7 +268,7 @@ private struct TimelineItemPage { } } -private func boundedTimelineString( +private func boundedLogString( _ value: String?, field: String, truncatedFields: inout [String] @@ -276,15 +276,15 @@ private func boundedTimelineString( guard let value else { return .null } - return boundedTimelineString(value, field: field, truncatedFields: &truncatedFields) + return boundedLogString(value, field: field, truncatedFields: &truncatedFields) } -private func boundedTimelineString( +private func boundedLogString( _ value: String, field: String, truncatedFields: inout [String] ) -> Value { - let bounded = value.boundedTimelineString() + let bounded = value.boundedLogString() if bounded.wasTruncated { truncatedFields.append(field) } @@ -292,7 +292,7 @@ private func boundedTimelineString( } private extension String { - func boundedTimelineString() -> (value: String, wasTruncated: Bool) { + func boundedLogString() -> (value: String, wasTruncated: Bool) { let limit = 4096 guard count > limit else { return (self, false) diff --git a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift similarity index 68% rename from Sources/CodexReviewMCPServer/ReviewMCPProjection.swift rename to Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 930b6841..24a2f0a2 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -1,7 +1,7 @@ import Foundation import CodexReviewKit -struct ReviewMCPProjection: Sendable, Equatable { +struct ReviewMCPLogProjection: Sendable, Equatable { struct Item: Sendable, Equatable { var id: String var kind: String @@ -23,12 +23,12 @@ struct ReviewMCPProjection: Sendable, Equatable { } var revision: String - var orderedItemIDs: [String] - var activeItemIDs: [String] - var activeItemCount: Int - var latestActivityID: String? - var terminalSummary: String? - var terminalResult: String? + var orderedEntryIDs: [String] + var activeEntryIDs: [String] + var activeEntryCount: Int + var latestEntryID: String? + var finalSummary: String? + var finalResult: String? var items: [Item] init(result: CodexReviewAPI.Read.Result) { @@ -51,11 +51,11 @@ struct ReviewMCPProjection: Sendable, Equatable { } self.items = items - self.orderedItemIDs = items.map(\.id) - self.activeItemIDs = status.isTerminal ? [] : items.map(\.id) - self.activeItemCount = activeItemIDs.count - self.latestActivityID = orderedItemIDs.last - self.terminalSummary = status.isTerminal ? output.summary : nil - self.terminalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil + self.orderedEntryIDs = items.map(\.id) + self.activeEntryIDs = status.isTerminal ? [] : items.map(\.id) + self.activeEntryCount = activeEntryIDs.count + self.latestEntryID = orderedEntryIDs.last + self.finalSummary = status.isTerminal ? output.summary : nil + self.finalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil } } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index dafc6206..d3d85b6a 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -248,7 +248,7 @@ struct AppServerClientTests { guard case .domainEvents = try await nextEvent(from: attempt.events), case .domainEvents = try await nextEvent(from: attempt.events) else { - Issue.record("expected direct timeline updates") + Issue.record("expected direct log updates") return } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 4d5d10b4..55b1801a 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -151,7 +151,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func awaitReviewReturnsWhenLocalTerminationUpdatesTimeline() async throws { + @Test func awaitReviewReturnsWhenLocalTerminationUpdatesRunOutput() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -320,7 +320,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func newlyStartedReviewAppearsBeforeExistingJobsInWorkspace() async throws { + @Test func newlyStartedReviewAppearsBeforeExistingRunsInWorkspace() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -1666,7 +1666,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func closeActiveReviewSessionsCancelsJobsWithoutClosingMCPServerSession() async throws { + @Test func closeActiveReviewSessionsCancelsRunsWithoutClosingMCPServerSession() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 5f7fadac..ed7b3fec 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -249,10 +249,10 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text") #expect( - awaited.value(for: ["result", "structuredContent", "timeline", "terminalSummary"]) as? String + awaited.value(for: ["result", "structuredContent", "log", "finalSummary"]) as? String == "Done") #expect( - awaited.value(for: ["result", "structuredContent", "timeline", "terminalResult"]) as? String + awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) as? String == "review text") #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) } @@ -477,7 +477,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadAddsSemanticTimeline() async throws { + @Test func streamableHTTPReviewReadAddsSemanticLog() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -529,31 +529,31 @@ struct CodexReviewMCPHTTPServerTests { #expect(defaultResponse.value(for: ["result", "structuredContent", "logsPage"]) == nil) #expect(defaultResponse.value(for: ["result", "structuredContent", "rawLogText"]) == nil) - let timeline = try #require( - defaultResponse.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) - #expect(timeline["orderedItemIds"] as? [String] == ["job-semantic:message"]) - #expect(timeline["activeItemIds"] as? [String] == []) - #expect(timeline["activeItemCount"] as? Int == 0) - #expect(timeline["latestActivityId"] as? String == "job-semantic:message") - let itemsPage = try #require(timeline["itemsPage"] as? [String: Any]) + let log = try #require( + defaultResponse.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) + #expect(log["orderedEntryIds"] as? [String] == ["job-semantic:message"]) + #expect(log["activeEntryIds"] as? [String] == []) + #expect(log["activeEntryCount"] as? Int == 0) + #expect(log["latestEntryId"] as? String == "job-semantic:message") + let itemsPage = try #require(log["itemsPage"] as? [String: Any]) #expect(itemsPage["total"] as? Int == 1) #expect(itemsPage["limit"] as? Int == 1) #expect(itemsPage["returned"] as? Int == 1) - let items = try #require(timeline["items"] as? [[String: Any]]) + let items = try #require(log["items"] as? [[String: Any]]) let messageItem = try #require(items.first) #expect(messageItem["id"] as? String == "job-semantic:message") #expect(messageItem["kind"] as? String == "agentMessage") let content = try #require(messageItem["content"] as? [String: Any]) #expect(content["type"] as? String == "message") - let timelineOutput = try #require(content["text"] as? String) - #expect(timelineOutput.hasPrefix("Tests passed")) - #expect(timelineOutput.hasSuffix("...")) - #expect(timelineOutput.count < longOutput.count) + let logOutput = try #require(content["text"] as? String) + #expect(logOutput.hasPrefix("Tests passed")) + #expect(logOutput.hasSuffix("...")) + #expect(logOutput.count < longOutput.count) #expect(content["truncatedFields"] as? [String] == ["text"]) } } - @Test func streamableHTTPReviewReadIncludesRunningSummaryInTimelineContent() async throws { + @Test func streamableHTTPReviewReadIncludesRunningSummaryInLogContent() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -591,11 +591,11 @@ struct CodexReviewMCPHTTPServerTests { ] ) - let timeline = try #require( - response.value(for: ["result", "structuredContent", "timeline"]) as? [String: Any]) - #expect(timeline["activeItemIds"] as? [String] == ["job-tool-progress:summary"]) - #expect(timeline["activeItemCount"] as? Int == 1) - let items = try #require(timeline["items"] as? [[String: Any]]) + let log = try #require( + response.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) + #expect(log["activeEntryIds"] as? [String] == ["job-tool-progress:summary"]) + #expect(log["activeEntryCount"] as? Int == 1) + let items = try #require(log["items"] as? [[String: Any]]) let item = try #require(items.first) #expect(item["id"] as? String == "job-tool-progress:summary") #expect(item["kind"] as? String == "diagnostic") @@ -672,7 +672,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPCancelDefaultsSelectorToActiveJobsInTransportSession() async throws { + @Test func streamableHTTPCancelDefaultsSelectorToActiveRunsInTransportSession() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index a70540c4..0a36fcfe 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -43,10 +43,10 @@ struct CodexReviewMCPServerTests { return } let read = snapshot.result - let timeline = snapshot.timeline + let log = snapshot.log #expect(read.runID == "job-1") #expect(read.core.lifecycle.status == .succeeded) - #expect(timeline.terminalSummary == "Done") - #expect(timeline.terminalResult == "review") + #expect(log.finalSummary == "Done") + #expect(log.finalResult == "review") } } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift similarity index 61% rename from Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift rename to Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 3a229ecd..85feac08 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -3,10 +3,10 @@ import Testing @testable import CodexReviewKit @testable import CodexReviewMCPServer -@Suite("Review MCP projection") -struct ReviewMCPProjectionTests { +@Suite("Review MCP log projection") +struct ReviewMCPLogProjectionTests { @Test func runningResultProjectsSummaryAsActiveDiagnosticItem() throws { - let projection = ReviewMCPProjection(result: .init( + let projection = ReviewMCPLogProjection(result: .init( runID: "job-1", core: .init( lifecycle: .init(status: .running), @@ -15,17 +15,17 @@ struct ReviewMCPProjectionTests { cancellable: true )) - #expect(projection.orderedItemIDs == ["job-1:summary"]) - #expect(projection.activeItemIDs == ["job-1:summary"]) - #expect(projection.activeItemCount == 1) - #expect(projection.latestActivityID == "job-1:summary") + #expect(projection.orderedEntryIDs == ["job-1:summary"]) + #expect(projection.activeEntryIDs == ["job-1:summary"]) + #expect(projection.activeEntryCount == 1) + #expect(projection.latestEntryID == "job-1:summary") let item = try #require(projection.items.first) #expect(item.kind == "diagnostic") #expect(item.content.type == "diagnostic") } - @Test func terminalResultProjectsFinalReviewText() throws { - let projection = ReviewMCPProjection(result: .init( + @Test func finalResultProjectsFinalReviewText() throws { + let projection = ReviewMCPLogProjection(result: .init( runID: "job-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), @@ -38,10 +38,10 @@ struct ReviewMCPProjectionTests { cancellable: false )) - #expect(projection.activeItemIDs == []) - #expect(projection.activeItemCount == 0) - #expect(projection.terminalSummary == "Done.") - #expect(projection.terminalResult == "No findings.") + #expect(projection.activeEntryIDs == []) + #expect(projection.activeEntryCount == 0) + #expect(projection.finalSummary == "Done.") + #expect(projection.finalResult == "No findings.") let item = try #require(projection.items.first) #expect(item.id == "job-2:message") #expect(item.kind == "agentMessage") diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index f06206b9..7910ffb1 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -177,7 +177,7 @@ struct ReviewMonitorSelectedCodexChatTests { let initialSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Chat snapshot") } - #expect(initialSnapshot.log.contains("Timeline fallback") == false) + #expect(initialSnapshot.log.contains("Legacy fallback") == false) try await runtime.transport.emitServerNotification( method: "item/updated", From bfae083df4f6b97d7061b03745884c227fe0af0e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:33:00 +0900 Subject: [PATCH 224/359] refactor(review): await terminal review runs --- .../ReviewObservationAwaiter.swift | 14 ++++++------ .../Store/CodexReviewStoreReviews.swift | 2 +- .../ReviewObservationAwaiterTests.swift | 22 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index 3446bbe3..e556f5ed 100644 --- a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -4,10 +4,10 @@ import ObservationBridge @MainActor public enum ReviewObservationAwaiter { public static func waitUntilTerminal( - job: ReviewRunRecord, + run: ReviewRunRecord, timeout: Duration? = nil ) async -> Bool { - if job.isTerminal { + if run.isTerminal { return true } @@ -15,7 +15,7 @@ public enum ReviewObservationAwaiter { return await withTaskCancellationHandler { await withCheckedContinuation { continuation in waiter.begin( - job: job, + run: run, timeout: timeout, continuation: continuation ) @@ -36,14 +36,14 @@ private final class ReviewTerminalObservationWaiter { private var isResolved = false func begin( - job: ReviewRunRecord, + run: ReviewRunRecord, timeout: Duration?, continuation: CheckedContinuation ) { self.continuation = continuation - token = withPortableContinuousObservation { [weak self, job] event in - _ = job.core.lifecycle.status - guard job.isTerminal else { + token = withPortableContinuousObservation { [weak self, run] event in + _ = run.core.lifecycle.status + guard run.isTerminal else { return } event.cancel() diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index ab0e9f6e..3811aadc 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -998,7 +998,7 @@ extension CodexReviewStore { return } _ = await ReviewObservationAwaiter.waitUntilTerminal( - job: job, + run: job, timeout: timeout ) } diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index 7211ee6f..e8dbad2e 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -5,52 +5,52 @@ import Testing @MainActor @Suite("review observation awaiter") struct ReviewObservationAwaiterTests { - @Test func resumesWhenJobReachesTerminalState() async throws { - let job = makeRunningJob() + @Test func resumesWhenRunReachesTerminalState() async throws { + let run = makeRunningRun() let task = Task { @MainActor in await ReviewObservationAwaiter.waitUntilTerminal( - job: job, + run: run, timeout: .seconds(1) ) } await Task.yield() - job.updateStateForTesting(status: .succeeded, summary: "Done") + run.updateStateForTesting(status: .succeeded, summary: "Done") let result = await task.value #expect(result) } - @Test func resumesWhenJobCancellationReachesTerminalState() async throws { - let job = makeRunningJob() + @Test func resumesWhenRunCancellationReachesTerminalState() async throws { + let run = makeRunningRun() let task = Task { @MainActor in await ReviewObservationAwaiter.waitUntilTerminal( - job: job, + run: run, timeout: .seconds(1) ) } await Task.yield() - job.updateStateForTesting(status: .cancelled, summary: "Stop") + run.updateStateForTesting(status: .cancelled, summary: "Stop") let result = await task.value #expect(result) } @Test func returnsFalseOnTimeout() async throws { - let job = makeRunningJob() + let run = makeRunningRun() let result = await ReviewObservationAwaiter.waitUntilTerminal( - job: job, + run: run, timeout: .milliseconds(10) ) #expect(result == false) } - private func makeRunningJob() -> ReviewRunRecord { + private func makeRunningRun() -> ReviewRunRecord { ReviewRunRecord.makeForTesting( id: "job-awaiter", targetSummary: "Uncommitted changes", From ba3aa3122221f7a933c5a5d97ff12f9fbb506476 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:36:21 +0900 Subject: [PATCH 225/359] refactor(review-ui): resolve cancellation by chat id --- .../Store/CodexReviewStoreOrderQueries.swift | 25 +++++++++++++ .../ReviewMonitorSidebarViewController.swift | 37 +------------------ 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 804739b5..f16ddddd 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -28,6 +28,15 @@ extension CodexReviewStore { reviewRuns.first(where: { $0.id == id }) } + package func cancellableReviewRunID(forChatID chatID: String) -> String? { + orderedReviewRuns.first { runRecord in + guard runRecord.isTerminal == false else { + return false + } + return runRecord.matchesChatID(chatID) + }?.id + } + package func reviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { reviewRuns.filter { $0.cwd == cwd } } @@ -74,6 +83,22 @@ extension CodexReviewStore { } } +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 + } +} + package func reorderedSortOrder( moving item: Item, toIndex destinationIndex: Int, diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift index 9304cd03..81fef3a5 100644 --- a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift @@ -705,32 +705,19 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let menu = NSMenu() - if let job = cancellableReviewRun(for: chat) { + if let runID = store.cancellableReviewRunID(forChatID: chat.id.rawValue) { let item = NSMenuItem( title: "Cancel Review", action: #selector(cancelReviewFromContextMenu(_:)), keyEquivalent: "" ) item.target = self - item.representedObject = job.id + item.representedObject = runID menu.addItem(item) } return menu.items.isEmpty ? nil : menu } - private func cancellableReviewRun( - for chat: ReviewMonitorCodexSidebarSnapshot.Chat - ) -> ReviewRunRecord? { - store.orderedReviewRuns.first { job in - guard job.isTerminal == false, - let chatID = job.sidebarChatID - else { - return false - } - return chatID == chat.id - } - } - @objc private func cancelReviewFromContextMenu(_ sender: NSMenuItem) { guard let runID = sender.representedObject as? String else { @@ -2029,26 +2016,6 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { #endif } -private extension ReviewRunRecord { - var sidebarChatID: CodexThreadID? { - if let reviewThreadID = nonEmptySidebarChatID(core.run.reviewThreadID) { - return CodexThreadID(rawValue: reviewThreadID) - } - if let threadID = nonEmptySidebarChatID(core.run.threadID) { - return CodexThreadID(rawValue: threadID) - } - return nil - } - - private func nonEmptySidebarChatID(_ value: String?) -> String? { - guard let value else { - return nil - } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - @MainActor private final class ReviewMonitorWorkspaceRowView: NSTableRowView { override var isEmphasized: Bool { From ae6f72b12903e57566b5576010f65333e0a25ee0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:38:23 +0900 Subject: [PATCH 226/359] refactor(review-ui): rename codex chat sidebar directory --- .../Sidebar/{Jobs => CodexChats}/ReviewMonitorChatRowView.swift | 0 .../{Jobs => CodexChats}/ReviewMonitorCodexSidebarLibrary.swift | 0 .../{Jobs => CodexChats}/ReviewMonitorSidebarViewController.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename Sources/ReviewUI/Sidebar/{Jobs => CodexChats}/ReviewMonitorChatRowView.swift (100%) rename Sources/ReviewUI/Sidebar/{Jobs => CodexChats}/ReviewMonitorCodexSidebarLibrary.swift (100%) rename Sources/ReviewUI/Sidebar/{Jobs => CodexChats}/ReviewMonitorSidebarViewController.swift (100%) diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift similarity index 100% rename from Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorChatRowView.swift rename to Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift similarity index 100% rename from Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorCodexSidebarLibrary.swift rename to Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift diff --git a/Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift similarity index 100% rename from Sources/ReviewUI/Sidebar/Jobs/ReviewMonitorSidebarViewController.swift rename to Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift From f18313164ba5d8d9076e3440286c6f48eeb370d5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:40:04 +0900 Subject: [PATCH 227/359] test(review-ui): add codex chat log fixture helpers --- .../ReviewMonitorChatLogTesting.swift | 313 ++++++++++++++---- 1 file changed, 257 insertions(+), 56 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index f5c52c36..7d44c3d0 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -141,32 +141,62 @@ func seedChatLogForTesting( logText: String = "", rawLogText: String = "" ) { - var entries: [ReviewChatLogEntryForTesting] = [] - let trimmedLogText = logText.trimmingCharacters(in: .newlines) - if trimmedLogText.isEmpty == false { - entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(run.id)", text: trimmedLogText)) - } - for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { - entries.append( - .init( - kind: .diagnostic, - groupID: "fixture-diagnostic-\(run.id)-\(index)", - text: String(line) - )) - } - ReviewChatLogFixtureStore.setEntries(entries, for: run) + seedChatLogForTesting( + chatID: run.previewChatIDForTesting, + fixtureID: run.id, + logText: logText, + rawLogText: rawLogText + ) +} + +@MainActor +func seedChatLogForTesting( + chatID: CodexThreadID, + fixtureID: String, + logText: String = "", + rawLogText: String = "" +) { + ReviewChatLogFixtureStore.setEntries( + makeChatLogEntriesForTesting(fixtureID: fixtureID, logText: logText, rawLogText: rawLogText), + for: chatID + ) } @MainActor func appendChatLogEntryForTesting(_ run: ReviewRunRecord, _ entry: ReviewChatLogEntryForTesting) { - ReviewChatLogFixtureStore.append(entry, to: run) + appendChatLogEntryForTesting(entry, to: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) +} + +@MainActor +func appendChatLogEntryForTesting( + _ entry: ReviewChatLogEntryForTesting, + to chatID: CodexThreadID, + turnID: CodexTurnID +) { + ReviewChatLogFixtureStore.append(entry, to: chatID, turnID: turnID) } @MainActor func replaceChatLogTextForTesting(_ run: ReviewRunRecord, _ text: String) { + replaceChatLogTextForTesting( + text, + for: run.previewChatIDForTesting, + fixtureID: run.id, + turnID: run.previewTurnIDForTesting + ) +} + +@MainActor +func replaceChatLogTextForTesting( + _ text: String, + for chatID: CodexThreadID, + fixtureID: String, + turnID: CodexTurnID +) { ReviewChatLogFixtureStore.replaceEntries( - [.init(kind: .agentMessage, groupID: "fixture-log-\(run.id)", text: text.trimmingCharacters(in: .newlines))], - for: run + [.init(kind: .agentMessage, groupID: "fixture-log-\(fixtureID)", text: text.trimmingCharacters(in: .newlines))], + for: chatID, + turnID: turnID ) } @@ -178,13 +208,32 @@ func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, reviewRun runStorePreviewSupportRetainers.append( ReviewChatLogFixtureRetainer( source: source, - runIDs: Set(reviewRuns.map(\.id)) + chatIDs: Set(reviewRuns.map(\.previewChatIDForTesting)) )) } @MainActor func reviewChatLogText(for run: ReviewRunRecord) -> String { - ReviewChatLogFixtureStore.logText(for: run) + reviewChatLogText( + for: makeCodexChatSnapshotForTesting( + chatID: run.previewChatIDForTesting, + turnID: run.previewTurnIDForTesting, + turnStatus: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status), + items: ReviewChatLogFixtureStore.items( + for: run.previewChatIDForTesting, + turnID: run.previewTurnIDForTesting + ) + ), + chatUpdatedAt: run.core.lifecycle.endedAt + ) +} + +@MainActor +func reviewChatLogText( + for snapshot: CodexChatSnapshot, + chatUpdatedAt: Date? = nil +) -> String { + ReviewChatLogFixtureStore.logText(for: snapshot, chatUpdatedAt: chatUpdatedAt) } @MainActor @@ -194,11 +243,47 @@ func awaitChatRenderForTesting( allowIncrementalUpdate: Bool = true, timeout: Duration = .seconds(2), matching predicate: (@Sendable (ReviewMonitorTransportViewController.RenderSnapshotForTesting) -> Bool)? = nil +) async throws -> ReviewMonitorTransportViewController.RenderSnapshotForTesting { + try await awaitChatRenderForTesting( + chatID: run.previewChatIDForTesting, + expectedLog: reviewChatLogText(for: run), + in: transport, + allowIncrementalUpdate: allowIncrementalUpdate, + timeout: timeout, + matching: predicate + ) +} + +@MainActor +func awaitChatRenderForTesting( + snapshot: CodexChatSnapshot, + 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.chatID, + 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 expectedLog = reviewChatLogText(for: run) let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = - .chat(chatIDForTesting(run).rawValue) + .chat(chatID.rawValue) let clock = ContinuousClock() let deadline = clock.now + timeout repeat { @@ -253,6 +338,27 @@ func makePreviewChatLogSourceForTesting( ) } +@MainActor +func makePreviewChatLogSourceForTesting( + chat: ReviewMonitorCodexSidebarSnapshot.Chat, + cwd: String, + streamID: String, + isRunning: Bool, + initialSnapshot: CodexChatSnapshot +) -> ReviewMonitorPreviewChatLogSource { + ReviewMonitorPreviewChatLogSource( + fixtures: [ + makePreviewChatLogFixtureForTesting( + chat: chat, + cwd: cwd, + streamID: streamID, + isRunning: isRunning, + initialSnapshot: initialSnapshot + ) + ] + ) +} + @MainActor func makePreviewMessageItemForTesting( id: String, @@ -272,7 +378,15 @@ func chatCommandOutputBlockIDForTesting( _ run: ReviewRunRecord, itemID: String ) -> ReviewMonitorLog.BlockID { - ReviewMonitorLog.BlockID("commandOutput:\(run.previewTurnIDForTesting.rawValue):\(itemID)") + chatCommandOutputBlockIDForTesting(turnID: run.previewTurnIDForTesting, itemID: itemID) +} + +@MainActor +func chatCommandOutputBlockIDForTesting( + turnID: CodexTurnID, + itemID: String +) -> ReviewMonitorLog.BlockID { + ReviewMonitorLog.BlockID("commandOutput:\(turnID.rawValue):\(itemID)") } @MainActor @@ -294,7 +408,7 @@ private func makePreviewChatLogFixtureForTesting( errorDescription: run.core.lifecycle.errorMessage, usage: nil ) - let initialSnapshot = CodexChatSnapshot( + let initialSnapshot = makeCodexChatSnapshotForTesting( chatID: chat.id, phase: CodexDataPhase( reviewRunStateForTesting: run.core.lifecycle.status, @@ -303,7 +417,7 @@ private func makePreviewChatLogFixtureForTesting( turns: [turn], items: makeItems(turn.id) ) - return ReviewMonitorPreviewChatLogFixture( + return makePreviewChatLogFixtureForTesting( chat: chat, cwd: run.cwd, streamID: run.id, @@ -312,47 +426,112 @@ private func makePreviewChatLogFixtureForTesting( ) } +@MainActor +func makeCodexChatSnapshotForTesting( + chatID: CodexThreadID, + turnID: CodexTurnID, + phase: CodexDataPhase = .loaded, + turnStatus: CodexTurnStatus = .completed, + turnErrorDescription: String? = nil, + items: [CodexChatItemSnapshot] = [] +) -> CodexChatSnapshot { + makeCodexChatSnapshotForTesting( + chatID: chatID, + phase: phase, + turns: [ + .init( + id: turnID, + status: turnStatus, + errorDescription: turnErrorDescription, + usage: nil + ) + ], + items: items + ) +} + +@MainActor +func makeCodexChatSnapshotForTesting( + chatID: CodexThreadID, + phase: CodexDataPhase = .loaded, + turns: [CodexChatTurnStateSnapshot], + items: [CodexChatItemSnapshot] = [] +) -> CodexChatSnapshot { + CodexChatSnapshot( + chatID: chatID, + phase: phase, + turns: turns, + items: items + ) +} + +@MainActor +func makePreviewChatLogFixtureForTesting( + chat: ReviewMonitorCodexSidebarSnapshot.Chat, + cwd: String, + streamID: String, + isRunning: Bool, + initialSnapshot: CodexChatSnapshot +) -> ReviewMonitorPreviewChatLogFixture { + ReviewMonitorPreviewChatLogFixture( + chat: chat, + cwd: cwd, + streamID: streamID, + isRunning: isRunning, + initialSnapshot: initialSnapshot + ) +} + @MainActor private enum ReviewChatLogFixtureStore { - private static var entriesByRunID: [String: [ReviewChatLogEntryForTesting]] = [:] + private static var entriesByChatID: [CodexThreadID: [ReviewChatLogEntryForTesting]] = [:] static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { - entriesByRunID[run.id] = entries + setEntries(entries, for: run.previewChatIDForTesting) + } + + static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for chatID: CodexThreadID) { + entriesByChatID[chatID] = entries } static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { - setEntries(entries, for: run) - updateRetainedPreviewSource(for: run) + replaceEntries(entries, for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) + } + + static func replaceEntries( + _ entries: [ReviewChatLogEntryForTesting], + for chatID: CodexThreadID, + turnID: CodexTurnID + ) { + setEntries(entries, for: chatID) + updateRetainedPreviewSource(for: chatID, turnID: turnID) } static func append(_ entry: ReviewChatLogEntryForTesting, to run: ReviewRunRecord) { - entriesByRunID[run.id, default: []].append(entry) - updateRetainedPreviewSource(for: run) + append(entry, to: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) + } + + static func append(_ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID) { + entriesByChatID[chatID, default: []].append(entry) + updateRetainedPreviewSource(for: chatID, turnID: turnID) } static func items(for run: ReviewRunRecord) -> [CodexChatItemSnapshot] { - makeChatItems(from: entriesByRunID[run.id, default: []], turnID: run.previewTurnIDForTesting) + items(for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) + } + + static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexChatItemSnapshot] { + makeChatItems(from: entriesByChatID[chatID, default: []], turnID: turnID) } - static func logText(for run: ReviewRunRecord) -> String { + static func logText(for snapshot: CodexChatSnapshot, chatUpdatedAt: Date?) -> String { var projection = ReviewMonitorSelectedCodexChatLogProjection() var document: ReviewMonitorLog.Document? - let snapshot = CodexChatSnapshot( - chatID: chatIDForTesting(run), - phase: .loaded, - turns: [ - .init( - id: run.previewTurnIDForTesting, - status: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status) - ) - ], - items: items(for: run) - ) for change in CodexChatChange.previewChangesForTesting(from: nil, to: snapshot) { document = projection.apply( change, chatCreatedAt: nil, - chatUpdatedAt: run.core.lifecycle.endedAt + chatUpdatedAt: chatUpdatedAt )?.sourceDocument ?? document } guard let document else { @@ -362,9 +541,9 @@ private enum ReviewChatLogFixtureStore { return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) } - private static func updateRetainedPreviewSource(for run: ReviewRunRecord) { - for support in runStorePreviewSupportRetainers where support.contains(runID: run.id) { - support.upsert(run: run, items: items(for: run)) + private static func updateRetainedPreviewSource(for chatID: CodexThreadID, turnID: CodexTurnID) { + for support in runStorePreviewSupportRetainers where support.contains(chatID: chatID) { + support.upsert(chatID: chatID, items: items(for: chatID, turnID: turnID)) } } } @@ -372,21 +551,18 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { let source: ReviewMonitorPreviewChatLogSource - private var runIDs: Set + private var chatIDs: Set - init(source: ReviewMonitorPreviewChatLogSource, runIDs: Set) { + init(source: ReviewMonitorPreviewChatLogSource, chatIDs: Set) { self.source = source - self.runIDs = runIDs + self.chatIDs = chatIDs } - func contains(runID: String) -> Bool { - runIDs.contains(runID) + func contains(chatID: CodexThreadID) -> Bool { + chatIDs.contains(chatID) } - func upsert(run: ReviewRunRecord, items: [CodexChatItemSnapshot]) { - guard let chatID = run.reviewChatIDForTesting else { - return - } + func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) { for item in items { source.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) } @@ -396,6 +572,27 @@ private final class ReviewChatLogFixtureRetainer { @MainActor private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] +private func makeChatLogEntriesForTesting( + fixtureID: String, + logText: String, + rawLogText: String +) -> [ReviewChatLogEntryForTesting] { + var entries: [ReviewChatLogEntryForTesting] = [] + let trimmedLogText = logText.trimmingCharacters(in: .newlines) + if trimmedLogText.isEmpty == false { + entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(fixtureID)", text: trimmedLogText)) + } + for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + entries.append( + .init( + kind: .diagnostic, + groupID: "fixture-diagnostic-\(fixtureID)-\(index)", + text: String(line) + )) + } + return entries +} + @MainActor private func makeChatItems( from entries: [ReviewChatLogEntryForTesting], @@ -577,6 +774,10 @@ private struct ReviewChatLogAccumulatedItem { } extension ReviewRunRecord { + var previewChatIDForTesting: CodexThreadID { + reviewChatIDForTesting ?? CodexThreadID(rawValue: id) + } + var reviewChatIDForTesting: CodexThreadID? { if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { return CodexThreadID(rawValue: reviewThreadID) From 2423407ee725d0dd427d46ea242657431862e7af Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:44:51 +0900 Subject: [PATCH 228/359] test(review-ui): select preview chats by chat id --- .../ReviewMonitorChatLogTesting.swift | 34 +--- Tests/ReviewUITests/ReviewUIShellTests.swift | 4 +- Tests/ReviewUITests/ReviewUITests.swift | 189 +++++++++--------- 3 files changed, 98 insertions(+), 129 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 7d44c3d0..f9ebc186 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -130,7 +130,7 @@ extension ReviewRunRecord { errorMessage: errorMessage, exitCode: exitCode ) - ReviewChatLogFixtureStore.setEntries(chatEntries, for: run) + ReviewChatLogFixtureStore.setEntries(chatEntries, for: run.previewChatIDForTesting) return run } } @@ -392,7 +392,7 @@ func chatCommandOutputBlockIDForTesting( @MainActor private func makePreviewChatLogFixtureForTesting(run: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { try makePreviewChatLogFixtureForTesting(run: run) { _ in - ReviewChatLogFixtureStore.items(for: run) + ReviewChatLogFixtureStore.items(for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) } } @@ -401,7 +401,7 @@ private func makePreviewChatLogFixtureForTesting( run: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogFixture { - let chat = try #require(run.reviewChatSelectionForTesting) + let chat = run.reviewChatSelectionForTesting let turn = CodexChatTurnStateSnapshot( id: run.previewTurnIDForTesting, status: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status), @@ -486,18 +486,10 @@ func makePreviewChatLogFixtureForTesting( private enum ReviewChatLogFixtureStore { private static var entriesByChatID: [CodexThreadID: [ReviewChatLogEntryForTesting]] = [:] - static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { - setEntries(entries, for: run.previewChatIDForTesting) - } - static func setEntries(_ entries: [ReviewChatLogEntryForTesting], for chatID: CodexThreadID) { entriesByChatID[chatID] = entries } - static func replaceEntries(_ entries: [ReviewChatLogEntryForTesting], for run: ReviewRunRecord) { - replaceEntries(entries, for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) - } - static func replaceEntries( _ entries: [ReviewChatLogEntryForTesting], for chatID: CodexThreadID, @@ -507,19 +499,11 @@ private enum ReviewChatLogFixtureStore { updateRetainedPreviewSource(for: chatID, turnID: turnID) } - static func append(_ entry: ReviewChatLogEntryForTesting, to run: ReviewRunRecord) { - append(entry, to: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) - } - static func append(_ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID) { entriesByChatID[chatID, default: []].append(entry) updateRetainedPreviewSource(for: chatID, turnID: turnID) } - static func items(for run: ReviewRunRecord) -> [CodexChatItemSnapshot] { - items(for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) - } - static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexChatItemSnapshot] { makeChatItems(from: entriesByChatID[chatID, default: []], turnID: turnID) } @@ -775,23 +759,17 @@ private struct ReviewChatLogAccumulatedItem { extension ReviewRunRecord { var previewChatIDForTesting: CodexThreadID { - reviewChatIDForTesting ?? CodexThreadID(rawValue: id) - } - - var reviewChatIDForTesting: CodexThreadID? { if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { return CodexThreadID(rawValue: reviewThreadID) } if let threadID = nonEmptyReviewChatProjectionStringForTesting(core.run.threadID) { return CodexThreadID(rawValue: threadID) } - return nil + return CodexThreadID(rawValue: id) } - var reviewChatSelectionForTesting: ReviewMonitorCodexSidebarSnapshot.Chat? { - guard let chatID = reviewChatIDForTesting else { - return nil - } + var reviewChatSelectionForTesting: ReviewMonitorCodexSidebarSnapshot.Chat { + let chatID = previewChatIDForTesting return ReviewMonitorCodexSidebarSnapshot.Chat( rowID: .chat(chatID), id: chatID, diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 07c7dd1f..044e4653 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1740,8 +1740,8 @@ private func renderDetailLogForShellLayoutTesting( viewController: ReviewMonitorSplitViewController, job: ReviewRunRecord ) async throws { - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) - let chatID = try #require(job.reviewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + let chatID = job.previewChatIDForTesting let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .chat(chatID.rawValue) try await waitForCondition { transport.renderedStateForTesting.selection == expectedSelection diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index c8c67f52..e1c92d75 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -21,15 +21,6 @@ private extension CodexReviewAuthModel { } } -@MainActor -func chatIDForTesting(_ job: ReviewRunRecord) -> CodexThreadID { - guard let chatID = job.reviewChatIDForTesting else { - Issue.record("Expected review run \(job.id) to have a chat id.") - return CodexThreadID(rawValue: job.id) - } - return chatID -} - @Suite(.serialized) @MainActor struct ReviewUITests { @@ -850,8 +841,8 @@ struct ReviewUITests { defer { backend.window.close() } let sidebar = viewController.sidebarViewControllerForTesting - let activeChatID = try #require(activeJob.reviewChatIDForTesting) - let recentChatID = try #require(recentJob.reviewChatIDForTesting) + let activeChatID = activeJob.previewChatIDForTesting + let recentChatID = recentJob.previewChatIDForTesting #expect(sidebar.reviewChatContextMenuTitlesForTesting(activeChatID) == ["Cancel Review"]) #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChatID).isEmpty) @@ -876,7 +867,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(recentJob.reviewChatSelectionForTesting) + chat: recentJob.reviewChatSelectionForTesting ) let selectedSnapshot = try await awaitChatRenderForTesting( @@ -968,7 +959,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(job.reviewChatSelectionForTesting) + chat: job.reviewChatSelectionForTesting ) let selectedSnapshot = try await awaitChatRenderForTesting( @@ -1019,7 +1010,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1095,7 +1086,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1151,7 +1142,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1209,7 +1200,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1283,7 +1274,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, @@ -1360,7 +1351,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, @@ -1522,7 +1513,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, @@ -1577,7 +1568,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, @@ -1641,7 +1632,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1705,7 +1696,7 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(activeJob.reviewChatSelectionForTesting) + chat: activeJob.reviewChatSelectionForTesting ) let activeSnapshot = try await awaitChatRenderForTesting( @@ -1718,7 +1709,7 @@ struct ReviewUITests { #expect(window.title == activeJob.targetSummary) #expect(window.subtitle == activeJob.cwd) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(recentJob.reviewChatSelectionForTesting) + chat: recentJob.reviewChatSelectionForTesting ) let recentSnapshot = try await awaitChatRenderForTesting( @@ -1757,7 +1748,7 @@ struct ReviewUITests { window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1798,7 +1789,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( activeJob, in: transport, @@ -1809,7 +1800,7 @@ struct ReviewUITests { let activeOffset = transport.logVerticalScrollOffsetForTesting #expect(activeOffset > 0) #expect(transport.isLogPinnedToBottomForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( recentJob, in: transport, @@ -1817,7 +1808,7 @@ struct ReviewUITests { ) #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( activeJob, in: transport, @@ -1858,7 +1849,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( activeJob, in: transport, @@ -1867,7 +1858,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( recentJob, in: transport, @@ -1877,7 +1868,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) appendChatLogEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) let snapshot = try await awaitChatRenderForTesting( activeJob, in: transport, @@ -1907,7 +1898,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -1957,7 +1948,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -1966,7 +1957,7 @@ struct ReviewUITests { transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -2002,14 +1993,14 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(longJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: longJob.previewChatIDForTesting) let longSnapshot = try await awaitChatRenderForTesting( longJob, in: transport, @@ -2048,13 +2039,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( shortJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( recentJob, in: transport, @@ -2063,7 +2054,7 @@ struct ReviewUITests { expectLogVisibleFragmentsWithoutForcingLayout(transport) replaceChatLogTextForTesting(shortJob, longLog) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(shortJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( shortJob, in: transport, @@ -2099,13 +2090,13 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(activeJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( activeJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(recentJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( recentJob, in: transport, @@ -2142,7 +2133,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) let selectedSnapshot = try await awaitChatRenderForTesting( job, @@ -2151,7 +2142,7 @@ struct ReviewUITests { ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatIDForTesting) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.previewChatIDForTesting) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @@ -2179,7 +2170,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, @@ -2306,7 +2297,7 @@ struct ReviewUITests { let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: try #require(job.reviewChatSelectionForTesting) + chat: job.reviewChatSelectionForTesting ) let selectedSnapshot = try await awaitChatRenderForTesting( @@ -2350,7 +2341,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) let selectedSnapshot = try await awaitChatRenderForTesting( job, @@ -2366,7 +2357,7 @@ struct ReviewUITests { replaceChatLogTextForTesting(job, "Updated log") let updatedSnapshot = try await awaitChatRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.reviewChatIDForTesting) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.previewChatIDForTesting) #expect(updatedSnapshot.summary == nil) #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: job))) } @@ -2398,7 +2389,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.reviewChatIDForTesting) + let chatID = job.previewChatIDForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) @@ -2446,7 +2437,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = try #require(job.reviewChatIDForTesting) + let chatID = job.previewChatIDForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting @@ -2484,7 +2475,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2520,7 +2511,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2553,7 +2544,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2586,7 +2577,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2624,7 +2615,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2674,7 +2665,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2719,7 +2710,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2779,7 +2770,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2817,7 +2808,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2863,7 +2854,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2898,7 +2889,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -2962,13 +2953,13 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting transport.setLogReduceMotionForTesting(false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -2977,7 +2968,7 @@ struct ReviewUITests { appendChatLogEntryForTesting( firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3011,7 +3002,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3057,7 +3048,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3092,7 +3083,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3136,7 +3127,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3179,7 +3170,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3225,7 +3216,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3262,7 +3253,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3297,7 +3288,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3339,7 +3330,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3349,7 +3340,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -3358,7 +3349,7 @@ struct ReviewUITests { let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting transport.setLogOverlayScrollersShownForTesting(true) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3390,7 +3381,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3426,7 +3417,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3457,7 +3448,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3489,7 +3480,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3532,7 +3523,7 @@ struct ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3561,7 +3552,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3646,7 +3637,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3695,7 +3686,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -3845,7 +3836,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3865,7 +3856,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -3905,7 +3896,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3926,7 +3917,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -3964,7 +3955,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(firstJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( firstJob, in: transport, @@ -3981,7 +3972,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting == false) let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(secondJob)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( secondJob, in: transport, @@ -4012,7 +4003,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4053,7 +4044,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4101,7 +4092,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4151,7 +4142,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4191,7 +4182,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4234,7 +4225,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4279,7 +4270,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4324,7 +4315,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4391,7 +4382,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) _ = try await awaitChatRenderForTesting( job, in: transport, @@ -4428,7 +4419,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) let snapshot = try await awaitChatRenderForTesting( job, @@ -4457,7 +4448,7 @@ struct ReviewUITests { store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatIDForTesting(job)) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) let snapshot = try await awaitChatRenderForTesting( job, From 1b6bd96384dd8ceaa211ab0c8763da35fdad9f7f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:47:47 +0900 Subject: [PATCH 229/359] refactor(review-ui): remove review workspace from sidebar api --- .../ReviewMonitorSidebarViewController.swift | 89 +++++++++---------- Tests/ReviewUITests/ReviewUITests.swift | 8 +- 2 files changed, 48 insertions(+), 49 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 81fef3a5..a7006757 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -42,7 +42,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD @MainActor private static func measuredWorkspaceRowHeight() -> CGFloat { let cellView = ReviewMonitorWorkspaceCellView() - cellView.configure(CodexReviewWorkspace(cwd: "/tmp/workspace-alpha")) + cellView.configure(title: "workspace-alpha", toolTip: "/tmp/workspace-alpha") return ceil(cellView.fittingSize.height) } @@ -756,9 +756,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return false } - private func row(for workspace: CodexReviewWorkspace) -> Int? { - row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: workspace.cwd))) - ?? row(forCodexSidebarSelectionID: .workspaceSection(workspace.cwd)) + private func row(forWorkspaceCWD cwd: String) -> Int? { + row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: cwd))) + ?? row(forCodexSidebarSelectionID: .workspaceSection(cwd)) } private func row(for chatID: CodexThreadID) -> Int? { @@ -1519,14 +1519,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } - func selectWorkspaceForTesting(_ workspace: CodexReviewWorkspace) { - guard let row = row(for: workspace) else { - uiState.selection = .workspaceSection( - .init( - id: workspace.cwd, - title: workspace.displayTitle, - workspaceCWDs: [workspace.cwd] - )) + func selectWorkspaceForTesting(cwd: String) { + guard let row = row(forWorkspaceCWD: cwd) else { + uiState.selection = fallbackWorkspaceSectionSelection(cwd: cwd) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -1541,8 +1536,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarOutlineTree.roots.allSatisfy { outlineView.isItemExpanded($0) } } - func workspaceIsSelectableForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - row(for: workspace).map { shouldAllowSelection(of: outlineView.item(atRow: $0)) } ?? false + func workspaceIsSelectableForTesting(cwd: String) -> Bool { + row(forWorkspaceCWD: cwd).map { shouldAllowSelection(of: outlineView.item(atRow: $0)) } ?? false } var floatsGroupRowsEnabledForTesting: Bool { @@ -1565,8 +1560,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD rowHeights.reviewChat + outlineView.intercellSpacing.height } - func workspaceRowHeightForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { - guard let row = row(for: workspace) else { + func workspaceRowHeightForTesting(cwd: String) -> CGFloat? { + guard let row = row(forWorkspaceCWD: cwd) else { return nil } view.layoutSubtreeIfNeeded() @@ -1581,8 +1576,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return outlineView.rect(ofRow: row).height } - func workspaceCellMinXForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { - guard let row = row(for: workspace) else { + func workspaceCellMinXForTesting(cwd: String) -> CGFloat? { + guard let row = row(forWorkspaceCWD: cwd) else { return nil } view.layoutSubtreeIfNeeded() @@ -1599,8 +1594,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return cellView.contentMinXForTesting(relativeTo: outlineView) } - func workspaceDisclosureMaxXForTesting(_ workspace: CodexReviewWorkspace) -> CGFloat? { - guard let row = row(for: workspace) else { + func workspaceDisclosureMaxXForTesting(cwd: String) -> CGFloat? { + guard let row = row(forWorkspaceCWD: cwd) else { return nil } view.layoutSubtreeIfNeeded() @@ -1674,15 +1669,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.mouseDown(with: mouseEventForTesting(at: point)) } - func clickWorkspaceHeaderForTesting(_ workspace: CodexReviewWorkspace) { + func clickWorkspaceHeaderForTesting(cwd: String) { view.layoutSubtreeIfNeeded() - guard let row = row(for: workspace) else { - uiState.selection = .workspaceSection( - .init( - id: workspace.cwd, - title: workspace.displayTitle, - workspaceCWDs: [workspace.cwd] - )) + guard let row = row(forWorkspaceCWD: cwd) else { + uiState.selection = fallbackWorkspaceSectionSelection(cwd: cwd) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -1708,12 +1698,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.menu != nil } - func workspaceIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - workspaceOutlineIsExpandedForTesting(workspace) + func workspaceIsExpandedForTesting(cwd: String) -> Bool { + workspaceOutlineIsExpandedForTesting(cwd: cwd) } - func workspaceOutlineIsExpandedForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - guard let row = row(for: workspace), + func workspaceOutlineIsExpandedForTesting(cwd: String) -> Bool { + guard let row = row(forWorkspaceCWD: cwd), let item = outlineView.item(atRow: row) else { return false @@ -1721,8 +1711,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return outlineView.isItemExpanded(item) } - func toggleWorkspaceDisclosureForTesting(_ workspace: CodexReviewWorkspace) { - guard let row = row(for: workspace), + func toggleWorkspaceDisclosureForTesting(cwd: String) { + guard let row = row(forWorkspaceCWD: cwd), let item = outlineView.item(atRow: row) else { preconditionFailure("Workspace row is not visible.") @@ -1734,8 +1724,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - func collapseWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { - guard let row = row(for: workspace), + func collapseWorkspaceInOutlineForTesting(cwd: String) { + guard let row = row(forWorkspaceCWD: cwd), let item = outlineView.item(atRow: row) else { preconditionFailure("Workspace row is not visible.") @@ -1743,8 +1733,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.collapseItem(item) } - func expandWorkspaceInOutlineForTesting(_ workspace: CodexReviewWorkspace) { - guard let row = row(for: workspace), + func expandWorkspaceInOutlineForTesting(cwd: String) { + guard let row = row(forWorkspaceCWD: cwd), let item = outlineView.item(atRow: row) else { preconditionFailure("Workspace row is not visible.") @@ -1752,8 +1742,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.expandItem(item) } - func workspaceRowIsFloatingForTesting(_ workspace: CodexReviewWorkspace) -> Bool { - guard let row = row(for: workspace), + func workspaceRowIsFloatingForTesting(cwd: String) -> Bool { + guard let row = row(forWorkspaceCWD: cwd), let rowView = outlineView.rowView(atRow: row, makeIfNecessary: true) else { return false @@ -1761,6 +1751,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return rowView.isFloating } + private func fallbackWorkspaceSectionSelection(cwd: String) -> ReviewMonitorSelection { + .workspaceSection( + .init( + id: cwd, + title: workspaceDisplayTitle(cwd: cwd), + workspaceCWDs: [cwd] + )) + } + + private func workspaceDisplayTitle(cwd: String) -> String { + let title = (cwd as NSString).lastPathComponent + return title.isEmpty ? cwd : title + } + func scrollSidebarToOffsetForTesting(_ yOffset: CGFloat) { let clampedOffset = max(0, yOffset) scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedOffset)) @@ -2249,11 +2253,6 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { nil } - func configure(_ workspace: CodexReviewWorkspace) { - objectValue = workspace - configure(title: workspace.displayTitle, toolTip: workspace.cwd) - } - func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { guard boundNode !== node else { return diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index e1c92d75..4485de88 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2177,7 +2177,7 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(workspace) + viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(cwd: workspace.cwd) _ = try await awaitTransportRender(transport) #expect( @@ -4624,15 +4624,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() From 9a3d5def1f2bf6cdb842e241995d99a8a12a0789 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:53:41 +0900 Subject: [PATCH 230/359] refactor(review): isolate run records from sidebar cancellation --- .../CodexReviewKit/Store/CodexReviewStore.swift | 10 +++++----- .../Store/CodexReviewStoreCancellation.swift | 15 +++++++++++++++ .../Store/CodexReviewStoreOrderQueries.swift | 8 ++++---- .../Store/CodexReviewStoreOrdering.swift | 14 +++++++------- .../Store/CodexReviewStoreTesting.swift | 16 ++++++++-------- .../CodexReviewMCPServer.swift | 4 ++-- .../ReviewMonitorSidebarViewController.swift | 4 ---- 7 files changed, 41 insertions(+), 30 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 4334b9f1..6f362a7c 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -363,12 +363,12 @@ public final class CodexReviewStore { guard let diagnosticsURL else { return } - let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { job in + let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in return CodexReviewStoreDiagnosticsSnapshot.Run( - status: job.core.lifecycle.status.rawValue, - summary: job.core.output.summary, - logText: job.core.reviewText, - rawLogText: job.core.reviewText + status: runRecord.core.lifecycle.status.rawValue, + summary: runRecord.core.output.summary, + logText: runRecord.core.reviewText, + rawLogText: runRecord.core.reviewText ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index ed97adc7..81cf8bbd 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -87,6 +87,21 @@ extension CodexReviewStore { 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 { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index f16ddddd..875d94ca 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -20,8 +20,8 @@ extension CodexReviewStore { workspaces.first(where: { $0.cwd == cwd }) } - package func workspace(containing job: ReviewRunRecord) -> CodexReviewWorkspace? { - workspace(cwd: job.cwd) + package func workspace(containing runRecord: ReviewRunRecord) -> CodexReviewWorkspace? { + workspace(cwd: runRecord.cwd) } package func reviewRun(id: String) -> ReviewRunRecord? { @@ -64,8 +64,8 @@ extension CodexReviewStore { package func normalizeReviewRunSortOrders(inWorkspace cwd: String) { let ordered = orderedReviewRuns(inWorkspace: cwd) - for (index, job) in ordered.enumerated() { - job.sortOrder = Double(ordered.count - index - 1) + for (index, runRecord) in ordered.enumerated() { + runRecord.sortOrder = Double(ordered.count - index - 1) } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift index 522a6c5e..445ebee7 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift @@ -64,11 +64,11 @@ extension CodexReviewStore { } let ordered = orderedReviewRuns(inWorkspace: cwd) - guard let job = ordered.first(where: { $0.id == id }) else { + guard let runRecord = ordered.first(where: { $0.id == id }) else { return false } - let remaining = ordered.filter { $0 !== job } + let remaining = ordered.filter { $0 !== runRecord } let destinationIndex: Int if let beforeRunID { guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeRunID }) else { @@ -92,8 +92,8 @@ extension CodexReviewStore { } let ordered = orderedReviewRuns(inWorkspace: cwd) - guard let job = ordered.first(where: { $0.id == id }), - let sourceIndex = ordered.firstIndex(where: { $0 === job }) + guard let runRecord = ordered.first(where: { $0.id == id }), + let sourceIndex = ordered.firstIndex(where: { $0 === runRecord }) else { return false } @@ -104,7 +104,7 @@ extension CodexReviewStore { } var sortOrder = reorderedSortOrder( - moving: job, + moving: runRecord, toIndex: destinationIndex, in: ordered, sortOrder: \.sortOrder @@ -112,7 +112,7 @@ extension CodexReviewStore { if sortOrder == nil { normalizeReviewRunSortOrders(inWorkspace: cwd) sortOrder = reorderedSortOrder( - moving: job, + moving: runRecord, toIndex: destinationIndex, in: orderedReviewRuns(inWorkspace: cwd), sortOrder: \.sortOrder @@ -121,7 +121,7 @@ extension CodexReviewStore { guard let sortOrder else { return false } - job.sortOrder = sortOrder + runRecord.sortOrder = sortOrder writeDiagnosticsIfNeeded() return true } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index b9a9d029..8792cd91 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -62,19 +62,19 @@ extension CodexReviewStore { self.workspaces = Set(resolvedWorkspaces) var runsByCWD: [String: [ReviewRunRecord]] = [:] - let resolvedRuns = reviewRuns.filter { job in - resolvedWorkspaces.contains(where: { $0.cwd == job.cwd }) + let resolvedRuns = reviewRuns.filter { runRecord in + resolvedWorkspaces.contains(where: { $0.cwd == runRecord.cwd }) } - for job in resolvedRuns { - runsByCWD[job.cwd, default: []].append(job) + for runRecord in resolvedRuns { + runsByCWD[runRecord.cwd, default: []].append(runRecord) } - for job in resolvedRuns { - guard let workspaceRuns = runsByCWD[job.cwd], - let index = workspaceRuns.firstIndex(where: { $0 === job }) + for runRecord in resolvedRuns { + guard let workspaceRuns = runsByCWD[runRecord.cwd], + let index = workspaceRuns.firstIndex(where: { $0 === runRecord }) else { continue } - job.sortOrder = Double(workspaceRuns.count - index - 1) + runRecord.sortOrder = Double(workspaceRuns.count - index - 1) } self.reviewRuns = Set(resolvedRuns) if let settingsSnapshot { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index a8596c69..97113259 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -122,12 +122,12 @@ package final class CodexReviewMCPServer { limit: limit )) case .reviewCancel(let sessionID, let selector, let reason): - let job = try store.resolveRun( + let runRecord = try store.resolveRun( sessionID: sessionID, selector: selector.defaultingToActiveStatusesForCancellation() ) return .reviewCancel(try await store.cancelReview( - runID: job.id, + runID: runRecord.id, cancellation: reason )) } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index a7006757..947e6745 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -733,12 +733,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD cancellation: .userInterface() ) } catch { - guard let job = self.store.reviewRun(id: runID) else { - return - } try? self.store.recordCancellationFailure( runID: runID, - sessionID: job.sessionID, message: error.localizedDescription ) } From b711c1486c7d3f11447fe88b85a17b28a067d582 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:59:00 +0900 Subject: [PATCH 231/359] test(review-ui): add chat fixture helper --- .../ReviewMonitorChatLogTesting.swift | 151 +++++++++++++++--- 1 file changed, 131 insertions(+), 20 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index f9ebc186..c88655e8 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -87,6 +87,69 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { } } +struct ReviewChatFixtureForTesting: Sendable { + var id: String + var cwd: String + var chat: ReviewMonitorCodexSidebarSnapshot.Chat + var turnID: CodexTurnID + var streamID: String + var isRunning: Bool + var initialSnapshot: CodexChatSnapshot + + var chatID: CodexThreadID { + chat.id + } +} + +@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: ReviewRunState = .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 = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(resolvedChatID), + id: resolvedChatID, + title: title, + preview: preview, + model: model, + workspaceCWD: cwd, + updatedAt: resolvedUpdatedAt, + recencyAt: resolvedUpdatedAt, + status: CodexThreadStatus(reviewRunStateForTesting: status) + ) + ReviewChatLogFixtureStore.setEntries(chatEntries, for: resolvedChatID) + let initialSnapshot = makeCodexChatSnapshotForTesting( + chatID: resolvedChatID, + turnID: resolvedTurnID, + phase: CodexDataPhase(reviewRunStateForTesting: status, errorMessage: errorMessage), + turnStatus: CodexTurnStatus(reviewRunStateForTesting: 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 + ) +} + extension ReviewRunRecord { @MainActor static func makeForTesting( @@ -202,13 +265,24 @@ func replaceChatLogTextForTesting( @MainActor func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, reviewRuns: [ReviewRunRecord]) { - let fixtures = reviewRuns.compactMap { try? makePreviewChatLogFixtureForTesting(run: $0) } + installPreviewChatLogSourceForTesting( + on: store, + fixtures: reviewRuns.map(\.reviewChatFixtureForTesting) + ) +} + +@MainActor +func installPreviewChatLogSourceForTesting( + on store: CodexReviewStore, + fixtures: [ReviewChatFixtureForTesting] +) { + let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting) let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) runStorePreviewSupportRetainers.append( ReviewChatLogFixtureRetainer( source: source, - chatIDs: Set(reviewRuns.map(\.previewChatIDForTesting)) + chatIDs: Set(fixtures.map(\.chat.id)) )) } @@ -391,7 +465,9 @@ func chatCommandOutputBlockIDForTesting( @MainActor private func makePreviewChatLogFixtureForTesting(run: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { - try makePreviewChatLogFixtureForTesting(run: run) { _ in + makePreviewChatLogFixtureForTesting( + fixture: run.reviewChatFixtureForTesting + ) { _ in ReviewChatLogFixtureStore.items(for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) } } @@ -401,27 +477,37 @@ private func makePreviewChatLogFixtureForTesting( run: ReviewRunRecord, items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] ) throws -> ReviewMonitorPreviewChatLogFixture { - let chat = run.reviewChatSelectionForTesting - let turn = CodexChatTurnStateSnapshot( - id: run.previewTurnIDForTesting, - status: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status), - errorDescription: run.core.lifecycle.errorMessage, - usage: nil + makePreviewChatLogFixtureForTesting( + fixture: run.reviewChatFixtureForTesting, + items: makeItems ) +} + +@MainActor +private func makePreviewChatLogFixtureForTesting( + _ fixture: ReviewChatFixtureForTesting +) -> ReviewMonitorPreviewChatLogFixture { + makePreviewChatLogFixtureForTesting(fixture: fixture) { _ in + fixture.initialSnapshot.items + } +} + +@MainActor +private func makePreviewChatLogFixtureForTesting( + fixture: ReviewChatFixtureForTesting, + items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] +) -> ReviewMonitorPreviewChatLogFixture { let initialSnapshot = makeCodexChatSnapshotForTesting( - chatID: chat.id, - phase: CodexDataPhase( - reviewRunStateForTesting: run.core.lifecycle.status, - errorMessage: run.core.lifecycle.errorMessage - ), - turns: [turn], - items: makeItems(turn.id) + chatID: fixture.chatID, + phase: fixture.initialSnapshot.phase, + turns: fixture.initialSnapshot.turns, + items: makeItems(fixture.turnID) ) return makePreviewChatLogFixtureForTesting( - chat: chat, - cwd: run.cwd, - streamID: run.id, - isRunning: run.core.lifecycle.status == .running, + chat: fixture.chat, + cwd: fixture.cwd, + streamID: fixture.streamID, + isRunning: fixture.isRunning, initialSnapshot: initialSnapshot ) } @@ -758,6 +844,31 @@ private struct ReviewChatLogAccumulatedItem { } extension ReviewRunRecord { + var reviewChatFixtureForTesting: ReviewChatFixtureForTesting { + let chat = reviewChatSelectionForTesting + let turnID = previewTurnIDForTesting + let initialSnapshot = makeCodexChatSnapshotForTesting( + chatID: chat.id, + turnID: turnID, + phase: CodexDataPhase( + reviewRunStateForTesting: core.lifecycle.status, + errorMessage: core.lifecycle.errorMessage + ), + turnStatus: CodexTurnStatus(reviewRunStateForTesting: core.lifecycle.status), + turnErrorDescription: core.lifecycle.errorMessage, + items: ReviewChatLogFixtureStore.items(for: chat.id, turnID: turnID) + ) + return ReviewChatFixtureForTesting( + id: id, + cwd: cwd, + chat: chat, + turnID: turnID, + streamID: id, + isRunning: core.lifecycle.status == .running, + initialSnapshot: initialSnapshot + ) + } + var previewChatIDForTesting: CodexThreadID { if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { return CodexThreadID(rawValue: reviewThreadID) From 3ec81097d53c70797ddd4f2f41d82108e63503b7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:06:34 +0900 Subject: [PATCH 232/359] test(review-ui): load basic sidebar tests from chat fixtures --- .../ReviewMonitorChatLogTesting.swift | 36 +++++++++ Tests/ReviewUITests/ReviewUITests.swift | 76 ++++++++++++------- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index c88655e8..8d90e685 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -302,6 +302,14 @@ func reviewChatLogText(for run: ReviewRunRecord) -> String { ) } +@MainActor +func reviewChatLogText(for fixture: ReviewChatFixtureForTesting) -> String { + reviewChatLogText( + for: codexChatSnapshotForTesting(fixture), + chatUpdatedAt: fixture.chat.updatedAt + ) +} + @MainActor func reviewChatLogText( for snapshot: CodexChatSnapshot, @@ -310,6 +318,24 @@ func reviewChatLogText( 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( _ run: ReviewRunRecord, @@ -512,6 +538,16 @@ private func makePreviewChatLogFixtureForTesting( ) } +@MainActor +func codexChatSnapshotForTesting(_ fixture: ReviewChatFixtureForTesting) -> CodexChatSnapshot { + makeCodexChatSnapshotForTesting( + chatID: fixture.chatID, + phase: fixture.initialSnapshot.phase, + turns: fixture.initialSnapshot.turns, + items: ReviewChatLogFixtureStore.items(for: fixture.chatID, turnID: fixture.turnID) + ) +} + @MainActor func makeCodexChatSnapshotForTesting( chatID: CodexThreadID, diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 4485de88..79595910 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -812,12 +812,12 @@ struct ReviewUITests { } @Test func reviewChatsPresentOnInitialLoadStayUnselected() { - let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") - let recentJob = makeJob(status: .succeeded, targetSummary: "Commit: abc123") + let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running) + let recentChat = makeReviewChatFixtureForTesting(title: "Commit: abc123", status: .succeeded) 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)) @@ -849,17 +849,21 @@ struct ReviewUITests { } @Test func selectingReviewChatUpdatesDetailPane() async throws { - let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes", logText: "Running review\n") - let recentJob = makeJob( + let activeChat = makeReviewChatFixtureForTesting( + title: "Uncommitted changes", + status: .running, + chatEntries: [.init(kind: .agentMessage, text: "Running review")] + ) + let recentChat = makeReviewChatFixtureForTesting( + title: "Commit: abc123", + preview: "MCP server codex_review ready.", status: .succeeded, - targetSummary: "Commit: abc123", - summary: "MCP server codex_review ready.", - logText: "Findings ready\n" + chatEntries: [.init(kind: .agentMessage, text: "Findings ready")] ) 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 @@ -867,25 +871,20 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: recentJob.reviewChatSelectionForTesting + chat: recentChat.chat ) let selectedSnapshot = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) - #expect( - selectedSnapshot - == .init( - title: nil, - summary: nil, - log: reviewChatLogText(for: recentJob), - isShowingEmptyState: false - ) - ) - #expect(window.title == recentJob.targetSummary) - #expect(window.subtitle == recentJob.cwd) + #expect(selectedSnapshot.title == nil) + #expect(selectedSnapshot.summary == nil) + #expect(selectedSnapshot.log == reviewChatLogText(for: recentChat)) + #expect(selectedSnapshot.isShowingEmptyState == false) + #expect(window.title == recentChat.chat.title) + #expect(window.subtitle == recentChat.cwd) #expect(transport.logUsesFindBarForTesting) #expect(transport.logIsIncrementalSearchingEnabledForTesting) #expect(transport.logFindBarVisibleForTesting == false) @@ -895,12 +894,19 @@ struct ReviewUITests { viewController.performTextFinderAction(findItem) #expect(transport.logFindBarVisibleForTesting) - activeJob.updateStateForTesting(summary: "Old selection should not render.") - replaceChatLogTextForTesting(activeJob, "Old selection log") + replaceChatLogTextForTesting( + "Old selection log", + for: activeChat.chatID, + fixtureID: activeChat.id, + turnID: activeChat.turnID + ) appendChatLogEntryForTesting( - recentJob, .init(kind: .progress, text: "Current selection log after stale mutation")) + .init(kind: .progress, text: "Current selection log after stale mutation"), + to: recentChat.chatID, + turnID: recentChat.turnID + ) - let updatedSnapshot = try await awaitChatRenderForTesting(recentJob, in: transport) { snapshot in + let updatedSnapshot = try await awaitChatRenderForTesting(recentChat, in: transport) { snapshot in snapshot.log.contains("Current selection log after stale mutation") } #expect(updatedSnapshot.log.contains("Old selection log") == false) @@ -5159,6 +5165,24 @@ func testAuthState(from auth: CodexReviewAuthModel) -> TestAuthState { @MainActor extension CodexReviewStore { + func loadForTesting( + serverState: CodexReviewServerState, + authState: TestAuthState = .signedOut, + serverURL: URL? = nil, + fixtures: [ReviewChatFixtureForTesting], + settingsSnapshot: CodexReviewSettings.Snapshot? = nil + ) { + loadForTesting( + serverState: serverState, + authState: authState, + serverURL: serverURL, + workspaces: [], + reviewRuns: [], + settingsSnapshot: settingsSnapshot + ) + installPreviewChatLogSourceForTesting(on: self, fixtures: fixtures) + } + func loadForTesting( serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, From c7496a399c934cc297db99a0711588196f151074 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:10:34 +0900 Subject: [PATCH 233/359] test(review-ui): drive chat log detail tests from chat fixtures --- Tests/ReviewUITests/ReviewUITests.swift | 117 ++++++++++++------------ 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 79595910..c1050433 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -914,18 +914,15 @@ struct ReviewUITests { } @Test func detailPaneRendersSelectedReviewChatLogProjection() async throws { - let job = ReviewRunRecord.makeForTesting( - id: "job-monitor-log", + 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.", + updatedAt: Date(timeIntervalSince1970: 201), chatEntries: [ .init( kind: .command, @@ -957,7 +954,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 @@ -965,18 +962,18 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: job.reviewChatSelectionForTesting + chat: chat.chat ) let selectedSnapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) #expect(selectedSnapshot.title == nil) #expect(selectedSnapshot.summary == nil) - #expect(window.title == job.targetSummary) - #expect(window.subtitle == job.cwd) + #expect(window.title == chat.chat.title) + #expect(window.subtitle == chat.cwd) let displayedLog = transport.displayedLogForTesting #expect(selectedSnapshot.log == displayedLog) @@ -992,21 +989,19 @@ struct ReviewUITests { } @Test func detailPaneRendersSelectedChatStreamUpdates() async throws { - let job = ReviewRunRecord.makeForTesting( - id: "job-selected-chat-stream-detail", + 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.", chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1016,38 +1011,44 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - job, - .init(kind: .agentMessage, groupID: "message-direct", text: "Selected chat detail update")) + .init(kind: .agentMessage, groupID: "message-direct", text: "Selected chat detail update"), + to: chat.chatID, + turnID: chat.turnID + ) - var snapshot = try await awaitChatRenderForTesting(job, in: transport) + var snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log == "Selected chat detail update") appendChatLogEntryForTesting( - job, .init( kind: .command, groupID: "cmd-direct", text: "$ swift test", metadata: .init(command: "swift test", commandStatus: "completed") - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) appendChatLogEntryForTesting( - job, .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 awaitChatRenderForTesting(job, in: transport) { + snapshot = try await awaitChatRenderForTesting(chat, in: transport) { $0.log.contains("Ran swift test") } #expect(snapshot.log.contains("Selected chat detail update")) @@ -1056,7 +1057,7 @@ struct ReviewUITests { #expect(snapshot.log.contains("Tests passed") == false) #expect(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect( @@ -1068,21 +1069,19 @@ struct ReviewUITests { } @Test func selectedChatFailedCommandPreservesFailedPanelStatus() async throws { - let job = ReviewRunRecord.makeForTesting( - id: "job-selected-chat-failed-command", + 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.", chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1092,29 +1091,31 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - job, .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 awaitChatRenderForTesting(job, in: transport) { + 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 = chatCommandOutputBlockIDForTesting(job, itemID: "cmd-failed-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-failed-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Failed") @@ -1124,21 +1125,19 @@ struct ReviewUITests { } @Test func selectedChatRunningCommandOutputStaysActive() async throws { - let job = ReviewRunRecord.makeForTesting( - id: "job-selected-chat-running-command", + 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.", chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1148,23 +1147,25 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - job, .init( kind: .commandOutput, groupID: "cmd-running-direct", text: "Building...", metadata: .init(command: "swift test", commandStatus: "running") - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) { + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) { $0.log.contains("Running swift test") } #expect(snapshot.log.contains("Running swift test")) @@ -1172,7 +1173,7 @@ struct ReviewUITests { #expect(snapshot.log.contains("Building...") == false) #expect(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd-running-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-running-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "running") From e7b3d4e06210f9e2cc114e58136352b659f6818b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:13:46 +0900 Subject: [PATCH 234/359] test(review-ui): use chat fixtures for log item panel tests --- Tests/ReviewUITests/ReviewUITests.swift | 50 ++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index c1050433..2de7efd0 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1183,21 +1183,19 @@ struct ReviewUITests { } @Test func selectedChatFileChangePreservesPanelTitle() async throws { - let job = ReviewRunRecord.makeForTesting( - id: "job-selected-chat-file-change", + 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.", chatEntries: [] ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1207,15 +1205,14 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - job, .init( kind: .fileChange, groupID: "file-change-direct", @@ -1224,9 +1221,12 @@ struct ReviewUITests { title: "Updated Sources/App.swift", commandStatus: "completed" ) - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) { + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) { $0.log.contains("Updated Sources/App.swift") } #expect(snapshot.log.contains("Updated Sources/App.swift")) @@ -1234,7 +1234,7 @@ struct ReviewUITests { #expect(snapshot.log.contains("Sources/App.swift | 12") == false) #expect(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "file-change-direct") + let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "file-change-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) await awaitNativeLayoutTurn() #expect(transport.logCommandOutputPanelResultTextForTesting == "Success") @@ -1245,15 +1245,13 @@ struct ReviewUITests { } @Test func contextCompactionMarkerRendersAsVisibleLogTextWithoutCommandPanel() async throws { - let job = ReviewRunRecord.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.", chatEntries: [ .init( kind: .contextCompaction, @@ -1271,7 +1269,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1281,10 +1279,10 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1293,7 +1291,6 @@ struct ReviewUITests { #expect(transport.logCommandOutputPanelCountForTesting == 0) appendChatLogEntryForTesting( - job, .init( kind: .contextCompaction, groupID: "compact_1", @@ -1304,8 +1301,11 @@ struct ReviewUITests { status: "completed", itemID: "compact_1" ) - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + 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) From 3e1ffa1b64ec31cae35a63400e454a7931ed9104 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:17:08 +0900 Subject: [PATCH 235/359] test(review-ui): render shell layout tests from chat fixtures --- Tests/ReviewUITests/ReviewUIShellTests.swift | 53 ++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 044e4653..247bf931 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1202,13 +1202,12 @@ extension ReviewUITests { @Test func detailLogViewExtendsBehindTitlebarWithoutOverlappingSidebar() async throws { let logText = "Safe area log\n" - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-safe-area", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1217,7 +1216,7 @@ extension ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) let logFrame = transport.logFrameForTesting let viewBounds = transport.viewBoundsForTesting @@ -1241,13 +1240,12 @@ extension ReviewUITests { @Test func shortDetailLogKeepsTextContentWithinDocumentBounds() async throws { let logText = "Short log\n" - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-short-log-layout", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1256,7 +1254,7 @@ extension ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) let textContentFrame = transport.logTextContentFrameForTesting let documentViewFrame = transport.logDocumentViewFrameForTesting @@ -1733,6 +1731,41 @@ private func contextCompactionTitle(_ item: CodexChatItemSnapshot) -> String { 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() +} + @MainActor private func renderDetailLogForShellLayoutTesting( _ text: String, From 6c48e4a7bb20fe40b0ae1ee8ee52c5fa8d7e3034 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:05:23 +0900 Subject: [PATCH 236/359] test(review-ui): render shell layout from chat fixtures --- Tests/ReviewUITests/ReviewUIShellTests.swift | 79 ++++++-------------- 1 file changed, 24 insertions(+), 55 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 247bf931..d81aea6e 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1268,13 +1268,12 @@ extension ReviewUITests { @Test func detailLogExpandsAfterSidebarReopensFromCompactWidth() async throws { let logText = Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40) .joined() - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-sidebar-width-regression", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1285,7 +1284,7 @@ extension ReviewUITests { try await waitForWindowContentKind(harness.rootViewController, .contentView) let transport = viewController.transportViewControllerForTesting let sidebarItem = try #require(viewController.splitViewItems.first) - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) window.setContentSize(NSSize(width: 360, height: 420)) sidebarItem.isCollapsed = true @@ -1314,13 +1313,12 @@ extension ReviewUITests { @Test func detailLogShrinksAfterSidebarReopensIntoNarrowWidth() async throws { let logText = Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40) .joined() - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-sidebar-width-shrink-regression", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1331,7 +1329,7 @@ extension ReviewUITests { try await waitForWindowContentKind(harness.rootViewController, .contentView) let transport = viewController.transportViewControllerForTesting let sidebarItem = try #require(viewController.splitViewItems.first) - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) let expandedDocumentWidth = transport.logDocumentViewFrameForTesting.width expectLogTextContainerWidthTracksContentView(transport) @@ -1353,13 +1351,12 @@ extension ReviewUITests { @Test func detailLogTracksSimpleWindowResizeInBothDirections() async throws { let logText = Array(repeating: "Long line that should reflow as the window resizes.\n", count: 40).joined() - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-window-resize-width-regression", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1368,7 +1365,7 @@ extension ReviewUITests { let window = harness.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) let wideWidth = transport.logDocumentViewFrameForTesting.width expectLogTextContainerWidthTracksContentView(transport) @@ -1400,14 +1397,12 @@ extension ReviewUITests { @Test func detailLogRewrapsVisibleTextDuringLiveWindowResize() async throws { let logText = String(repeating: "wrap-sensitive text ", count: 600) - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-window-live-resize-log", - status: .running, - targetSummary: "Uncommitted changes", - summary: "Running review." + 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) @@ -1422,7 +1417,7 @@ extension ReviewUITests { } window.close() } - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + 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 @@ -1456,14 +1451,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( + let chat = makeShellReviewChatForTesting( id: "job-window-live-resize-stream-log", - status: .running, - targetSummary: "Uncommitted changes", - summary: "Running review." + 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) @@ -1479,7 +1472,7 @@ extension ReviewUITests { window.close() } try await renderDetailLogForShellLayoutTesting( - streamLog, in: transport, viewController: viewController, job: job) + streamLog, in: transport, viewController: viewController, chat: chat) #expect(transport.isLogPinnedToBottomForTesting) transport.beginLogLiveResizeForTesting() @@ -1502,13 +1495,12 @@ extension ReviewUITests { let logText = Array( repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40 ).joined() - let job = makeJob( + let chat = makeShellReviewChatForTesting( id: "job-toolbar-sidebar-toggle-textkit-width-regression", - status: .running, - targetSummary: "Uncommitted changes" + 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) @@ -1523,7 +1515,7 @@ extension ReviewUITests { window.layoutIfNeeded() viewController.view.layoutSubtreeIfNeeded() transport.view.layoutSubtreeIfNeeded() - try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, job: job) + try await renderDetailLogForShellLayoutTesting(logText, in: transport, viewController: viewController, chat: chat) viewController.toggleSidebar(nil) await awaitNativeLayoutTurn() @@ -1766,29 +1758,6 @@ private func renderDetailLogForShellLayoutTesting( transport.view.layoutSubtreeIfNeeded() } -@MainActor -private func renderDetailLogForShellLayoutTesting( - _ text: String, - in transport: ReviewMonitorTransportViewController, - viewController: ReviewMonitorSplitViewController, - job: ReviewRunRecord -) async throws { - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) - let chatID = job.previewChatIDForTesting - let expectedSelection: ReviewMonitorTransportViewController.DisplayedSelectionForTesting = .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 makeSidebarReviewChatFilterDefaultsForTesting() throws -> (defaults: UserDefaults, suiteName: String) { let suiteName = "ReviewMonitorSidebarReviewChatFilterDefaultsTests-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) From 7038032632f9c235f63db396d7eabb775b40d1b7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:12:01 +0900 Subject: [PATCH 237/359] test(review-ui): render command panels from chat fixtures --- Tests/ReviewUITests/ReviewUITests.swift | 118 +++++++++++++++--------- 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 2de7efd0..e9c99d0c 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1325,15 +1325,12 @@ struct ReviewUITests { exitCode: 0, commandStatus: "completed" ) - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( @@ -1348,7 +1345,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1358,10 +1355,10 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1423,14 +1420,16 @@ struct ReviewUITests { #expect(scrolledOutputScrollOffset < initialOutputScrollMaximumOffset) let expandedOutputAppendReloadCount = transport.logReloadCountForTesting appendChatLogEntryForTesting( - job, .init( kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 10", metadata: commandMetadata - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + to: chat.chatID, + turnID: chat.turnID + ) + _ = try await awaitChatRenderForTesting(chat, in: transport) await awaitNativeLayoutTurn() #expect(transport.logReloadCountForTesting == expandedOutputAppendReloadCount) let offsetAfterOutputAppend = try #require(transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) @@ -1459,15 +1458,21 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("output line 9") == false) appendChatLogEntryForTesting( - job, .init( kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 11", metadata: commandMetadata - )) - appendChatLogEntryForTesting(job, .init(kind: .agentMessage, text: "Visible text after command output.")) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + to: chat.chatID, + turnID: chat.turnID + ) + 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) @@ -1481,15 +1486,12 @@ struct ReviewUITests { let secondOutput = (1...80) .map { "second output line \($0)" } .joined(separator: "\n") - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init( @@ -1510,7 +1512,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1520,16 +1522,16 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let firstBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "cmd_1") - let secondBlockID = chatCommandOutputBlockIDForTesting(job, itemID: "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() @@ -1549,15 +1551,12 @@ struct ReviewUITests { } @Test func startedCommandRendersAsCollapsedPanelBeforeOutputArrives() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test") ] @@ -1565,7 +1564,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1575,10 +1574,10 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1587,7 +1586,6 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("$ swift test") == false) appendChatLogEntryForTesting( - job, .init( kind: .commandOutput, groupID: "cmd_1", @@ -1600,8 +1598,11 @@ struct ReviewUITests { exitCode: 0, commandStatus: "completed" ) - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + 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) @@ -1612,15 +1613,12 @@ struct ReviewUITests { let outputText = (1...5) .map { "output line \($0)" } .joined(separator: "\n") - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .command, groupID: "cmd_1", text: "$ swift test"), .init(kind: .commandOutput, groupID: "cmd_1", text: outputText), @@ -1629,7 +1627,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [job]) + fixtures: [chat] ) let backend = makeWindowHarness( store: store, @@ -1639,9 +1637,9 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1666,8 +1664,11 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("output line 3") == false) appendChatLogEntryForTesting( - job, .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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) @@ -4959,6 +4960,31 @@ func makeJob( return job } +@MainActor +func makeReviewChatFixtureForTesting( + id: String = UUID().uuidString, + cwd: String = "/tmp/repo", + startedAt: Date = Date(timeIntervalSince1970: 200), + status: ReviewRunState, + targetSummary: String, + summary: String? = nil, + logText: String = "" +) -> ReviewChatFixtureForTesting { + let trimmedLogText = logText.trimmingCharacters(in: .newlines) + return makeReviewChatFixtureForTesting( + id: id, + cwd: cwd, + title: targetSummary, + preview: summary ?? status.displayText, + status: status, + startedAt: startedAt, + updatedAt: status.isTerminal ? startedAt.addingTimeInterval(1) : startedAt, + chatEntries: trimmedLogText.isEmpty + ? [] + : [.init(kind: .agentMessage, groupID: "fixture-log-\(id)", text: trimmedLogText)] + ) +} + func reviewChatCellTestChat( id: String, title: String, From f19eef821cee010e7cf5eab275d4112f49f2ae3a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:32:36 +0900 Subject: [PATCH 238/359] test(review-ui): finish chat fixture migration --- .../ReviewMonitorChatLogTesting.swift | 254 ---- Tests/ReviewUITests/ReviewUIShellTests.swift | 4 +- Tests/ReviewUITests/ReviewUITests.swift | 1232 +++++++++-------- 3 files changed, 667 insertions(+), 823 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 8d90e685..aef1100a 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -150,86 +150,6 @@ func makeReviewChatFixtureForTesting( ) } -extension ReviewRunRecord { - @MainActor - static func makeForTesting( - id: String = UUID().uuidString, - sessionID: String = "session-1", - cwd: String = "/tmp/repo", - targetSummary: String, - model: String? = "gpt-5", - threadID: String? = nil, - turnID: String? = nil, - status: ReviewRunState, - cancellationRequested: Bool = false, - cancellation: ReviewCancellation? = nil, - startedAt: Date? = nil, - endedAt: Date? = nil, - summary: String, - hasFinalReview: Bool = false, - reviewResult: ParsedReviewResult? = nil, - lastAgentMessage: String? = "", - chatEntries: [ReviewChatLogEntryForTesting] = [], - errorMessage: String? = nil, - exitCode: Int? = nil - ) -> ReviewRunRecord { - let run = ReviewRunRecord.makeForTesting( - id: id, - sessionID: sessionID, - cwd: cwd, - targetSummary: targetSummary, - model: model, - threadID: threadID, - turnID: turnID, - status: status, - cancellationRequested: cancellationRequested, - cancellation: cancellation, - startedAt: startedAt, - endedAt: endedAt, - summary: summary, - hasFinalReview: hasFinalReview, - reviewResult: reviewResult, - lastAgentMessage: lastAgentMessage, - errorMessage: errorMessage, - exitCode: exitCode - ) - ReviewChatLogFixtureStore.setEntries(chatEntries, for: run.previewChatIDForTesting) - return run - } -} - -@MainActor -func seedChatLogForTesting( - _ run: ReviewRunRecord, - logText: String = "", - rawLogText: String = "" -) { - seedChatLogForTesting( - chatID: run.previewChatIDForTesting, - fixtureID: run.id, - logText: logText, - rawLogText: rawLogText - ) -} - -@MainActor -func seedChatLogForTesting( - chatID: CodexThreadID, - fixtureID: String, - logText: String = "", - rawLogText: String = "" -) { - ReviewChatLogFixtureStore.setEntries( - makeChatLogEntriesForTesting(fixtureID: fixtureID, logText: logText, rawLogText: rawLogText), - for: chatID - ) -} - -@MainActor -func appendChatLogEntryForTesting(_ run: ReviewRunRecord, _ entry: ReviewChatLogEntryForTesting) { - appendChatLogEntryForTesting(entry, to: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) -} - @MainActor func appendChatLogEntryForTesting( _ entry: ReviewChatLogEntryForTesting, @@ -239,16 +159,6 @@ func appendChatLogEntryForTesting( ReviewChatLogFixtureStore.append(entry, to: chatID, turnID: turnID) } -@MainActor -func replaceChatLogTextForTesting(_ run: ReviewRunRecord, _ text: String) { - replaceChatLogTextForTesting( - text, - for: run.previewChatIDForTesting, - fixtureID: run.id, - turnID: run.previewTurnIDForTesting - ) -} - @MainActor func replaceChatLogTextForTesting( _ text: String, @@ -263,14 +173,6 @@ func replaceChatLogTextForTesting( ) } -@MainActor -func installPreviewChatLogSourceForTesting(on store: CodexReviewStore, reviewRuns: [ReviewRunRecord]) { - installPreviewChatLogSourceForTesting( - on: store, - fixtures: reviewRuns.map(\.reviewChatFixtureForTesting) - ) -} - @MainActor func installPreviewChatLogSourceForTesting( on store: CodexReviewStore, @@ -286,22 +188,6 @@ func installPreviewChatLogSourceForTesting( )) } -@MainActor -func reviewChatLogText(for run: ReviewRunRecord) -> String { - reviewChatLogText( - for: makeCodexChatSnapshotForTesting( - chatID: run.previewChatIDForTesting, - turnID: run.previewTurnIDForTesting, - turnStatus: CodexTurnStatus(reviewRunStateForTesting: run.core.lifecycle.status), - items: ReviewChatLogFixtureStore.items( - for: run.previewChatIDForTesting, - turnID: run.previewTurnIDForTesting - ) - ), - chatUpdatedAt: run.core.lifecycle.endedAt - ) -} - @MainActor func reviewChatLogText(for fixture: ReviewChatFixtureForTesting) -> String { reviewChatLogText( @@ -336,24 +222,6 @@ func awaitChatRenderForTesting( ) } -@MainActor -func awaitChatRenderForTesting( - _ run: ReviewRunRecord, - 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: run.previewChatIDForTesting, - expectedLog: reviewChatLogText(for: run), - in: transport, - allowIncrementalUpdate: allowIncrementalUpdate, - timeout: timeout, - matching: predicate - ) -} - @MainActor func awaitChatRenderForTesting( snapshot: CodexChatSnapshot, @@ -428,16 +296,6 @@ func reviewChatRenderedLogMatches(_ actual: String, _ expected: String) -> Bool || actual.trimmingCharacters(in: .newlines) == expected.trimmingCharacters(in: .newlines) } -@MainActor -func makePreviewChatLogSourceForTesting( - run: ReviewRunRecord, - items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] -) throws -> ReviewMonitorPreviewChatLogSource { - ReviewMonitorPreviewChatLogSource( - fixtures: [try makePreviewChatLogFixtureForTesting(run: run, items: makeItems)] - ) -} - @MainActor func makePreviewChatLogSourceForTesting( chat: ReviewMonitorCodexSidebarSnapshot.Chat, @@ -473,14 +331,6 @@ func makePreviewMessageItemForTesting( ) } -@MainActor -func chatCommandOutputBlockIDForTesting( - _ run: ReviewRunRecord, - itemID: String -) -> ReviewMonitorLog.BlockID { - chatCommandOutputBlockIDForTesting(turnID: run.previewTurnIDForTesting, itemID: itemID) -} - @MainActor func chatCommandOutputBlockIDForTesting( turnID: CodexTurnID, @@ -489,26 +339,6 @@ func chatCommandOutputBlockIDForTesting( ReviewMonitorLog.BlockID("commandOutput:\(turnID.rawValue):\(itemID)") } -@MainActor -private func makePreviewChatLogFixtureForTesting(run: ReviewRunRecord) throws -> ReviewMonitorPreviewChatLogFixture { - makePreviewChatLogFixtureForTesting( - fixture: run.reviewChatFixtureForTesting - ) { _ in - ReviewChatLogFixtureStore.items(for: run.previewChatIDForTesting, turnID: run.previewTurnIDForTesting) - } -} - -@MainActor -private func makePreviewChatLogFixtureForTesting( - run: ReviewRunRecord, - items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] -) throws -> ReviewMonitorPreviewChatLogFixture { - makePreviewChatLogFixtureForTesting( - fixture: run.reviewChatFixtureForTesting, - items: makeItems - ) -} - @MainActor private func makePreviewChatLogFixtureForTesting( _ fixture: ReviewChatFixtureForTesting @@ -678,27 +508,6 @@ private final class ReviewChatLogFixtureRetainer { @MainActor private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] -private func makeChatLogEntriesForTesting( - fixtureID: String, - logText: String, - rawLogText: String -) -> [ReviewChatLogEntryForTesting] { - var entries: [ReviewChatLogEntryForTesting] = [] - let trimmedLogText = logText.trimmingCharacters(in: .newlines) - if trimmedLogText.isEmpty == false { - entries.append(.init(kind: .agentMessage, groupID: "fixture-log-\(fixtureID)", text: trimmedLogText)) - } - for (index, line) in rawLogText.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { - entries.append( - .init( - kind: .diagnostic, - groupID: "fixture-diagnostic-\(fixtureID)-\(index)", - text: String(line) - )) - } - return entries -} - @MainActor private func makeChatItems( from entries: [ReviewChatLogEntryForTesting], @@ -879,62 +688,6 @@ private struct ReviewChatLogAccumulatedItem { } } -extension ReviewRunRecord { - var reviewChatFixtureForTesting: ReviewChatFixtureForTesting { - let chat = reviewChatSelectionForTesting - let turnID = previewTurnIDForTesting - let initialSnapshot = makeCodexChatSnapshotForTesting( - chatID: chat.id, - turnID: turnID, - phase: CodexDataPhase( - reviewRunStateForTesting: core.lifecycle.status, - errorMessage: core.lifecycle.errorMessage - ), - turnStatus: CodexTurnStatus(reviewRunStateForTesting: core.lifecycle.status), - turnErrorDescription: core.lifecycle.errorMessage, - items: ReviewChatLogFixtureStore.items(for: chat.id, turnID: turnID) - ) - return ReviewChatFixtureForTesting( - id: id, - cwd: cwd, - chat: chat, - turnID: turnID, - streamID: id, - isRunning: core.lifecycle.status == .running, - initialSnapshot: initialSnapshot - ) - } - - var previewChatIDForTesting: CodexThreadID { - if let reviewThreadID = nonEmptyReviewChatProjectionStringForTesting(core.run.reviewThreadID) { - return CodexThreadID(rawValue: reviewThreadID) - } - if let threadID = nonEmptyReviewChatProjectionStringForTesting(core.run.threadID) { - return CodexThreadID(rawValue: threadID) - } - return CodexThreadID(rawValue: id) - } - - var reviewChatSelectionForTesting: ReviewMonitorCodexSidebarSnapshot.Chat { - let chatID = previewChatIDForTesting - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: displayTitle, - preview: nonEmptyReviewChatProjectionStringForTesting(core.output.lastAgentMessage) - ?? nonEmptyReviewChatProjectionStringForTesting(core.output.summary), - workspaceCWD: cwd, - updatedAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - recencyAt: core.lifecycle.endedAt ?? core.lifecycle.startedAt, - status: CodexThreadStatus(reviewRunStateForTesting: core.lifecycle.status) - ) - } - - var previewTurnIDForTesting: CodexTurnID { - core.run.turnID.map(CodexTurnID.init(rawValue:)) ?? CodexTurnID(rawValue: "\(id):preview-turn") - } -} - private extension CodexThreadStatus { init(reviewRunStateForTesting jobState: ReviewRunState) { switch jobState { @@ -989,13 +742,6 @@ private func codexTurnStatus(for entry: ReviewChatLogEntryForTesting) -> CodexTu return CodexTurnStatus(rawValue: rawValue) } -private func nonEmptyReviewChatProjectionStringForTesting(_ value: String?) -> String? { - guard let value, value.isEmpty == false else { - return nil - } - return value -} - private extension CodexChatChange { static func previewChangesForTesting(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { guard let previous else { diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index d81aea6e..6605e3ec 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -699,7 +699,7 @@ extension ReviewUITests { store.loadForTesting( serverState: .failed("Embedded server is unavailable in preview mode."), - content: makeSidebarContent(from: []) + workspaces: [] ) try await waitForSidebarPresentation( viewController, @@ -710,7 +710,7 @@ extension ReviewUITests { store.loadForTesting( serverState: .running, serverURL: URL(string: "http://localhost:9417/mcp"), - content: makeSidebarContent(from: []) + workspaces: [] ) try await waitForSidebarPresentation( viewController, diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index e9c99d0c..38ebca38 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -829,23 +829,41 @@ struct ReviewUITests { } @Test func reviewChatContextMenuOffersCancellationForRunningChatOnly() throws { - let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") - let recentJob = makeJob(status: .succeeded, targetSummary: "Commit: abc123") + let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running) + let recentChat = makeReviewChatFixtureForTesting(title: "Commit: abc123", status: .succeeded) + let activeRun = ReviewRunRecord.makeForTesting( + id: "run-cancellable", + cwd: activeChat.cwd, + targetSummary: activeChat.chat.title, + threadID: activeChat.chatID.rawValue, + turnID: activeChat.turnID.rawValue, + status: .running, + summary: "Running review." + ) + let recentRun = ReviewRunRecord.makeForTesting( + id: "run-terminal", + cwd: recentChat.cwd, + targetSummary: recentChat.chat.title, + threadID: recentChat.chatID.rawValue, + turnID: recentChat.turnID.rawValue, + status: .succeeded, + endedAt: Date(timeIntervalSince1970: 201), + summary: "Review complete." + ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [activeJob, recentJob]) + workspaces: [CodexReviewWorkspace(cwd: activeChat.cwd)], + reviewRuns: [activeRun, recentRun] ) + installPreviewChatLogSourceForTesting(on: store, fixtures: [activeChat, recentChat]) let backend = makeWindowHarness(store: store) let viewController = backend.viewController defer { backend.window.close() } let sidebar = viewController.sidebarViewControllerForTesting - let activeChatID = activeJob.previewChatIDForTesting - let recentChatID = recentJob.previewChatIDForTesting - - #expect(sidebar.reviewChatContextMenuTitlesForTesting(activeChatID) == ["Cancel Review"]) - #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChatID).isEmpty) + #expect(sidebar.reviewChatContextMenuTitlesForTesting(activeChat.chatID) == ["Cancel Review"]) + #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChat.chatID).isEmpty) } @Test func selectingReviewChatUpdatesDetailPane() async throws { @@ -1679,14 +1697,14 @@ struct ReviewUITests { } @Test func switchingSelectedReviewChatRebindsDetailPane() async throws { - let activeJob = makeJob( + let activeChat = makeReviewChatFixtureForTesting( id: "job-active", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: "Active log\n" ) - let recentJob = makeJob( + let recentChat = makeReviewChatFixtureForTesting( id: "job-recent", status: .succeeded, targetSummary: "Commit: abc123", @@ -1696,7 +1714,7 @@ 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 @@ -1704,24 +1722,24 @@ struct ReviewUITests { defer { window.close() } let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: activeJob.reviewChatSelectionForTesting + chat: activeChat.chat ) let activeSnapshot = try await awaitChatRenderForTesting( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) #expect(activeSnapshot.title == nil) #expect(activeSnapshot.summary == nil) - #expect(window.title == activeJob.targetSummary) - #expect(window.subtitle == activeJob.cwd) + #expect(window.title == activeChat.chat.title) + #expect(window.subtitle == activeChat.cwd) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: recentJob.reviewChatSelectionForTesting + chat: recentChat.chat ) let recentSnapshot = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) @@ -1730,17 +1748,17 @@ struct ReviewUITests { == .init( title: nil, summary: nil, - log: reviewChatLogText(for: recentJob), + log: reviewChatLogText(for: recentChat), isShowingEmptyState: false ) ) - #expect(window.title == recentJob.targetSummary) - #expect(window.subtitle == recentJob.cwd) + #expect(window.title == recentChat.chat.title) + #expect(window.subtitle == recentChat.cwd) } @Test func firstSelectionFromEmptyStatePinsUnvisitedReviewChatToBottom() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-first-bottom", status: .running, targetSummary: "Uncommitted changes", @@ -1748,7 +1766,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -1756,9 +1774,9 @@ struct ReviewUITests { window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1770,14 +1788,14 @@ struct ReviewUITests { @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( + let activeChat = makeReviewChatFixtureForTesting( id: "job-active-scroll", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: longActiveLog ) - let recentJob = makeJob( + let recentChat = makeReviewChatFixtureForTesting( id: "job-recent-scroll", status: .succeeded, targetSummary: "Commit: abc123", @@ -1787,7 +1805,7 @@ 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)) @@ -1797,9 +1815,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) _ = try await awaitChatRenderForTesting( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) @@ -1808,17 +1826,17 @@ struct ReviewUITests { let activeOffset = transport.logVerticalScrollOffsetForTesting #expect(activeOffset > 0) #expect(transport.isLogPinnedToBottomForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) _ = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) _ = try await awaitChatRenderForTesting( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) @@ -1830,14 +1848,14 @@ struct ReviewUITests { @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( + let activeChat = makeReviewChatFixtureForTesting( id: "job-active-bottom", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: longActiveLog ) - let recentJob = makeJob( + let recentChat = makeReviewChatFixtureForTesting( id: "job-recent-bottom", status: .succeeded, targetSummary: "Commit: abc123", @@ -1847,7 +1865,7 @@ 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)) @@ -1857,28 +1875,32 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) _ = try await awaitChatRenderForTesting( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) _ = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) #expect(transport.isLogPinnedToBottomForTesting) - appendChatLogEntryForTesting(activeJob, .init(kind: .progress, text: "Newest active line")) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) + 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( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) @@ -1889,7 +1911,7 @@ struct ReviewUITests { @Test func rehydratingSameSelectedReviewChatPreservesLogScrollPosition() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-rehydrated", status: .running, targetSummary: "Uncommitted changes", @@ -1897,7 +1919,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -1906,9 +1928,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -1917,14 +1939,14 @@ struct ReviewUITests { let preservedOffset = transport.logVerticalScrollOffsetForTesting #expect(preservedOffset > 0) - let replacement = makeJob( + let replacement = makeReviewChatFixtureForTesting( id: "job-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 == reviewChatLogText(for: replacement)) #expect(transport.logVerticalScrollOffsetForTesting == preservedOffset) @@ -1932,14 +1954,14 @@ struct ReviewUITests { @Test func switchingReviewChatWithIdenticalLogTextStartsUnvisitedReviewChatAtBottom() async throws { let sharedLog = (0..<400).map { "shared line \($0)" }.joined(separator: "\n") - let firstJob = makeJob( + let firstChat = makeReviewChatFixtureForTesting( id: "job-identical-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", logText: sharedLog ) - let secondJob = makeJob( + let secondChat = makeReviewChatFixtureForTesting( id: "job-identical-2", status: .succeeded, targetSummary: "Commit: abc123", @@ -1947,7 +1969,7 @@ struct ReviewUITests { logText: sharedLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -1956,18 +1978,18 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) transport.scrollLogToOffsetForTesting(120) #expect(transport.logVerticalScrollOffsetForTesting > 0) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) @@ -1980,14 +2002,14 @@ struct ReviewUITests { let longLog = (0..<700) .map { "long visible fragment line \($0) with enough text to exercise viewport surface reuse" } .joined(separator: "\n") - let shortJob = makeJob( + let shortChat = makeReviewChatFixtureForTesting( id: "job-fragment-short", status: .running, targetSummary: "Short log", summary: "Short preview.", logText: shortLog ) - let longJob = makeJob( + let longChat = makeReviewChatFixtureForTesting( id: "job-fragment-long", status: .succeeded, targetSummary: "Long log", @@ -1995,27 +2017,27 @@ struct ReviewUITests { 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.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID) _ = try await awaitChatRenderForTesting( - shortJob, + shortChat, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: longJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: longChat.chatID) let longSnapshot = try await awaitChatRenderForTesting( - longJob, + longChat, in: transport, allowIncrementalUpdate: false ) - #expect(longSnapshot.log == reviewChatLogText(for: longJob)) + #expect(longSnapshot.log == reviewChatLogText(for: longChat)) #expect(transport.isLogPinnedToBottomForTesting) expectLogVisibleFragmentsWithoutForcingLayout(transport) } @@ -2023,14 +2045,14 @@ struct ReviewUITests { @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( + let shortChat = makeReviewChatFixtureForTesting( id: "job-short-cache", status: .running, targetSummary: "Uncommitted changes", summary: "Short preview.", logText: shortLog ) - let recentJob = makeJob( + let recentChat = makeReviewChatFixtureForTesting( id: "job-short-cache-recent", status: .succeeded, targetSummary: "Commit: abc123", @@ -2038,7 +2060,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [shortJob, recentJob])) + store.loadForTesting(serverState: .running, fixtures: [shortChat, recentChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -2047,24 +2069,29 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID) _ = try await awaitChatRenderForTesting( - shortJob, + shortChat, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) _ = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) expectLogVisibleFragmentsWithoutForcingLayout(transport) - replaceChatLogTextForTesting(shortJob, longLog) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortJob.previewChatIDForTesting) + replaceChatLogTextForTesting( + longLog, + for: shortChat.chatID, + fixtureID: shortChat.id, + turnID: shortChat.turnID + ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: shortChat.chatID) _ = try await awaitChatRenderForTesting( - shortJob, + shortChat, in: transport, allowIncrementalUpdate: false ) @@ -2074,14 +2101,14 @@ struct ReviewUITests { } @Test func previouslySelectedReviewChatUpdatesDoNotRepaintCurrentDetailPane() async throws { - let activeJob = makeJob( + let activeChat = makeReviewChatFixtureForTesting( id: "job-old-selection", status: .running, targetSummary: "Uncommitted changes", summary: "Active review.", logText: "Active log\n" ) - let recentJob = makeJob( + let recentChat = makeReviewChatFixtureForTesting( id: "job-current-selection", status: .succeeded, targetSummary: "Commit: abc123", @@ -2089,7 +2116,7 @@ struct ReviewUITests { logText: "Recent log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [activeJob, recentJob])) + store.loadForTesting(serverState: .running, fixtures: [activeChat, recentChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -2098,22 +2125,30 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) _ = try await awaitChatRenderForTesting( - activeJob, + activeChat, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) _ = try await awaitChatRenderForTesting( - recentJob, + recentChat, in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting(activeJob, .init(kind: .progress, text: "stale update")) - appendChatLogEntryForTesting(recentJob, .init(kind: .progress, text: "fresh update")) + appendChatLogEntryForTesting( + .init(kind: .progress, text: "stale update"), + to: activeChat.chatID, + turnID: activeChat.turnID + ) + appendChatLogEntryForTesting( + .init(kind: .progress, text: "fresh update"), + to: recentChat.chatID, + turnID: recentChat.turnID + ) - let updatedSnapshot = try await awaitChatRenderForTesting(recentJob, in: transport) { snapshot in + let updatedSnapshot = try await awaitChatRenderForTesting(recentChat, in: transport) { snapshot in snapshot.log.contains("fresh update") } #expect(updatedSnapshot.log.contains("stale update") == false) @@ -2121,7 +2156,7 @@ struct ReviewUITests { } @Test func clickingSidebarBlankAreaKeepsSelectionAndDetailPane() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-selected", status: .running, targetSummary: "Uncommitted changes", @@ -2131,7 +2166,7 @@ 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)) @@ -2141,21 +2176,21 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let selectedSnapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) viewController.sidebarViewControllerForTesting.clickBlankAreaForTesting() - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.previewChatIDForTesting) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == chat.chatID) #expect(transport.renderSnapshotForTesting == selectedSnapshot) } @Test func clickingWorkspaceHeaderSelectsWorkspacePlaceholder() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-selected", cwd: "/tmp/workspace-alpha", status: .running, @@ -2163,12 +2198,11 @@ struct ReviewUITests { summary: "Review is still running.", logText: "Selected log\n" ) - let workspace = CodexReviewWorkspace(cwd: job.cwd) + let workspace = CodexReviewWorkspace(cwd: chat.cwd) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - workspaces: [workspace], - reviewRuns: [job] + fixtures: [chat] ) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) @@ -2178,10 +2212,10 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -2197,7 +2231,7 @@ struct ReviewUITests { } @Test func newJobsArrivingWhileUnselectedDoNotAutoSelect() { - let activeJob = makeJob(status: .running, targetSummary: "Uncommitted changes") + let activeChat = makeReviewChatFixtureForTesting(status: .running, targetSummary: "Uncommitted changes") let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, workspaces: []) let viewController = ReviewMonitorSplitViewController( @@ -2209,7 +2243,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, - content: makeSidebarContent(from: [activeJob]) + fixtures: [activeChat] ) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) @@ -2286,7 +2320,7 @@ struct ReviewUITests { } @Test func clearingSelectionShowsEmptyStateAndClearsDetailPane() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-1", status: .running, targetSummary: "Uncommitted changes", @@ -2296,7 +2330,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 @@ -2305,17 +2339,17 @@ struct ReviewUITests { let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: job.reviewChatSelectionForTesting + chat: chat.chat ) let selectedSnapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) #expect(selectedSnapshot.title == nil) - #expect(window.title == job.targetSummary) - #expect(window.subtitle == job.cwd) + #expect(window.title == chat.chat.title) + #expect(window.subtitle == chat.cwd) viewController.sidebarViewControllerForTesting.clearSelectionForTesting() let emptySnapshot = try await awaitContentPaneRender(contentPane) @@ -2325,15 +2359,19 @@ struct ReviewUITests { #expect(emptySnapshot.log.isEmpty) #expect(window.title == "") #expect(window.subtitle == "") - job.updateStateForTesting(summary: "Deselected summary") - replaceChatLogTextForTesting(job, "Deselected log") + replaceChatLogTextForTesting( + "Deselected log", + for: chat.chatID, + fixtureID: chat.id, + turnID: chat.turnID + ) #expect(contentPane.selectedChatLogTaskForTesting == nil) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) } @Test func inPlaceReviewChatUpdateKeepsSelectionAndRefreshesDetailPane() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-1", status: .running, targetSummary: "Uncommitted changes", @@ -2343,49 +2381,48 @@ 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)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let selectedSnapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) #expect(selectedSnapshot.title == nil) #expect(selectedSnapshot.summary == nil) - job.updateStateForTesting( - status: .succeeded, - summary: "Review completed successfully." + replaceChatLogTextForTesting( + "Updated log", + for: chat.chatID, + fixtureID: chat.id, + turnID: chat.turnID ) - replaceChatLogTextForTesting(job, "Updated log") - let updatedSnapshot = try await awaitChatRenderForTesting(job, in: transport) - #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == job.previewChatIDForTesting) + let updatedSnapshot = try await awaitChatRenderForTesting(chat, in: transport) + #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == chat.chatID) #expect(updatedSnapshot.summary == nil) - #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: job))) + #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: chat))) } @Test func selectedReviewChatLogAppendUsesAppendPath() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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." + chatEntries: [ + .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") + ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = try makePreviewChatLogSourceForTesting(run: job) { turnID in - [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] - } + store.loadForTesting(serverState: .running, fixtures: [chat]) + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -2397,7 +2434,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = job.previewChatIDForTesting + let chatID = chat.chatID viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) @@ -2419,21 +2456,19 @@ struct ReviewUITests { } @Test func separatorPrefixedProgressAppendDoesNotUseGenericWordGlow() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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." + chatEntries: [ + .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") + ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) - let previewChatLogSource = try makePreviewChatLogSourceForTesting(run: job) { turnID in - [makePreviewMessageItemForTesting(id: "msg_1", text: "Initial", turnID: turnID)] - } + store.loadForTesting(serverState: .running, fixtures: [chat]) + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -2445,7 +2480,7 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - let chatID = job.previewChatIDForTesting + let chatID = chat.chatID viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting @@ -2464,28 +2499,25 @@ struct ReviewUITests { @Test func logCanonicalEquivalentPrefixReloadsWhenUTF16LengthChanges() async throws { let decomposedPrefix = "Caf\u{0065}\u{0301}" let precomposedUpdate = "Caf\u{00E9} appended" - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: decomposedPrefix) ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -2500,116 +2532,129 @@ struct ReviewUITests { } @Test func coalescedLogTextUpdateDisplaysCombinedSuffix() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " one")) - appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: " two")) + appendChatLogEntryForTesting( + .init(kind: .agentMessage, groupID: "msg_1", text: " one"), + to: chat.chatID, + turnID: chat.turnID + ) + appendChatLogEntryForTesting( + .init(kind: .agentMessage, groupID: "msg_1", text: " two"), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log == "Initial one two") } @Test func coalescedProgressSuffixDisplaysLatestProgress() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Initial") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001")) - appendChatLogEntryForTesting(job, .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002")) + appendChatLogEntryForTesting( + .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001"), + to: chat.chatID, + turnID: chat.turnID + ) + appendChatLogEntryForTesting( + .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002"), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log.hasSuffix("stream.tick 002")) } @Test func coalescedMixedReasoningAndProgressSuffixAnimatesOnlyReasoningRange() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", 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 viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) let wordGlowCount = transport.logWordGlowCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) appendChatLogEntryForTesting( - job, + .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"), + to: chat.chatID, + turnID: chat.turnID + ) + appendChatLogEntryForTesting( .init( kind: .progress, groupID: "progress_1", text: String(repeating: "progress ", count: 20) - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: 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( + let chat = makeReviewChatFixtureForTesting( id: "job-short-append-frame-stability", status: .running, targetSummary: "Uncommitted changes", @@ -2617,15 +2662,15 @@ struct ReviewUITests { 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -2637,13 +2682,15 @@ struct ReviewUITests { abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5) appendChatLogEntryForTesting( - job, .init( kind: .progress, text: "stream.tick 001 delta/layout +2 -0 while the short log remains below the scrollable viewport height" - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + to: chat.chatID, + turnID: chat.turnID + ) + _ = try await awaitChatRenderForTesting(chat, in: transport) transport.view.layoutSubtreeIfNeeded() let appendedDocumentFrame = transport.logDocumentViewFrameForTesting @@ -2654,28 +2701,25 @@ struct ReviewUITests { } @Test func selectedReviewChatGroupedReplacementUsesReplacementPath() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -2683,9 +2727,12 @@ struct ReviewUITests { let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting appendChatLogEntryForTesting( - job, .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated")) + .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated"), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log == "- updated") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) @@ -2694,15 +2741,12 @@ struct ReviewUITests { @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws { let startedAt = Date(timeIntervalSince1970: 200) - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init( kind: .rawReasoning, @@ -2712,15 +2756,15 @@ struct ReviewUITests { ] ) 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -2728,7 +2772,6 @@ struct ReviewUITests { let reloadCount = transport.logReloadCountForTesting appendChatLogEntryForTesting( - job, .init( kind: .command, groupID: "cmd_1", @@ -2741,16 +2784,21 @@ struct ReviewUITests { startedAt: startedAt, commandStatus: "inProgress" ) - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) appendChatLogEntryForTesting( - job, .init( kind: .rawReasoning, groupID: "reasoning_2", text: "Inspecting details after the command starts." - )) + ), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log.contains("Need to inspect files.")) #expect(snapshot.log.contains("Running git diff")) #expect(snapshot.log.contains("Inspecting details after the command starts.")) @@ -2759,37 +2807,38 @@ struct ReviewUITests { } @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "**bo") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .agentMessage, groupID: "msg_1", text: "ld**")) + appendChatLogEntryForTesting( + .init(kind: .agentMessage, groupID: "msg_1", text: "ld**"), + to: chat.chatID, + turnID: chat.turnID + ) - let snapshot = try await awaitChatRenderForTesting(job, in: transport) + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log == "bold") #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReplaceCountForTesting == replaceCount + 1) @@ -2797,45 +2846,48 @@ struct ReviewUITests { } @Test func staleGroupedReplacementIsNotReplayedAfterHiddenCommandOutput() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .plan, groupID: "plan_1", text: "- original") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - job, .init( kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated with longer replacement text" - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + to: chat.chatID, + turnID: chat.turnID + ) + _ = try await awaitChatRenderForTesting(chat, in: transport) let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output")) - _ = try await awaitChatRenderForTesting(job, in: transport) { snapshot in + 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("Running Command") } @@ -2849,7 +2901,7 @@ struct ReviewUITests { } @Test func metadataOnlyUpdatesDoNotTouchLogView() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-metadata", status: .running, targetSummary: "Uncommitted changes", @@ -2857,162 +2909,176 @@ struct ReviewUITests { logText: "Initial log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) + let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) let viewController = ReviewMonitorSplitViewController( - store: store, uiState: ReviewMonitorUIState(auth: store.auth)) + store: store, + uiState: ReviewMonitorUIState(auth: store.auth), + previewChatLogSource: previewChatLogSource + ) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - job.updateStateForTesting(summary: "Updated summary.") + previewChatLogSource.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 == reviewChatLogText(for: job)) + #expect(transport.displayedLogForTesting == reviewChatLogText(for: chat)) #expect(transport.logAppendCountForTesting == appendCount) #expect(transport.logReloadCountForTesting == reloadCount) } @Test func reasoningAppendUsesWordGlowAndReduceMotionDisablesGlow() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", 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 viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) appendChatLogEntryForTesting( - job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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) - appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) appendChatLogEntryForTesting( - job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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 = ReviewRunRecord.makeForTesting( + let firstChat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .rawReasoning, groupID: "reasoning_1", text: "Thinking") ] ) - let secondJob = ReviewRunRecord.makeForTesting( + let secondChat = makeReviewChatFixtureForTesting( id: "job-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.", chatEntries: [ .init(kind: .agentMessage, groupID: "msg_1", text: "Other job") ] ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting transport.setLogReduceMotionForTesting(false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) appendChatLogEntryForTesting( - firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog")) + .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog"), + to: firstChat.chatID, + turnID: firstChat.turnID + ) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) #expect(transport.logWordGlowCountForTesting == 0) - appendChatLogEntryForTesting(firstJob, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live")) - _ = try await awaitChatRenderForTesting(firstJob, in: transport) + 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 = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3020,8 +3086,11 @@ struct ReviewUITests { let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting appendChatLogEntryForTesting( - job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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) @@ -3036,36 +3105,37 @@ struct ReviewUITests { } @Test func delayedFirstWordGlowTickDoesNotImmediatelyClearAnimation() async throws { - let job = ReviewRunRecord.makeForTesting( + let chat = makeReviewChatFixtureForTesting( id: "job-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.", 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) - appendChatLogEntryForTesting(job, .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -3074,7 +3144,7 @@ struct ReviewUITests { @Test func logAutoFollowRunsOnlyWhenPinnedToBottom() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-autofollow", status: .running, targetSummary: "Uncommitted changes", @@ -3082,7 +3152,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3091,9 +3161,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3105,23 +3175,31 @@ struct ReviewUITests { transport.scrollLogToTopForTesting() #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Unpinned update")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Pinned update")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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( + let chat = makeReviewChatFixtureForTesting( id: "job-autofollow-wrapped-append", status: .running, targetSummary: "Uncommitted changes", @@ -3129,15 +3207,15 @@ struct ReviewUITests { 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3148,8 +3226,12 @@ struct ReviewUITests { let wrappedLine = (0..<140) .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") - appendChatLogEntryForTesting(job, .init(kind: .progress, text: wrappedLine)) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -3161,7 +3243,7 @@ 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( + let chat = makeReviewChatFixtureForTesting( id: "job-append-near-bottom-scroll-stability", status: .running, targetSummary: "Uncommitted changes", @@ -3169,7 +3251,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3178,9 +3260,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3192,12 +3274,14 @@ struct ReviewUITests { let autoFollowBeforeAppend = transport.logAutoFollowCountForTesting let programmaticScrollsBeforeAppend = transport.logProgrammaticScrollCountForTesting appendChatLogEntryForTesting( - job, .init( kind: .progress, text: "Near-bottom append should not snap inertial or manual scrolling to the document end" - )) - _ = try await awaitChatRenderForTesting(job, in: transport) + ), + to: chat.chatID, + turnID: chat.turnID + ) + _ = try await awaitChatRenderForTesting(chat, in: transport) #expect(transport.logAutoFollowCountForTesting == autoFollowBeforeAppend) #expect(transport.logProgrammaticScrollCountForTesting == programmaticScrollsBeforeAppend) @@ -3207,7 +3291,7 @@ struct ReviewUITests { @Test func programmaticLogAutoFollowRequestsOverlayScrollerHideWhenShown() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-overlay-hide", status: .running, targetSummary: "Uncommitted changes", @@ -3215,7 +3299,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3224,9 +3308,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3235,8 +3319,12 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -3244,7 +3332,7 @@ struct ReviewUITests { @Test func legacyScrollerStyleDoesNotRequestOverlayScrollerHide() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-legacy-hide", status: .running, targetSummary: "Uncommitted changes", @@ -3252,7 +3340,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3261,9 +3349,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3272,14 +3360,18 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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( + let chat = makeReviewChatFixtureForTesting( id: "job-overlay-short", status: .running, targetSummary: "Uncommitted changes", @@ -3287,7 +3379,7 @@ struct ReviewUITests { logText: "short log" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3296,9 +3388,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3306,22 +3398,26 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "short update")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 selectingReviewChatRequestsOverlayScrollerHideWhenRestoringScrollPosition() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let firstJob = makeJob( + let firstChat = makeReviewChatFixtureForTesting( id: "job-restore-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", logText: longLog ) - let secondJob = makeJob( + let secondChat = makeReviewChatFixtureForTesting( id: "job-restore-2", status: .running, targetSummary: "Uncommitted changes", @@ -3329,7 +3425,7 @@ struct ReviewUITests { logText: longLog + "\nsecond chat" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3338,9 +3434,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) @@ -3348,18 +3444,18 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToOffsetForTesting(120) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) let hideCountBeforeRestore = transport.logOverlayScrollerHideRequestCountForTesting transport.setLogOverlayScrollersShownForTesting(true) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) @@ -3372,7 +3468,7 @@ struct ReviewUITests { @Test func privateOverlayBridgeNoOpsWhenScrollerImpPairIsUnavailable() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-missing-pair", status: .running, targetSummary: "Uncommitted changes", @@ -3380,7 +3476,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3389,9 +3485,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3400,15 +3496,19 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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( + let chat = makeReviewChatFixtureForTesting( id: "job-missing-hide", status: .running, targetSummary: "Uncommitted changes", @@ -3416,7 +3516,7 @@ struct ReviewUITests { logText: longLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3425,9 +3525,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3436,14 +3536,18 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest line")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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( + let chat = makeReviewChatFixtureForTesting( id: "job-log-config", status: .running, targetSummary: "Uncommitted changes", @@ -3451,14 +3555,14 @@ struct ReviewUITests { logText: "Initial log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3474,7 +3578,7 @@ struct ReviewUITests { @Test func logViewMaintainsVisibleTextKit2FragmentCoverageWhenScrolled() async throws { let longLog = (0..<1_000).map { "fragment coverage line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-fragments", status: .running, targetSummary: "Uncommitted changes", @@ -3482,15 +3586,15 @@ struct ReviewUITests { 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3517,7 +3621,7 @@ struct ReviewUITests { @Test func logAppendDoesNotLeaveStaleTextKit2FragmentViews() async throws { let longLog = (0..<400).map { "append fragment line \($0)" }.joined(separator: "\n") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-fragment-append", status: .running, targetSummary: "Uncommitted changes", @@ -3525,21 +3629,25 @@ struct ReviewUITests { 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.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) let appendCount = transport.logAppendCountForTesting - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "Newest fragment line")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -3547,7 +3655,7 @@ struct ReviewUITests { } @Test func logViewSupportsReadOnlySelectAllCopyFindValidationAndAccessibility() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-readonly", status: .running, targetSummary: "Uncommitted changes", @@ -3555,14 +3663,14 @@ struct ReviewUITests { logText: "First readonly line\nSecond readonly line\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3570,7 +3678,7 @@ struct ReviewUITests { #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.showFindInterface))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.nextMatch))) #expect(viewController.validateUserInterfaceItem(textFinderMenuItemForTesting(.replace)) == false) - #expect(transport.logAccessibilityValueForTesting == reviewChatLogText(for: job)) + #expect(transport.logAccessibilityValueForTesting == reviewChatLogText(for: chat)) #expect(transport.logDocumentViewExportsUserInterfaceValidationForTesting) let copyItem = commandMenuItemForTesting("copy:") @@ -3585,7 +3693,7 @@ struct ReviewUITests { #expect(transport.validateLogDocumentUserInterfaceItemForTesting(deleteItem) == false) transport.selectAllLogForTesting() - #expect(transport.logSelectedTextForTesting == reviewChatLogText(for: job)) + #expect(transport.logSelectedTextForTesting == reviewChatLogText(for: chat)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(copyItem)) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(cutItem) == false) #expect(transport.validateLogDocumentUserInterfaceItemForTesting(pasteItem) == false) @@ -3593,7 +3701,7 @@ struct ReviewUITests { NSPasteboard.general.clearContents() transport.copyLogSelectionForTesting() - #expect(NSPasteboard.general.string(forType: .string) == reviewChatLogText(for: job)) + #expect(NSPasteboard.general.string(forType: .string) == reviewChatLogText(for: chat)) transport.clearLogFinderSelectedRangesForTesting() #expect(transport.logSelectedTextForTesting == nil) @@ -3629,7 +3737,7 @@ struct ReviewUITests { let wrappedLine = (1...80) .map { "wrapped-segment-\($0)" } .joined(separator: " ") - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-soft-wrap-keyboard", status: .running, targetSummary: "Uncommitted changes", @@ -3637,7 +3745,7 @@ struct ReviewUITests { 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 @@ -3645,9 +3753,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -3677,7 +3785,7 @@ struct ReviewUITests { (1...140) .map { "needle \($0) with enough trailing text to wrap in the visible log surface" } .joined(separator: "\n") + "\n" - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-system-highlights", status: .running, targetSummary: "Uncommitted changes", @@ -3685,7 +3793,7 @@ struct ReviewUITests { logText: initialLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3694,14 +3802,14 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let renderedInitialLog = reviewChatLogText(for: job) + let renderedInitialLog = reviewChatLogText(for: chat) let renderedInitialLength = (renderedInitialLog as NSString).length let visibleRanges = transport.logFindVisibleCharacterRangesForTesting #expect(transport.logFindIncrementalSearchUsesSystemHighlightingForTesting) @@ -3722,10 +3830,14 @@ struct ReviewUITests { } #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitChatRenderForTesting(job, in: transport) + appendChatLogEntryForTesting( + .init(kind: .progress, text: "needle appended"), + to: chat.chatID, + turnID: chat.turnID + ) + _ = try await awaitChatRenderForTesting(chat, in: transport) - let appendedLength = (reviewChatLogText(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") @@ -3749,8 +3861,11 @@ struct ReviewUITests { let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting appendChatLogEntryForTesting( - job, .init(kind: .progress, text: "needle appended while the log is not following bottom")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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") @@ -3758,7 +3873,7 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - var burstText = reviewChatLogText(for: job) + var burstText = reviewChatLogText(for: chat) for index in 0..<8 { burstText += "\nneedle burst \(index)" #expect(transport.renderLogForTesting(text: burstText, allowIncrementalUpdate: true)) @@ -3820,14 +3935,14 @@ struct ReviewUITests { } @Test func logFindClearsVisibleSnapshotWhenLogContentIsReused() async throws { - let firstJob = makeJob( + let firstChat = makeReviewChatFixtureForTesting( id: "job-log-find-reuse-first", status: .running, targetSummary: "First job", summary: "Running review.", logText: "needle first job\n" ) - let secondJob = makeJob( + let secondChat = makeReviewChatFixtureForTesting( id: "job-log-find-reuse-second", status: .running, targetSummary: "Second job", @@ -3835,7 +3950,7 @@ struct ReviewUITests { logText: "needle second job\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3844,29 +3959,33 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) - appendChatLogEntryForTesting(firstJob, .init(kind: .progress, text: "needle appended")) - _ = try await awaitChatRenderForTesting(firstJob, in: transport) + 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) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting == false) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) @@ -3880,14 +3999,14 @@ struct ReviewUITests { let initialLog = "needle initial" let appendedLine = "needle appended" let reusedLog = initialLog + "\n\n" + appendedLine - let firstJob = makeJob( + let firstChat = makeReviewChatFixtureForTesting( id: "job-log-find-same-text-reuse-first", status: .running, targetSummary: "First job", summary: "Running review.", logText: initialLog ) - let secondJob = makeJob( + let secondChat = makeReviewChatFixtureForTesting( id: "job-log-find-same-text-reuse-second", status: .running, targetSummary: "Second job", @@ -3895,7 +4014,7 @@ struct ReviewUITests { logText: reusedLog ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3904,30 +4023,34 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) - appendChatLogEntryForTesting(firstJob, .init(kind: .progress, text: appendedLine)) - _ = try await awaitChatRenderForTesting(firstJob, in: transport) + 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: secondJob).trimmingCharacters(in: .newlines) + == reviewChatLogText(for: secondChat).trimmingCharacters(in: .newlines) ) #expect(transport.logFindBarVisibleForTesting) #expect(transport.logFindClientUsesSnapshotForTesting) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) @@ -3939,14 +4062,14 @@ struct ReviewUITests { @Test func logFindContentReuseClearsSnapshotForPrefixRelatedLogs() async throws { let firstLog = "needle shared prefix\n" - let firstJob = makeJob( + let firstChat = makeReviewChatFixtureForTesting( id: "job-log-find-prefix-reuse-first", status: .running, targetSummary: "First job", summary: "Running review.", logText: firstLog ) - let secondJob = makeJob( + let secondChat = makeReviewChatFixtureForTesting( id: "job-log-find-prefix-reuse-second", status: .running, targetSummary: "Second job", @@ -3954,7 +4077,7 @@ struct ReviewUITests { logText: firstLog + "needle second suffix\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [firstJob, secondJob])) + store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -3963,14 +4086,14 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: firstChat.chatID) _ = try await awaitChatRenderForTesting( - firstJob, + firstChat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) @@ -3980,9 +4103,9 @@ struct ReviewUITests { #expect(transport.logFindClientUsesSnapshotForTesting == false) let finderIdentifierBeforeSwitch = transport.logTextFinderIdentifierForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondJob.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: secondChat.chatID) _ = try await awaitChatRenderForTesting( - secondJob, + secondChat, in: transport, allowIncrementalUpdate: false ) @@ -3990,11 +4113,11 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.logTextFinderIdentifierForTesting == finderIdentifierBeforeSwitch) #expect(transport.logFindClientUsesSnapshotForTesting == false) - #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: secondJob) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: secondChat) as NSString).length) } @Test func logFindHidingVisibleSnapshotReturnsClientToLiveString() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-hide-snapshot", status: .running, targetSummary: "Hide snapshot", @@ -4002,7 +4125,7 @@ struct ReviewUITests { logText: "needle initial\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4011,31 +4134,35 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } @Test func logFindClearedSelectionReturnsVisibleUpdatesToLiveString() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-cleared-selection", status: .running, targetSummary: "Cleared selection", @@ -4043,7 +4170,7 @@ struct ReviewUITests { logText: "needle initial\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4052,20 +4179,24 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -4074,16 +4205,20 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logSelectedTextForTesting == nil) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared selection")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } @Test func logFindClearedQueryReturnsVisibleUpdatesToLiveString() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-cleared-query", status: .running, targetSummary: "Cleared query", @@ -4091,7 +4226,7 @@ struct ReviewUITests { logText: "needle initial\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4100,20 +4235,24 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let firstNeedleRange = (reviewChatLogText(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)) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended into snapshot")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -4123,17 +4262,21 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logHasActiveFindQueryForTesting == false) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle appended after cleared query")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } } @Test func logFindDoesNotFreezeVisibleBarBeforeSearchQuery() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-visible-no-query", status: .running, targetSummary: "Visible find bar", @@ -4141,7 +4284,7 @@ struct ReviewUITests { logText: "initial log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4150,9 +4293,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -4163,17 +4306,21 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "future-only needle")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } } @Test func logFindPreservesDirectFindBarQueryDuringLogUpdates() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-direct-query", status: .running, targetSummary: "Visible find bar direct query", @@ -4181,7 +4328,7 @@ struct ReviewUITests { logText: "core initial log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4190,22 +4337,26 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let initialLength = (reviewChatLogText(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) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "core appended while query is visible")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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") @@ -4216,7 +4367,7 @@ struct ReviewUITests { } @Test func logFindQueryChangeRefreshesVisibleSnapshotAfterLogUpdates() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-query-change", status: .running, targetSummary: "Visible find bar query change", @@ -4224,7 +4375,7 @@ struct ReviewUITests { logText: "alpha initial log\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4233,22 +4384,26 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) - let initialLength = (reviewChatLogText(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) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "beta appended after active search")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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) @@ -4256,12 +4411,12 @@ struct ReviewUITests { #expect(transport.logVisibleFindBarSearchStringForTesting == "beta") #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logFindClientSnapshotMapsToDocumentForTesting) - #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } } @Test func logFindVisibleBarNormalSelectionKeepsUpdatesLive() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-visible-normal-selection", status: .running, targetSummary: "Visible find bar normal selection", @@ -4269,7 +4424,7 @@ struct ReviewUITests { logText: "copyable text before updates\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4278,9 +4433,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -4290,23 +4445,26 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") - let normalSelectionRange = (reviewChatLogText(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) appendChatLogEntryForTesting( - job, .init(kind: .progress, text: "needle appended after normal selection")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } } @Test func logFindPreservesNoResultSearchStateDuringLogUpdatesUntilHidden() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-visible-no-result", status: .running, targetSummary: "Visible find bar no result", @@ -4314,7 +4472,7 @@ struct ReviewUITests { logText: "initial log without the active query\n" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4323,9 +4481,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -4338,10 +4496,13 @@ struct ReviewUITests { transport.simulateLogFinderEmptySelectedRangesForTesting() #expect(transport.logHasActiveFindQueryForTesting) - let initialLength = (reviewChatLogText(for: job) as NSString).length + let initialLength = (reviewChatLogText(for: chat) as NSString).length appendChatLogEntryForTesting( - job, .init(kind: .progress, text: "active query appears after no-result search")) - _ = try await awaitChatRenderForTesting(job, in: transport) + .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) @@ -4373,7 +4534,7 @@ struct ReviewUITests { } @Test func logFindKeepsFirstAppendIntoEmptyVisibleLogLive() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-log-find-empty-append", status: .running, targetSummary: "Empty log", @@ -4381,7 +4542,7 @@ struct ReviewUITests { logText: "" ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, content: makeSidebarContent(from: [job])) + store.loadForTesting(serverState: .running, fixtures: [chat]) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) @@ -4390,9 +4551,9 @@ struct ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) @@ -4401,16 +4562,20 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) - appendChatLogEntryForTesting(job, .init(kind: .progress, text: "needle first content")) - _ = try await awaitChatRenderForTesting(job, in: transport) + 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 == (reviewChatLogText(for: job) as NSString).length) + #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } @Test func authFailedJobShowsNormalFailureDetails() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-auth", status: .failed, targetSummary: "Uncommitted changes", @@ -4421,25 +4586,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)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let snapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) #expect(snapshot.summary == nil) - #expect(snapshot.log == reviewChatLogText(for: job)) + #expect(snapshot.log == reviewChatLogText(for: chat)) } @Test func authenticatedAuthFailedJobStillShowsNormalFailureDetails() async throws { - let job = makeJob( + let chat = makeReviewChatFixtureForTesting( id: "job-auth-restored", status: .failed, targetSummary: "Uncommitted changes", @@ -4450,21 +4615,21 @@ 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)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: job.previewChatIDForTesting) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let snapshot = try await awaitChatRenderForTesting( - job, + chat, in: transport, allowIncrementalUpdate: false ) #expect(snapshot.summary == nil) - #expect(snapshot.log == reviewChatLogText(for: job)) + #expect(snapshot.log == reviewChatLogText(for: chat)) } } @@ -4929,37 +5094,6 @@ final class UncheckedSendableBox: @unchecked Sendable { } } -@MainActor -func makeJob( - id: String = UUID().uuidString, - cwd: String = "/tmp/repo", - startedAt: Date = Date(timeIntervalSince1970: 200), - status: ReviewRunState, - targetSummary: String, - summary: String? = nil, - reviewResult: ParsedReviewResult? = nil, - logText: String = "", - rawLogText: String = "" -) -> ReviewRunRecord { - let job = ReviewRunRecord.makeForTesting( - id: id, - cwd: cwd, - targetSummary: targetSummary, - threadID: status == .queued ? nil : UUID().uuidString, - turnID: UUID().uuidString, - status: status, - startedAt: startedAt, - endedAt: status.isTerminal ? startedAt.addingTimeInterval(1) : nil, - summary: summary ?? status.displayText, - reviewResult: reviewResult, - lastAgentMessage: "", - chatEntries: [], - errorMessage: status == .failed ? summary ?? status.displayText : nil - ) - seedChatLogForTesting(job, logText: logText, rawLogText: rawLogText) - return job -} - @MainActor func makeReviewChatFixtureForTesting( id: String = UUID().uuidString, @@ -5003,24 +5137,6 @@ func reviewChatCellTestChat( ) } -@MainActor -func makeWorkspaces(from reviewRuns: [ReviewRunRecord]) -> [CodexReviewWorkspace] { - var seenCWDs: Set = [] - var order: [String] = [] - for job in reviewRuns { - if seenCWDs.contains(job.cwd) == false { - order.insert(job.cwd, at: 0) - seenCWDs.insert(job.cwd) - } - } - return order.map { CodexReviewWorkspace(cwd: $0) } -} - -@MainActor -func makeSidebarContent(from reviewRuns: [ReviewRunRecord]) -> (workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord]) { - return (makeWorkspaces(from: reviewRuns), Array(reviewRuns.reversed())) -} - struct LinkedWorktreeFixtureForTesting { var rootURL: URL var firstWorktreeURL: URL @@ -5218,7 +5334,6 @@ extension CodexReviewStore { reviewRuns: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { - installPreviewChatLogSourceForTesting(on: self, reviewRuns: reviewRuns) loadForTesting( serverState: serverState, authPhase: authState.phase, @@ -5242,23 +5357,6 @@ extension CodexReviewStore { settingsSnapshot: settingsSnapshot ) } - - func loadForTesting( - serverState: CodexReviewServerState, - authState: TestAuthState = .signedOut, - serverURL: URL? = nil, - content: (workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord]), - settingsSnapshot: CodexReviewSettings.Snapshot? = nil - ) { - loadForTesting( - serverState: serverState, - authState: authState, - serverURL: serverURL, - workspaces: content.workspaces, - reviewRuns: content.reviewRuns, - settingsSnapshot: settingsSnapshot - ) - } } @MainActor From 8ee1c02cc95773f8b811eecca34f8871e152601e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:34:23 +0900 Subject: [PATCH 239/359] refactor(review): remove unused lifecycle status --- .../Domain/ReviewLifecycleStatus.swift | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift diff --git a/Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift b/Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift deleted file mode 100644 index a867d232..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewLifecycleStatus.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -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 - } - } -} From 2d0c515b0cd352aad9586a08f99a433a1a7af751 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:41:34 +0900 Subject: [PATCH 240/359] test(review-ui): rename chat fixtures away from run state --- .../ReviewMonitorPreviewChatLogSource.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 56 +++++++++---------- .../ReviewMonitorChatLogTesting.swift | 52 +++++++++++++---- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 4 files changed, 72 insertions(+), 40 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index 5acccd58..edc5df9e 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -166,7 +166,7 @@ private final class PreviewReviewChat { ) { let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( itemName: step.itemName, - runID: streamID, + streamID: streamID, cycle: cycle ) switch step.mode { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index e39f0139..a194a419 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -23,7 +23,7 @@ public enum ReviewMonitorPreviewContent { let endedOffset: TimeInterval? } - private struct PreviewRunFixture { + private struct PreviewChatFixture { let id: String let chatID: CodexThreadID let turnID: CodexTurnID @@ -188,7 +188,7 @@ public enum ReviewMonitorPreviewContent { let chatItems = makeCommandOutputPreviewChatLogItems() let chatID = CodexThreadID(rawValue: "preview-command-output-panel") let turnID = CodexTurnID(rawValue: "preview-command-output-turn") - let review = PreviewRunFixture( + let chatFixture = PreviewChatFixture( id: "preview-command-output-panel", chatID: chatID, turnID: turnID, @@ -210,7 +210,7 @@ public enum ReviewMonitorPreviewContent { workspaces: [] ) let fixture = makeChatLogFixture( - for: review + for: chatFixture ) store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) @@ -242,10 +242,10 @@ public enum ReviewMonitorPreviewContent { package static func previewChatLogItemID( itemName: String, - runID: String, + streamID: String, cycle: Int ) -> String { - "preview-\(itemName)-\(runID)-\(cycle)" + "preview-\(itemName)-\(streamID)-\(cycle)" } private static func previewTurnID(_ tick: Int) -> String { @@ -612,7 +612,7 @@ public enum ReviewMonitorPreviewContent { let chatItems = makePreviewChatLogItems(for: definition, workspaceName: workspaceName) let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(chatIndex)") let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(chatIndex)") - let review = PreviewRunFixture( + let chatFixture = PreviewChatFixture( id: "preview-\(workspaceIndex)-\(chatIndex)", chatID: chatID, turnID: turnID, @@ -628,7 +628,7 @@ public enum ReviewMonitorPreviewContent { ) chatLogFixtures.append( makeChatLogFixture( - for: review + for: chatFixture )) } } @@ -638,43 +638,43 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( - for review: PreviewRunFixture + for chatFixture: PreviewChatFixture ) -> ReviewMonitorPreviewChatLogFixture { let chat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(review.chatID), - id: review.chatID, - title: review.targetSummary, - preview: review.lastAgentMessage.nilIfEmpty ?? review.summary.nilIfEmpty, - model: review.model, - workspaceCWD: review.cwd, - updatedAt: review.endedAt ?? review.startedAt, - recencyAt: review.endedAt ?? review.startedAt, - status: CodexThreadStatus(previewLifecycle: review.lifecycle) + rowID: .chat(chatFixture.chatID), + id: chatFixture.chatID, + title: chatFixture.targetSummary, + preview: chatFixture.lastAgentMessage.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) ) let turn = CodexChatTurnStateSnapshot( - id: review.turnID, - status: CodexTurnStatus(review.lifecycle), - errorDescription: review.lifecycle == .failed ? review.summary : nil, + id: chatFixture.turnID, + status: CodexTurnStatus(chatFixture.lifecycle), + errorDescription: chatFixture.lifecycle == .failed ? chatFixture.summary : nil, usage: nil ) let initialSnapshot = CodexChatSnapshot( chatID: chat.id, phase: CodexDataPhase( - review.lifecycle, - errorMessage: review.lifecycle == .failed ? review.summary : nil + chatFixture.lifecycle, + errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil ), turns: [turn], items: makeInitialChatItems( - streamID: review.id, - chatItems: review.chatItems, + streamID: chatFixture.id, + chatItems: chatFixture.chatItems, turnID: turn.id ) ) return ReviewMonitorPreviewChatLogFixture( chat: chat, - cwd: review.cwd, - streamID: review.id, - isRunning: review.lifecycle == .running, + cwd: chatFixture.cwd, + streamID: chatFixture.id, + isRunning: chatFixture.lifecycle == .running, initialSnapshot: initialSnapshot ) } @@ -689,7 +689,7 @@ public enum ReviewMonitorPreviewContent { let snapshot = item.itemSnapshot( id: previewChatLogItemID( itemName: item.itemName, - runID: streamID, + streamID: streamID, cycle: 0 ), turnID: turnID diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index aef1100a..e263f6fd 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -101,6 +101,38 @@ struct ReviewChatFixtureForTesting: Sendable { } } +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, @@ -110,7 +142,7 @@ func makeReviewChatFixtureForTesting( model: String? = "gpt-5", chatID: CodexThreadID? = nil, turnID: CodexTurnID? = nil, - status: ReviewRunState = .succeeded, + status: ReviewChatFixtureStatus = .succeeded, startedAt: Date? = Date(timeIntervalSince1970: 200), updatedAt: Date? = nil, chatEntries: [ReviewChatLogEntryForTesting] = [], @@ -128,14 +160,14 @@ func makeReviewChatFixtureForTesting( workspaceCWD: cwd, updatedAt: resolvedUpdatedAt, recencyAt: resolvedUpdatedAt, - status: CodexThreadStatus(reviewRunStateForTesting: status) + status: CodexThreadStatus(chatFixtureStatusForTesting: status) ) ReviewChatLogFixtureStore.setEntries(chatEntries, for: resolvedChatID) let initialSnapshot = makeCodexChatSnapshotForTesting( chatID: resolvedChatID, turnID: resolvedTurnID, - phase: CodexDataPhase(reviewRunStateForTesting: status, errorMessage: errorMessage), - turnStatus: CodexTurnStatus(reviewRunStateForTesting: status), + phase: CodexDataPhase(chatFixtureStatusForTesting: status, errorMessage: errorMessage), + turnStatus: CodexTurnStatus(chatFixtureStatusForTesting: status), turnErrorDescription: errorMessage, items: ReviewChatLogFixtureStore.items(for: resolvedChatID, turnID: resolvedTurnID) ) @@ -689,8 +721,8 @@ private struct ReviewChatLogAccumulatedItem { } private extension CodexThreadStatus { - init(reviewRunStateForTesting jobState: ReviewRunState) { - switch jobState { + init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus) { + switch status { case .queued, .running: self = .active(activeFlags: []) case .succeeded, .failed, .cancelled: @@ -700,8 +732,8 @@ private extension CodexThreadStatus { } private extension CodexTurnStatus { - init(reviewRunStateForTesting jobState: ReviewRunState) { - switch jobState { + init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus) { + switch status { case .queued, .running: self = .running case .succeeded: @@ -715,8 +747,8 @@ private extension CodexTurnStatus { } private extension CodexDataPhase { - init(reviewRunStateForTesting jobState: ReviewRunState, errorMessage: String?) { - switch jobState { + init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus, errorMessage: String?) { + switch status { case .queued, .running, .succeeded, .cancelled: self = .loaded case .failed: diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 38ebca38..2a12e40f 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5099,7 +5099,7 @@ func makeReviewChatFixtureForTesting( id: String = UUID().uuidString, cwd: String = "/tmp/repo", startedAt: Date = Date(timeIntervalSince1970: 200), - status: ReviewRunState, + status: ReviewChatFixtureStatus, targetSummary: String, summary: String? = nil, logText: String = "" From 2912d057589668b2e66136b489fa29e203688fcb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:45:00 +0900 Subject: [PATCH 241/359] test(review-ui): isolate lifecycle fixture loading --- ...eviewMonitorCodexSidebarLibraryTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 36 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index a0cd0b18..a460af27 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -413,7 +413,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { updatedAt: Date(timeIntervalSince1970: 4_000) ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( + store.loadReviewLifecycleForTesting( serverState: .running, workspaces: [CodexReviewWorkspace(cwd: repo.path)], reviewRuns: [legacyJob] diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 2a12e40f..65a300c0 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -851,7 +851,7 @@ struct ReviewUITests { summary: "Review complete." ) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( + store.loadReviewLifecycleForTesting( serverState: .running, workspaces: [CodexReviewWorkspace(cwd: activeChat.cwd)], reviewRuns: [activeRun, recentRun] @@ -5320,7 +5320,6 @@ extension CodexReviewStore { authState: authState, serverURL: serverURL, workspaces: [], - reviewRuns: [], settingsSnapshot: settingsSnapshot ) installPreviewChatLogSourceForTesting(on: self, fixtures: fixtures) @@ -5331,7 +5330,38 @@ extension CodexReviewStore { authState: TestAuthState = .signedOut, serverURL: URL? = nil, workspaces: [CodexReviewWorkspace], - reviewRuns: [ReviewRunRecord] = [], + settingsSnapshot: CodexReviewSettings.Snapshot? = nil + ) { + loadForTesting( + serverState: serverState, + 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: workspaces, + reviewRuns: [], + settingsSnapshot: settingsSnapshot + ) + } + + func loadReviewLifecycleForTesting( + serverState: CodexReviewServerState, + authState: TestAuthState = .signedOut, + serverURL: URL? = nil, + workspaces: [CodexReviewWorkspace], + reviewRuns: [ReviewRunRecord], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { loadForTesting( From 4e9e232ef448dd8a9f32157bc17f4e950a7987c2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:47:25 +0900 Subject: [PATCH 242/359] test(review-ui): rename display fixtures as chats --- ...eviewMonitorCodexSidebarLibraryTests.swift | 10 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 18 +- Tests/ReviewUITests/ReviewUITests.swift | 172 +++++++++--------- 3 files changed, 100 insertions(+), 100 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index a460af27..0fbbb7fd 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -338,7 +338,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-job-store")] + workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-chat-store")] ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( @@ -394,8 +394,8 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let legacyJob = ReviewRunRecord.makeForTesting( - id: "legacy-job", + let legacyRun = ReviewRunRecord.makeForTesting( + id: "legacy-run", cwd: repo.path, targetSummary: "Legacy review row", threadID: "legacy-review-thread", @@ -416,7 +416,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { store.loadReviewLifecycleForTesting( serverState: .running, workspaces: [CodexReviewWorkspace(cwd: repo.path)], - reviewRuns: [legacyJob] + reviewRuns: [legacyRun] ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( @@ -460,7 +460,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-job-store")] + workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-chat-store")] ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 6605e3ec..eb526d61 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -106,7 +106,7 @@ extension ReviewUITests { #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) } - @Test func splitViewShowsEmptyStateWithoutJobs() { + @Test func splitViewShowsEmptyStateWithoutChats() { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, @@ -1203,7 +1203,7 @@ extension ReviewUITests { @Test func detailLogViewExtendsBehindTitlebarWithoutOverlappingSidebar() async throws { let logText = "Safe area log\n" let chat = makeShellReviewChatForTesting( - id: "job-safe-area", + id: "chat-safe-area", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1241,7 +1241,7 @@ extension ReviewUITests { @Test func shortDetailLogKeepsTextContentWithinDocumentBounds() async throws { let logText = "Short log\n" let chat = makeShellReviewChatForTesting( - id: "job-short-log-layout", + id: "chat-short-log-layout", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1269,7 +1269,7 @@ extension ReviewUITests { let logText = Array(repeating: "Long line that should reflow across the widened detail pane.\n", count: 40) .joined() let chat = makeShellReviewChatForTesting( - id: "job-sidebar-width-regression", + id: "chat-sidebar-width-regression", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1314,7 +1314,7 @@ extension ReviewUITests { let logText = Array(repeating: "Long line that should reflow when the detail pane narrows.\n", count: 40) .joined() let chat = makeShellReviewChatForTesting( - id: "job-sidebar-width-shrink-regression", + id: "chat-sidebar-width-shrink-regression", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1352,7 +1352,7 @@ extension ReviewUITests { @Test func detailLogTracksSimpleWindowResizeInBothDirections() async throws { let logText = Array(repeating: "Long line that should reflow as the window resizes.\n", count: 40).joined() let chat = makeShellReviewChatForTesting( - id: "job-window-resize-width-regression", + id: "chat-window-resize-width-regression", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1398,7 +1398,7 @@ extension ReviewUITests { @Test func detailLogRewrapsVisibleTextDuringLiveWindowResize() async throws { let logText = String(repeating: "wrap-sensitive text ", count: 600) let chat = makeShellReviewChatForTesting( - id: "job-window-live-resize-log", + id: "chat-window-live-resize-log", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1452,7 +1452,7 @@ extension ReviewUITests { } .joined(separator: "\n\n") let chat = makeShellReviewChatForTesting( - id: "job-window-live-resize-stream-log", + id: "chat-window-live-resize-stream-log", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() @@ -1496,7 +1496,7 @@ extension ReviewUITests { repeating: "Long line that should reflow after the toolbar sidebar toggle path.\n", count: 40 ).joined() let chat = makeShellReviewChatForTesting( - id: "job-toolbar-sidebar-toggle-textkit-width-regression", + id: "chat-toolbar-sidebar-toggle-textkit-width-regression", title: "Uncommitted changes" ) let store = CodexReviewStore.makePreviewStore() diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 65a300c0..2a8f6038 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1344,7 +1344,7 @@ struct ReviewUITests { commandStatus: "completed" ) let chat = makeReviewChatFixtureForTesting( - id: "job-command-output-panel", + id: "chat-command-output-panel", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -1505,7 +1505,7 @@ struct ReviewUITests { .map { "second output line \($0)" } .joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-command-output-panel-isolation", + id: "chat-command-output-panel-isolation", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -1570,7 +1570,7 @@ struct ReviewUITests { @Test func startedCommandRendersAsCollapsedPanelBeforeOutputArrives() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-command-start-panel", + id: "chat-command-start-panel", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -1632,7 +1632,7 @@ struct ReviewUITests { .map { "output line \($0)" } .joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-command-output-find-refresh", + id: "chat-command-output-find-refresh", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -1698,14 +1698,14 @@ struct ReviewUITests { @Test func switchingSelectedReviewChatRebindsDetailPane() async throws { let activeChat = makeReviewChatFixtureForTesting( - id: "job-active", + id: "chat-active", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: "Active log\n" ) let recentChat = makeReviewChatFixtureForTesting( - id: "job-recent", + id: "chat-recent", status: .succeeded, targetSummary: "Commit: abc123", summary: "Recent review completed.", @@ -1759,7 +1759,7 @@ struct ReviewUITests { @Test func firstSelectionFromEmptyStatePinsUnvisitedReviewChatToBottom() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-first-bottom", + id: "chat-first-bottom", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -1789,14 +1789,14 @@ struct ReviewUITests { let longActiveLog = (0..<400).map { "active line \($0)" }.joined(separator: "\n") let longRecentLog = (0..<400).map { "recent line \($0)" }.joined(separator: "\n") let activeChat = makeReviewChatFixtureForTesting( - id: "job-active-scroll", + id: "chat-active-scroll", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: longActiveLog ) let recentChat = makeReviewChatFixtureForTesting( - id: "job-recent-scroll", + id: "chat-recent-scroll", status: .succeeded, targetSummary: "Commit: abc123", summary: "Recent review completed.", @@ -1849,14 +1849,14 @@ struct ReviewUITests { let longActiveLog = (0..<400).map { "active line \($0)" }.joined(separator: "\n") let longRecentLog = (0..<400).map { "recent line \($0)" }.joined(separator: "\n") let activeChat = makeReviewChatFixtureForTesting( - id: "job-active-bottom", + id: "chat-active-bottom", status: .running, targetSummary: "Uncommitted changes", summary: "Active review in progress.", logText: longActiveLog ) let recentChat = makeReviewChatFixtureForTesting( - id: "job-recent-bottom", + id: "chat-recent-bottom", status: .succeeded, targetSummary: "Commit: abc123", summary: "Recent review completed.", @@ -1912,7 +1912,7 @@ struct ReviewUITests { @Test func rehydratingSameSelectedReviewChatPreservesLogScrollPosition() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-rehydrated", + id: "chat-rehydrated", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -1940,7 +1940,7 @@ struct ReviewUITests { #expect(preservedOffset > 0) let replacement = makeReviewChatFixtureForTesting( - id: "job-rehydrated", + id: "chat-rehydrated", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -1955,14 +1955,14 @@ struct ReviewUITests { @Test func switchingReviewChatWithIdenticalLogTextStartsUnvisitedReviewChatAtBottom() async throws { let sharedLog = (0..<400).map { "shared line \($0)" }.joined(separator: "\n") let firstChat = makeReviewChatFixtureForTesting( - id: "job-identical-1", + id: "chat-identical-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", logText: sharedLog ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-identical-2", + id: "chat-identical-2", status: .succeeded, targetSummary: "Commit: abc123", summary: "Review completed.", @@ -1997,20 +1997,20 @@ struct ReviewUITests { 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 shortChat = makeReviewChatFixtureForTesting( - id: "job-fragment-short", + id: "chat-fragment-short", status: .running, targetSummary: "Short log", summary: "Short preview.", logText: shortLog ) let longChat = makeReviewChatFixtureForTesting( - id: "job-fragment-long", + id: "chat-fragment-long", status: .succeeded, targetSummary: "Long log", summary: "Long review completed.", @@ -2046,14 +2046,14 @@ struct ReviewUITests { let shortLog = (0..<3).map { "short line \($0)" }.joined(separator: "\n") let longLog = (0..<400).map { "long line \($0)" }.joined(separator: "\n") let shortChat = makeReviewChatFixtureForTesting( - id: "job-short-cache", + id: "chat-short-cache", status: .running, targetSummary: "Uncommitted changes", summary: "Short preview.", logText: shortLog ) let recentChat = makeReviewChatFixtureForTesting( - id: "job-short-cache-recent", + id: "chat-short-cache-recent", status: .succeeded, targetSummary: "Commit: abc123", summary: "Recent review completed.", @@ -2102,14 +2102,14 @@ struct ReviewUITests { @Test func previouslySelectedReviewChatUpdatesDoNotRepaintCurrentDetailPane() async throws { let activeChat = makeReviewChatFixtureForTesting( - id: "job-old-selection", + id: "chat-old-selection", status: .running, targetSummary: "Uncommitted changes", summary: "Active review.", logText: "Active log\n" ) let recentChat = makeReviewChatFixtureForTesting( - id: "job-current-selection", + id: "chat-current-selection", status: .succeeded, targetSummary: "Commit: abc123", summary: "Recent review.", @@ -2157,7 +2157,7 @@ struct ReviewUITests { @Test func clickingSidebarBlankAreaKeepsSelectionAndDetailPane() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-selected", + id: "chat-selected", status: .running, targetSummary: "Uncommitted changes", summary: "Review is still running.", @@ -2191,7 +2191,7 @@ struct ReviewUITests { @Test func clickingWorkspaceHeaderSelectsWorkspacePlaceholder() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-selected", + id: "chat-selected", cwd: "/tmp/workspace-alpha", status: .running, targetSummary: "Uncommitted changes", @@ -2230,7 +2230,7 @@ struct ReviewUITests { #expect(transport.isShowingNoFindingsStateForTesting) } - @Test func newJobsArrivingWhileUnselectedDoNotAutoSelect() { + @Test func newChatsArrivingWhileUnselectedDoNotAutoSelect() { let activeChat = makeReviewChatFixtureForTesting(status: .running, targetSummary: "Uncommitted changes") let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, workspaces: []) @@ -2321,7 +2321,7 @@ struct ReviewUITests { @Test func clearingSelectionShowsEmptyStateAndClearsDetailPane() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-1", + id: "chat-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -2372,7 +2372,7 @@ struct ReviewUITests { @Test func inPlaceReviewChatUpdateKeepsSelectionAndRefreshesDetailPane() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-1", + id: "chat-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -2411,7 +2411,7 @@ struct ReviewUITests { @Test func selectedReviewChatLogAppendUsesAppendPath() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-append", + id: "chat-append", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2457,7 +2457,7 @@ struct ReviewUITests { @Test func separatorPrefixedProgressAppendDoesNotUseGenericWordGlow() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-progress-separator-append", + id: "chat-progress-separator-append", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2500,7 +2500,7 @@ struct ReviewUITests { let decomposedPrefix = "Caf\u{0065}\u{0301}" let precomposedUpdate = "Caf\u{00E9} appended" let chat = makeReviewChatFixtureForTesting( - id: "job-canonical-append", + id: "chat-canonical-append", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2533,7 +2533,7 @@ struct ReviewUITests { @Test func coalescedLogTextUpdateDisplaysCombinedSuffix() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-coalesced", + id: "chat-coalesced", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2571,7 +2571,7 @@ struct ReviewUITests { @Test func coalescedProgressSuffixDisplaysLatestProgress() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-coalesced-progress", + id: "chat-coalesced-progress", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2609,7 +2609,7 @@ struct ReviewUITests { @Test func coalescedMixedReasoningAndProgressSuffixAnimatesOnlyReasoningRange() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-coalesced-mixed-reasoning-progress", + id: "chat-coalesced-mixed-reasoning-progress", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2655,7 +2655,7 @@ struct ReviewUITests { @Test func shortLogAppendDoesNotGrowDocumentFrameBeforeContentIsScrollable() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-short-append-frame-stability", + id: "chat-short-append-frame-stability", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -2702,7 +2702,7 @@ struct ReviewUITests { @Test func selectedReviewChatGroupedReplacementUsesReplacementPath() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-reload", + id: "chat-reload", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2742,7 +2742,7 @@ struct ReviewUITests { @Test func coalescedCommandAppendAfterReasoningKeepsReasoningAndDoesNotReload() async throws { let startedAt = Date(timeIntervalSince1970: 200) let chat = makeReviewChatFixtureForTesting( - id: "job-reasoning-command-append", + id: "chat-reasoning-command-append", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2808,7 +2808,7 @@ struct ReviewUITests { @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-markdown-append-fallback", + id: "chat-markdown-append-fallback", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2847,7 +2847,7 @@ struct ReviewUITests { @Test func staleGroupedReplacementIsNotReplayedAfterHiddenCommandOutput() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-stale-replacement", + id: "chat-stale-replacement", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2902,7 +2902,7 @@ struct ReviewUITests { @Test func metadataOnlyUpdatesDoNotTouchLogView() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-metadata", + id: "chat-metadata", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -2940,7 +2940,7 @@ struct ReviewUITests { @Test func reasoningAppendUsesWordGlowAndReduceMotionDisablesGlow() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-reasoning-glow", + id: "chat-reasoning-glow", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -2996,7 +2996,7 @@ struct ReviewUITests { @Test func screenSwitchBacklogDoesNotAnimateButNextVisibleReasoningAppendDoes() async throws { let firstChat = makeReviewChatFixtureForTesting( - id: "job-reasoning-switch-backlog", + id: "chat-reasoning-switch-backlog", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -3006,13 +3006,13 @@ struct ReviewUITests { ] ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-other-selected", + id: "chat-other-selected", cwd: "/tmp/workspace-beta", title: "Uncommitted changes", status: .running, startedAt: Date(timeIntervalSince1970: 201), chatEntries: [ - .init(kind: .agentMessage, groupID: "msg_1", text: "Other job") + .init(kind: .agentMessage, groupID: "msg_1", text: "Other chat") ] ) let store = CodexReviewStore.makePreviewStore() @@ -3060,7 +3060,7 @@ struct ReviewUITests { @Test func reasoningWordGlowCompletesAndClearsRenderingAttributes() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-reasoning-glow-completion", + id: "chat-reasoning-glow-completion", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -3106,7 +3106,7 @@ struct ReviewUITests { @Test func delayedFirstWordGlowTickDoesNotImmediatelyClearAnimation() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-reasoning-glow-delayed-first-tick", + id: "chat-reasoning-glow-delayed-first-tick", cwd: "/tmp/workspace-alpha", title: "Uncommitted changes", status: .running, @@ -3145,7 +3145,7 @@ struct ReviewUITests { @Test func logAutoFollowRunsOnlyWhenPinnedToBottom() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-autofollow", + id: "chat-autofollow", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3200,7 +3200,7 @@ struct ReviewUITests { @Test func logAutoFollowKeepsBottomAfterWrappedSingleLineAppend() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-autofollow-wrapped-append", + id: "chat-autofollow-wrapped-append", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3244,7 +3244,7 @@ struct ReviewUITests { .map { "scroll stability line \($0) with enough text to keep TextKit 2 viewport layout active" } .joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-append-near-bottom-scroll-stability", + id: "chat-append-near-bottom-scroll-stability", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3292,7 +3292,7 @@ struct ReviewUITests { @Test func programmaticLogAutoFollowRequestsOverlayScrollerHideWhenShown() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-overlay-hide", + id: "chat-overlay-hide", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3333,7 +3333,7 @@ struct ReviewUITests { @Test func legacyScrollerStyleDoesNotRequestOverlayScrollerHide() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-legacy-hide", + id: "chat-legacy-hide", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3372,7 +3372,7 @@ struct ReviewUITests { @Test func shortLogDoesNotRequestOverlayScrollerHideWhenNoScrollRange() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-overlay-short", + id: "chat-overlay-short", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3411,14 +3411,14 @@ struct ReviewUITests { @Test func selectingReviewChatRequestsOverlayScrollerHideWhenRestoringScrollPosition() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let firstChat = makeReviewChatFixtureForTesting( - id: "job-restore-1", + id: "chat-restore-1", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", logText: longLog ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-restore-2", + id: "chat-restore-2", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3469,7 +3469,7 @@ struct ReviewUITests { @Test func privateOverlayBridgeNoOpsWhenScrollerImpPairIsUnavailable() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-missing-pair", + id: "chat-missing-pair", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3509,7 +3509,7 @@ struct ReviewUITests { @Test func privateOverlayBridgeNoOpsWhenHideSelectorsAreUnavailable() async throws { let longLog = (0..<400).map { "line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-missing-hide", + id: "chat-missing-hide", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3548,7 +3548,7 @@ struct ReviewUITests { @Test func logViewUsesCustomTextKit2SurfaceAndDisablesEditingFeatures() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-config", + id: "chat-log-config", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3579,7 +3579,7 @@ struct ReviewUITests { @Test func logViewMaintainsVisibleTextKit2FragmentCoverageWhenScrolled() async throws { let longLog = (0..<1_000).map { "fragment coverage line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-log-fragments", + id: "chat-log-fragments", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3622,7 +3622,7 @@ struct ReviewUITests { @Test func logAppendDoesNotLeaveStaleTextKit2FragmentViews() async throws { let longLog = (0..<400).map { "append fragment line \($0)" }.joined(separator: "\n") let chat = makeReviewChatFixtureForTesting( - id: "job-log-fragment-append", + id: "chat-log-fragment-append", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3656,7 +3656,7 @@ struct ReviewUITests { @Test func logViewSupportsReadOnlySelectAllCopyFindValidationAndAccessibility() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-readonly", + id: "chat-log-readonly", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3738,7 +3738,7 @@ struct ReviewUITests { .map { "wrapped-segment-\($0)" } .joined(separator: " ") let chat = makeReviewChatFixtureForTesting( - id: "job-log-soft-wrap-keyboard", + id: "chat-log-soft-wrap-keyboard", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3786,7 +3786,7 @@ struct ReviewUITests { .map { "needle \($0) with enough trailing text to wrap in the visible log surface" } .joined(separator: "\n") + "\n" let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-system-highlights", + id: "chat-log-find-system-highlights", status: .running, targetSummary: "Uncommitted changes", summary: "Running review.", @@ -3936,16 +3936,16 @@ struct ReviewUITests { @Test func logFindClearsVisibleSnapshotWhenLogContentIsReused() async throws { let firstChat = makeReviewChatFixtureForTesting( - id: "job-log-find-reuse-first", + id: "chat-log-find-reuse-first", status: .running, - targetSummary: "First job", + targetSummary: "First chat", summary: "Running review.", logText: "needle first job\n" ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-log-find-reuse-second", + id: "chat-log-find-reuse-second", status: .running, - targetSummary: "Second job", + targetSummary: "Second chat", summary: "Running review.", logText: "needle second job\n" ) @@ -4000,16 +4000,16 @@ struct ReviewUITests { let appendedLine = "needle appended" let reusedLog = initialLog + "\n\n" + appendedLine let firstChat = makeReviewChatFixtureForTesting( - id: "job-log-find-same-text-reuse-first", + id: "chat-log-find-same-text-reuse-first", status: .running, - targetSummary: "First job", + targetSummary: "First chat", summary: "Running review.", logText: initialLog ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-log-find-same-text-reuse-second", + id: "chat-log-find-same-text-reuse-second", status: .running, - targetSummary: "Second job", + targetSummary: "Second chat", summary: "Running review.", logText: reusedLog ) @@ -4063,16 +4063,16 @@ struct ReviewUITests { @Test func logFindContentReuseClearsSnapshotForPrefixRelatedLogs() async throws { let firstLog = "needle shared prefix\n" let firstChat = makeReviewChatFixtureForTesting( - id: "job-log-find-prefix-reuse-first", + id: "chat-log-find-prefix-reuse-first", status: .running, - targetSummary: "First job", + targetSummary: "First chat", summary: "Running review.", logText: firstLog ) let secondChat = makeReviewChatFixtureForTesting( - id: "job-log-find-prefix-reuse-second", + id: "chat-log-find-prefix-reuse-second", status: .running, - targetSummary: "Second job", + targetSummary: "Second chat", summary: "Running review.", logText: firstLog + "needle second suffix\n" ) @@ -4118,7 +4118,7 @@ struct ReviewUITests { @Test func logFindHidingVisibleSnapshotReturnsClientToLiveString() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-hide-snapshot", + id: "chat-log-find-hide-snapshot", status: .running, targetSummary: "Hide snapshot", summary: "Running review.", @@ -4163,7 +4163,7 @@ struct ReviewUITests { @Test func logFindClearedSelectionReturnsVisibleUpdatesToLiveString() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-cleared-selection", + id: "chat-log-find-cleared-selection", status: .running, targetSummary: "Cleared selection", summary: "Running review.", @@ -4219,7 +4219,7 @@ struct ReviewUITests { @Test func logFindClearedQueryReturnsVisibleUpdatesToLiveString() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-cleared-query", + id: "chat-log-find-cleared-query", status: .running, targetSummary: "Cleared query", summary: "Running review.", @@ -4277,7 +4277,7 @@ struct ReviewUITests { @Test func logFindDoesNotFreezeVisibleBarBeforeSearchQuery() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-visible-no-query", + id: "chat-log-find-visible-no-query", status: .running, targetSummary: "Visible find bar", summary: "Running review.", @@ -4321,7 +4321,7 @@ struct ReviewUITests { @Test func logFindPreservesDirectFindBarQueryDuringLogUpdates() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-direct-query", + id: "chat-log-find-direct-query", status: .running, targetSummary: "Visible find bar direct query", summary: "Running review.", @@ -4368,7 +4368,7 @@ struct ReviewUITests { @Test func logFindQueryChangeRefreshesVisibleSnapshotAfterLogUpdates() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-query-change", + id: "chat-log-find-query-change", status: .running, targetSummary: "Visible find bar query change", summary: "Running review.", @@ -4417,7 +4417,7 @@ struct ReviewUITests { @Test func logFindVisibleBarNormalSelectionKeepsUpdatesLive() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-visible-normal-selection", + id: "chat-log-find-visible-normal-selection", status: .running, targetSummary: "Visible find bar normal selection", summary: "Running review.", @@ -4465,7 +4465,7 @@ struct ReviewUITests { @Test func logFindPreservesNoResultSearchStateDuringLogUpdatesUntilHidden() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-visible-no-result", + id: "chat-log-find-visible-no-result", status: .running, targetSummary: "Visible find bar no result", summary: "Running review.", @@ -4535,7 +4535,7 @@ struct ReviewUITests { @Test func logFindKeepsFirstAppendIntoEmptyVisibleLogLive() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-log-find-empty-append", + id: "chat-log-find-empty-append", status: .running, targetSummary: "Empty log", summary: "Running review.", @@ -4574,9 +4574,9 @@ struct ReviewUITests { #expect(transport.logFindStringLengthForTesting == (reviewChatLogText(for: chat) as NSString).length) } - @Test func authFailedJobShowsNormalFailureDetails() async throws { + @Test func authFailedChatShowsNormalFailureDetails() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-auth", + id: "chat-auth", status: .failed, targetSummary: "Uncommitted changes", summary: "Failed to start review.", @@ -4603,9 +4603,9 @@ struct ReviewUITests { #expect(snapshot.log == reviewChatLogText(for: chat)) } - @Test func authenticatedAuthFailedJobStillShowsNormalFailureDetails() async throws { + @Test func authenticatedAuthFailedChatStillShowsNormalFailureDetails() async throws { let chat = makeReviewChatFixtureForTesting( - id: "job-auth-restored", + id: "chat-auth-restored", status: .failed, targetSummary: "Uncommitted changes", summary: "Failed to start review.", From 826d657b28d8f8619c7a6bfebd60e7b13d4f704f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:54:20 +0900 Subject: [PATCH 243/359] refactor(review): name lifecycle records as runs --- .../Store/CodexReviewStoreCancellation.swift | 74 +-- .../Store/CodexReviewStoreReviews.swift | 458 +++++++++--------- 2 files changed, 266 insertions(+), 266 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 81cf8bbd..76b9af22 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -33,28 +33,28 @@ extension CodexReviewStore { sessionID: String, cancellation: ReviewCancellation = .system() ) throws { - guard let job = reviewRun(id: runID) + guard let runRecord = reviewRun(id: runID) else { throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } - guard job.sessionID == sessionID + guard runRecord.sessionID == sessionID else { throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } - guard job.isTerminal == false else { + guard runRecord.isTerminal == false else { return } let endedAt = clock.now() - 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 = + runRecord.cancellationRequested = false + runRecord.core.lifecycle.cancellation = cancellation + runRecord.core.lifecycle.status = .cancelled + runRecord.core.output.summary = cancellation.message + runRecord.core.output.hasFinalReview = false + runRecord.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty - ?? job.core.lifecycle.errorMessage - job.core.lifecycle.endedAt = endedAt + ?? runRecord.core.lifecycle.errorMessage + runRecord.core.lifecycle.endedAt = endedAt noteReviewRunMutation() } @@ -63,26 +63,26 @@ extension CodexReviewStore { sessionID: String, message: String ) throws { - guard let job = reviewRun(id: runID) + guard let runRecord = reviewRun(id: runID) else { throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } - guard job.sessionID == sessionID + guard runRecord.sessionID == sessionID else { throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } - job.cancellationRequested = false - job.core.lifecycle.cancellation = nil + runRecord.cancellationRequested = false + runRecord.core.lifecycle.cancellation = nil if let message = message.nilIfEmpty { if message == "Failed to cancel review." { - job.core.output.summary = message + runRecord.core.output.summary = message } else { - job.core.output.summary = "Failed to cancel review: \(message)" + runRecord.core.output.summary = "Failed to cancel review: \(message)" } - job.core.lifecycle.errorMessage = message + runRecord.core.lifecycle.errorMessage = message } else { - job.core.output.summary = "Failed to cancel review." + runRecord.core.output.summary = "Failed to cancel review." } writeDiagnosticsIfNeeded() } @@ -110,18 +110,18 @@ extension CodexReviewStore { ) let cancellableReviewRuns = orderedReviewRuns.filter { $0.isTerminal == false } var firstError: (any Error)? - for job in cancellableReviewRuns { + for runRecord in cancellableReviewRuns { do { _ = try await cancelReview( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) } catch { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) try? recordCancellationFailure( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, message: message.isEmpty ? "Failed to cancel review." : message ) if firstError == nil { @@ -194,10 +194,10 @@ extension CodexReviewStore { } for runID in activeReviewRunIDs { - if let job = reviewRun(id: runID), job.isTerminal == false { + if let runRecord = reviewRun(id: runID), runRecord.isTerminal == false { try? completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: reason ) } @@ -263,21 +263,21 @@ extension CodexReviewStore { failureMessage: String ) { let resolvedError = failureMessage.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - for job in orderedReviewRuns where job.isTerminal == false { - job.cancellationRequested = false - job.core.lifecycle.cancellation = nil - job.core.lifecycle.status = .failed + for runRecord in orderedReviewRuns where runRecord.isTerminal == false { + runRecord.cancellationRequested = false + runRecord.core.lifecycle.cancellation = nil + runRecord.core.lifecycle.status = .failed if let resolvedError { - job.core.output.summary = "Failed to cancel review: \(resolvedError)" + runRecord.core.output.summary = "Failed to cancel review: \(resolvedError)" } else { - job.core.output.summary = "Failed to cancel review." + runRecord.core.output.summary = "Failed to cancel review." } - job.core.output.hasFinalReview = false - job.core.lifecycle.errorMessage = + runRecord.core.output.hasFinalReview = false + runRecord.core.lifecycle.errorMessage = resolvedError ?? reason.nilIfEmpty - ?? job.core.lifecycle.errorMessage - job.core.lifecycle.endedAt = clock.now() + ?? runRecord.core.lifecycle.errorMessage + runRecord.core.lifecycle.endedAt = clock.now() } noteReviewRunMutation() } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 3811aadc..03663fbf 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -42,11 +42,11 @@ extension CodexReviewStore { runID: String, timeout: Duration? = nil ) async throws -> CodexReviewAPI.Read.Result { - let job = try job(runID: runID) - if let sessionID, job.sessionID != sessionID { + 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 { + if runRecord.isTerminal == false { await waitForReviewTerminal(runID: runID, timeout: timeout) } return try readReview(sessionID: sessionID, runID: runID) @@ -64,7 +64,7 @@ extension CodexReviewStore { let validatedRequest = try request.validated() let runID = idGenerator.next() let createdAt = clock.now() - let job = ReviewRunRecord( + let runRecord = ReviewRunRecord( id: runID, sessionID: sessionID, cwd: validatedRequest.cwd, @@ -75,8 +75,8 @@ extension CodexReviewStore { output: .init(summary: "Queued.") ) ) - insertReviewRun(job) - markReviewRunning(job, startedAt: createdAt) + insertReviewRun(runRecord) + markReviewRunning(runRecord, startedAt: createdAt) runtimeState.markStarting(runID) launchReviewWorker(runID: runID, sessionID: sessionID, request: validatedRequest) return runID @@ -99,7 +99,7 @@ extension CodexReviewStore { sessionID: String, request validatedRequest: CodexReviewAPI.Start.Request ) async { - guard let job = reviewRun(id: runID) else { + guard let runRecord = reviewRun(id: runID) else { runtimeState.clearStarting(runID) runtimeState.removeActiveWorker(for: runID) return @@ -119,38 +119,38 @@ extension CodexReviewStore { if Task.isCancelled { throw CancellationError() } - applyBackendRun(backendRun, to: job) + 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( - runID: 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( - runID: 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) runtimeState.clearReviewRunState(for: runID) } else { let currentRun = try await consumeReviewEvents( for: backendAttempt, - job: job, + runRecord: runRecord, startRequest: startRequest ) run = currentRun @@ -161,13 +161,13 @@ extension CodexReviewStore { runtimeState.clearStarting(runID) let startupCancellation = runtimeState.takeStartupCancellation(for: runID) if let cleanupRun = runtimeState.activeRun(for: runID) ?? run { - await interruptReviewAfterTaskCancellation(cleanupRun, job: job) + 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( - runID: 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() ) } runtimeState.clearReviewRunState(for: runID) @@ -178,53 +178,53 @@ extension CodexReviewStore { await backend.cleanupReview(cleanupRun) } runtimeState.clearReviewRunState(for: runID) - if job.isTerminal == false, let startupCancellation { + if runRecord.isTerminal == false, let startupCancellation { try? completeCancellationLocally( - runID: 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) } } runtimeState.removeActiveWorker(for: runID) runtimeState.removeDetachedWorker(for: runID) } - private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, job: ReviewRunRecord) + private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, runRecord: ReviewRunRecord) async { - guard job.isTerminal == false else { + 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.output.summary = cancellation.message + runRecord.core.lifecycle.errorMessage = cancellation.message do { try await backend.interruptReview( run, reason: .init(message: cancellation.message) ) try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) } catch { try? recordCancellationFailure( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, message: error.localizedDescription ) } } - private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to job: ReviewRunRecord) { - runtimeState.setActiveRun(backendRun, for: job.id) - 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, @@ -234,52 +234,52 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func appendRecoveryProgress(_ message: String, to job: ReviewRunRecord) { - job.core.output.summary = message + private func appendRecoveryProgress(_ message: String, to runRecord: ReviewRunRecord) { + runRecord.core.output.summary = message writeDiagnosticsIfNeeded() } - private func markReviewWaitingForNetworkRecovery(_ job: ReviewRunRecord) { - job.resetReviewAttemptOutputForRecovery() - appendRecoveryProgress(networkRecoveryUnavailableMessage, to: job) + private func markReviewWaitingForNetworkRecovery(_ runRecord: ReviewRunRecord) { + runRecord.resetReviewAttemptOutputForRecovery() + appendRecoveryProgress(networkRecoveryUnavailableMessage, to: runRecord) } private func applyDomainEvents( _ events: [ReviewDomainEvent], - to job: ReviewRunRecord, + to runRecord: ReviewRunRecord, at timestamp: Date ) { for event in events { - guard shouldApplyDomainEvent(event, to: job) else { + guard shouldApplyDomainEvent(event, to: runRecord) else { continue } - updateCoreOutput(from: event, for: job) + updateCoreOutput(from: event, for: runRecord) } } - private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to job: ReviewRunRecord) -> Bool { + private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to runRecord: ReviewRunRecord) -> Bool { switch event { case .textDelta(let itemID, _, let family, _, _) - where family == .message && job.completedAgentMessageItemIDs.contains(itemID.rawValue): + where family == .message && runRecord.completedAgentMessageItemIDs.contains(itemID.rawValue): false default: true } } - private func updateCoreOutput(from event: ReviewDomainEvent, for job: ReviewRunRecord) { + private func updateCoreOutput(from event: ReviewDomainEvent, for runRecord: ReviewRunRecord) { switch event { case .itemStarted(let seed), .itemUpdated(let seed): - updateCoreOutput(from: seed, isCompleted: false, for: job) + updateCoreOutput(from: seed, isCompleted: false, for: runRecord) case .itemCompleted(let seed): - updateCoreOutput(from: seed, isCompleted: true, for: job) + updateCoreOutput(from: seed, isCompleted: true, for: runRecord) case .textDelta(let itemID, _, let family, _, let delta) where family == .message: - guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID.rawValue, delta: delta) else { + guard let updatedMessage = runRecord.appendAgentMessageDelta(itemID: itemID.rawValue, delta: delta) else { return } - job.core.output.lastAgentMessage = updatedMessage - job.core.output.summary = updatedMessage + runRecord.core.output.lastAgentMessage = updatedMessage + runRecord.core.output.summary = updatedMessage case .runStarted, .textDelta, .reviewCompleted, @@ -292,14 +292,14 @@ extension CodexReviewStore { private func updateCoreOutput( from seed: ReviewEventItemSeed, isCompleted: Bool, - for job: ReviewRunRecord + for runRecord: ReviewRunRecord ) { guard seed.family == .message, case .message(let message) = seed.content else { return } - job.noteAgentMessageSnapshot( + runRecord.noteAgentMessageSnapshot( itemID: seed.id.rawValue, text: message.text, isCompleted: isCompleted @@ -307,42 +307,42 @@ extension CodexReviewStore { guard let text = message.text.nilIfEmpty else { return } - job.core.output.lastAgentMessage = text - job.core.output.summary = text + runRecord.core.output.lastAgentMessage = text + runRecord.core.output.summary = text } private func applyMessageSnapshot( _ text: String, itemID: String?, isCompleted: Bool, - to job: ReviewRunRecord, + to runRecord: ReviewRunRecord, at timestamp: Date ) { let resolvedItemID = itemID?.nilIfEmpty - ?? job.nextSyntheticMessageItemID(prefix: "message") - job.noteAgentMessageSnapshot( + ?? runRecord.nextSyntheticMessageItemID(prefix: "message") + runRecord.noteAgentMessageSnapshot( itemID: resolvedItemID, text: text, isCompleted: isCompleted ) if let text = text.nilIfEmpty { - job.core.output.lastAgentMessage = text - job.core.output.summary = text + runRecord.core.output.lastAgentMessage = text + runRecord.core.output.summary = text } } private func applyMessageDelta( _ text: String, itemID: String, - to job: ReviewRunRecord, + to runRecord: ReviewRunRecord, at timestamp: Date ) { - guard let updatedMessage = job.appendAgentMessageDelta(itemID: itemID, delta: text) else { + guard let updatedMessage = runRecord.appendAgentMessageDelta(itemID: itemID, delta: text) else { return } - job.core.output.lastAgentMessage = updatedMessage - job.core.output.summary = updatedMessage + runRecord.core.output.lastAgentMessage = updatedMessage + runRecord.core.output.summary = updatedMessage } private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { @@ -381,15 +381,15 @@ extension CodexReviewStore { sessionID: String?, runID: String ) throws -> CodexReviewAPI.Read.Result { - let job = try job(runID: runID) - if let sessionID, job.sessionID != sessionID { + 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( - runID: job.id, - core: job.core, - elapsedSeconds: elapsedSeconds(for: job), - cancellable: job.isTerminal == false && job.cancellationRequested == false + runID: runRecord.id, + core: runRecord.core, + elapsedSeconds: elapsedSeconds(for: runRecord), + cancellable: runRecord.isTerminal == false && runRecord.cancellationRequested == false ) } @@ -410,14 +410,14 @@ extension CodexReviewStore { limit: Int? = nil ) -> CodexReviewAPI.List.Result { let statusSet = statuses.map(Set.init) - let filtered = orderedReviewRuns.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 @@ -432,23 +432,23 @@ extension CodexReviewStore { package func resolveRun(sessionID: String?, selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord { let statusSet = selector.statuses.map(Set.init) - let matches = orderedReviewRuns.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 runID = selector.runID, runID != 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.runNotFound("No review run matched the selector.") @@ -461,7 +461,7 @@ extension CodexReviewStore { sessionID: String, cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { - guard let job = reviewRun(id: runID), job.sessionID == sessionID else { + guard let runRecord = reviewRun(id: runID), runRecord.sessionID == sessionID else { throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") } return try await cancelReview(runID: runID, cancellation: cancellation) @@ -472,33 +472,33 @@ extension CodexReviewStore { runID: String, cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { - let job = try job(runID: runID) - guard job.isTerminal == false else { - return .init(runID: job.id, cancelled: false, core: job.core) + let runRecord = try requireReviewRun(runID: runID) + guard runRecord.isTerminal == false 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.output.summary = cancellation.message + runRecord.core.lifecycle.errorMessage = cancellation.message - if job.core.lifecycle.status == .queued { + if runRecord.core.lifecycle.status == .queued { try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) - return .init(runID: job.id, cancelled: true, core: job.core) + return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) } if runtimeState.isWaitingForNetworkRecovery(runID) { try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) runtimeState.cancelActiveWorker(for: runID) - return .init(runID: job.id, cancelled: true, core: job.core) + return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) } if let run = runtimeState.activeRun(for: runID) { @@ -508,35 +508,35 @@ extension CodexReviewStore { reason: .init(message: cancellation.message) ) try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) runtimeState.cancelActiveWorker(for: runID) } catch { try recordCancellationFailure( - runID: 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( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) runtimeState.cancelActiveWorker(for: runID) } catch { try recordCancellationFailure( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, message: error.localizedDescription ) throw error @@ -544,19 +544,19 @@ extension CodexReviewStore { } else if runtimeState.isStarting(runID) { runtimeState.setStartupCancellation(cancellation, for: runID) try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) - return .init(runID: job.id, cancelled: true, core: job.core) + return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) } else { try completeCancellationLocally( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) } - return .init(runID: job.id, cancelled: true, core: job.core) + return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) } package func closeSession( @@ -579,79 +579,79 @@ extension CodexReviewStore { } } - private func job(runID: String) throws -> ReviewRunRecord { - guard let job = reviewRun(id: runID) else { + 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 filteredReviewRuns(cwd: String?, statuses: [ReviewRunState]?) -> [ReviewRunRecord] { let statusSet = statuses.map(Set.init) - return orderedReviewRuns.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: ReviewRunRecord) -> CodexReviewAPI.Run.ListItem { + private func makeListItem(_ runRecord: ReviewRunRecord) -> CodexReviewAPI.Run.ListItem { CodexReviewAPI.Run.ListItem( - runID: job.id, - cwd: job.cwd, - targetSummary: job.targetSummary, - core: job.core, - elapsedSeconds: elapsedSeconds(for: job), - cancellable: job.isTerminal == false && job.cancellationRequested == false + runID: runRecord.id, + cwd: runRecord.cwd, + targetSummary: runRecord.targetSummary, + core: runRecord.core, + elapsedSeconds: elapsedSeconds(for: runRecord), + cancellable: runRecord.isTerminal == false && runRecord.cancellationRequested == false ) } - private func elapsedSeconds(for job: ReviewRunRecord) -> 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 insertReviewRun(_ job: ReviewRunRecord) { - if workspace(cwd: job.cwd) == nil { + private func insertReviewRun(_ runRecord: ReviewRunRecord) { + if workspace(cwd: runRecord.cwd) == nil { let workspace = CodexReviewWorkspace( - cwd: job.cwd, + cwd: runRecord.cwd, sortOrder: nextWorkspaceSortOrder() ) workspaces.insert(workspace) } - reviewRuns.insert(job) + reviewRuns.insert(runRecord) writeDiagnosticsIfNeeded() } - private func markReviewRunning(_ job: ReviewRunRecord, startedAt: Date) { - job.core.lifecycle.status = .running - job.core.lifecycle.startedAt = startedAt - job.core.output.summary = "Review started." + private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { + runRecord.core.lifecycle.status = .running + runRecord.core.lifecycle.startedAt = startedAt + runRecord.core.output.summary = "Review started." writeDiagnosticsIfNeeded() } - private func markReviewFailed(_ job: ReviewRunRecord, 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.core.lifecycle.status = .failed - job.core.lifecycle.endedAt = endedAt - job.core.lifecycle.errorMessage = message - job.core.output.summary = message + runRecord.core.lifecycle.status = .failed + runRecord.core.lifecycle.endedAt = endedAt + runRecord.core.lifecycle.errorMessage = message + runRecord.core.output.summary = message writeDiagnosticsIfNeeded() } private func consumeReviewEvents( for initialAttempt: BackendReviewAttempt, - job: ReviewRunRecord, + runRecord: ReviewRunRecord, startRequest: CodexReviewBackendModel.Review.Start ) async throws -> CodexReviewBackendModel.Review.Run { let inputs = await reviewWorkerInputs(for: initialAttempt) @@ -661,7 +661,7 @@ 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 { @@ -673,10 +673,10 @@ extension CodexReviewStore { } 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): @@ -687,7 +687,7 @@ extension CodexReviewStore { continue } if try handleReviewEventsFinished( - job: job, + runRecord: runRecord, isWaitingForNetworkRecovery: recoveryState.isWaitingForNetworkRecovery ) { return recoveryState.currentRun @@ -718,7 +718,7 @@ extension CodexReviewStore { case .none: continue case .restartSettling: - appendRecoveryProgress(networkRecoveryRestoredMessage, to: job) + appendRecoveryProgress(networkRecoveryRestoredMessage, to: runRecord) } case .networkRecoverySettled(let recoveryGeneration): guard @@ -729,7 +729,7 @@ extension CodexReviewStore { continue } switch try await restartReviewAfterNetworkRestore( - job: job, + runRecord: runRecord, startRequest: startRequest, inputs: inputs, preparedRestartToken: recoveryState.preparedRestartToken @@ -738,26 +738,26 @@ extension CodexReviewStore { recoveryState.markWaitingForNetworkRecovery() continue case .finished: - runtimeState.clearWaitingForNetworkRecovery(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) - runtimeState.clearWaitingForNetworkRecovery(job.id) + runtimeState.clearWaitingForNetworkRecovery(runRecord.id) activeEventSubscriptionID = await inputs.subscribe(to: recoveredAttempt) } case .networkOutageConfirmed: guard recoveryState.isWaitingForNetworkRecovery == false, - job.isTerminal == false, - job.cancellationRequested == false, + runRecord.isTerminal == false, + runRecord.cancellationRequested == false, await inputs.networkStatusTracker.currentStatus() != .satisfied else { continue } recoveryState.markWaitingForNetworkRecovery() - markReviewWaitingForNetworkRecovery(job) - runtimeState.markWaitingForNetworkRecovery(job.id) + markReviewWaitingForNetworkRecovery(runRecord) + runtimeState.markWaitingForNetworkRecovery(runRecord.id) activeEventSubscriptionID = nil await inputs.cancelActiveEventSubscription() let restartToken = try await backend.prepareReviewRestart(recoveryState.currentRun) @@ -768,21 +768,21 @@ extension CodexReviewStore { 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 + runRecord, + summary: runRecord.core.output.summary, + result: runRecord.core.output.lastAgentMessage ) } return recoveryState.currentRun } private func handleReviewEventsFinished( - job: ReviewRunRecord, + runRecord: ReviewRunRecord, isWaitingForNetworkRecovery: Bool ) throws -> Bool { if Task.isCancelled { @@ -790,17 +790,17 @@ 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 + runRecord, + summary: runRecord.core.output.summary, + result: runRecord.core.output.lastAgentMessage ) } return true @@ -816,18 +816,18 @@ extension CodexReviewStore { } private func restartReviewAfterNetworkRestore( - job: ReviewRunRecord, + runRecord: ReviewRunRecord, startRequest: CodexReviewBackendModel.Review.Start, inputs: ReviewWorkerInputs, 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 { @@ -841,53 +841,53 @@ extension CodexReviewStore { 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: ReviewRunRecord + 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( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) } catch { await backend.cleanupReview(recoveredRun) try? recordCancellationFailure( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, message: error.localizedDescription ) throw error @@ -898,49 +898,49 @@ extension CodexReviewStore { private func handleReviewEvent( _ event: CodexReviewBackendModel.Review.Event, - job: ReviewRunRecord, + 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): - applyDomainEvents(events, to: job, at: clock.now()) + applyDomainEvents(events, to: runRecord, at: clock.now()) 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 - runtimeState.setActiveRun(updatedRun, for: job.id) - job.core.output.summary = "Review started." + runtimeState.setActiveRun(updatedRun, for: runRecord.id) + runRecord.core.output.summary = "Review started." case .message(let text): let now = clock.now() - job.core.output.lastAgentMessage = text - job.core.output.summary = text - applyMessageSnapshot(text, itemID: nil, isCompleted: true, to: job, at: now) + runRecord.core.output.lastAgentMessage = text + runRecord.core.output.summary = text + applyMessageSnapshot(text, itemID: nil, isCompleted: true, to: runRecord, at: now) case .messageDelta(let text, let itemID): - applyMessageDelta(text, itemID: itemID, to: job, at: clock.now()) + applyMessageDelta(text, itemID: itemID, to: runRecord, at: clock.now()) case .log(let text): - if job.core.output.summary.isEmpty || job.core.output.summary == "Review started." { - job.core.output.summary = text + if runRecord.core.output.summary.isEmpty || runRecord.core.output.summary == "Review started." { + runRecord.core.output.summary = text } case .completed(let summary, let result): - completeReview(job, summary: summary, result: result) + completeReview(runRecord, summary: summary, result: result) 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( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) } @@ -948,42 +948,42 @@ extension CodexReviewStore { return updatedRun } - private func completePendingCancellationIfNeeded(for job: ReviewRunRecord) -> 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( - runID: job.id, - sessionID: job.sessionID, + runID: runRecord.id, + sessionID: runRecord.sessionID, cancellation: cancellation ) return true } private func completeReview( - _ job: ReviewRunRecord, + _ runRecord: ReviewRunRecord, summary: String, result: String? ) { - guard job.isTerminal == false else { + guard runRecord.isTerminal == false else { return } let endedAt = clock.now() - let previousAgentMessage = job.core.output.lastAgentMessage?.nilIfEmpty + let previousAgentMessage = runRecord.core.output.lastAgentMessage?.nilIfEmpty let resultText = result?.nilIfEmpty let finalReviewText = resultText ?? previousAgentMessage - 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) + runRecord.core.lifecycle.status = .succeeded + runRecord.core.lifecycle.endedAt = endedAt + runRecord.core.output.summary = summary + runRecord.core.output.lastAgentMessage = finalReviewText ?? summary + runRecord.core.output.hasFinalReview = finalReviewText != nil + runRecord.core.output.reviewResult = ParsedReviewResult.parse(finalReviewText: finalReviewText) if let result = resultText, result != previousAgentMessage { - job.noteAgentMessageSnapshot( - itemID: job.nextSyntheticMessageItemID(prefix: "message"), + runRecord.noteAgentMessageSnapshot( + itemID: runRecord.nextSyntheticMessageItemID(prefix: "message"), text: result, isCompleted: true ) @@ -992,13 +992,13 @@ extension CodexReviewStore { } private func waitForReviewTerminal(runID: String, timeout: Duration?) async { - guard let job = reviewRun(id: runID), - job.isTerminal == false + guard let runRecord = reviewRun(id: runID), + runRecord.isTerminal == false else { return } _ = await ReviewObservationAwaiter.waitUntilTerminal( - run: job, + run: runRecord, timeout: timeout ) } From 72c9eacb0fa3f07cc2ea8f52786d6894a4a263f9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:58:56 +0900 Subject: [PATCH 244/359] test(review): name store snapshots as runs --- Sources/CodexReviewTesting/TestSupport.swift | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 82290286..841400b5 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -542,16 +542,16 @@ package final class StoreSnapshotProbe { } return lhs.sortOrder > rhs.sortOrder } - .map { job in - let runtimeState = store.runtimeReviewRunState(runID: job.id) + .map { runRecord in + let runtimeState = store.runtimeReviewRunState(runID: runRecord.id) return StoreRunSnapshot( - runID: job.id, - status: job.core.lifecycle.status, - summary: job.core.output.summary, - lastAgentMessage: job.core.output.lastAgentMessage, - run: job.core.run, + runID: runRecord.id, + status: runRecord.core.lifecycle.status, + summary: runRecord.core.output.summary, + lastAgentMessage: runRecord.core.output.lastAgentMessage, + run: runRecord.core.run, activeRun: runtimeState.activeRun, - cancellationRequested: job.cancellationRequested + cancellationRequested: runRecord.cancellationRequested ) } return StoreSnapshot(reviewRuns: reviewRuns) @@ -563,7 +563,7 @@ package final class StoreSnapshotProbe { timeout: Duration = .seconds(2) ) async -> StoreSnapshot? { await waitUntil(timeout: timeout) { snapshot in - snapshot.job(runID)?.status == status + snapshot.run(runID)?.status == status } } @@ -573,7 +573,7 @@ package final class StoreSnapshotProbe { timeout: Duration = .seconds(2) ) async -> StoreSnapshot? { await waitUntil(timeout: timeout) { snapshot in - snapshot.job(runID)?.activeRun?.attemptID == attemptID + snapshot.run(runID)?.activeRun?.attemptID == attemptID } } @@ -599,7 +599,7 @@ package final class StoreSnapshotProbe { package struct StoreSnapshot: Sendable { package var reviewRuns: [StoreRunSnapshot] - package func job(_ runID: String? = nil) -> StoreRunSnapshot? { + package func run(_ runID: String? = nil) -> StoreRunSnapshot? { guard let runID else { return reviewRuns.first } From ed84f36bb62072ee87e76f4ec14417da10f0d31d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:14:10 +0900 Subject: [PATCH 245/359] feat(mcp): project review logs from CodexChat turns --- Package.swift | 3 + .../LiveCodexReviewStoreBackend.swift | 29 ++++- .../CodexReviewMCPServer.swift | 49 +++++++- .../CodexReviewMCPToolResults.swift | 11 ++ .../ReviewMCPLogProjection.swift | 106 +++++++++++++++++- .../CodexReviewHostTests.swift | 16 ++- .../ReviewMCPLogProjectionTests.swift | 44 ++++++++ 7 files changed, 240 insertions(+), 18 deletions(-) diff --git a/Package.swift b/Package.swift index 663bb703..cce44a9d 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,7 @@ let package = Package( .target( name: "CodexReviewMCPServer", dependencies: [ + .product(name: "CodexKit", package: "CodexKit"), "CodexReviewKit", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), @@ -69,6 +70,7 @@ let package = Package( .target( name: "CodexReviewHost", dependencies: [ + .product(name: "CodexKit", package: "CodexKit"), .product(name: "CodexAppServerKit", package: "CodexKit"), "CodexReviewKit", "CodexReviewAppServer", @@ -130,6 +132,7 @@ let package = Package( .testTarget( name: "CodexReviewMCPServerTests", dependencies: [ + .product(name: "CodexKit", package: "CodexKit"), "CodexReviewMCPServer", "CodexReviewTesting", .product(name: "NIOCore", package: "swift-nio"), diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 0bf8f769..f41584f1 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import OSLog +import CodexKit import CodexAppServerKit import CodexReviewKit import CodexReviewAppServer @@ -158,7 +159,8 @@ 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, @@ -198,12 +200,14 @@ 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 appServer: CodexAppServer? + private var appServerModelContainer: CodexModelContainer? private var appServerBackend: AppServerCodexReviewBackend? private var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? private var loginChallenge: CodexReviewBackendModel.Login.Challenge? @@ -236,9 +240,12 @@ 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 ) }, @@ -440,13 +447,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let runtime = try await appServerRuntimeFactory(codexHomeURL) let appServer = runtime.appServer let backend = runtime.backend + let modelContainer = CodexModelContainer(appServer: appServer) startedAppServer = appServer self.appServer = appServer + self.appServerModelContainer = modelContainer self.appServerBackend = backend appServerLifecycleHandler?(appServer) 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 @@ -462,6 +478,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await startedHTTPServer?.stop() await startedAppServer?.close() self.appServer = nil + self.appServerModelContainer = nil self.appServerBackend = nil self.mcpHTTPServer = nil appServerLifecycleHandler?(nil) @@ -536,6 +553,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } self.appServer = nil + self.appServerModelContainer = nil self.mcpHTTPServer = nil appServerLifecycleHandler?(nil) authNotificationTask?.cancel() @@ -1171,6 +1189,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let failedAppServer = appServer let failedMCPHTTPServer = mcpHTTPServer appServer = nil + appServerModelContainer = nil appServerBackend = nil mcpHTTPServer = nil authNotificationTask = nil diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 97113259..bb67be28 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -1,10 +1,15 @@ import Foundation +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" @@ -62,9 +67,14 @@ package extension CodexReviewMCP.Tool { @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] { @@ -90,13 +100,13 @@ package final class CodexReviewMCPServer { } else { result = try await store.startReview(sessionID: sessionID, request: reviewRequest) } - let snapshot = try reviewSnapshot( + let snapshot = try await reviewSnapshot( result, sessionID: sessionID ) return .reviewStart(snapshot) case .reviewAwait(let sessionID, let runID, let waitTimeout): - let snapshot = try reviewSnapshot( + let snapshot = try await reviewSnapshot( try await store.awaitReview( sessionID: sessionID, runID: runID, @@ -106,7 +116,7 @@ package final class CodexReviewMCPServer { ) return .reviewAwait(snapshot) case .reviewRead(let sessionID, let runID): - let snapshot = try reviewSnapshot( + let snapshot = try await reviewSnapshot( try store.readReview( sessionID: sessionID, runID: runID @@ -136,11 +146,12 @@ package final class CodexReviewMCPServer { private func reviewSnapshot( _ result: CodexReviewAPI.Read.Result, sessionID: String? - ) throws -> CodexReviewMCP.Tool.ReviewSnapshot { + ) async throws -> CodexReviewMCP.Tool.ReviewSnapshot { if let sessionID { _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID)) } - return .init(result: result, log: ReviewMCPLogProjection(result: result)) + let log = await logProjectionProvider?(result) ?? ReviewMCPLogProjection(result: result) + return .init(result: result, log: log) } package func closeSession(_ sessionID: String) async { @@ -152,6 +163,32 @@ package final class CodexReviewMCPServer { } } +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 chat.refresh(includeTurns: true) + } catch { + return nil + } + guard let snapshot = chat.turnSnapshot(for: CodexTurnID(rawValue: turnID)) else { + return nil + } + return ReviewMCPLogProjection(result: result, turnSnapshot: snapshot) + } + } +} + private extension CodexReviewAPI.Run.Selector { func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Run.Selector { guard runID == nil, statuses == nil else { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 79948770..549689c3 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -203,6 +203,17 @@ private extension ReviewMCPLogProjection.Content { ), "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)), + ]) } } } diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 24a2f0a2..fa9655c9 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -1,7 +1,8 @@ import Foundation +import CodexKit import CodexReviewKit -struct ReviewMCPLogProjection: Sendable, Equatable { +package struct ReviewMCPLogProjection: Sendable, Equatable { struct Item: Sendable, Equatable { var id: String var kind: String @@ -11,6 +12,7 @@ struct ReviewMCPLogProjection: Sendable, Equatable { enum Content: Sendable, Equatable { case message(String) case diagnostic(String) + case entry(type: String, text: String) var type: String { switch self { @@ -18,6 +20,8 @@ struct ReviewMCPLogProjection: Sendable, Equatable { "message" case .diagnostic: "diagnostic" + case .entry(let type, _): + type } } } @@ -58,4 +62,104 @@ struct ReviewMCPLogProjection: Sendable, Equatable { self.finalSummary = status.isTerminal ? output.summary : nil self.finalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil } + + @MainActor + init(result: CodexReviewAPI.Read.Result, turnSnapshot: CodexChatTurnSnapshot) { + self.init( + result: result, + turnID: turnSnapshot.turnID, + threadItems: turnSnapshot.threadItems + ) + } + + init( + result: CodexReviewAPI.Read.Result, + turnID: CodexTurnID, + threadItems: [CodexThreadItem] + ) { + let lifecycle = result.core.lifecycle + let output = result.core.output + 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 + "\(item.id):\(item.kind.rawValue):\(item.text?.count ?? 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.finalSummary = status.isTerminal ? output.summary : nil + self.finalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil + } +} + +private extension ReviewMCPLogProjection.Content { + 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/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 00f390f9..68093537 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -58,7 +58,8 @@ private extension CodexReviewStore { externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = { _ in }, mcpHTTPServerFactory: (@MainActor @Sendable ( CodexReviewStore, - CodexReviewMCPHTTPServer.Configuration + CodexReviewMCPHTTPServer.Configuration, + ReviewMCPLogProjectionProvider? ) -> any CodexReviewMCPHTTPServing)? = nil, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, @@ -291,7 +292,7 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - mcpHTTPServerFactory: { _, configuration in + mcpHTTPServerFactory: { _, configuration, _ in NoopMCPHTTPServer(endpoint: configuration.url()) }, mcpHTTPServerBindChecker: { _ in }, @@ -322,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( @@ -329,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( @@ -348,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() @@ -362,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 @@ -405,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 }, @@ -1301,7 +1305,7 @@ struct CodexReviewHostTests { 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) diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 85feac08..f25ddc07 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import CodexKit @testable import CodexReviewKit @testable import CodexReviewMCPServer @@ -47,4 +48,47 @@ struct ReviewMCPLogProjectionTests { #expect(item.kind == "agentMessage") #expect(item.content.type == "message") } + + @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), + output: .init(summary: "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"]) + } } From b6870125244256b4d956e29eca23c5812a5e88ba Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:02:56 +0900 Subject: [PATCH 246/359] test(store): use run vocabulary in lifecycle tests --- .../ReviewObservationAwaiterTests.swift | 2 +- .../CodexReviewStoreCommandTests.swift | 234 +++++++++--------- ...ReviewStoreRateLimitAutoRefreshTests.swift | 8 +- .../ReviewRunRecordTests.swift | 8 +- 4 files changed, 126 insertions(+), 126 deletions(-) diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index e8dbad2e..f71fb50d 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -52,7 +52,7 @@ struct ReviewObservationAwaiterTests { private func makeRunningRun() -> ReviewRunRecord { ReviewRunRecord.makeForTesting( - id: "job-awaiter", + id: "run-awaiter", targetSummary: "Uncommitted changes", status: .running, summary: "Running review." diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 55b1801a..ae860d6c 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -6,12 +6,12 @@ import CodexReviewTesting @Suite("Codex review store", .serialized) @MainActor struct CodexReviewStoreCommandTests { - @Test func reviewStartPublishesCompletedJobAndRetainsResult() async throws { + @Test func reviewStartPublishesCompletedRunAndRetainsResult() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), clock: .init(now: { Date(timeIntervalSince1970: 1) }), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -22,10 +22,10 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let read = try await result - #expect(read.runID == "job-1") + #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) #expect(read.core.output.lastAgentMessage == "review text") - #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["job-1"]) + #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["run-1"]) let commands = await backend.recordedCommands() #expect( @@ -43,7 +43,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -53,14 +53,14 @@ struct CodexReviewStoreCommandTests { ) let running = try await result - #expect(running.runID == "job-1") + #expect(running.runID == "run-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", - runID: "job-1", + runID: "run-1", timeout: .seconds(1) ) @@ -69,11 +69,11 @@ struct CodexReviewStoreCommandTests { } } - @Test func awaitReviewReturnsWhenRunningJobCompletes() async throws { + @Test func awaitReviewReturnsWhenRunningRunCompletes() 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 withStoreCommandTestCleanup(backend: backend, store: store) { async let start = store.startReview( @@ -85,7 +85,7 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "job-1", + runID: "run-1", timeout: .seconds(1) ) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) @@ -96,11 +96,11 @@ struct CodexReviewStoreCommandTests { } } - @Test func awaitReviewReturnsWhenRunningJobIsCancelled() async throws { + @Test func awaitReviewReturnsWhenRunningRunIsCancelled() 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 withStoreCommandTestCleanup(backend: backend, store: store) { async let start = store.startReview( @@ -112,11 +112,11 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "job-1", + runID: "run-1", timeout: .seconds(1) ) _ = try await store.cancelReview( - runID: "job-1", + runID: "run-1", cancellation: .mcpClient(message: "Stop") ) let final = try await awaited @@ -130,7 +130,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let start = store.startReview( @@ -142,7 +142,7 @@ struct CodexReviewStoreCommandTests { let snapshot = try await store.awaitReview( sessionID: "session-1", - runID: "job-1", + runID: "run-1", timeout: .milliseconds(10) ) @@ -155,7 +155,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let start = store.startReview( @@ -167,7 +167,7 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "job-1", + runID: "run-1", timeout: .seconds(1) ) await Task.yield() @@ -201,7 +201,7 @@ struct CodexReviewStoreCommandTests { reviewBackend: backend, seed: .init(initialSettingsSnapshot: .init(fallbackModel: "gpt-5.5")) ), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -226,7 +226,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -240,7 +240,7 @@ struct CodexReviewStoreCommandTests { #expect(read.core.output.lastAgentMessage == "second") #expect(read.core.reviewText == "second") - #expect(store.reviewRun(id: "job-1")?.core.output.lastAgentMessage == "second") + #expect(store.reviewRun(id: "run-1")?.core.output.lastAgentMessage == "second") } } @@ -248,7 +248,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -355,17 +355,17 @@ struct CodexReviewStoreCommandTests { let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), clock: .init(now: { clock.now() }), - idGenerator: .init(next: { "job-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) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) clock.current = Date(timeIntervalSince1970: 13) - #expect(try store.readReview(runID: "job-1").elapsedSeconds == 12) + #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -379,7 +379,7 @@ struct CodexReviewStoreCommandTests { ) try await withStoreCommandTestCleanup(backend: backend, store: store) { let existing = ReviewRunRecord.makeForTesting( - id: "job-existing", + id: "run-existing", cwd: "/tmp/project", targetSummary: "Existing", status: .succeeded, @@ -390,7 +390,7 @@ struct CodexReviewStoreCommandTests { workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [existing] ) - store.reviewRun(id: "job-existing")?.sortOrder = 10 + store.reviewRun(id: "run-existing")?.sortOrder = 10 async let result = store.startReview( sessionID: "session-1", @@ -433,27 +433,27 @@ struct CodexReviewStoreCommandTests { #expect(store.reorderWorkspaces(cwds: [firstGroupedWorkspace.cwd], beforeCWD: "/tmp/missing") == false) } - @Test func jobReorderBeforeAnchorMovesItemAndReportsMutation() { + @Test func runReorderBeforeAnchorMovesItemAndReportsMutation() { let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) ) let workspace = CodexReviewWorkspace(cwd: "/tmp/project") - let firstJob = ReviewRunRecord.makeForTesting( - id: "job-first", + let firstRun = ReviewRunRecord.makeForTesting( + id: "run-first", cwd: workspace.cwd, targetSummary: "First", status: .running, summary: "Running" ) - let secondJob = ReviewRunRecord.makeForTesting( - id: "job-second", + let secondRun = ReviewRunRecord.makeForTesting( + id: "run-second", cwd: workspace.cwd, targetSummary: "Second", status: .running, summary: "Running" ) - let thirdJob = ReviewRunRecord.makeForTesting( - id: "job-third", + let thirdRun = ReviewRunRecord.makeForTesting( + id: "run-third", cwd: workspace.cwd, targetSummary: "Third", status: .running, @@ -462,36 +462,36 @@ struct CodexReviewStoreCommandTests { store.loadForTesting( serverState: .running, workspaces: [workspace], - reviewRuns: [firstJob, secondJob, thirdJob] + reviewRuns: [firstRun, secondRun, thirdRun] ) - #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: thirdJob.id)) - #expect(store.orderedReviewRuns(in: workspace).map(\.id) == ["job-second", "job-first", "job-third"]) - #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: firstJob.id) == false) - #expect(store.reorderReviewRun(id: firstJob.id, inWorkspace: workspace.cwd, beforeRunID: "job-missing") == false) + #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: thirdRun.id)) + #expect(store.orderedReviewRuns(in: workspace).map(\.id) == ["run-second", "run-first", "run-third"]) + #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: firstRun.id) == false) + #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: "run-missing") == false) } @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() 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 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: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) let cancel = try await store.cancelReview( - runID: "job-1", + runID: "run-1", cancellation: .mcpClient(message: "Stop") ) await backend.yield(.cancelled("Stop")) _ = try await result #expect(cancel.cancelled) - #expect(try store.readReview(runID: "job-1").core.lifecycle.status == .cancelled) + #expect(try store.readReview(runID: "run-1").core.lifecycle.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains( @@ -508,7 +508,7 @@ struct CodexReviewStoreCommandTests { let debounceGate = AsyncGate() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init( outageDebounce: .seconds(10), @@ -553,12 +553,12 @@ struct CodexReviewStoreCommandTests { } } - @Test func sustainedNetworkOutageInterruptsForRecoveryWithoutTerminalJob() async throws { + @Test func sustainedNetworkOutageInterruptsForRecoveryWithoutTerminalRun() async throws { let backend = FakeCodexReviewBackend() let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -571,10 +571,10 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - let running = try store.readReview(runID: "job-1") + let running = try store.readReview(runID: "run-1") #expect(running.core.lifecycle.status == .running) #expect(running.core.output.summary == "Network unavailable; waiting to reconnect.") - _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) await backend.yield(.cancelled("Stop")) _ = try await result } @@ -601,7 +601,7 @@ struct CodexReviewStoreCommandTests { let sleeper = ControlledTestSleeper(gate: settleGate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init( outageDebounce: .seconds(10), @@ -621,7 +621,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) #expect( await waitUntil { - store.reviewRun(id: "job-1")?.core.output.summary == "Network restored; restarting review." + store.reviewRun(id: "run-1")?.core.output.summary == "Network restored; restarting review." }) networkMonitor.yield(.satisfied()) await settleGate.open() @@ -656,7 +656,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -674,7 +674,7 @@ struct CodexReviewStoreCommandTests { ), for: initialRun) #expect( await waitUntil { - store.reviewRun(id: "job-1")?.core.run.turnID == "turn-actual" + store.reviewRun(id: "run-1")?.core.run.turnID == "turn-actual" }) networkMonitor.yield(.init(status: .unsatisfied)) @@ -727,7 +727,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -774,7 +774,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -820,7 +820,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -867,7 +867,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -882,7 +882,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) - let cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) #expect(cancel.cancelled) await recoverGate.open() @@ -928,7 +928,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -955,7 +955,7 @@ struct CodexReviewStoreCommandTests { } try #require( await waitUntil { - let state = store.runtimeReviewRunState(runID: "job-1") + let state = store.runtimeReviewRunState(runID: "run-1") return state.hasActiveWorker == false && state.activeRun == nil }) await recoverGate.open() @@ -990,7 +990,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -1006,12 +1006,12 @@ struct CodexReviewStoreCommandTests { _ = try await running await backend.finishEvents(for: initialRun) - let cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) let cleanedUp = await waitUntil { - let runtimeState = store.runtimeReviewRunState(runID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "run-1") return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil } - let read = try store.readReview(runID: "job-1") + let read = try store.readReview(runID: "run-1") #expect(cancel.cancelled) #expect(cleanedUp) @@ -1030,7 +1030,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend(nextRun: run) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let running = store.startReview( @@ -1044,23 +1044,23 @@ struct CodexReviewStoreCommandTests { reason: .system(message: "Review runtime stopped."), cancelWorkers: false ) - let cancelled = try store.readReview(runID: "job-1") + let cancelled = try store.readReview(runID: "run-1") - #expect(locallyCancelledReviewRunIDs == ["job-1"]) + #expect(locallyCancelledReviewRunIDs == ["run-1"]) #expect(cancelled.core.lifecycle.status == .cancelled) - let runtimeStateBeforeDetach = store.runtimeReviewRunState(runID: "job-1") + let runtimeStateBeforeDetach = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeStateBeforeDetach.hasActiveWorker) #expect(runtimeStateBeforeDetach.activeRun == run) store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "job-1") + let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeStateAfterDetach.hasActiveWorker == false) #expect(runtimeStateAfterDetach.activeRun == nil) } } - @Test func stopInterruptsActiveReviewBeforeMarkingJobStopped() async throws { + @Test func stopInterruptsActiveReviewBeforeMarkingRunStopped() async throws { let run = CodexReviewBackendModel.Review.Run( threadID: "thread-1", turnID: "turn-1", @@ -1072,7 +1072,7 @@ struct CodexReviewStoreCommandTests { await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { await store.start() @@ -1087,17 +1087,17 @@ struct CodexReviewStoreCommandTests { await store.stop() } try await backend.waitForInterruptReview(timeout: .seconds(2)) - let inFlight = try store.readReview(runID: "job-1") + 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: "job-1") + 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: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeState.activeRun == nil) #expect(runtimeState.hasActiveWorker == false) } @@ -1114,7 +1114,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -1135,7 +1135,7 @@ struct CodexReviewStoreCommandTests { ) store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - let runtimeState = store.runtimeReviewRunState(runID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1153,7 +1153,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -1177,13 +1177,13 @@ struct CodexReviewStoreCommandTests { return true } let request = try #require(await recorder.onlyRequest()) - let read = try store.readReview(runID: "job-1") + 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: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) @@ -1200,7 +1200,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend(nextRun: run) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let running = store.startReview( @@ -1217,7 +1217,7 @@ struct CodexReviewStoreCommandTests { store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) - #expect(store.runtimeReviewRunState(runID: "job-1").hasDetachedWorker == false) + #expect(store.runtimeReviewRunState(runID: "run-1").hasDetachedWorker == false) #expect(await backend.recordedCommands().contains(.cleanupReview(run))) } } @@ -1228,7 +1228,7 @@ struct CodexReviewStoreCommandTests { await backend.holdStartReview(with: startReviewGate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { let running = Task { @MainActor in @@ -1248,9 +1248,9 @@ struct CodexReviewStoreCommandTests { await startReviewGate.open() let result = try #require(resultBeforeStartReviewUnblocked) - #expect(locallyCancelledReviewRunIDs == ["job-1"]) + #expect(locallyCancelledReviewRunIDs == ["run-1"]) #expect(result.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(runID: "job-1") + let runtimeState = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeState.hasActiveWorker == false) #expect(runtimeState.activeRun == nil) } @@ -1267,7 +1267,7 @@ struct CodexReviewStoreCommandTests { let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }) ) @@ -1279,7 +1279,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) await backend.finishEvents(for: initialRun) let read = try await result @@ -1294,7 +1294,7 @@ struct CodexReviewStoreCommandTests { let debounceGate = AsyncGate() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in await debounceGate.wait() }) ) @@ -1303,10 +1303,10 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) networkMonitor.yield(.init(status: .unsatisfied)) - _ = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) await debounceGate.open() await backend.yield(.cancelled("Stop")) let read = try await result @@ -1332,11 +1332,11 @@ struct CodexReviewStoreCommandTests { } } - @Test func sessionScopedCancelRejectsJobFromDifferentSession() async throws { + @Test func sessionScopedCancelRejectsRunFromDifferentSession() 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 withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1346,12 +1346,12 @@ struct CodexReviewStoreCommandTests { await #expect(throws: (any Error).self) { try await store.cancelReview( - runID: "job-1", + runID: "run-1", sessionID: "session-2", cancellation: .mcpClient(message: "Stop") ) } - #expect(try store.readReview(runID: "job-1").cancellable) + #expect(try store.readReview(runID: "run-1").cancellable) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -1371,16 +1371,16 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) _ = try await store.cancelReview( - runID: "job-1", + runID: "run-1", cancellation: .mcpClient(message: "Stop") ) await backend.finishEvents(throwing: StreamClosedError()) @@ -1395,14 +1395,14 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) await backend.yield(.message("partial review")) await backend.finishEvents(throwing: StreamClosedError()) let read = try await result @@ -1433,7 +1433,7 @@ struct CodexReviewStoreCommandTests { let debounceGate = AsyncGate() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }), + idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, networkRecoveryPolicy: .init( outageDebounce: .seconds(10), @@ -1449,7 +1449,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) networkMonitor.yield(.init(status: .unsatisfied)) await outageSleepStarted.wait() @@ -1457,7 +1457,7 @@ struct CodexReviewStoreCommandTests { let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store) - .waitUntilRunStatus(.failed, runID: "job-1", timeout: .milliseconds(100)) != nil + .waitUntilRunStatus(.failed, runID: "run-1", timeout: .milliseconds(100)) != nil #expect(failedBeforeOutageConfirmed == false) await debounceGate.open() @@ -1478,14 +1478,14 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) await backend.finishEvents(throwing: CancellationError()) let read = try await result @@ -1504,7 +1504,7 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { let task = Task { @MainActor in @@ -1532,21 +1532,21 @@ struct CodexReviewStoreCommandTests { await backend.failInterrupts(message: "Interrupt failed") let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) await #expect(throws: FakeCodexReviewBackendError.self) { try await store.cancelReview( - runID: "job-1", + runID: "run-1", cancellation: .mcpClient(message: "Stop") ) } - let readAfterFailure = try store.readReview(runID: "job-1") + let readAfterFailure = try store.readReview(runID: "run-1") #expect(readAfterFailure.cancellable) #expect(readAfterFailure.core.lifecycle.cancellation == nil) @@ -1561,16 +1561,16 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "job-1") != nil) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) _ = try await store.cancelReview( - runID: "job-1", + runID: "run-1", cancellation: .mcpClient(message: "Stop") ) await backend.yield(.completed(summary: "Succeeded.", result: "late result")) @@ -1588,14 +1588,14 @@ struct CodexReviewStoreCommandTests { await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-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) ) - async let cancel = store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + async let cancel = store.cancelReview(runID: "run-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() @@ -1614,7 +1614,7 @@ struct CodexReviewStoreCommandTests { 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 withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1622,7 +1622,7 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) try await backend.waitForStartReview(timeout: .seconds(2)) - let cancel = try await store.cancelReview(runID: "job-1", cancellation: .mcpClient(message: "Stop")) + 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) @@ -1670,11 +1670,11 @@ struct CodexReviewStoreCommandTests { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "job-1" }) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { let running = ReviewRunRecord.makeForTesting( - id: "running-job", + id: "running-run", sessionID: "session-1", cwd: "/tmp/project", targetSummary: "Running", @@ -1697,7 +1697,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let read = try await result - #expect(read.runID == "job-1") + #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 3e56de96..5ace0d7f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -75,7 +75,7 @@ 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) @@ -162,9 +162,9 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { ]) } - @Test func storeTargetsFollowRunningJobState() { + @Test func storeTargetsFollowRunningRunState() { let account = makeAccount(lastFetchAt: now) - let runningJob = ReviewRunRecord.makeForTesting( + let runningRun = ReviewRunRecord.makeForTesting( targetSummary: "Review changes", status: .running, startedAt: now, @@ -177,7 +177,7 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { .init(accountKey: account.accountKey, kind: .selectedIdleInterval, dueAt: now.addingTimeInterval(15 * 60)), ]) - loadStore(store, account: account, reviewRuns: [runningJob]) + loadStore(store, account: account, reviewRuns: [runningRun]) #expect(store.accountRateLimitAutoRefreshTargets(now: now) == [ .init(accountKey: account.accountKey, kind: .selectedRunningInterval, dueAt: now.addingTimeInterval(60)), ]) diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 8c15b6bd..bbd644e1 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -7,7 +7,7 @@ import Testing struct ReviewRunRecordTests { @Test func prebuiltTerminalRunsKeepCoreTerminalState() { let succeeded = ReviewRunRecord.makeForTesting( - id: "job-terminal-succeeded", + id: "run-terminal-succeeded", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", status: .succeeded, @@ -20,7 +20,7 @@ struct ReviewRunRecordTests { #expect(succeeded.core.reviewText == "No findings.") let succeededWithoutFinalReview = ReviewRunRecord.makeForTesting( - id: "job-terminal-succeeded-no-review", + id: "run-terminal-succeeded-no-review", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", status: .succeeded, @@ -33,7 +33,7 @@ struct ReviewRunRecordTests { #expect(succeededWithoutFinalReview.core.reviewText == "Succeeded.") let failed = ReviewRunRecord.makeForTesting( - id: "job-terminal-failed", + id: "run-terminal-failed", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", status: .failed, @@ -45,7 +45,7 @@ struct ReviewRunRecordTests { #expect(failed.core.reviewText == "Backend failed.") let cancelled = ReviewRunRecord.makeForTesting( - id: "job-terminal-cancelled", + id: "run-terminal-cancelled", cwd: "/tmp/workspace", targetSummary: "Uncommitted changes", status: .cancelled, From f763eb770928d776c23f2170736e41f635102f5d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:19:36 +0900 Subject: [PATCH 247/359] test(mcp): rename job fixtures to runs --- .../AppServerClientTests.swift | 6 +- .../CodexReviewMCPHTTPServerTests.swift | 110 +++++++++--------- .../CodexReviewMCPServerTests.swift | 4 +- .../ReviewMCPLogProjectionTests.swift | 12 +- 4 files changed, 66 insertions(+), 66 deletions(-) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index d3d85b6a..07ee1ea1 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -159,10 +159,10 @@ struct AppServerClientTests { await runtime.transport.waitForNotificationStreamCount(1) let backend = AppServerCodexReviewBackend(appServer: runtime.server) - let firstAttempt = try await backend.startReview(makeReviewStart(runID: "job-1", sessionID: "session-1")) + 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: "job-2", sessionID: "session-2")) + let secondAttempt = try await backend.startReview(makeReviewStart(runID: "run-2", sessionID: "session-2")) try await runtime.transport.emitServerNotification( method: "item/commandExecution/outputDelta", @@ -562,7 +562,7 @@ private func eventSequence( } private func makeReviewStart( - runID: String = "job-1", + runID: String = "run-1", sessionID: String = "session-1", target: CodexReviewAPI.Target = .uncommittedChanges ) -> CodexReviewBackendModel.Review.Start { diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index ed7b3fec..51c0d6de 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -133,7 +133,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 @@ -164,7 +164,7 @@ struct CodexReviewMCPHTTPServerTests { let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == false) - #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "job-1") + #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( @@ -176,7 +176,7 @@ struct CodexReviewMCPHTTPServerTests { commands.contains( .startReview( .init( - runID: "job-1", + runID: "run-1", sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -188,7 +188,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, @@ -219,7 +219,7 @@ 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", "runId"]) 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) @@ -237,7 +237,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_await", "arguments": [ - "runId": "job-1" + "runId": "run-1" ], ], ] @@ -262,7 +262,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 @@ -291,13 +291,13 @@ struct CodexReviewMCPHTTPServerTests { await backend.yield(.completed(summary: "Done", result: "review text")) let resolved = try decodeSSEJSON(from: try await responseData) - #expect(resolved.value(for: ["result", "structuredContent", "runId"]) 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( - runID: "job-1", + runID: "run-1", sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -309,7 +309,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 @@ -336,7 +336,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", "runId"]) 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") } } @@ -349,7 +349,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let included = ReviewRunRecord.makeForTesting( - id: "job-included", + id: "run-included", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Uncommitted changes", @@ -357,7 +357,7 @@ struct CodexReviewMCPHTTPServerTests { summary: "Done" ) let otherSession = ReviewRunRecord.makeForTesting( - id: "job-other-session", + id: "run-other-session", sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Uncommitted changes", @@ -365,7 +365,7 @@ struct CodexReviewMCPHTTPServerTests { summary: "Done" ) let otherWorkspace = ReviewRunRecord.makeForTesting( - id: "job-other-workspace", + id: "run-other-workspace", sessionID: sessionID, cwd: "/tmp/other", targetSummary: "Uncommitted changes", @@ -399,7 +399,7 @@ struct CodexReviewMCPHTTPServerTests { ) let items = try #require(response.value(for: ["result", "structuredContent", "items"]) as? [[String: Any]]) - #expect(items.compactMap { $0["runId"] as? String } == ["job-included"]) + #expect(items.compactMap { $0["runId"] as? String } == ["run-included"]) } } @@ -411,8 +411,8 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let includedJob = ReviewRunRecord.makeForTesting( - id: "job-in-session", + let includedRun = ReviewRunRecord.makeForTesting( + id: "run-in-session", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", @@ -425,9 +425,9 @@ struct CodexReviewMCPHTTPServerTests { serverState: .running, workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [ - includedJob, + includedRun, ReviewRunRecord.makeForTesting( - id: "job-other-session", + id: "run-other-session", sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Other", @@ -446,7 +446,7 @@ struct CodexReviewMCPHTTPServerTests { "method": "tools/call", "params": [ "name": "review_read", - "arguments": ["runId": "job-in-session"], + "arguments": ["runId": "run-in-session"], ], ] ) @@ -459,12 +459,12 @@ struct CodexReviewMCPHTTPServerTests { "method": "tools/call", "params": [ "name": "review_read", - "arguments": ["runID": "job-other-session"], + "arguments": ["runID": "run-other-session"], ], ] ) #expect(allowed.value(for: ["result", "isError"]) as? Bool == false) - #expect(allowed.value(for: ["result", "structuredContent", "runId"]) as? String == "job-in-session") + #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 @@ -473,7 +473,7 @@ struct CodexReviewMCPHTTPServerTests { #expect(denied.value(for: ["result", "isError"]) as? Bool == true) #expect( (denied.value(for: ["result", "content"]) as? [[String: Any]])?.first?["text"] as? String - == "Run job-other-session was not found.") + == "Run run-other-session was not found.") } } @@ -486,8 +486,8 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let longOutput = "Tests passed\n" + String(repeating: "x", count: 4500) - let job = ReviewRunRecord.makeForTesting( - id: "job-semantic", + let runRecord = ReviewRunRecord.makeForTesting( + id: "run-semantic", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", @@ -499,7 +499,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - reviewRuns: [job] + reviewRuns: [runRecord] ) let defaultResponse = try await postJSONRPC( @@ -512,13 +512,13 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_read", "arguments": [ - "runId": "job-semantic" + "runId": "run-semantic" ], ], ] ) - #expect(defaultResponse.value(for: ["result", "structuredContent", "runId"]) 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 @@ -531,17 +531,17 @@ struct CodexReviewMCPHTTPServerTests { let log = try #require( defaultResponse.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["orderedEntryIds"] as? [String] == ["job-semantic:message"]) + #expect(log["orderedEntryIds"] as? [String] == ["run-semantic:message"]) #expect(log["activeEntryIds"] as? [String] == []) #expect(log["activeEntryCount"] as? Int == 0) - #expect(log["latestEntryId"] as? String == "job-semantic:message") + #expect(log["latestEntryId"] as? String == "run-semantic:message") let itemsPage = try #require(log["itemsPage"] as? [String: Any]) #expect(itemsPage["total"] as? Int == 1) #expect(itemsPage["limit"] as? Int == 1) #expect(itemsPage["returned"] as? Int == 1) let items = try #require(log["items"] as? [[String: Any]]) let messageItem = try #require(items.first) - #expect(messageItem["id"] as? String == "job-semantic:message") + #expect(messageItem["id"] as? String == "run-semantic:message") #expect(messageItem["kind"] as? String == "agentMessage") let content = try #require(messageItem["content"] as? [String: Any]) #expect(content["type"] as? String == "message") @@ -561,8 +561,8 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let job = ReviewRunRecord.makeForTesting( - id: "job-tool-progress", + let runRecord = ReviewRunRecord.makeForTesting( + id: "run-tool-progress", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", @@ -572,7 +572,7 @@ struct CodexReviewMCPHTTPServerTests { store.loadForTesting( serverState: .running, workspaces: [.init(cwd: "/tmp/project")], - reviewRuns: [job] + reviewRuns: [runRecord] ) let response = try await postJSONRPC( @@ -585,7 +585,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_read", "arguments": [ - "runId": "job-tool-progress" + "runId": "run-tool-progress" ], ], ] @@ -593,11 +593,11 @@ struct CodexReviewMCPHTTPServerTests { let log = try #require( response.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["activeEntryIds"] as? [String] == ["job-tool-progress:summary"]) + #expect(log["activeEntryIds"] as? [String] == ["run-tool-progress:summary"]) #expect(log["activeEntryCount"] as? Int == 1) let items = try #require(log["items"] as? [[String: Any]]) let item = try #require(items.first) - #expect(item["id"] as? String == "job-tool-progress:summary") + #expect(item["id"] as? String == "run-tool-progress:summary") #expect(item["kind"] as? String == "diagnostic") let content = try #require(item["content"] as? [String: Any]) #expect(content["type"] as? String == "diagnostic") @@ -614,7 +614,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let running = ReviewRunRecord.makeForTesting( - id: "job-running", + id: "run-running", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Uncommitted changes", @@ -624,7 +624,7 @@ struct CodexReviewMCPHTTPServerTests { summary: "Running" ) let otherSession = ReviewRunRecord.makeForTesting( - id: "job-other-session", + id: "run-other-session", sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Uncommitted changes", @@ -657,7 +657,7 @@ struct CodexReviewMCPHTTPServerTests { ] ) - #expect(response.value(for: ["result", "structuredContent", "runId"]) 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") @@ -681,7 +681,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let completed = ReviewRunRecord.makeForTesting( - id: "job-completed", + id: "run-completed", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Completed", @@ -691,7 +691,7 @@ struct CodexReviewMCPHTTPServerTests { summary: "Done" ) let running = ReviewRunRecord.makeForTesting( - id: "job-running", + id: "run-running", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Running", @@ -723,7 +723,7 @@ struct CodexReviewMCPHTTPServerTests { ] ) - #expect(response.value(for: ["result", "structuredContent", "runId"]) 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) @@ -743,7 +743,7 @@ struct CodexReviewMCPHTTPServerTests { workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [ ReviewRunRecord.makeForTesting( - id: "job-running-1", + id: "run-running-1", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "First", @@ -753,7 +753,7 @@ struct CodexReviewMCPHTTPServerTests { summary: "Running" ), ReviewRunRecord.makeForTesting( - id: "job-running-2", + id: "run-running-2", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Second", @@ -785,8 +785,8 @@ struct CodexReviewMCPHTTPServerTests { #expect(response.value(for: ["result", "isError"]) as? Bool == true) #expect(text?.contains("matched multiple review runs") == true) - #expect(text?.contains("job-running-1") == true) - #expect(text?.contains("job-running-2") == true) + #expect(text?.contains("run-running-1") == true) + #expect(text?.contains("run-running-2") == true) } } @@ -799,7 +799,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let running = ReviewRunRecord.makeForTesting( - id: "job-running", + id: "run-running", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Running", @@ -824,14 +824,14 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "runId": "job-running", + "runId": "run-running", "reason": "Stop from MCP", ], ], ] ) - #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "job-running") + #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running") #expect(running.core.lifecycle.status == .cancelled) } } @@ -842,7 +842,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( @@ -875,7 +875,7 @@ struct CodexReviewMCPHTTPServerTests { await backend.yield(.completed(summary: "Done", result: "review text")) let resolved = try decodeSSEJSON(from: try await responseData) - #expect(resolved.value(for: ["result", "structuredContent", "runId"]) 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 @@ -969,7 +969,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let other = ReviewRunRecord.makeForTesting( - id: "job-other-session", + id: "run-other-session", sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Other", @@ -994,7 +994,7 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "runID": "job-other-session", + "runID": "run-other-session", "reason": "Stop from MCP", ], ], @@ -1015,7 +1015,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) let running = ReviewRunRecord.makeForTesting( - id: "job-running", + id: "run-running", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Running", diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 0a36fcfe..b5069e98 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -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) @@ -44,7 +44,7 @@ struct CodexReviewMCPServerTests { } let read = snapshot.result let log = snapshot.log - #expect(read.runID == "job-1") + #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) #expect(log.finalSummary == "Done") #expect(log.finalResult == "review") diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index f25ddc07..9fd06010 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -8,7 +8,7 @@ import CodexKit struct ReviewMCPLogProjectionTests { @Test func runningResultProjectsSummaryAsActiveDiagnosticItem() throws { let projection = ReviewMCPLogProjection(result: .init( - runID: "job-1", + runID: "run-1", core: .init( lifecycle: .init(status: .running), output: .init(summary: "Review started.") @@ -16,10 +16,10 @@ struct ReviewMCPLogProjectionTests { cancellable: true )) - #expect(projection.orderedEntryIDs == ["job-1:summary"]) - #expect(projection.activeEntryIDs == ["job-1:summary"]) + #expect(projection.orderedEntryIDs == ["run-1:summary"]) + #expect(projection.activeEntryIDs == ["run-1:summary"]) #expect(projection.activeEntryCount == 1) - #expect(projection.latestEntryID == "job-1:summary") + #expect(projection.latestEntryID == "run-1:summary") let item = try #require(projection.items.first) #expect(item.kind == "diagnostic") #expect(item.content.type == "diagnostic") @@ -27,7 +27,7 @@ struct ReviewMCPLogProjectionTests { @Test func finalResultProjectsFinalReviewText() throws { let projection = ReviewMCPLogProjection(result: .init( - runID: "job-2", + runID: "run-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), output: .init( @@ -44,7 +44,7 @@ struct ReviewMCPLogProjectionTests { #expect(projection.finalSummary == "Done.") #expect(projection.finalResult == "No findings.") let item = try #require(projection.items.first) - #expect(item.id == "job-2:message") + #expect(item.id == "run-2:message") #expect(item.kind == "agentMessage") #expect(item.content.type == "message") } From b61279b0501ed0e9257bfd8c088841fd136fb05b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:33:47 +0900 Subject: [PATCH 248/359] refactor(review): remove domain event pipeline --- .../AppServerCodexReviewBackend.swift | 288 +---------------- .../ReviewBackendEventSession.swift | 44 +-- .../CodexReviewKit/CodexReviewBackend.swift | 3 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 1 - .../Domain/ReviewDomainEvent.swift | 43 --- .../Domain/ReviewEventItem.swift | 306 ------------------ .../Domain/ReviewIdentifiers.swift | 71 ---- .../CodexReviewKit/Domain/ReviewKinds.swift | 271 ---------------- .../Store/CodexReviewStoreReviews.swift | 72 +---- .../ReviewMonitorPreviewContent.swift | 20 +- .../AppServerClientTests.swift | 110 ++----- .../ReviewBackendEventSessionTests.swift | 50 +-- 12 files changed, 52 insertions(+), 1227 deletions(-) delete mode 100644 Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewEventItem.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift delete mode 100644 Sources/CodexReviewKit/Domain/ReviewKinds.swift diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index a020e4fe..fac9d573 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -493,10 +493,10 @@ private enum AppServerTypedReviewEventAdapter { .init(events: messageEvents(message), controlThreadID: controlThreadID) case .messageDelta(let delta, _): .init(events: messageDeltaEvents(delta), controlThreadID: controlThreadID) - case .reasoningSummaryPartAdded(let part, _): - .init(events: reasoningPartEvents(part), controlThreadID: controlThreadID) - case .reasoningDelta(let delta, _): - .init(events: reasoningDeltaEvents(delta), 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: _)): @@ -543,14 +543,7 @@ private enum AppServerTypedReviewEventAdapter { _ status: String, turnID: String ) -> [CodexReviewBackendModel.Review.Event] { - let seed = ReviewEventItemSeed( - id: .init(rawValue: "\(turnID):status"), - kind: .init(rawValue: "reviewThreadStatus"), - family: .diagnostic, - phase: .completed, - content: .diagnostic(.init(message: "Review thread status changed: \(status).")) - ) - return [.domainEvents([.itemCompleted(seed)])] + [.log("Review thread \(turnID) status changed: \(status).")] } private static func terminalFailureEvents( @@ -580,23 +573,12 @@ private enum AppServerTypedReviewEventAdapter { ) ] } - guard item.kind != .userMessage, - let seed = eventItemSeed(for: item, phase: phase) + guard item.kind == .agentMessage, + let text = item.text?.nilIfEmpty else { return [] } - let domainEvent: ReviewDomainEvent = - switch phase { - case .started: - .itemStarted(seed) - case .updated: - .itemUpdated(seed) - case .completed: - .itemCompleted(seed) - } - return [ - .domainEvents([domainEvent]) - ] + return [.message(text)] } private static func messageEvents( @@ -607,12 +589,7 @@ private enum AppServerTypedReviewEventAdapter { else { return [] } - let item = CodexThreadItem( - id: message.id, - kind: .agentMessage, - content: .message(message) - ) - return itemEvents(item, phase: .completed) + return [.message(message.text)] } private static func messageDeltaEvents( @@ -622,252 +599,13 @@ private enum AppServerTypedReviewEventAdapter { return [] } let itemID = delta.itemID?.nilIfEmpty ?? "agent-message-delta" - let domainEvent = ReviewDomainEvent.textDelta( - itemID: .init(rawValue: itemID), - kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: delta.text - ) - return [ - .domainEvents([domainEvent]) - ] - } - - private static func reasoningPartEvents( - _ part: CodexReasoningPart - ) -> [CodexReviewBackendModel.Review.Event] { - let style: ReviewEventItem.Reasoning.Style = - switch part.kind { - case .summary: - .summary - case .text: - .raw - } - let seed = ReviewEventItemSeed( - id: .init(rawValue: part.id), - kind: .reasoning, - family: .reasoning, - phase: .running, - content: .reasoning(.init(text: "", style: style)) - ) - return [.domainEvents([.itemStarted(seed)])] - } - - private static func reasoningDeltaEvents( - _ delta: CodexReasoningDelta - ) -> [CodexReviewBackendModel.Review.Event] { - guard delta.delta.isEmpty == false else { - return [] - } - let style: ReviewEventItem.Reasoning.Style = - switch delta.part.kind { - case .summary: - .summary - case .text: - .raw - } - let domainEvent = ReviewDomainEvent.textDelta( - itemID: .init(rawValue: delta.id), - kind: .reasoning, - family: .reasoning, - content: .reasoning(.init(text: "", style: style)), - delta: delta.delta - ) - return [.domainEvents([domainEvent])] + return [.messageDelta(delta.text, itemID: itemID)] } private static func unknownEvents( _ raw: CodexRawNotification ) -> [CodexReviewBackendModel.Review.Event] { - let itemID = - raw.turnID?.rawValue - ?? raw.threadID?.rawValue - ?? raw.method - let detail = String(data: raw.params, encoding: .utf8) - let seed = ReviewEventItemSeed( - id: .init(rawValue: "\(itemID):\(raw.method)"), - kind: .init(rawValue: raw.method), - family: .unknown, - phase: .running, - content: .unknown( - .init( - title: raw.method, - detail: detail, - rawKind: .init(rawValue: raw.method) - )) - ) - return [.domainEvents([.itemUpdated(seed)])] - } - - private static func eventItemSeed( - for item: CodexThreadItem, - phase: AppServerTypedItemPhase - ) -> ReviewEventItemSeed? { - ReviewEventItemSeed( - id: .init(rawValue: item.id), - kind: .init(rawValue: item.kind.rawValue), - family: item.reviewItemFamily, - phase: item.reviewItemPhase(defaultingTo: phase), - content: item.reviewEventContent - ) - } - - private static func contextCompactionText( - phase: AppServerTypedItemPhase, - status: String? - ) -> String { - switch phase { - case .started, .updated: - return appServerContextCompactionStartedText - case .completed: - 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 extension CodexThreadItem { - var reviewItemFamily: ReviewItemFamily { - switch kind { - case .agentMessage: - .message - case .plan: - .plan - case .reasoning: - .reasoning - case .commandExecution: - .command - case .fileChange: - .fileChange - case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .imageView, .imageGeneration, .sleep: - .tool - case .webSearch: - .search - case .contextCompaction: - .contextCompaction - case .diagnostic, .error: - .diagnostic - case .userMessage, .subAgentActivity, .unknown: - .unknown - } - } - - var reviewEventContent: ReviewEventItem.Content { - switch content { - case .message(let message): - .message(.init(text: message.text)) - case .plan(let text): - .plan(.init(markdown: text)) - case .reasoning(let reasoning): - .reasoning( - .init( - text: reasoning.text, - style: reasoning.summary.isEmpty ? .raw : .summary - )) - case .command(let command): - .command( - .init( - command: command.command, - cwd: command.cwd, - output: command.output ?? "", - exitCode: command.exitCode, - status: command.status.map { .init(rawValue: $0.rawValue) } - )) - case .fileChange(let fileChange): - .fileChange( - .init( - title: "File changes", - output: fileChange.output ?? "", - paths: fileChange.path.map { [$0] } ?? [], - status: fileChange.status.map { .init(rawValue: $0.rawValue) } - )) - case .toolCall(let toolCall): - .toolCall( - .init( - namespace: toolCall.namespace, - server: toolCall.server, - tool: toolCall.name, - arguments: toolCall.arguments, - result: toolCall.result, - error: toolCall.error, - status: toolCall.status.map { .init(rawValue: $0.rawValue) } - )) - case .contextCompaction(let text): - .contextCompaction( - .init( - title: text?.nilIfEmpty ?? appServerContextCompactionStartedText, - status: logStatus(phase: .updated).map { .init(rawValue: $0) } - )) - case .diagnostic(let text): - .diagnostic( - .init( - message: text, - severity: kind == .error ? .error : nil - )) - case .log(let text): - .unknown(.init(title: kind.rawValue, detail: text, rawKind: .init(rawValue: kind.rawValue))) - case .unknown(let raw): - .unknown( - .init( - title: raw.rawType, - detail: raw.text, - rawKind: .init(rawValue: raw.rawType) - )) - } - } - - func reviewItemPhase(defaultingTo phase: AppServerTypedItemPhase) -> ReviewItemPhase { - if let status = statusRaw?.nilIfEmpty { - let normalized = ReviewItemPhase.normalized(status) - if phase == .completed, normalized == .running { - return .completed - } - return normalized - } - return switch phase { - case .started, .updated: - .running - case .completed: - .completed - } - } - - var statusRaw: String? { - switch content { - case .command(let command): - command.status?.rawValue - case .fileChange(let fileChange): - fileChange.status?.rawValue - case .toolCall(let toolCall): - toolCall.status?.rawValue - case .contextCompaction, .diagnostic, .log, .message, .plan, .reasoning, .unknown: - nil - } - } - - func logStatus(phase: AppServerTypedItemPhase) -> String? { - if let status = statusRaw?.nilIfEmpty { - return status - } - return switch phase { - case .started: - "inProgress" - case .updated: - nil - case .completed: - "completed" - } + [.log("Unhandled app-server notification: \(raw.method)")] } } @@ -1090,10 +828,6 @@ private extension CodexLoginHandle { } } -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 reasoningSummaryGroupID(itemID: String, summaryIndex: Int) -> String { "\(itemID):summary:\(summaryIndex)" diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 251c171e..dfb7f328 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -167,9 +167,6 @@ package actor ReviewBackendEventSession { typedReviewResultText = text } return event - case .domainEvents(let events): - recordTypedMessageText(from: events) - return event case .completed(let summary, nil): return .completed(summary: summary, result: typedReviewResultText) case .started, @@ -182,41 +179,6 @@ package actor ReviewBackendEventSession { } } - private func recordTypedMessageText(from events: [ReviewDomainEvent]) { - for event in events { - switch event { - case .itemStarted(let seed), - .itemUpdated(let seed), - .itemCompleted(let seed): - recordTypedMessageText(from: seed) - case .textDelta(let itemID, _, let family, _, let delta) where family == .message: - let text = (typedMessageTextByItemID[itemID.rawValue] ?? "") + delta - typedMessageTextByItemID[itemID.rawValue] = text - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - case .runStarted, - .textDelta, - .reviewCompleted, - .reviewFailed, - .reviewCancelled: - break - } - } - } - - private func recordTypedMessageText(from seed: ReviewEventItemSeed) { - guard seed.family == .message, - case .message(let message) = seed.content - else { - return - } - typedMessageTextByItemID[seed.id.rawValue] = message.text - if let text = message.text.nilIfEmpty { - typedReviewResultText = text - } - } - private func finishSession(cancellationMessage: String?) async { guard finished == false else { return @@ -276,8 +238,7 @@ package actor ReviewBackendEventSession { await callbacks.recordTurnStarted(turnID) case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) - case .domainEvents, - .message, + case .message, .messageDelta, .log: break @@ -308,8 +269,7 @@ private extension CodexReviewBackendModel.Review.Event { switch self { case .completed, .failed, .cancelled: true - case .domainEvents, - .started, + case .started, .message, .messageDelta, .log: diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 74f5a81f..3de49e43 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -178,8 +178,7 @@ package actor BackendReviewEventMailbox { switch event { case .completed, .failed, .cancelled: return true - case .domainEvents, - .started, + case .started, .message, .messageDelta, .log: diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 087fe2e5..18eebf1c 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -277,7 +277,6 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { - case domainEvents([ReviewDomainEvent]) case started(turnID: String, reviewThreadID: String?, model: String?) case message(String) case messageDelta(String, itemID: String) diff --git a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift b/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift deleted file mode 100644 index 34b77f0e..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewDomainEvent.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -package enum ReviewDomainEvent: Equatable, Sendable { - case runStarted(turnID: ReviewTurn.ID, reviewThreadID: ReviewThread.ID?, model: String?) - case itemStarted(ReviewEventItemSeed) - case itemUpdated(ReviewEventItemSeed) - case itemCompleted(ReviewEventItemSeed) - case textDelta(itemID: ReviewEventItem.ID, kind: ReviewItemKind, family: ReviewItemFamily, content: ReviewEventItem.Content, delta: String) - case reviewCompleted(summary: String, result: String?) - case reviewFailed(String) - case reviewCancelled(String) -} - -package struct ReviewEventItemSeed: Equatable, Sendable { - public var id: ReviewEventItem.ID - public var kind: ReviewItemKind - public var family: ReviewItemFamily - public var phase: ReviewItemPhase - public var content: ReviewEventItem.Content - public var startedAt: Date? - public var completedAt: Date? - public var durationMs: Int? - - public init( - id: ReviewEventItem.ID, - kind: ReviewItemKind, - family: ReviewItemFamily, - phase: ReviewItemPhase, - content: ReviewEventItem.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/CodexReviewKit/Domain/ReviewEventItem.swift b/Sources/CodexReviewKit/Domain/ReviewEventItem.swift deleted file mode 100644 index 87078f8f..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewEventItem.swift +++ /dev/null @@ -1,306 +0,0 @@ -import Foundation - -package enum ReviewEventItem { - public typealias ID = ReviewEventItemID - - 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 - } - } -} diff --git a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift b/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift deleted file mode 100644 index aa375e7f..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewIdentifiers.swift +++ /dev/null @@ -1,71 +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 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 - } -} - -package struct ReviewEventItemID: 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/CodexReviewKit/Domain/ReviewKinds.swift b/Sources/CodexReviewKit/Domain/ReviewKinds.swift deleted file mode 100644 index 966c42dd..00000000 --- a/Sources/CodexReviewKit/Domain/ReviewKinds.swift +++ /dev/null @@ -1,271 +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 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/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 03663fbf..7daf1143 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -244,73 +244,6 @@ extension CodexReviewStore { appendRecoveryProgress(networkRecoveryUnavailableMessage, to: runRecord) } - private func applyDomainEvents( - _ events: [ReviewDomainEvent], - to runRecord: ReviewRunRecord, - at timestamp: Date - ) { - for event in events { - guard shouldApplyDomainEvent(event, to: runRecord) else { - continue - } - updateCoreOutput(from: event, for: runRecord) - } - } - - private func shouldApplyDomainEvent(_ event: ReviewDomainEvent, to runRecord: ReviewRunRecord) -> Bool { - switch event { - case .textDelta(let itemID, _, let family, _, _) - where family == .message && runRecord.completedAgentMessageItemIDs.contains(itemID.rawValue): - false - default: - true - } - } - - private func updateCoreOutput(from event: ReviewDomainEvent, for runRecord: ReviewRunRecord) { - switch event { - case .itemStarted(let seed), - .itemUpdated(let seed): - updateCoreOutput(from: seed, isCompleted: false, for: runRecord) - case .itemCompleted(let seed): - updateCoreOutput(from: seed, isCompleted: true, for: runRecord) - case .textDelta(let itemID, _, let family, _, let delta) where family == .message: - guard let updatedMessage = runRecord.appendAgentMessageDelta(itemID: itemID.rawValue, delta: delta) else { - return - } - runRecord.core.output.lastAgentMessage = updatedMessage - runRecord.core.output.summary = updatedMessage - case .runStarted, - .textDelta, - .reviewCompleted, - .reviewFailed, - .reviewCancelled: - break - } - } - - private func updateCoreOutput( - from seed: ReviewEventItemSeed, - isCompleted: Bool, - for runRecord: ReviewRunRecord - ) { - guard seed.family == .message, - case .message(let message) = seed.content - else { - return - } - runRecord.noteAgentMessageSnapshot( - itemID: seed.id.rawValue, - text: message.text, - isCompleted: isCompleted - ) - guard let text = message.text.nilIfEmpty else { - return - } - runRecord.core.output.lastAgentMessage = text - runRecord.core.output.summary = text - } - private func applyMessageSnapshot( _ text: String, itemID: String?, @@ -910,8 +843,6 @@ extension CodexReviewStore { } var updatedRun = currentRun switch event { - case .domainEvents(let events): - applyDomainEvents(events, to: runRecord, at: clock.now()) case .started(let turnID, let reviewThreadID, let model): runRecord.core.run.turnID = turnID runRecord.core.run.reviewThreadID = reviewThreadID ?? runRecord.core.run.reviewThreadID @@ -1017,8 +948,7 @@ private extension CodexReviewBackendModel.Review.Event { switch self { case .completed, .failed, .cancelled: true - case .domainEvents, - .started, + case .started, .message, .messageDelta, .log: diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index a194a419..2489c82b 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -747,8 +747,7 @@ public enum ReviewMonitorPreviewContent { private static func contextCompactionItem( _ itemName: String, - title: String, - status: ReviewContextCompactionStatus + title: String ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, @@ -778,7 +777,7 @@ public enum ReviewMonitorPreviewContent { _ itemName: String, output: String, exitCode: Int, - status: ReviewCommandStatus + status: CodexTurnStatus ) -> PreviewChatLogItemTemplate { .init( itemName: itemName, @@ -788,7 +787,7 @@ public enum ReviewMonitorPreviewContent { command: "", output: output, exitCode: exitCode, - status: CodexTurnStatus(status) + status: status )) ) } @@ -796,12 +795,12 @@ public enum ReviewMonitorPreviewContent { private static func toolCallItem( _ itemName: String, result: String, - status: ReviewToolCallStatus + status: CodexTurnStatus ) -> PreviewChatLogItemTemplate { return PreviewChatLogItemTemplate( itemName: itemName, kind: .mcpToolCall, - content: .toolCall(.init(result: result, status: CodexTurnStatus(status))) + content: .toolCall(.init(result: result, status: status)) ) } @@ -923,8 +922,7 @@ public enum ReviewMonitorPreviewContent { ), contextCompactionItem( "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)", - title: "Context automatically compacted", - status: .completed + title: "Context automatically compacted" ), planItem( "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)", @@ -958,7 +956,7 @@ public enum ReviewMonitorPreviewContent { toolCallItem( "running-tool-\(workspaceName)-\(definition.targetSummary)", result: "MCP codex_review.review_start started.", - status: .started + status: .running ), reasoningItem( "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)", @@ -1063,10 +1061,6 @@ private extension CodexTurnStatus { self = .cancelled } } - - init(_ status: some ReviewOpenStringValue) { - self.init(rawValue: status.rawValue) - } } private extension CodexDataPhase { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 07ee1ea1..f27beaf5 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -8,35 +8,16 @@ import CodexReviewKit private struct BackendReviewEventSequence: AsyncSequence { 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 == false { - switch event { - case .domainEvents: - continue - case .started, - .message, - .messageDelta, - .log, - .completed, - .failed, - .cancelled: - break - } - } - return event - } - return nil + try await mailbox.next() } } var mailbox: BackendReviewEventMailbox - var includesDomainEvents: Bool func makeAsyncIterator() -> AsyncIterator { - AsyncIterator(mailbox: mailbox, includesDomainEvents: includesDomainEvents) + AsyncIterator(mailbox: mailbox) } } @@ -83,7 +64,7 @@ struct AppServerClientTests { let backend = AppServerCodexReviewBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt, includingDomainEvents: true).makeAsyncIterator() + var iterator = eventSequence(attempt).makeAsyncIterator() try await runtime.transport.emitServerNotification( method: "item/completed", @@ -118,41 +99,11 @@ struct AppServerClientTests { #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - guard case .domainEvents(let commandDomainEvents) = try await iterator.next() - else { - Issue.record("expected typed command domain event") - return - } - guard case .itemCompleted(let commandSeed) = try #require(commandDomainEvents.first) else { - Issue.record("expected completed command seed") - return - } - #expect(commandSeed.id.rawValue == "cmd-1") - guard case .command(let command) = commandSeed.content else { - Issue.record("expected command content") - return - } - #expect(command.command == "swift test") - #expect(command.output == "passed") - guard case .domainEvents(let messageDomainEvents) = try await iterator.next() - else { - Issue.record("expected typed message delta domain event") - return - } - guard - case .textDelta(let itemID, kind: _, family: let family, content: _, delta: let delta) = - try #require(messageDomainEvents.first) - else { - Issue.record("expected message text delta") - return - } - #expect(itemID.rawValue == "msg-1") - #expect(family == .message) - #expect(delta == "Looks good.") + #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) } - @Test func backendScopesCommandOutputDeltasByReviewThread() async throws { + @Test func backendScopesMessageDeltasByReviewThread() 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") @@ -165,52 +116,38 @@ struct AppServerClientTests { let secondAttempt = try await backend.startReview(makeReviewStart(runID: "run-2", sessionID: "session-2")) try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", + method: "item/agentMessage/delta", params: TestDeltaNotification( threadID: "review-thread-1", turnID: "turn-1", - itemID: "cmd-1", + itemID: "msg-1", delta: "first" ) ) try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", + method: "item/agentMessage/delta", params: TestDeltaNotification( threadID: "review-thread-2", turnID: "turn-2", - itemID: "cmd-1", + itemID: "msg-1", delta: "second" ) ) - var firstIterator = eventSequence(firstAttempt, includingDomainEvents: true).makeAsyncIterator() + var firstIterator = eventSequence(firstAttempt).makeAsyncIterator() #expect( try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) - guard case .domainEvents(let firstDomainEvents) = try await firstIterator.next(), - case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), - case .command(let firstCommand) = firstSeed.content - else { - Issue.record("expected first command output domain event") - return - } - #expect(firstCommand.output == "first") + #expect(try await firstIterator.next() == .messageDelta("first", itemID: "msg-1")) - var secondIterator = eventSequence(secondAttempt, includingDomainEvents: true).makeAsyncIterator() + var secondIterator = eventSequence(secondAttempt).makeAsyncIterator() #expect( try await secondIterator.next() == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) - guard case .domainEvents(let secondDomainEvents) = try await secondIterator.next(), - case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), - case .command(let secondCommand) = secondSeed.content - else { - Issue.record("expected second command output domain event") - return - } - #expect(secondCommand.output == "second") + #expect(try await secondIterator.next() == .messageDelta("second", itemID: "msg-1")) } - @Test func backendEmitsTypedCommandOutputDeltasAsDomainEvents() async throws { + @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") @@ -244,13 +181,15 @@ struct AppServerClientTests { delta: "second" ) ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init(id: "turn-1", status: "completed") + ) + ) - guard case .domainEvents = try await nextEvent(from: attempt.events), - case .domainEvents = try await nextEvent(from: attempt.events) - else { - Issue.record("expected direct log updates") - return - } + #expect(try await nextEvent(from: attempt.events) == .completed(summary: "Succeeded.", result: nil)) } @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { @@ -555,10 +494,9 @@ private func nextEvent( } private func eventSequence( - _ attempt: BackendReviewAttempt, - includingDomainEvents: Bool = false + _ attempt: BackendReviewAttempt ) -> BackendReviewEventSequence { - BackendReviewEventSequence(mailbox: attempt.events, includesDomainEvents: includingDomainEvents) + BackendReviewEventSequence(mailbox: attempt.events) } private func makeReviewStart( diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index 9b8d7bc7..dc33408e 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -51,31 +51,16 @@ struct ReviewBackendEventSessionTests { #expect(await recorder.finishedMetrics() == metrics) } - @Test func emitsDomainEventsWithoutDelayingLaterUpdates() async throws { + @Test func emitsLogsWithoutDelayingLaterUpdates() async throws { let session = ReviewBackendEventSession(run: makeRun()) let attempt = await session.attempt() - await session.receive([commandDomainEvent(output: "first")], controlThreadID: "review-thread") - await session.receive([commandDomainEvent(output: "firstsecond")], controlThreadID: "review-thread") + await session.receive([.log("first")], controlThreadID: "review-thread") + await session.receive([.log("second")], controlThreadID: "review-thread") await session.finish(throwing: nil) - guard case .domainEvents(let firstDomainEvents) = try await nextEvent(from: attempt.events), - case .itemUpdated(let firstSeed) = try #require(firstDomainEvents.first), - case .command(let firstCommand) = firstSeed.content - else { - Issue.record("expected first domain event") - return - } - #expect(firstCommand.output == "first") - - guard case .domainEvents(let secondDomainEvents) = try await nextEvent(from: attempt.events), - case .itemUpdated(let secondSeed) = try #require(secondDomainEvents.first), - case .command(let secondCommand) = secondSeed.content - else { - Issue.record("expected second domain event") - return - } - #expect(secondCommand.output == "firstsecond") + #expect(try await nextEvent(from: attempt.events) == .log("first")) + #expect(try await nextEvent(from: attempt.events) == .log("second")) #expect(try await nextEvent(from: attempt.events) == nil) } @@ -160,32 +145,9 @@ private func makeRun() -> CodexReviewBackendModel.Review.Run { ) } -private func commandDomainEvent(output: String) -> CodexReviewBackendModel.Review.Event { - .domainEvents( - [ - .itemUpdated( - .init( - id: "cmd-1", - kind: .commandExecution, - family: .command, - phase: .running, - content: .command(.init(command: "swift test", output: output)) - )) - ]) -} - private func messageTextDelta( _ delta: String, itemID: String ) -> CodexReviewBackendModel.Review.Event { - .domainEvents( - [ - .textDelta( - itemID: .init(rawValue: itemID), - kind: .agentMessage, - family: .message, - content: .message(.init(text: "")), - delta: delta - ) - ]) + .messageDelta(delta, itemID: itemID) } From d21f486054c8136f5dfda229423bd2837a90ab3a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:39:08 +0900 Subject: [PATCH 249/359] test(review-ui): use review mode preview flag --- .../Store/CodexReviewStoreDiagnostics.swift | 2 -- .../xcshareddata/xcschemes/CodexReviewMonitor.xcscheme | 7 +------ .../CodexReviewMonitor/CodexReviewMonitorApp.swift | 4 ---- .../CodexReviewMonitorCITests.swift | 8 ++++---- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index db2079ef..8ee95004 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -2,12 +2,10 @@ import Foundation public enum CodexReviewStoreTestEnvironment { public static let reviewModeKey = "REVIEW_MONITOR_REVIEW_MODE" - public static let mockReviewRunsKey = "REVIEW_MONITOR_MOCK_REVIEW_RUNS" 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 mockReviewRunsArgument = "--review-monitor-mock-review-runs" 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" 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 @@ - - Date: Mon, 29 Jun 2026 01:52:31 +0900 Subject: [PATCH 250/359] refactor(review): keep live transcript out of run output --- .../Model/ReviewRunRecord.swift | 3 ++ .../Store/CodexReviewStoreReviews.swift | 30 ++++++++++--------- .../CodexReviewMCPToolResults.swift | 20 +++++++------ .../ReviewMCPLogProjection.swift | 18 ++++++++++- .../CodexReviewStoreCommandTests.swift | 16 ++++++++-- .../ReviewMCPLogProjectionTests.swift | 29 ++++++++++++++++++ 6 files changed, 90 insertions(+), 26 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index 961db985..de0530a4 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -15,6 +15,8 @@ public final class ReviewRunRecord: Identifiable, Hashable { @ObservationIgnored package var agentMessagesByItemID: [String: String] @ObservationIgnored + package var latestAgentMessageItemID: String? + @ObservationIgnored package var completedAgentMessageItemIDs: Set @ObservationIgnored private var syntheticMessageItemCounter: UInt64 @@ -48,6 +50,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { self.core = core self.cancellationRequested = cancellationRequested self.agentMessagesByItemID = [:] + self.latestAgentMessageItemID = nil self.completedAgentMessageItemIDs = [] self.syntheticMessageItemCounter = 0 } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 7daf1143..f157e47b 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -259,10 +259,6 @@ extension CodexReviewStore { text: text, isCompleted: isCompleted ) - if let text = text.nilIfEmpty { - runRecord.core.output.lastAgentMessage = text - runRecord.core.output.summary = text - } } private func applyMessageDelta( @@ -271,11 +267,7 @@ extension CodexReviewStore { to runRecord: ReviewRunRecord, at timestamp: Date ) { - guard let updatedMessage = runRecord.appendAgentMessageDelta(itemID: itemID, delta: text) else { - return - } - runRecord.core.output.lastAgentMessage = updatedMessage - runRecord.core.output.summary = updatedMessage + _ = runRecord.appendAgentMessageDelta(itemID: itemID, delta: text) } private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { @@ -708,7 +700,7 @@ extension CodexReviewStore { completeReview( runRecord, summary: runRecord.core.output.summary, - result: runRecord.core.output.lastAgentMessage + result: runRecord.latestBufferedAgentMessage ) } return recoveryState.currentRun @@ -733,7 +725,7 @@ extension CodexReviewStore { completeReview( runRecord, summary: runRecord.core.output.summary, - result: runRecord.core.output.lastAgentMessage + result: runRecord.latestBufferedAgentMessage ) } return true @@ -854,8 +846,6 @@ extension CodexReviewStore { runRecord.core.output.summary = "Review started." case .message(let text): let now = clock.now() - runRecord.core.output.lastAgentMessage = text - runRecord.core.output.summary = text applyMessageSnapshot(text, itemID: nil, isCompleted: true, to: runRecord, at: now) case .messageDelta(let text, let itemID): applyMessageDelta(text, itemID: itemID, to: runRecord, at: clock.now()) @@ -901,7 +891,7 @@ extension CodexReviewStore { return } let endedAt = clock.now() - let previousAgentMessage = runRecord.core.output.lastAgentMessage?.nilIfEmpty + let previousAgentMessage = runRecord.latestBufferedAgentMessage let resultText = result?.nilIfEmpty let finalReviewText = resultText ?? previousAgentMessage runRecord.core.lifecycle.status = .succeeded @@ -971,17 +961,28 @@ private extension ReviewRunRecord { ) } + var latestBufferedAgentMessage: String? { + guard let latestAgentMessageItemID, + let latestMessage = agentMessagesByItemID[latestAgentMessageItemID]?.nilIfEmpty + else { + return nil + } + return latestMessage + } + func appendAgentMessageDelta(itemID: String, delta: String) -> String? { guard completedAgentMessageItemIDs.contains(itemID) == false else { return nil } let updated = (agentMessagesByItemID[itemID] ?? "") + delta agentMessagesByItemID[itemID] = updated + latestAgentMessageItemID = itemID return updated } func noteAgentMessageSnapshot(itemID: String, text: String, isCompleted: Bool) { agentMessagesByItemID[itemID] = text + latestAgentMessageItemID = itemID if isCompleted { completedAgentMessageItemIDs.insert(itemID) } else { @@ -994,6 +995,7 @@ private extension ReviewRunRecord { core.output.hasFinalReview = false core.output.reviewResult = nil agentMessagesByItemID.removeAll(keepingCapacity: true) + latestAgentMessageItemID = nil completedAgentMessageItemIDs.removeAll(keepingCapacity: true) } } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 549689c3..05cefe2c 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -10,11 +10,11 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul case .reviewStart(let snapshot), .reviewAwait(let snapshot): value = snapshot.result.structuredContentForStartOrAwait(log: snapshot.log) - text = snapshot.result.textContentForStartOrAwait() + 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() + text = snapshot.result.textContentForRead(log: snapshot.log) isError = snapshot.result.core.lifecycle.status == .failed case .reviewList(let result): value = result.structuredContent() @@ -33,13 +33,13 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul } private extension CodexReviewAPI.Read.Result { - func textContent() -> String { - core.reviewText.nilIfEmpty ?? core.lifecycle.status.rawValue + func textContent(log: ReviewMCPLogProjection) -> String { + log.finalResult?.nilIfEmpty ?? core.reviewText.nilIfEmpty ?? core.lifecycle.status.rawValue } - func textContentForStartOrAwait() -> String { + func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String { if core.lifecycle.status.isTerminal { - return textContent() + return textContent(log: log) } var status = "Review \(core.lifecycle.status.rawValue)" @@ -49,9 +49,9 @@ private extension CodexReviewAPI.Read.Result { return "\(status). runId: \(runID). Call `review_await` with this runId to continue waiting." } - func textContentForRead() -> String { + func textContentForRead(log: ReviewMCPLogProjection) -> String { if core.lifecycle.status.isTerminal { - return textContent() + return textContent(log: log) } var status = "Review \(core.lifecycle.status.rawValue)" @@ -89,7 +89,9 @@ private extension CodexReviewAPI.Read.Result { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.output.structuredContent(review: core.reviewText), + "output": core.output.structuredContent( + review: log.finalResult?.nilIfEmpty ?? core.reviewText + ), ] object["log"] = includeDetails diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index fa9655c9..80ec3984 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -108,11 +108,27 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last self.finalSummary = status.isTerminal ? output.summary : nil - self.finalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil + self.finalResult = + status == .succeeded + ? projectedItems.lastAgentMessageText ?? result.core.reviewText.nilIfEmpty + : nil + } +} + +private extension [ReviewMCPLogProjection.Item] { + var lastAgentMessageText: String? { + reversed().compactMap { $0.content.messageText }.first } } 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): diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index ae860d6c..5057751d 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -235,6 +235,14 @@ struct CodexReviewStoreCommandTests { ) await backend.yield(.messageDelta("first", itemID: "message-1")) await backend.yield(.messageDelta("second", itemID: "message-2")) + #expect( + await waitUntil { + store.reviewRun(id: "run-1")?.agentMessagesByItemID["message-2"] == "second" + }) + let running = try #require(store.reviewRun(id: "run-1")) + #expect(running.core.output.lastAgentMessage == nil) + #expect(running.core.reviewText == "Review started.") + await backend.yield(.completed(summary: "Succeeded.", result: nil)) let read = try await result @@ -1391,7 +1399,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func failedReviewPreservesBufferedEventsBeforeStreamError() async throws { + @Test func failedReviewKeepsBufferedMessagesOutOfRunOutput() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1404,11 +1412,15 @@ struct CodexReviewStoreCommandTests { ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) await backend.yield(.message("partial review")) + #expect( + await waitUntil { + store.reviewRun(id: "run-1")?.agentMessagesByItemID.values.contains("partial review") == true + }) 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.core.output.lastAgentMessage == nil) } } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 9fd06010..2c1b130c 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -91,4 +91,33 @@ struct ReviewMCPLogProjectionTests { #expect(projection.items.map { $0.kind } == ["agentMessage", "reasoning", "commandExecution"]) #expect(projection.items.map { $0.content.type } == ["message", "reasoning", "command"]) } + + @Test func terminalTurnItemsProvideFinalResultBeforeRunOutputFallback() 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)), + output: .init( + summary: "Done.", + hasFinalReview: true, + lastAgentMessage: "stale fallback" + ) + ), + 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.finalSummary == "Done.") + #expect(projection.finalResult == "CodexChat final") + } } From ea3d4826b11849b231af73b88cbe95954d0ea6b7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:48:10 +0900 Subject: [PATCH 251/359] fix(review-ui): store sidebar chat selection by identity --- ...ReviewMonitorTransportViewController.swift | 30 ++++---- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- Sources/ReviewUI/ReviewMonitorSelection.swift | 6 +- .../ReviewMonitorSplitViewController.swift | 10 ++- .../ReviewMonitorWindowController.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 75 +++++++++---------- ...eviewMonitorCodexSidebarLibraryTests.swift | 20 ++++- .../ReviewMonitorSelectedCodexChatTests.swift | 30 +------- 8 files changed, 81 insertions(+), 94 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index ba1b980d..17182e57 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -125,8 +125,8 @@ final class ReviewMonitorTransportViewController: NSViewController { return displayedSelection != selection?.id case .workspace: return displayedSelection != selection?.id - case .chat(let selectedChat): - return boundChatID != selectedChat.id + case .chat(let selectedChatID): + return boundChatID != selectedChatID || displayedSelection != selection?.id case nil: return displayedSelection != nil @@ -148,8 +148,8 @@ final class ReviewMonitorTransportViewController: NSViewController { displayedSelection = selection?.id case .chat: - if case .chat(let selectedChat) = selection { - displayChat(selectedChat) + if case .chat(let selectedChatID) = selection { + displayChat(selectedChatID) } hidePlaceholder() logScrollView.isHidden = false @@ -163,8 +163,8 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private func displayChat(_ selectedChat: ReviewMonitorCodexSidebarSnapshot.Chat) { - let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChat.id + private func displayChat(_ selectedChatID: CodexThreadID) { + let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChatID cacheBoundLogScrollTarget() if isSwitchingRenderedChat { logScrollView.resetFindStateForContentReuse() @@ -172,20 +172,20 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedChatLogTask?.cancel() selectedChatLogTask = nil resetLogRenderer() - boundChatID = selectedChat.id - let restorationTarget = restorationTarget(chatID: selectedChat.id) - selectedCodexChat.bind(toChatID: selectedChat.id) - if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChat.id) { + boundChatID = selectedChatID + let restorationTarget = restorationTarget(chatID: selectedChatID) + selectedCodexChat.bind(toChatID: selectedChatID) + if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChatID) { startPreviewChatChangeStream( previewStream, - chatUpdatedAt: selectedChat.updatedAt, - target: .chat(selectedChat.id), + chatUpdatedAt: previewChatLogSource?.snapshot.chat(id: selectedChatID)?.updatedAt, + target: .chat(selectedChatID), initialRestorationTarget: restorationTarget ) return } startSelectedCodexChatLogStream( - target: .chat(selectedChat.id), + target: .chat(selectedChatID), initialRestorationTarget: restorationTarget ) } @@ -847,8 +847,8 @@ final class ReviewMonitorTransportViewController: NSViewController { .workspaceSection(section.id) case .workspace(let workspace): .workspace(workspace.id.rawValue) - case .chat(let chat): - .chat(chat.id.rawValue) + case .chat(let id): + .chat(id.rawValue) case nil: nil } diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index a3a6e7e5..98c32efc 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -327,7 +327,7 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( nil } if let initialChat = previewChatLogSource?.initialChat { - uiState.selection = .chat(initialChat) + uiState.selection = .chat(initialChat.id) } if ownsPreviewStore, let previewChatLogSource { ReviewMonitorPreviewContent.retainChatLogStreamer( diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 434d8fe8..5e3dea88 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -11,7 +11,7 @@ enum ReviewMonitorSelectionID: Hashable, Sendable { enum ReviewMonitorSelection { case workspaceSection(ReviewMonitorWorkspaceSectionSelection) case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) - case chat(ReviewMonitorCodexSidebarSnapshot.Chat) + case chat(CodexThreadID) var id: ReviewMonitorSelectionID { switch self { @@ -19,8 +19,8 @@ enum ReviewMonitorSelection { return .workspaceSection(section.id) case .workspace(let workspace): return .workspace(workspace.id) - case .chat(let chat): - return .chat(chat.id) + case .chat(let id): + return .chat(id) } } } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 119e6be0..f4b9cfb4 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -161,6 +161,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe func attach(to window: NSWindow) { loadViewIfNeeded() + sidebarViewController?.loadViewIfNeeded() let isNewWindow = attachedWindow !== window attachedWindow = window @@ -256,10 +257,13 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe title: workspace.title, subtitle: workspace.cwd ) - case .chat(let chat): + case .chat(let id): + guard let presentation = sidebarViewController?.codexChatTitlePresentation(id: id) else { + return WindowTitlePresentation(title: "", subtitle: "") + } return WindowTitlePresentation( - title: chat.title, - subtitle: chat.workspaceCWD ?? "" + title: presentation.title, + subtitle: presentation.subtitle ) case nil: return WindowTitlePresentation(title: "", subtitle: "") diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index cddf438e..bed7c791 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -100,7 +100,7 @@ public final class ReviewMonitorWindowController: NSWindowController { sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) if let initialChat = previewChatLogSource?.initialChat { - uiState.selection = .chat(initialChat) + uiState.selection = .chat(initialChat.id) } let rootViewController = ReviewMonitorRootViewController( store: store, diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 947e6745..ee07bb20 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -106,9 +106,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarKindObservation: PortableObservationTracking.Token? private var sidebarFilterObservation: PortableObservationTracking.Token? + private var sidebarSelectionObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? private var codexSidebarSnapshotObservation: PortableObservationTracking.Token? - private var selectedChatSnapshotObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private var codexSidebarModelContext: CodexModelContext? @@ -145,9 +145,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD isolated deinit { sidebarKindObservation?.cancel() sidebarFilterObservation?.cancel() + sidebarSelectionObservation?.cancel() codexSidebarObservation?.cancel() codexSidebarSnapshotObservation?.cancel() - selectedChatSnapshotObservation?.cancel() codexSidebarFetchTask?.cancel() } @@ -252,7 +252,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func bindObservation() { sidebarKindObservation?.cancel() sidebarFilterObservation?.cancel() - selectedChatSnapshotObservation?.cancel() + sidebarSelectionObservation?.cancel() sidebarKindObservation = withPortableContinuousObservation { [weak self, uiState, store] _ in let sidebarSelection = uiState.sidebarSelection @@ -276,23 +276,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD self?.applyFilteredCodexSidebarSnapshot() } - selectedChatSnapshotObservation = withPortableContinuousObservation { [weak self, uiState] _ in - guard let self, - case .chat(let selectedChat) = uiState.selection + sidebarSelectionObservation = withPortableContinuousObservation { [weak self, uiState] event in + _ = uiState.selection?.id + guard event.kind != .initial, + let self, + self.hasCodexSidebarContent else { return } - guard self.hasCodexSidebarContent else { - return - } - guard let currentChat = self.currentChatSelection(id: selectedChat.id) else { - uiState.selection = nil - return - } - guard currentChat != selectedChat else { - return - } - uiState.selection = .chat(currentChat) + self.reconcileOutlineSelection() } } @@ -644,19 +636,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - case .chat(let selectedChat): - guard let currentChat = currentChatSelection(id: selectedChat.id) - else { + case .chat(let selectedChatID): + guard currentChatSelection(id: selectedChatID) != nil else { uiState.selection = nil outlineView.deselectAll(nil) return } - if currentChat != selectedChat { - uiState.selection = .chat(currentChat) - } - - guard let row = row(forCurrentChatSelectionID: selectedChat.id) + guard let row = row(forCurrentChatSelectionID: selectedChatID) else { return } @@ -688,7 +675,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .workspace(let workspace): uiState.selection = .workspace(workspace) case .chat(let chat): - uiState.selection = .chat(chat) + uiState.selection = .chat(chat.id) } } else { uiState.selection = nil @@ -791,8 +778,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return codexWorkspaceSectionSelection(id: section.id) != nil case .workspace(let workspace): return codexWorkspaceSelection(id: workspace.id) != nil - case .chat(let chat): - return currentChatSelection(id: chat.id) != nil + case .chat(let id): + return currentChatSelection(id: id) != nil } } @@ -831,6 +818,22 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return chat } + func codexChatTitlePresentation(id: CodexThreadID) -> (title: String, subtitle: String)? { + if let chat = codexSidebarLibrary?.chat(id: id) { + return ( + title: chat.title, + subtitle: chat.workspace?.url.path ?? "" + ) + } + guard let chat = currentChatSelection(id: id) else { + return nil + } + return ( + title: chat.title, + subtitle: chat.workspaceCWD ?? "" + ) + } + private func dragPayload(for item: Any) -> SidebarDragPayload? { guard let node = codexSidebarNode(from: item) else { return nil @@ -1384,10 +1387,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var selectedReviewChatIDForTesting: CodexThreadID? { - guard case .chat(let chat) = uiState.selection else { + guard case .chat(let id) = uiState.selection else { return nil } - return chat.id + return id } var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { @@ -1489,7 +1492,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func selectReviewChatForTesting(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { guard let row = row(forCodexSidebarSelectionID: .chat(chat.id)) else { - uiState.selection = .chat(chat) + uiState.selection = .chat(chat.id) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -1501,15 +1504,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return } guard let row = row(forCodexSidebarSelectionID: .chat(chatID)) else { - uiState.selection = .chat( - ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: chatID.rawValue, - preview: nil, - workspaceCWD: nil, - updatedAt: nil - )) + uiState.selection = .chat(chatID) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 0fbbb7fd..233c500d 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexKit import CodexAppServerKitTesting import Foundation @@ -346,6 +347,8 @@ struct ReviewMonitorCodexSidebarLibraryTests { uiState: uiState, modelContext: context ) + let window = NSWindow(contentViewController: viewController) + defer { window.close() } viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -367,12 +370,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(sidebar.codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(threadID)) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) - guard case .chat(let selectedChat) = uiState.selection else { + guard case .chat(let selectedChatID) = uiState.selection else { Issue.record("Expected selecting a Codex sidebar chat row to select the chat.") return } - #expect(selectedChat.id == threadID) - #expect(selectedChat.title == "App review") + #expect(selectedChatID == threadID) + #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review") } @Test func codexSidebarSelectionDoesNotFallBackToReviewRunRows() async throws { @@ -432,7 +435,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { } #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(legacyChat.id)) == nil) - uiState.selection = .chat(legacyChat) + uiState.selection = .chat(legacyChat.id) try await waitForCondition { uiState.selection == nil && sidebar.selectedReviewChatIDForTesting == nil @@ -479,6 +482,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition(timeout: .milliseconds(500)) { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" } + 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) @@ -512,6 +520,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate) #expect( sidebar.displayedCodexSidebarTitlesForTesting == [ diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 7910ffb1..a9146c9f 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -43,15 +43,7 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - uiState.selection = .chat( - .init( - rowID: .chat(CodexThreadID(rawValue: "review-thread")), - id: CodexThreadID(rawValue: "review-thread"), - title: "Review", - preview: nil, - workspaceCWD: "/tmp/project", - updatedAt: nil - )) + uiState.selection = .chat(CodexThreadID(rawValue: "review-thread")) try await waitForCondition { transport.selectedCodexChatIDForTesting == "review-thread" @@ -303,15 +295,7 @@ struct ReviewMonitorSelectedCodexChatTests { ) transport.loadViewIfNeeded() - uiState.selection = .chat( - .init( - rowID: .chat(CodexThreadID(rawValue: "chat-thread")), - id: CodexThreadID(rawValue: "chat-thread"), - title: "Generic chat", - preview: nil, - workspaceCWD: "/tmp/project", - updatedAt: nil - )) + uiState.selection = .chat(CodexThreadID(rawValue: "chat-thread")) _ = try await awaitTransportRender(transport) { snapshot in snapshot.log.contains("Generic chat snapshot") @@ -342,18 +326,10 @@ struct ReviewMonitorSelectedCodexChatTests { private func selectChat( id: String, - title: String = "Review", in uiState: ReviewMonitorUIState ) { let chatID = CodexThreadID(rawValue: id) - uiState.selection = .chat(.init( - rowID: .chat(chatID), - id: chatID, - title: title, - preview: nil, - workspaceCWD: "/tmp/project", - updatedAt: nil - )) + uiState.selection = .chat(chatID) } } From d5d0166c3479ed3710f53610c44ba745d5a5a6e7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:04:18 +0900 Subject: [PATCH 252/359] test(review-ui): align sidebar selection fixture with chat binding --- ...eviewMonitorCodexSidebarLibraryTests.swift | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 233c500d..ce56432a 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -347,8 +347,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { uiState: uiState, modelContext: context ) - let window = NSWindow(contentViewController: viewController) - defer { window.close() } viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -471,6 +469,8 @@ struct ReviewMonitorCodexSidebarLibraryTests { uiState: uiState, modelContext: context ) + let window = NSWindow(contentViewController: viewController) + defer { window.close() } viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting @@ -482,6 +482,20 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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 { @@ -498,19 +512,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { name: "App review renamed", updatedAt: Date(timeIntervalSince1970: 6_000) )) - try await runtime.transport.enqueueThreadList( - .init( - threads: [ - .init( - id: threadID, - workspace: repo, - name: "App review renamed", - updatedAt: Date(timeIntervalSince1970: 6_000) - ) - ] - )) try await chat.refresh(includeTurns: false) - #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) try await waitForCondition { sidebar.codexSidebarSnapshotForTesting? From 3705567e4b1e3f2212595ff8a60184231bd78fc8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:20:18 +0900 Subject: [PATCH 253/359] refactor(review): drop store workspace ordering --- .../Model/CodexReviewWorkspace.swift | 27 ---- .../Store/CodexReviewStore.swift | 2 - .../Store/CodexReviewStoreOrderQueries.swift | 44 +----- .../Store/CodexReviewStoreOrdering.swift | 128 ------------------ .../Store/CodexReviewStoreReviews.swift | 17 +-- .../Store/CodexReviewStoreTesting.swift | 39 +----- .../ReviewMonitorPreviewContent.swift | 6 +- .../CodexReviewStoreCommandTests.swift | 112 ++------------- ...ReviewStoreRateLimitAutoRefreshTests.swift | 10 +- .../CodexReviewMCPHTTPServerTests.swift | 13 -- ...eviewMonitorCodexSidebarLibraryTests.swift | 13 +- .../ReviewUITests/ReviewUISettingsTests.swift | 2 - Tests/ReviewUITests/ReviewUIShellTests.swift | 51 +++---- Tests/ReviewUITests/ReviewUITests.swift | 63 +++------ 14 files changed, 68 insertions(+), 459 deletions(-) delete mode 100644 Sources/CodexReviewKit/Model/CodexReviewWorkspace.swift delete mode 100644 Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift diff --git a/Sources/CodexReviewKit/Model/CodexReviewWorkspace.swift b/Sources/CodexReviewKit/Model/CodexReviewWorkspace.swift deleted file mode 100644 index 8aaf24d6..00000000 --- a/Sources/CodexReviewKit/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/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 6f362a7c..3711e7ad 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -8,7 +8,6 @@ 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 reviewRuns: Set = [] package var shouldAutoStartEmbeddedServer: Bool { backend.seed.shouldAutoStartEmbeddedServer @@ -418,7 +417,6 @@ public final class CodexReviewStore { } private func resetReviews() { - workspaces = [] reviewRuns = [] } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 875d94ca..4406605d 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -1,29 +1,17 @@ extension CodexReviewStore { - package var orderedWorkspaces: [CodexReviewWorkspace] { - workspaces.sorted { + package var orderedReviewRuns: [ReviewRunRecord] { + reviewRuns.sorted { if $0.sortOrder == $1.sortOrder { - return $0.cwd < $1.cwd + return $0.id < $1.id } return $0.sortOrder > $1.sortOrder } } - package var orderedReviewRuns: [ReviewRunRecord] { - orderedWorkspaces.flatMap { orderedReviewRuns(in: $0) } - } - package var hasReviewRuns: Bool { reviewRuns.isEmpty == false } - package func workspace(cwd: String) -> CodexReviewWorkspace? { - workspaces.first(where: { $0.cwd == cwd }) - } - - package func workspace(containing runRecord: ReviewRunRecord) -> CodexReviewWorkspace? { - workspace(cwd: runRecord.cwd) - } - package func reviewRun(id: String) -> ReviewRunRecord? { reviewRuns.first(where: { $0.id == id }) } @@ -41,21 +29,8 @@ extension CodexReviewStore { reviewRuns.filter { $0.cwd == cwd } } - package func orderedReviewRuns(in workspace: CodexReviewWorkspace) -> [ReviewRunRecord] { - orderedReviewRuns(inWorkspace: workspace.cwd) - } - package func orderedReviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { - reviewRuns(inWorkspace: cwd).sorted { - if $0.sortOrder == $1.sortOrder { - return $0.id < $1.id - } - return $0.sortOrder > $1.sortOrder - } - } - - package func reviewRunCount(in workspace: CodexReviewWorkspace) -> Int { - reviewRuns(inWorkspace: workspace.cwd).count + orderedReviewRuns.filter { $0.cwd == cwd } } package func totalReviewRunCount() -> Int { @@ -69,16 +44,9 @@ extension CodexReviewStore { } } - package func normalizeWorkspaceSortOrders() { - let ordered = orderedWorkspaces - for (index, workspace) in ordered.enumerated() { - workspace.sortOrder = Double(ordered.count - index - 1) - } - } - package func normalizeAllReviewRunSortOrders() { - for workspace in workspaces { - normalizeReviewRunSortOrders(inWorkspace: workspace.cwd) + for cwd in Set(reviewRuns.map(\.cwd)) { + normalizeReviewRunSortOrders(inWorkspace: cwd) } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrdering.swift deleted file mode 100644 index 445ebee7..00000000 --- a/Sources/CodexReviewKit/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 reorderReviewRun( - id: String, - inWorkspace cwd: String, - beforeRunID: String? - ) -> Bool { - guard beforeRunID != id, - workspace(cwd: cwd) != nil - else { - return false - } - - let ordered = orderedReviewRuns(inWorkspace: cwd) - guard let runRecord = ordered.first(where: { $0.id == id }) else { - return false - } - - let remaining = ordered.filter { $0 !== runRecord } - let destinationIndex: Int - if let beforeRunID { - guard let beforeIndex = remaining.firstIndex(where: { $0.id == beforeRunID }) else { - return false - } - destinationIndex = beforeIndex - } else { - destinationIndex = remaining.count - } - return reorderReviewRun(id: id, inWorkspace: cwd, toIndex: destinationIndex) - } - - @discardableResult - package func reorderReviewRun( - id: String, - inWorkspace cwd: String, - toIndex: Int - ) -> Bool { - guard workspace(cwd: cwd) != nil else { - return false - } - - let ordered = orderedReviewRuns(inWorkspace: cwd) - guard let runRecord = ordered.first(where: { $0.id == id }), - let sourceIndex = ordered.firstIndex(where: { $0 === runRecord }) - else { - return false - } - - let destinationIndex = max(0, min(toIndex, ordered.count - 1)) - guard sourceIndex != destinationIndex else { - return false - } - - var sortOrder = reorderedSortOrder( - moving: runRecord, - toIndex: destinationIndex, - in: ordered, - sortOrder: \.sortOrder - ) - if sortOrder == nil { - normalizeReviewRunSortOrders(inWorkspace: cwd) - sortOrder = reorderedSortOrder( - moving: runRecord, - toIndex: destinationIndex, - in: orderedReviewRuns(inWorkspace: cwd), - sortOrder: \.sortOrder - ) - } - guard let sortOrder else { - return false - } - runRecord.sortOrder = sortOrder - writeDiagnosticsIfNeeded() - return true - } -} diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index f157e47b..3cf9f451 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -68,7 +68,7 @@ extension CodexReviewStore { id: runID, sessionID: sessionID, cwd: validatedRequest.cwd, - sortOrder: nextReviewRunSortOrder(inWorkspace: validatedRequest.cwd), + sortOrder: nextReviewRunSortOrder(), targetSummary: validatedRequest.target.displaySummary, core: .init( lifecycle: .init(status: .queued), @@ -544,13 +544,6 @@ extension CodexReviewStore { } private func insertReviewRun(_ runRecord: ReviewRunRecord) { - if workspace(cwd: runRecord.cwd) == nil { - let workspace = CodexReviewWorkspace( - cwd: runRecord.cwd, - sortOrder: nextWorkspaceSortOrder() - ) - workspaces.insert(workspace) - } reviewRuns.insert(runRecord) writeDiagnosticsIfNeeded() } @@ -924,12 +917,8 @@ extension CodexReviewStore { ) } - private func nextReviewRunSortOrder(inWorkspace cwd: String) -> Double { - (reviewRuns(inWorkspace: cwd).map(\.sortOrder).max() ?? -1) + 1 - } - - private func nextWorkspaceSortOrder() -> Double { - (workspaces.map(\.sortOrder).max() ?? -1) + 1 + private func nextReviewRunSortOrder() -> Double { + (reviewRuns.map(\.sortOrder).max() ?? -1) + 1 } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index 8792cd91..bac56de4 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -19,7 +19,6 @@ extension CodexReviewStore { account: CodexReviewAccount? = nil, persistedAccounts: [CodexReviewAccount]? = nil, serverURL: URL? = nil, - workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord] = [], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { @@ -41,42 +40,10 @@ extension CodexReviewStore { self.auth.updateCurrentAccount(account) } self.serverURL = serverURL - var existingByCWD: [String: CodexReviewWorkspace] = [:] - for workspace in self.workspaces { - existingByCWD[workspace.cwd] = workspace + for (index, runRecord) in reviewRuns.enumerated() { + runRecord.sortOrder = Double(reviewRuns.count - index - 1) } - - 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 runsByCWD: [String: [ReviewRunRecord]] = [:] - let resolvedRuns = reviewRuns.filter { runRecord in - resolvedWorkspaces.contains(where: { $0.cwd == runRecord.cwd }) - } - for runRecord in resolvedRuns { - runsByCWD[runRecord.cwd, default: []].append(runRecord) - } - for runRecord in resolvedRuns { - guard let workspaceRuns = runsByCWD[runRecord.cwd], - let index = workspaceRuns.firstIndex(where: { $0 === runRecord }) - else { - continue - } - runRecord.sortOrder = Double(workspaceRuns.count - index - 1) - } - self.reviewRuns = Set(resolvedRuns) + self.reviewRuns = Set(reviewRuns) if let settingsSnapshot { settings.loadForTesting(snapshot: settingsSnapshot) } diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 2489c82b..92084087 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -142,8 +142,7 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp"), - workspaces: [] + serverURL: URL(string: "http://localhost:9417/mcp") ) let chatLogSource = ReviewMonitorPreviewChatLogSource( fixtures: previewContent.chatLogFixtures @@ -206,8 +205,7 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp"), - workspaces: [] + serverURL: URL(string: "http://localhost:9417/mcp") ) let fixture = makeChatLogFixture( for: chatFixture diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 5057751d..eb032d3e 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -280,7 +280,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func newlyStartedWorkspaceAppearsBeforeExistingWorkspaces() async throws { + @Test func newlyStartedReviewAppearsBeforeExistingRunsAcrossWorkspaces() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -301,30 +301,7 @@ struct CodexReviewStoreCommandTests { 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"]) + #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"]) } } @@ -380,7 +357,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func newlyStartedReviewUsesSortOrderAboveCurrentWorkspaceMaximum() async throws { + @Test func newlyStartedReviewUsesSortOrderAboveCurrentMaximum() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -395,7 +372,6 @@ struct CodexReviewStoreCommandTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [existing] ) store.reviewRun(id: "run-existing")?.sortOrder = 10 @@ -411,74 +387,6 @@ struct CodexReviewStoreCommandTests { } } - @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 runReorderBeforeAnchorMovesItemAndReportsMutation() { - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) - ) - let workspace = CodexReviewWorkspace(cwd: "/tmp/project") - let firstRun = ReviewRunRecord.makeForTesting( - id: "run-first", - cwd: workspace.cwd, - targetSummary: "First", - status: .running, - summary: "Running" - ) - let secondRun = ReviewRunRecord.makeForTesting( - id: "run-second", - cwd: workspace.cwd, - targetSummary: "Second", - status: .running, - summary: "Running" - ) - let thirdRun = ReviewRunRecord.makeForTesting( - id: "run-third", - cwd: workspace.cwd, - targetSummary: "Third", - status: .running, - summary: "Running" - ) - store.loadForTesting( - serverState: .running, - workspaces: [workspace], - reviewRuns: [firstRun, secondRun, thirdRun] - ) - - #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: thirdRun.id)) - #expect(store.orderedReviewRuns(in: workspace).map(\.id) == ["run-second", "run-first", "run-third"]) - #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: firstRun.id) == false) - #expect(store.reorderReviewRun(id: firstRun.id, inWorkspace: workspace.cwd, beforeRunID: "run-missing") == false) - } - @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( @@ -1695,7 +1603,6 @@ struct CodexReviewStoreCommandTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [running] ) @@ -1757,8 +1664,7 @@ struct CodexReviewStoreCommandTests { store.loadForTesting( serverState: .running, account: selectedAccount, - persistedAccounts: [selectedAccount, otherAccount], - workspaces: [] + persistedAccounts: [selectedAccount, otherAccount] ) let displayedSelectedAccount = try #require(store.auth.selectedAccount) let displayedOtherAccount = try #require( @@ -1817,16 +1723,16 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - store.loadForTesting(serverState: .stopped, authPhase: .signedOut, workspaces: []) + store.loadForTesting(serverState: .stopped, authPhase: .signedOut) #expect(store.canPerformPrimaryAuthenticationAction) - store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut, workspaces: []) + store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut) #expect(store.canPerformPrimaryAuthenticationAction) - store.loadForTesting(serverState: .starting, authPhase: .signedOut, workspaces: []) + store.loadForTesting(serverState: .starting, authPhase: .signedOut) #expect(store.canPerformPrimaryAuthenticationAction == false) - store.loadForTesting(serverState: .running, authPhase: .signedOut, workspaces: []) + store.loadForTesting(serverState: .running, authPhase: .signedOut) #expect(store.canPerformPrimaryAuthenticationAction) store.auth.updatePhase(.signingIn(.init(title: "Sign in", detail: "Open browser."))) @@ -1840,7 +1746,7 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) await withStoreCommandTestCleanup(backend: backend, store: store) { - store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut, workspaces: []) + store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut) await store.performPrimaryAuthenticationAction() diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 5ace0d7f..16887ed3 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -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) @@ -311,7 +308,6 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { authPhase: .signedOut, account: account, persistedAccounts: [account], - workspaces: [CodexReviewWorkspace(cwd: "/tmp/repo")], reviewRuns: reviewRuns ) } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 51c0d6de..4e199bf1 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -374,10 +374,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [ - .init(cwd: "/tmp/project"), - .init(cwd: "/tmp/other"), - ], reviewRuns: [included, otherSession, otherWorkspace] ) let response = try await postJSONRPC( @@ -423,7 +419,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [ includedRun, ReviewRunRecord.makeForTesting( @@ -498,7 +493,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [runRecord] ) @@ -571,7 +565,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [runRecord] ) @@ -635,7 +628,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [running, otherSession] ) let response = try await postJSONRPC( @@ -702,7 +694,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [completed, running] ) @@ -740,7 +731,6 @@ struct CodexReviewMCPHTTPServerTests { let sessionID = try await initializeSession(endpoint: await server.url) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [ ReviewRunRecord.makeForTesting( id: "run-running-1", @@ -810,7 +800,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [running] ) @@ -980,7 +969,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [other] ) @@ -1026,7 +1014,6 @@ struct CodexReviewMCPHTTPServerTests { ) store.loadForTesting( serverState: .running, - workspaces: [.init(cwd: "/tmp/project")], reviewRuns: [running] ) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index ce56432a..431d967b 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -338,8 +338,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( - serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-chat-store")] + serverState: .running ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( @@ -416,7 +415,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { let store = CodexReviewStore.makePreviewStore() store.loadReviewLifecycleForTesting( serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: repo.path)], reviewRuns: [legacyRun] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -460,8 +458,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting( - serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: "/tmp/review-chat-store")] + serverState: .running ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( @@ -560,7 +557,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting(serverState: .running) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -621,7 +618,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting(serverState: .running) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -675,7 +672,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting(serverState: .running) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), diff --git a/Tests/ReviewUITests/ReviewUISettingsTests.swift b/Tests/ReviewUITests/ReviewUISettingsTests.swift index 2d52ed0b..7a10623f 100644 --- a/Tests/ReviewUITests/ReviewUISettingsTests.swift +++ b/Tests/ReviewUITests/ReviewUISettingsTests.swift @@ -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 eb526d61..7c7ee55a 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -110,8 +110,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 viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) @@ -128,8 +127,7 @@ 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)) @@ -144,8 +142,7 @@ 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)) @@ -157,8 +154,7 @@ 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)) @@ -171,8 +167,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 viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) @@ -187,8 +182,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 uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) @@ -218,8 +212,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) @@ -305,8 +298,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 viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) @@ -315,8 +307,7 @@ extension ReviewUITests { #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, @@ -327,8 +318,7 @@ 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, @@ -555,8 +545,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 @@ -673,8 +662,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 uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController(store: store, uiState: uiState) @@ -698,8 +686,7 @@ extension ReviewUITests { ) 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, @@ -709,8 +696,7 @@ 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, @@ -894,8 +880,7 @@ extension ReviewUITests { serverState: .running, authPhase: .signedOut, account: currentAccount, - persistedAccounts: [], - workspaces: [] + persistedAccounts: [] ) let windowController = ReviewMonitorWindowController( store: store, @@ -923,8 +908,7 @@ extension ReviewUITests { serverState: .running, authPhase: .signedOut, account: currentAccount, - persistedAccounts: [], - workspaces: [] + persistedAccounts: [] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -963,8 +947,7 @@ extension ReviewUITests { serverState: .running, authPhase: .signedOut, account: nil, - persistedAccounts: [CodexReviewAccount(email: "saved@example.com", planType: "pro")], - workspaces: [] + persistedAccounts: [CodexReviewAccount(email: "saved@example.com", planType: "pro")] ) let windowController = ReviewMonitorWindowController( store: store, diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 2a8f6038..fd763921 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -37,8 +37,7 @@ struct ReviewUITests { ) ), account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -64,8 +63,7 @@ struct ReviewUITests { serverState: .running, authPhase: .signedOut, account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -104,8 +102,7 @@ struct ReviewUITests { serverState: .running, authPhase: .signedOut, account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -149,8 +146,7 @@ struct ReviewUITests { ) ), account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -181,8 +177,7 @@ struct ReviewUITests { ) ), account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) @@ -214,8 +209,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -247,8 +241,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: firstAccount, - persistedAccounts: [firstAccount, secondAccount, thirdAccount], - workspaces: [] + persistedAccounts: [firstAccount, secondAccount, thirdAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -303,8 +296,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: detachedAccount, - persistedAccounts: [firstAccount, secondAccount], - workspaces: [] + persistedAccounts: [firstAccount, secondAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -439,8 +431,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -495,8 +486,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -538,8 +528,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -573,8 +562,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -611,8 +599,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -665,8 +652,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount], - workspaces: [] + persistedAccounts: [activeAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -705,8 +691,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: savedAccount, - persistedAccounts: [savedAccount], - workspaces: [] + persistedAccounts: [savedAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -776,8 +761,7 @@ struct ReviewUITests { store.loadForTesting( serverState: .running, account: activeAccount, - persistedAccounts: [activeAccount, otherAccount], - workspaces: [] + persistedAccounts: [activeAccount, otherAccount] ) let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .account @@ -853,7 +837,6 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadReviewLifecycleForTesting( serverState: .running, - workspaces: [CodexReviewWorkspace(cwd: activeChat.cwd)], reviewRuns: [activeRun, recentRun] ) installPreviewChatLogSourceForTesting(on: store, fixtures: [activeChat, recentChat]) @@ -2198,7 +2181,6 @@ struct ReviewUITests { summary: "Review is still running.", logText: "Selected log\n" ) - let workspace = CodexReviewWorkspace(cwd: chat.cwd) let store = CodexReviewStore.makePreviewStore() store.loadForTesting( serverState: .running, @@ -2219,12 +2201,12 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(cwd: workspace.cwd) + viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(cwd: chat.cwd) _ = try await awaitTransportRender(transport) #expect( viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ - workspace.cwd + chat.cwd ]) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(transport.isShowingNoFindingsStateForTesting) @@ -2233,7 +2215,7 @@ struct ReviewUITests { @Test func newChatsArrivingWhileUnselectedDoNotAutoSelect() { let activeChat = makeReviewChatFixtureForTesting(status: .running, targetSummary: "Uncommitted changes") let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting(serverState: .running) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -2281,7 +2263,7 @@ struct ReviewUITests { ])) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting(serverState: .running, workspaces: []) + store.loadForTesting(serverState: .running) let viewController = ReviewMonitorSplitViewController( store: store, uiState: ReviewMonitorUIState(auth: store.auth), @@ -5319,7 +5301,6 @@ extension CodexReviewStore { serverState: serverState, authState: authState, serverURL: serverURL, - workspaces: [], settingsSnapshot: settingsSnapshot ) installPreviewChatLogSourceForTesting(on: self, fixtures: fixtures) @@ -5329,7 +5310,6 @@ extension CodexReviewStore { serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, serverURL: URL? = nil, - workspaces: [CodexReviewWorkspace], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { loadForTesting( @@ -5350,7 +5330,6 @@ extension CodexReviewStore { ] } ?? [], serverURL: serverURL, - workspaces: workspaces, reviewRuns: [], settingsSnapshot: settingsSnapshot ) @@ -5360,7 +5339,6 @@ extension CodexReviewStore { serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, serverURL: URL? = nil, - workspaces: [CodexReviewWorkspace], reviewRuns: [ReviewRunRecord], settingsSnapshot: CodexReviewSettings.Snapshot? = nil ) { @@ -5382,7 +5360,6 @@ extension CodexReviewStore { ] } ?? [], serverURL: serverURL, - workspaces: workspaces, reviewRuns: reviewRuns, settingsSnapshot: settingsSnapshot ) From 1ef636f17864587700e659e6b42603866b03bfa4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:49:03 +0900 Subject: [PATCH 254/359] refactor(review): derive final review output --- .../CodexReviewKit/Model/ReviewRunCore.swift | 13 +------- .../Model/ReviewRunRecordTesting.swift | 6 +--- .../Store/CodexReviewStoreCancellation.swift | 2 -- .../Store/CodexReviewStoreReviews.swift | 4 --- .../CodexReviewMCPToolResults.swift | 33 ++++++++++++++----- .../CodexReviewStoreCommandTests.swift | 14 ++++---- .../ReviewRunRecordTests.swift | 2 -- .../CodexReviewMCPHTTPServerTests.swift | 2 -- .../ReviewMCPLogProjectionTests.swift | 2 -- 9 files changed, 34 insertions(+), 44 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index e2c01d8e..2034151c 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -50,20 +50,14 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { 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 + lastAgentMessage: String? = nil ) { self.summary = summary - self.hasFinalReview = hasFinalReview self.lastAgentMessage = lastAgentMessage - self.reviewResult = reviewResult } } @@ -87,11 +81,6 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { 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 } diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index ebfc8cca..e0b809d9 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -15,8 +15,6 @@ extension ReviewRunRecord { startedAt: Date? = nil, endedAt: Date? = nil, summary: String, - hasFinalReview: Bool = false, - reviewResult: ParsedReviewResult? = nil, lastAgentMessage: String? = "", errorMessage: String? = nil, exitCode: Int? = nil @@ -43,9 +41,7 @@ extension ReviewRunRecord { ), output: .init( summary: summary, - hasFinalReview: hasFinalReview, - lastAgentMessage: lastAgentMessage, - reviewResult: reviewResult + lastAgentMessage: lastAgentMessage ) ), cancellationRequested: cancellationRequested diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 76b9af22..11c1b0a0 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -50,7 +50,6 @@ extension CodexReviewStore { runRecord.core.lifecycle.cancellation = cancellation runRecord.core.lifecycle.status = .cancelled runRecord.core.output.summary = cancellation.message - runRecord.core.output.hasFinalReview = false runRecord.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty ?? runRecord.core.lifecycle.errorMessage @@ -272,7 +271,6 @@ extension CodexReviewStore { } else { runRecord.core.output.summary = "Failed to cancel review." } - runRecord.core.output.hasFinalReview = false runRecord.core.lifecycle.errorMessage = resolvedError ?? reason.nilIfEmpty diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 3cf9f451..8102d032 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -891,8 +891,6 @@ extension CodexReviewStore { runRecord.core.lifecycle.endedAt = endedAt runRecord.core.output.summary = summary runRecord.core.output.lastAgentMessage = finalReviewText ?? summary - runRecord.core.output.hasFinalReview = finalReviewText != nil - runRecord.core.output.reviewResult = ParsedReviewResult.parse(finalReviewText: finalReviewText) if let result = resultText, result != previousAgentMessage { @@ -981,8 +979,6 @@ private extension ReviewRunRecord { func resetReviewAttemptOutputForRecovery() { core.output.lastAgentMessage = nil - core.output.hasFinalReview = false - core.output.reviewResult = nil agentMessagesByItemID.removeAll(keepingCapacity: true) latestAgentMessageItemID = nil completedAgentMessageItemIDs.removeAll(keepingCapacity: true) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 05cefe2c..cd668b3f 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -90,7 +90,8 @@ private extension CodexReviewAPI.Read.Result { cancellable: cancellable ), "output": core.output.structuredContent( - review: log.finalResult?.nilIfEmpty ?? core.reviewText + review: log.finalResult?.nilIfEmpty ?? core.reviewText, + finalReview: log.finalResult?.nilIfEmpty ), ] object["log"] = @@ -326,7 +327,10 @@ private extension CodexReviewAPI.Run.ListItem { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.output.structuredContent(review: core.reviewText), + "output": core.output.structuredContent( + review: core.reviewText, + finalReview: core.finalReviewText + ), ]) } } @@ -357,7 +361,10 @@ private extension CodexReviewAPI.Cancel.Outcome { elapsedSeconds: nil, cancellable: false ), - "output": core.output.structuredContent(review: core.reviewText), + "output": core.output.structuredContent( + review: core.reviewText, + finalReview: core.finalReviewText + ), ]) } } @@ -391,14 +398,24 @@ private extension ReviewRunCore.Lifecycle { } } +private extension ReviewRunCore { + var finalReviewText: String? { + guard lifecycle.status == .succeeded else { + return nil + } + return reviewText.nilIfEmpty + } +} + private extension ReviewRunCore.Output { - func structuredContent(review: String) -> Value { - .object([ + func structuredContent(review: String, finalReview: String?) -> Value { + let finalReview = finalReview?.nilIfEmpty + return .object([ "summary": .string(summary), "review": .string(review), - "hasFinalReview": .bool(hasFinalReview), - "lastAgentMessage": lastAgentMessage.map(Value.string) ?? .null, - "reviewResult": reviewResult.map { $0.structuredContent() } ?? .null, + "hasFinalReview": .bool(finalReview != nil), + "lastAgentMessage": finalReview.map(Value.string) ?? .null, + "reviewResult": ParsedReviewResult.parse(finalReviewText: finalReview).structuredContent(), ]) } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index eb032d3e..8dc45d4f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -55,7 +55,7 @@ struct CodexReviewStoreCommandTests { #expect(running.runID == "run-1") #expect(running.core.lifecycle.status == .running) - #expect(running.core.output.hasFinalReview == false) + #expect(running.core.output.lastAgentMessage == nil) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let final = try await store.awaitReview( @@ -147,7 +147,7 @@ struct CodexReviewStoreCommandTests { ) #expect(snapshot.core.lifecycle.status == .running) - #expect(snapshot.core.output.hasFinalReview == false) + #expect(snapshot.core.output.lastAgentMessage == nil) } } @@ -273,10 +273,10 @@ struct CodexReviewStoreCommandTests { """)) 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") + let parsedResult = ParsedReviewResult.parse(finalReviewText: read.core.reviewText) + #expect(parsedResult.state == .hasFindings) + #expect(parsedResult.findingCount == 1) + #expect(parsedResult.findings.first?.title == "[P2] Add parser tests") } } @@ -1524,7 +1524,7 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .cancelled) #expect(read.core.output.summary == "Stop") - #expect(read.core.output.hasFinalReview == false) + #expect(read.core.output.lastAgentMessage == nil) } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index bbd644e1..9d44bd1a 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -12,7 +12,6 @@ struct ReviewRunRecordTests { targetSummary: "Uncommitted changes", status: .succeeded, summary: "Succeeded.", - hasFinalReview: true, lastAgentMessage: "No findings." ) #expect(succeeded.core.lifecycle.status == .succeeded) @@ -25,7 +24,6 @@ struct ReviewRunRecordTests { targetSummary: "Uncommitted changes", status: .succeeded, summary: "Succeeded.", - hasFinalReview: false, lastAgentMessage: "Succeeded." ) #expect(succeededWithoutFinalReview.core.lifecycle.status == .succeeded) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 4e199bf1..fe8c4a73 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -414,7 +414,6 @@ struct CodexReviewMCPHTTPServerTests { targetSummary: "Included", status: .succeeded, summary: "Done", - hasFinalReview: true, lastAgentMessage: "No correctness issues found." ) store.loadForTesting( @@ -488,7 +487,6 @@ struct CodexReviewMCPHTTPServerTests { targetSummary: "Included", status: .succeeded, summary: "Done", - hasFinalReview: true, lastAgentMessage: longOutput ) store.loadForTesting( diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 2c1b130c..6f854415 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -32,7 +32,6 @@ struct ReviewMCPLogProjectionTests { lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), output: .init( summary: "Done.", - hasFinalReview: true, lastAgentMessage: "No findings." ) ), @@ -101,7 +100,6 @@ struct ReviewMCPLogProjectionTests { lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), output: .init( summary: "Done.", - hasFinalReview: true, lastAgentMessage: "stale fallback" ) ), From 3e50da31455a04e437c326e669a7894c174ee553 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:54:23 +0900 Subject: [PATCH 255/359] refactor(review-ui): cancel reviews by chat identity --- .../Store/CodexReviewStoreOrderQueries.swift | 8 +++-- .../Store/CodexReviewStoreReviews.swift | 11 +++++++ .../ReviewMonitorSidebarViewController.swift | 21 ++++-------- ...eviewMonitorCodexSidebarLibraryTests.swift | 32 +++++++++---------- Tests/ReviewUITests/ReviewUITests.swift | 4 +-- 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 4406605d..54761dc7 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -16,13 +16,17 @@ extension CodexReviewStore { reviewRuns.first(where: { $0.id == id }) } - package func cancellableReviewRunID(forChatID chatID: String) -> String? { + package func hasCancellableReview(forChatID chatID: String) -> Bool { + cancellableReviewRun(forChatID: chatID) != nil + } + + package func cancellableReviewRun(forChatID chatID: String) -> ReviewRunRecord? { orderedReviewRuns.first { runRecord in guard runRecord.isTerminal == false else { return false } return runRecord.matchesChatID(chatID) - }?.id + } } package func reviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 8102d032..36cc622f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -484,6 +484,17 @@ extension CodexReviewStore { 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( _ sessionID: String, reason: ReviewCancellation = .sessionClosed() diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index ee07bb20..a63be381 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -692,14 +692,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let menu = NSMenu() - if let runID = store.cancellableReviewRunID(forChatID: chat.id.rawValue) { + if store.hasCancellableReview(forChatID: chat.id.rawValue) { let item = NSMenuItem( title: "Cancel Review", action: #selector(cancelReviewFromContextMenu(_:)), keyEquivalent: "" ) item.target = self - item.representedObject = runID + item.representedObject = chat.id.rawValue menu.addItem(item) } return menu.items.isEmpty ? nil : menu @@ -707,24 +707,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD @objc private func cancelReviewFromContextMenu(_ sender: NSMenuItem) { - guard let runID = sender.representedObject as? String else { + guard let chatID = sender.representedObject as? String else { return } Task { @MainActor [weak self] in guard let self else { return } - do { - _ = try await self.store.cancelReview( - runID: runID, - cancellation: .userInterface() - ) - } catch { - try? self.store.recordCancellationFailure( - runID: runID, - message: error.localizedDescription - ) - } + _ = try? await self.store.cancelReview( + chatID: chatID, + cancellation: .userInterface() + ) } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 431d967b..2b9a0ee4 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -380,7 +380,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() let visibleThreadID = CodexThreadID(rawValue: "thread-app") - let hiddenLegacyThreadID = CodexThreadID(rawValue: "legacy-review-thread") + let hiddenRunThreadID = CodexThreadID(rawValue: "run-backed-review-thread") try await runtime.transport.enqueueThreadList( .init( @@ -394,28 +394,28 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let legacyRun = ReviewRunRecord.makeForTesting( - id: "legacy-run", + let runBackedRecord = ReviewRunRecord.makeForTesting( + id: "run-backed-record", cwd: repo.path, - targetSummary: "Legacy review row", - threadID: "legacy-review-thread", - turnID: "legacy-turn", + targetSummary: "Run-backed review row", + threadID: hiddenRunThreadID.rawValue, + turnID: "run-backed-turn", status: .running, startedAt: Date(timeIntervalSince1970: 4_000), - summary: "Running legacy review." + summary: "Running review." ) - let legacyChat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(hiddenLegacyThreadID), - id: hiddenLegacyThreadID, - title: "Legacy review row", - preview: "Running legacy review.", + let runBackedChat = ReviewMonitorCodexSidebarSnapshot.Chat( + rowID: .chat(hiddenRunThreadID), + id: hiddenRunThreadID, + title: "Run-backed review row", + preview: "Running review.", workspaceCWD: repo.path, updatedAt: Date(timeIntervalSince1970: 4_000) ) let store = CodexReviewStore.makePreviewStore() - store.loadReviewLifecycleForTesting( + store.loadReviewCancellationStateForTesting( serverState: .running, - reviewRuns: [legacyRun] + reviewRuns: [runBackedRecord] ) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( @@ -429,9 +429,9 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(visibleThreadID)) == "App review" } - #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(legacyChat.id)) == nil) + #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(runBackedChat.id)) == nil) - uiState.selection = .chat(legacyChat.id) + uiState.selection = .chat(runBackedChat.id) try await waitForCondition { uiState.selection == nil && sidebar.selectedReviewChatIDForTesting == nil diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index fd763921..1ea19084 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -835,7 +835,7 @@ struct ReviewUITests { summary: "Review complete." ) let store = CodexReviewStore.makePreviewStore() - store.loadReviewLifecycleForTesting( + store.loadReviewCancellationStateForTesting( serverState: .running, reviewRuns: [activeRun, recentRun] ) @@ -5335,7 +5335,7 @@ extension CodexReviewStore { ) } - func loadReviewLifecycleForTesting( + func loadReviewCancellationStateForTesting( serverState: CodexReviewServerState, authState: TestAuthState = .signedOut, serverURL: URL? = nil, From 574aef7f46dd36e6faa6128b19720eeb99639df4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:57:07 +0900 Subject: [PATCH 256/359] refactor(review): keep run record lifecycle-only --- .../Model/ReviewRunRecord.swift | 8 -- .../ReviewRunRecordTests.swift | 82 +++++++++---------- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index de0530a4..98fec7d1 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -25,14 +25,6 @@ public final class ReviewRunRecord: Identifiable, Hashable { core.isTerminal } - public var displayTitle: String { - targetSummary - } - - public var reviewText: String { - core.reviewText - } - package init( id: String, sessionID: String, diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 9d44bd1a..669f6f22 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -2,56 +2,52 @@ import Foundation import Testing @testable import CodexReviewKit -@Suite("Review run record") +@Suite("Review run core") @MainActor -struct ReviewRunRecordTests { - @Test func prebuiltTerminalRunsKeepCoreTerminalState() { - let succeeded = ReviewRunRecord.makeForTesting( - id: "run-terminal-succeeded", - cwd: "/tmp/workspace", - targetSummary: "Uncommitted changes", - status: .succeeded, - summary: "Succeeded.", - lastAgentMessage: "No findings." +struct ReviewRunCoreTests { + @Test func reviewTextReflectsTerminalCoreState() { + let succeeded = ReviewRunCore( + lifecycle: .init(status: .succeeded), + output: .init( + summary: "Succeeded.", + lastAgentMessage: "No findings." + ) ) - #expect(succeeded.core.lifecycle.status == .succeeded) - #expect(succeeded.core.output.summary == "Succeeded.") - #expect(succeeded.core.reviewText == "No findings.") + #expect(succeeded.lifecycle.status == .succeeded) + #expect(succeeded.output.summary == "Succeeded.") + #expect(succeeded.reviewText == "No findings.") - let succeededWithoutFinalReview = ReviewRunRecord.makeForTesting( - id: "run-terminal-succeeded-no-review", - cwd: "/tmp/workspace", - targetSummary: "Uncommitted changes", - status: .succeeded, - summary: "Succeeded.", - lastAgentMessage: "Succeeded." + let succeededWithoutFinalReview = ReviewRunCore( + lifecycle: .init(status: .succeeded), + output: .init( + summary: "Succeeded.", + lastAgentMessage: "Succeeded." + ) ) - #expect(succeededWithoutFinalReview.core.lifecycle.status == .succeeded) - #expect(succeededWithoutFinalReview.core.output.summary == "Succeeded.") - #expect(succeededWithoutFinalReview.core.reviewText == "Succeeded.") + #expect(succeededWithoutFinalReview.lifecycle.status == .succeeded) + #expect(succeededWithoutFinalReview.output.summary == "Succeeded.") + #expect(succeededWithoutFinalReview.reviewText == "Succeeded.") - let failed = ReviewRunRecord.makeForTesting( - id: "run-terminal-failed", - cwd: "/tmp/workspace", - targetSummary: "Uncommitted changes", - status: .failed, - summary: "Failed.", - errorMessage: "Backend failed." + let failed = ReviewRunCore( + lifecycle: .init( + status: .failed, + errorMessage: "Backend failed." + ), + output: .init(summary: "Failed.") ) - #expect(failed.core.lifecycle.status == .failed) - #expect(failed.core.lifecycle.errorMessage == "Backend failed.") - #expect(failed.core.reviewText == "Backend failed.") + #expect(failed.lifecycle.status == .failed) + #expect(failed.lifecycle.errorMessage == "Backend failed.") + #expect(failed.reviewText == "Backend failed.") - let cancelled = ReviewRunRecord.makeForTesting( - id: "run-terminal-cancelled", - cwd: "/tmp/workspace", - targetSummary: "Uncommitted changes", - status: .cancelled, - cancellation: .mcpClient(message: "Session closed."), - summary: "Cancelled." + let cancelled = ReviewRunCore( + lifecycle: .init( + status: .cancelled, + cancellation: .mcpClient(message: "Session closed.") + ), + output: .init(summary: "Cancelled.") ) - #expect(cancelled.core.lifecycle.status == .cancelled) - #expect(cancelled.core.lifecycle.cancellation?.message == "Session closed.") - #expect(cancelled.core.reviewText == "Cancelled.") + #expect(cancelled.lifecycle.status == .cancelled) + #expect(cancelled.lifecycle.cancellation?.message == "Session closed.") + #expect(cancelled.reviewText == "Cancelled.") } } From e95bc633a8fae4d3efb7ff3a0ec8aa4a1d5a8dee Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:00:53 +0900 Subject: [PATCH 257/359] refactor(review): hide run lifecycle records --- .../ReviewObservationAwaiter.swift | 4 ++-- .../Model/ReviewRunRecord.swift | 22 +++++++++---------- .../Store/CodexReviewStore.swift | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index e556f5ed..065ad046 100644 --- a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -2,8 +2,8 @@ import Foundation import ObservationBridge @MainActor -public enum ReviewObservationAwaiter { - public static func waitUntilTerminal( +package enum ReviewObservationAwaiter { + package static func waitUntilTerminal( run: ReviewRunRecord, timeout: Duration? = nil ) async -> Bool { diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index 98fec7d1..6beecfc0 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -3,14 +3,14 @@ import Observation @MainActor @Observable -public final class ReviewRunRecord: Identifiable, Hashable { - public nonisolated let id: String - 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: ReviewRunCore - public internal(set) var cancellationRequested: Bool +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 @ObservationIgnored package var agentMessagesByItemID: [String: String] @@ -21,7 +21,7 @@ public final class ReviewRunRecord: Identifiable, Hashable { @ObservationIgnored private var syntheticMessageItemCounter: UInt64 - public var isTerminal: Bool { + package var isTerminal: Bool { core.isTerminal } @@ -47,11 +47,11 @@ public final class ReviewRunRecord: Identifiable, Hashable { self.syntheticMessageItemCounter = 0 } - public nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { + package nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { lhs.id == rhs.id } - public nonisolated func hash(into hasher: inout Hasher) { + package nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 3711e7ad..65f01a81 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -8,7 +8,7 @@ public final class CodexReviewStore { public let auth: CodexReviewAuthModel package let settings: SettingsStore public package(set) var serverURL: URL? - public package(set) var reviewRuns: Set = [] + package var reviewRuns: Set = [] package var shouldAutoStartEmbeddedServer: Bool { backend.seed.shouldAutoStartEmbeddedServer } From 9364f7e12b2569e7cc4fa1cb1ea45af208d89fa0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:02:49 +0900 Subject: [PATCH 258/359] refactor(review): remove stale run ordering helpers --- .../Store/CodexReviewStoreOrderQueries.swift | 78 ------------------- .../CodexReviewStoreCommandTests.swift | 4 +- 2 files changed, 2 insertions(+), 80 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 54761dc7..914479e7 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -8,10 +8,6 @@ extension CodexReviewStore { } } - package var hasReviewRuns: Bool { - reviewRuns.isEmpty == false - } - package func reviewRun(id: String) -> ReviewRunRecord? { reviewRuns.first(where: { $0.id == id }) } @@ -29,30 +25,6 @@ extension CodexReviewStore { } } - package func reviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { - reviewRuns.filter { $0.cwd == cwd } - } - - package func orderedReviewRuns(inWorkspace cwd: String) -> [ReviewRunRecord] { - orderedReviewRuns.filter { $0.cwd == cwd } - } - - package func totalReviewRunCount() -> Int { - reviewRuns.count - } - - package func normalizeReviewRunSortOrders(inWorkspace cwd: String) { - let ordered = orderedReviewRuns(inWorkspace: cwd) - for (index, runRecord) in ordered.enumerated() { - runRecord.sortOrder = Double(ordered.count - index - 1) - } - } - - package func normalizeAllReviewRunSortOrders() { - for cwd in Set(reviewRuns.map(\.cwd)) { - normalizeReviewRunSortOrders(inWorkspace: cwd) - } - } } private extension ReviewRunRecord { @@ -70,53 +42,3 @@ private extension ReviewRunRecord { return candidate == chatID } } - -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/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 8dc45d4f..7201c5c5 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -327,7 +327,7 @@ struct CodexReviewStoreCommandTests { _ = try await second #expect( - store.orderedReviewRuns(inWorkspace: "/tmp/project").map(\.targetSummary) == [ + store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary) == [ "Uncommitted changes", "Base branch: main", ]) @@ -383,7 +383,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(summary: "Succeeded.", result: "new")) _ = try await result - #expect(store.orderedReviewRuns(inWorkspace: "/tmp/project").map(\.targetSummary).first == "Uncommitted changes") + #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") } } From 60df2c8c63556df63cd43d6e77b7b6c77dc24f5e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:07:23 +0900 Subject: [PATCH 259/359] refactor(mcp): require chat projection for review logs --- .../CodexReviewMCPServer.swift | 2 +- .../CodexReviewMCPToolResults.swift | 19 ++++---- .../ReviewMCPLogProjection.swift | 24 ++++------ .../CodexReviewMCPHTTPServerTests.swift | 48 +++++++------------ .../CodexReviewMCPServerTests.swift | 3 +- .../ReviewMCPLogProjectionTests.swift | 30 +++++------- 6 files changed, 50 insertions(+), 76 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index bb67be28..fcd4310b 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -150,7 +150,7 @@ package final class CodexReviewMCPServer { if let sessionID { _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID)) } - let log = await logProjectionProvider?(result) ?? ReviewMCPLogProjection(result: result) + let log = await logProjectionProvider?(result) ?? ReviewMCPLogProjection.unavailable(result: result) return .init(result: result, log: log) } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index cd668b3f..e2c8b074 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -34,7 +34,7 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul private extension CodexReviewAPI.Read.Result { func textContent(log: ReviewMCPLogProjection) -> String { - log.finalResult?.nilIfEmpty ?? core.reviewText.nilIfEmpty ?? core.lifecycle.status.rawValue + log.finalResult?.nilIfEmpty ?? core.lifecycleText } func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String { @@ -90,7 +90,7 @@ private extension CodexReviewAPI.Read.Result { cancellable: cancellable ), "output": core.output.structuredContent( - review: log.finalResult?.nilIfEmpty ?? core.reviewText, + review: log.finalResult?.nilIfEmpty ?? core.lifecycleText, finalReview: log.finalResult?.nilIfEmpty ), ] @@ -328,8 +328,8 @@ private extension CodexReviewAPI.Run.ListItem { cancellable: cancellable ), "output": core.output.structuredContent( - review: core.reviewText, - finalReview: core.finalReviewText + review: core.lifecycleText, + finalReview: nil ), ]) } @@ -362,8 +362,8 @@ private extension CodexReviewAPI.Cancel.Outcome { cancellable: false ), "output": core.output.structuredContent( - review: core.reviewText, - finalReview: core.finalReviewText + review: core.lifecycleText, + finalReview: nil ), ]) } @@ -399,11 +399,8 @@ private extension ReviewRunCore.Lifecycle { } private extension ReviewRunCore { - var finalReviewText: String? { - guard lifecycle.status == .succeeded else { - return nil - } - return reviewText.nilIfEmpty + var lifecycleText: String { + lifecycle.errorMessage?.nilIfEmpty ?? output.summary.nilIfEmpty ?? lifecycle.status.rawValue } } diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 80ec3984..a8f15bca 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -35,7 +35,11 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { var finalResult: String? var items: [Item] - init(result: CodexReviewAPI.Read.Result) { + static func unavailable(result: CodexReviewAPI.Read.Result) -> Self { + Self(result: result) + } + + private init(result: CodexReviewAPI.Read.Result) { let lifecycle = result.core.lifecycle let output = result.core.output let status = lifecycle.status @@ -44,23 +48,15 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { status.rawValue, lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", output.summary, - output.lastAgentMessage ?? "", ].joined(separator: ":") - var items: [Item] = [] - if let text = output.lastAgentMessage?.nilIfEmpty { - items.append(.init(id: "\(result.runID):message", kind: "agentMessage", content: .message(text))) - } else if let summary = output.summary.nilIfEmpty { - items.append(.init(id: "\(result.runID):summary", kind: "diagnostic", content: .diagnostic(summary))) - } - - self.items = items - self.orderedEntryIDs = items.map(\.id) - self.activeEntryIDs = status.isTerminal ? [] : items.map(\.id) + self.items = [] + self.orderedEntryIDs = [] + self.activeEntryIDs = [] self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last self.finalSummary = status.isTerminal ? output.summary : nil - self.finalResult = status == .succeeded ? result.core.reviewText.nilIfEmpty : nil + self.finalResult = nil } @MainActor @@ -110,7 +106,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.finalSummary = status.isTerminal ? output.summary : nil self.finalResult = status == .succeeded - ? projectedItems.lastAgentMessageText ?? result.core.reviewText.nilIfEmpty + ? projectedItems.lastAgentMessageText : nil } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index fe8c4a73..76501e43 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -170,7 +170,7 @@ struct CodexReviewMCPHTTPServerTests { #expect( resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - resolved.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text") + resolved.value(for: ["result", "structuredContent", "output", "review"]) as? String == "Done") let commands = await backend.recordedCommands() #expect( commands.contains( @@ -247,13 +247,11 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "review text") + awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "Done") #expect( awaited.value(for: ["result", "structuredContent", "log", "finalSummary"]) as? String == "Done") - #expect( - awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) as? String - == "review text") + #expect(awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) is NSNull) #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) } } @@ -462,7 +460,7 @@ struct CodexReviewMCPHTTPServerTests { #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 == "No correctness issues found.") + #expect(readText == "Done") #expect(readText?.contains("rawLogText") == false) #expect(denied.value(for: ["result", "isError"]) as? Bool == true) #expect( @@ -471,7 +469,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadAddsSemanticLog() async throws { + @Test func streamableHTTPReviewReadLeavesLogEmptyWithoutChatProvider() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -523,29 +521,20 @@ struct CodexReviewMCPHTTPServerTests { let log = try #require( defaultResponse.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["orderedEntryIds"] as? [String] == ["run-semantic:message"]) + #expect(log["orderedEntryIds"] as? [String] == []) #expect(log["activeEntryIds"] as? [String] == []) #expect(log["activeEntryCount"] as? Int == 0) - #expect(log["latestEntryId"] as? String == "run-semantic:message") + #expect(log["latestEntryId"] is NSNull) let itemsPage = try #require(log["itemsPage"] as? [String: Any]) - #expect(itemsPage["total"] as? Int == 1) - #expect(itemsPage["limit"] as? Int == 1) - #expect(itemsPage["returned"] as? Int == 1) + #expect(itemsPage["total"] as? Int == 0) + #expect(itemsPage["limit"] as? Int == 0) + #expect(itemsPage["returned"] as? Int == 0) let items = try #require(log["items"] as? [[String: Any]]) - let messageItem = try #require(items.first) - #expect(messageItem["id"] as? String == "run-semantic:message") - #expect(messageItem["kind"] as? String == "agentMessage") - let content = try #require(messageItem["content"] as? [String: Any]) - #expect(content["type"] as? String == "message") - let logOutput = try #require(content["text"] as? String) - #expect(logOutput.hasPrefix("Tests passed")) - #expect(logOutput.hasSuffix("...")) - #expect(logOutput.count < longOutput.count) - #expect(content["truncatedFields"] as? [String] == ["text"]) + #expect(items.isEmpty) } } - @Test func streamableHTTPReviewReadIncludesRunningSummaryInLogContent() async throws { + @Test func streamableHTTPReviewReadDoesNotProjectRunningSummaryAsLogContent() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -584,15 +573,10 @@ struct CodexReviewMCPHTTPServerTests { let log = try #require( response.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["activeEntryIds"] as? [String] == ["run-tool-progress:summary"]) - #expect(log["activeEntryCount"] as? Int == 1) + #expect(log["activeEntryIds"] as? [String] == []) + #expect(log["activeEntryCount"] as? Int == 0) let items = try #require(log["items"] as? [[String: Any]]) - let item = try #require(items.first) - #expect(item["id"] as? String == "run-tool-progress:summary") - #expect(item["kind"] as? String == "diagnostic") - let content = try #require(item["content"] as? [String: Any]) - #expect(content["type"] as? String == "diagnostic") - #expect(content["message"] as? String == "Running") + #expect(items.isEmpty) } } @@ -866,7 +850,7 @@ struct CodexReviewMCPHTTPServerTests { #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 == "Done") #expect(startText?.contains("rawLogText") == false) let tools = try await postJSONRPC( endpoint: endpoint, diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index b5069e98..c20a8722 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -47,6 +47,7 @@ struct CodexReviewMCPServerTests { #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) #expect(log.finalSummary == "Done") - #expect(log.finalResult == "review") + #expect(log.finalResult == nil) + #expect(log.items.isEmpty) } } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 6f854415..6b9ad114 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -6,8 +6,8 @@ import CodexKit @Suite("Review MCP log projection") struct ReviewMCPLogProjectionTests { - @Test func runningResultProjectsSummaryAsActiveDiagnosticItem() throws { - let projection = ReviewMCPLogProjection(result: .init( + @Test func unavailableProjectionDoesNotRebuildLogFromRunOutput() throws { + let projection = ReviewMCPLogProjection.unavailable(result: .init( runID: "run-1", core: .init( lifecycle: .init(status: .running), @@ -16,17 +16,16 @@ struct ReviewMCPLogProjectionTests { cancellable: true )) - #expect(projection.orderedEntryIDs == ["run-1:summary"]) - #expect(projection.activeEntryIDs == ["run-1:summary"]) - #expect(projection.activeEntryCount == 1) - #expect(projection.latestEntryID == "run-1:summary") - let item = try #require(projection.items.first) - #expect(item.kind == "diagnostic") - #expect(item.content.type == "diagnostic") + #expect(projection.orderedEntryIDs == []) + #expect(projection.activeEntryIDs == []) + #expect(projection.activeEntryCount == 0) + #expect(projection.latestEntryID == nil) + #expect(projection.items.isEmpty) + #expect(projection.finalResult == nil) } - @Test func finalResultProjectsFinalReviewText() throws { - let projection = ReviewMCPLogProjection(result: .init( + @Test func unavailableTerminalProjectionKeepsSummaryButNoFinalResult() throws { + let projection = ReviewMCPLogProjection.unavailable(result: .init( runID: "run-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), @@ -41,11 +40,8 @@ struct ReviewMCPLogProjectionTests { #expect(projection.activeEntryIDs == []) #expect(projection.activeEntryCount == 0) #expect(projection.finalSummary == "Done.") - #expect(projection.finalResult == "No findings.") - let item = try #require(projection.items.first) - #expect(item.id == "run-2:message") - #expect(item.kind == "agentMessage") - #expect(item.content.type == "message") + #expect(projection.finalResult == nil) + #expect(projection.items.isEmpty) } @Test func turnItemsProjectAsOrderedLogItems() throws { @@ -91,7 +87,7 @@ struct ReviewMCPLogProjectionTests { #expect(projection.items.map { $0.content.type } == ["message", "reasoning", "command"]) } - @Test func terminalTurnItemsProvideFinalResultBeforeRunOutputFallback() throws { + @Test func terminalTurnItemsProvideFinalResultFromCodexChatOnly() throws { let projection = ReviewMCPLogProjection( result: .init( runID: "run-1", From ddcbb36d850204534d758c21143024bf364e04d1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:17:30 +0900 Subject: [PATCH 260/359] refactor(review): remove run message buffers --- .../CodexReviewKit/Model/ReviewRunCore.swift | 10 +- .../Model/ReviewRunRecord.swift | 17 ---- .../Model/ReviewRunRecordTesting.swift | 6 +- .../Store/CodexReviewStore.swift | 4 +- .../Store/CodexReviewStoreDiagnostics.swift | 2 - .../Store/CodexReviewStoreReviews.swift | 95 ++----------------- .../CodexReviewMCPToolResults.swift | 2 +- .../ReviewMCPLogProjection.swift | 4 +- Sources/CodexReviewTesting/TestSupport.swift | 2 - .../CodexReviewStoreCommandTests.swift | 61 +----------- .../ReviewRunRecordTests.swift | 18 +--- .../CodexReviewMCPHTTPServerTests.swift | 7 +- .../ReviewMCPLogProjectionTests.swift | 10 +- 13 files changed, 23 insertions(+), 215 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 2034151c..07568826 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -50,14 +50,9 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { public struct Output: Codable, Sendable, Hashable { public internal(set) var summary: String - public internal(set) var lastAgentMessage: String? - public init( - summary: String, - lastAgentMessage: String? = nil - ) { + public init(summary: String) { self.summary = summary - self.lastAgentMessage = lastAgentMessage } } @@ -86,9 +81,6 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { } return output.summary } - if let lastAgentMessage = output.lastAgentMessage?.nilIfEmpty { - return lastAgentMessage - } if let errorMessage = lifecycle.errorMessage?.nilIfEmpty { return errorMessage } diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index 6beecfc0..acb522de 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -12,15 +12,6 @@ package final class ReviewRunRecord: Identifiable, Hashable { package var core: ReviewRunCore package var cancellationRequested: Bool - @ObservationIgnored - package var agentMessagesByItemID: [String: String] - @ObservationIgnored - package var latestAgentMessageItemID: String? - @ObservationIgnored - package var completedAgentMessageItemIDs: Set - @ObservationIgnored - private var syntheticMessageItemCounter: UInt64 - package var isTerminal: Bool { core.isTerminal } @@ -41,10 +32,6 @@ package final class ReviewRunRecord: Identifiable, Hashable { self.targetSummary = targetSummary self.core = core self.cancellationRequested = cancellationRequested - self.agentMessagesByItemID = [:] - self.latestAgentMessageItemID = nil - self.completedAgentMessageItemIDs = [] - self.syntheticMessageItemCounter = 0 } package nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { @@ -55,8 +42,4 @@ package final class ReviewRunRecord: Identifiable, Hashable { hasher.combine(id) } - package func nextSyntheticMessageItemID(prefix: String) -> String { - syntheticMessageItemCounter &+= 1 - return "\(id):\(prefix):\(syntheticMessageItemCounter)" - } } diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index e0b809d9..77fecb0f 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -15,7 +15,6 @@ extension ReviewRunRecord { startedAt: Date? = nil, endedAt: Date? = nil, summary: String, - lastAgentMessage: String? = "", errorMessage: String? = nil, exitCode: Int? = nil ) -> ReviewRunRecord { @@ -39,10 +38,7 @@ extension ReviewRunRecord { cancellation: cancellation, errorMessage: errorMessage ), - output: .init( - summary: summary, - lastAgentMessage: lastAgentMessage - ) + output: .init(summary: summary) ), cancellationRequested: cancellationRequested ) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 65f01a81..90a0ef07 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -365,9 +365,7 @@ public final class CodexReviewStore { let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in return CodexReviewStoreDiagnosticsSnapshot.Run( status: runRecord.core.lifecycle.status.rawValue, - summary: runRecord.core.output.summary, - logText: runRecord.core.reviewText, - rawLogText: runRecord.core.reviewText + summary: runRecord.core.output.summary ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index 8ee95004..9a1fdf76 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -15,8 +15,6 @@ struct CodexReviewStoreDiagnosticsSnapshot: Encodable { struct Run: Encodable { var status: String var summary: String - var logText: String - var rawLogText: String } var serverState: String diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 36cc622f..de81bd3e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -240,36 +240,9 @@ extension CodexReviewStore { } private func markReviewWaitingForNetworkRecovery(_ runRecord: ReviewRunRecord) { - runRecord.resetReviewAttemptOutputForRecovery() appendRecoveryProgress(networkRecoveryUnavailableMessage, to: runRecord) } - private func applyMessageSnapshot( - _ text: String, - itemID: String?, - isCompleted: Bool, - to runRecord: ReviewRunRecord, - at timestamp: Date - ) { - let resolvedItemID = - itemID?.nilIfEmpty - ?? runRecord.nextSyntheticMessageItemID(prefix: "message") - runRecord.noteAgentMessageSnapshot( - itemID: resolvedItemID, - text: text, - isCompleted: isCompleted - ) - } - - private func applyMessageDelta( - _ text: String, - itemID: String, - to runRecord: ReviewRunRecord, - at timestamp: Date - ) { - _ = runRecord.appendAgentMessageDelta(itemID: itemID, delta: text) - } - private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { let networkMonitor = self.networkMonitor let policy = self.networkRecoveryPolicy @@ -703,8 +676,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.output.summary, - result: runRecord.latestBufferedAgentMessage + summary: runRecord.core.output.summary ) } return recoveryState.currentRun @@ -728,8 +700,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.output.summary, - result: runRecord.latestBufferedAgentMessage + summary: runRecord.core.output.summary ) } return true @@ -848,17 +819,14 @@ extension CodexReviewStore { updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) runRecord.core.output.summary = "Review started." - case .message(let text): - let now = clock.now() - applyMessageSnapshot(text, itemID: nil, isCompleted: true, to: runRecord, at: now) - case .messageDelta(let text, let itemID): - applyMessageDelta(text, itemID: itemID, to: runRecord, at: clock.now()) + case .message, .messageDelta: + break case .log(let text): if runRecord.core.output.summary.isEmpty || runRecord.core.output.summary == "Review started." { runRecord.core.output.summary = text } - case .completed(let summary, let result): - completeReview(runRecord, summary: summary, result: result) + case .completed(let summary, _): + completeReview(runRecord, summary: summary) case .failed(let message): markReviewFailed(runRecord, message: message) case .cancelled(let message): @@ -888,29 +856,15 @@ extension CodexReviewStore { private func completeReview( _ runRecord: ReviewRunRecord, - summary: String, - result: String? + summary: String ) { guard runRecord.isTerminal == false else { return } let endedAt = clock.now() - let previousAgentMessage = runRecord.latestBufferedAgentMessage - let resultText = result?.nilIfEmpty - let finalReviewText = resultText ?? previousAgentMessage runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt runRecord.core.output.summary = summary - runRecord.core.output.lastAgentMessage = finalReviewText ?? summary - if let result = resultText, - result != previousAgentMessage - { - runRecord.noteAgentMessageSnapshot( - itemID: runRecord.nextSyntheticMessageItemID(prefix: "message"), - text: result, - isCompleted: true - ) - } writeDiagnosticsIfNeeded() } @@ -959,41 +913,6 @@ private extension ReviewRunRecord { ) } - var latestBufferedAgentMessage: String? { - guard let latestAgentMessageItemID, - let latestMessage = agentMessagesByItemID[latestAgentMessageItemID]?.nilIfEmpty - else { - return nil - } - return latestMessage - } - - func appendAgentMessageDelta(itemID: String, delta: String) -> String? { - guard completedAgentMessageItemIDs.contains(itemID) == false else { - return nil - } - let updated = (agentMessagesByItemID[itemID] ?? "") + delta - agentMessagesByItemID[itemID] = updated - latestAgentMessageItemID = itemID - return updated - } - - func noteAgentMessageSnapshot(itemID: String, text: String, isCompleted: Bool) { - agentMessagesByItemID[itemID] = text - latestAgentMessageItemID = itemID - if isCompleted { - completedAgentMessageItemIDs.insert(itemID) - } else { - completedAgentMessageItemIDs.remove(itemID) - } - } - - func resetReviewAttemptOutputForRecovery() { - core.output.lastAgentMessage = nil - agentMessagesByItemID.removeAll(keepingCapacity: true) - latestAgentMessageItemID = nil - completedAgentMessageItemIDs.removeAll(keepingCapacity: true) - } } private struct ReviewWorkerReviewEvent: Sendable { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index e2c8b074..e0a91ac0 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -411,7 +411,7 @@ private extension ReviewRunCore.Output { "summary": .string(summary), "review": .string(review), "hasFinalReview": .bool(finalReview != nil), - "lastAgentMessage": finalReview.map(Value.string) ?? .null, + "finalReview": finalReview.map(Value.string) ?? .null, "reviewResult": ParsedReviewResult.parse(finalReviewText: finalReview).structuredContent(), ]) } diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index a8f15bca..ae616d79 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -106,13 +106,13 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.finalSummary = status.isTerminal ? output.summary : nil self.finalResult = status == .succeeded - ? projectedItems.lastAgentMessageText + ? projectedItems.lastAssistantMessageText : nil } } private extension [ReviewMCPLogProjection.Item] { - var lastAgentMessageText: String? { + var lastAssistantMessageText: String? { reversed().compactMap { $0.content.messageText }.first } } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 841400b5..aef588d4 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -548,7 +548,6 @@ package final class StoreSnapshotProbe { runID: runRecord.id, status: runRecord.core.lifecycle.status, summary: runRecord.core.output.summary, - lastAgentMessage: runRecord.core.output.lastAgentMessage, run: runRecord.core.run, activeRun: runtimeState.activeRun, cancellationRequested: runRecord.cancellationRequested @@ -611,7 +610,6 @@ package struct StoreRunSnapshot: Sendable { package var runID: String package var status: ReviewRunState package var summary: String - package var lastAgentMessage: String? package var run: ReviewRunCore.Run package var activeRun: CodexReviewBackendModel.Review.Run? package var cancellationRequested: Bool diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 7201c5c5..e1333e68 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -6,7 +6,7 @@ import CodexReviewTesting @Suite("Codex review store", .serialized) @MainActor struct CodexReviewStoreCommandTests { - @Test func reviewStartPublishesCompletedRunAndRetainsResult() async throws { + @Test func reviewStartPublishesCompletedRunLifecycle() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -24,7 +24,6 @@ struct CodexReviewStoreCommandTests { #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.output.lastAgentMessage == "review text") #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["run-1"]) let commands = await backend.recordedCommands() @@ -55,7 +54,6 @@ struct CodexReviewStoreCommandTests { #expect(running.runID == "run-1") #expect(running.core.lifecycle.status == .running) - #expect(running.core.output.lastAgentMessage == nil) await backend.yield(.completed(summary: "Succeeded.", result: "review text")) let final = try await store.awaitReview( @@ -65,7 +63,6 @@ struct CodexReviewStoreCommandTests { ) #expect(final.core.lifecycle.status == .succeeded) - #expect(final.core.output.lastAgentMessage == "review text") } } @@ -92,7 +89,6 @@ struct CodexReviewStoreCommandTests { let final = try await awaited #expect(final.core.lifecycle.status == .succeeded) - #expect(final.core.output.lastAgentMessage == "review text") } } @@ -147,7 +143,6 @@ struct CodexReviewStoreCommandTests { ) #expect(snapshot.core.lifecycle.status == .running) - #expect(snapshot.core.output.lastAgentMessage == nil) } } @@ -222,7 +217,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func reviewStartTracksAgentMessageDeltasByItemID() async throws { + @Test func reviewStartKeepsMessageDeltasOutOfRunOutput() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -235,48 +230,12 @@ struct CodexReviewStoreCommandTests { ) await backend.yield(.messageDelta("first", itemID: "message-1")) await backend.yield(.messageDelta("second", itemID: "message-2")) - #expect( - await waitUntil { - store.reviewRun(id: "run-1")?.agentMessagesByItemID["message-2"] == "second" - }) let running = try #require(store.reviewRun(id: "run-1")) - #expect(running.core.output.lastAgentMessage == nil) #expect(running.core.reviewText == "Review started.") 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(store.reviewRun(id: "run-1")?.core.output.lastAgentMessage == "second") - } - } - - @Test func reviewStartParsesFinalReviewFindings() 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) - ) - 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 - - let parsedResult = ParsedReviewResult.parse(finalReviewText: read.core.reviewText) - #expect(parsedResult.state == .hasFindings) - #expect(parsedResult.findingCount == 1) - #expect(parsedResult.findings.first?.title == "[P2] Add parser tests") + #expect(read.core.reviewText == "Succeeded.") } } @@ -549,7 +508,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .succeeded) #expect(read.core.run.turnID == "turn-2") - #expect(read.core.output.lastAgentMessage == "recovered review") } } @@ -665,7 +623,6 @@ struct CodexReviewStoreCommandTests { 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") } } @@ -713,7 +670,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .succeeded) #expect(read.core.run.turnID == "turn-2") - #expect(read.core.output.lastAgentMessage == "recovered review") } } @@ -758,7 +714,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .succeeded) #expect(read.core.run.turnID == "turn-2") - #expect(read.core.output.lastAgentMessage == "recovered review") } } @@ -1307,7 +1262,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func failedReviewKeepsBufferedMessagesOutOfRunOutput() async throws { + @Test func failedReviewKeepsMessageEventsOutOfRunOutput() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1320,15 +1275,10 @@ struct CodexReviewStoreCommandTests { ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) await backend.yield(.message("partial review")) - #expect( - await waitUntil { - store.reviewRun(id: "run-1")?.agentMessagesByItemID.values.contains("partial review") == true - }) await backend.finishEvents(throwing: StreamClosedError()) let read = try await result #expect(read.core.lifecycle.status == .failed) - #expect(read.core.output.lastAgentMessage == nil) } } @@ -1390,7 +1340,6 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.output.lastAgentMessage == "recovered review") } } @@ -1498,7 +1447,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .cancelled) #expect(read.core.output.summary == "Stop") - #expect(read.core.output.lastAgentMessage == nil) } } @@ -1524,7 +1472,6 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .cancelled) #expect(read.core.output.summary == "Stop") - #expect(read.core.output.lastAgentMessage == nil) } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 669f6f22..ff0fcc5d 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -8,25 +8,11 @@ struct ReviewRunCoreTests { @Test func reviewTextReflectsTerminalCoreState() { let succeeded = ReviewRunCore( lifecycle: .init(status: .succeeded), - output: .init( - summary: "Succeeded.", - lastAgentMessage: "No findings." - ) + output: .init(summary: "Succeeded.") ) #expect(succeeded.lifecycle.status == .succeeded) #expect(succeeded.output.summary == "Succeeded.") - #expect(succeeded.reviewText == "No findings.") - - let succeededWithoutFinalReview = ReviewRunCore( - lifecycle: .init(status: .succeeded), - output: .init( - summary: "Succeeded.", - lastAgentMessage: "Succeeded." - ) - ) - #expect(succeededWithoutFinalReview.lifecycle.status == .succeeded) - #expect(succeededWithoutFinalReview.output.summary == "Succeeded.") - #expect(succeededWithoutFinalReview.reviewText == "Succeeded.") + #expect(succeeded.reviewText == "Succeeded.") let failed = ReviewRunCore( lifecycle: .init( diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 76501e43..1c2acc82 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -411,8 +411,7 @@ struct CodexReviewMCPHTTPServerTests { cwd: "/tmp/project", targetSummary: "Included", status: .succeeded, - summary: "Done", - lastAgentMessage: "No correctness issues found." + summary: "Done" ) store.loadForTesting( serverState: .running, @@ -477,15 +476,13 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let longOutput = "Tests passed\n" + String(repeating: "x", count: 4500) let runRecord = ReviewRunRecord.makeForTesting( id: "run-semantic", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", status: .succeeded, - summary: "Done", - lastAgentMessage: longOutput + summary: "Done" ) store.loadForTesting( serverState: .running, diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 6b9ad114..f7ef6fe9 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -29,10 +29,7 @@ struct ReviewMCPLogProjectionTests { runID: "run-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - output: .init( - summary: "Done.", - lastAgentMessage: "No findings." - ) + output: .init(summary: "Done.") ), cancellable: false )) @@ -94,10 +91,7 @@ struct ReviewMCPLogProjectionTests { core: .init( run: .init(threadID: "thread-1", turnID: "turn-1"), lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - output: .init( - summary: "Done.", - lastAgentMessage: "stale fallback" - ) + output: .init(summary: "Done.") ), cancellable: false ), From 7ac0c1f94f926ccfe596406a10d9a9ad012a81e2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:22:45 +0900 Subject: [PATCH 261/359] refactor(review): remove run review text --- Sources/CodexReviewKit/Model/ReviewRunCore.swift | 13 ------------- .../CodexReviewStoreCommandTests.swift | 4 ++-- .../CodexReviewKitTests/ReviewRunRecordTests.swift | 7 +++---- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 07568826..62d3c3ef 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -73,17 +73,4 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { public var isTerminal: Bool { lifecycle.status.isTerminal } - - public var reviewText: String { - if lifecycle.status == .cancelled { - if let errorMessage = lifecycle.errorMessage?.nilIfEmpty { - return errorMessage - } - return output.summary - } - if let errorMessage = lifecycle.errorMessage?.nilIfEmpty { - return errorMessage - } - return output.summary - } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index e1333e68..5f724609 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -231,11 +231,11 @@ struct CodexReviewStoreCommandTests { await backend.yield(.messageDelta("first", itemID: "message-1")) await backend.yield(.messageDelta("second", itemID: "message-2")) let running = try #require(store.reviewRun(id: "run-1")) - #expect(running.core.reviewText == "Review started.") + #expect(running.core.output.summary == "Review started.") await backend.yield(.completed(summary: "Succeeded.", result: nil)) let read = try await result - #expect(read.core.reviewText == "Succeeded.") + #expect(read.core.output.summary == "Succeeded.") } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index ff0fcc5d..52c3a683 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -5,14 +5,13 @@ import Testing @Suite("Review run core") @MainActor struct ReviewRunCoreTests { - @Test func reviewTextReflectsTerminalCoreState() { + @Test func coreKeepsLifecycleAndSummaryOnly() { let succeeded = ReviewRunCore( lifecycle: .init(status: .succeeded), output: .init(summary: "Succeeded.") ) #expect(succeeded.lifecycle.status == .succeeded) #expect(succeeded.output.summary == "Succeeded.") - #expect(succeeded.reviewText == "Succeeded.") let failed = ReviewRunCore( lifecycle: .init( @@ -23,7 +22,7 @@ struct ReviewRunCoreTests { ) #expect(failed.lifecycle.status == .failed) #expect(failed.lifecycle.errorMessage == "Backend failed.") - #expect(failed.reviewText == "Backend failed.") + #expect(failed.output.summary == "Failed.") let cancelled = ReviewRunCore( lifecycle: .init( @@ -34,6 +33,6 @@ struct ReviewRunCoreTests { ) #expect(cancelled.lifecycle.status == .cancelled) #expect(cancelled.lifecycle.cancellation?.message == "Session closed.") - #expect(cancelled.reviewText == "Cancelled.") + #expect(cancelled.output.summary == "Cancelled.") } } From 7bcebbf646c4d2263987d05c0864a93460a6f1bf Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:26:08 +0900 Subject: [PATCH 262/359] refactor(review): flatten run summary --- .../CodexReviewKit/Model/ReviewRunCore.swift | 14 +++-------- .../Model/ReviewRunRecordTesting.swift | 4 ++-- .../Store/CodexReviewStore.swift | 2 +- .../Store/CodexReviewStoreCancellation.swift | 12 +++++----- .../Store/CodexReviewStoreReviews.swift | 24 +++++++++---------- .../CodexReviewMCPToolResults.swift | 12 ++++------ .../ReviewMCPLogProjection.swift | 10 ++++---- Sources/CodexReviewTesting/TestSupport.swift | 2 +- .../CodexReviewStoreCommandTests.swift | 20 ++++++++-------- .../ReviewRunRecordTests.swift | 12 +++++----- .../ReviewMCPLogProjectionTests.swift | 8 +++---- 11 files changed, 55 insertions(+), 65 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 62d3c3ef..b9934662 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -48,26 +48,18 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { } } - public struct Output: Codable, Sendable, Hashable { - public internal(set) var summary: String - - public init(summary: String) { - self.summary = summary - } - } - public internal(set) var run: Run public internal(set) var lifecycle: Lifecycle - public internal(set) var output: Output + public internal(set) var summary: String public init( run: Run = .init(), lifecycle: Lifecycle, - output: Output + summary: String ) { self.run = run self.lifecycle = lifecycle - self.output = output + self.summary = summary } public var isTerminal: Bool { diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index 77fecb0f..cd539721 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -38,7 +38,7 @@ extension ReviewRunRecord { cancellation: cancellation, errorMessage: errorMessage ), - output: .init(summary: summary) + summary: summary ), cancellationRequested: cancellationRequested ) @@ -63,7 +63,7 @@ extension ReviewRunRecord { core.lifecycle.endedAt = nil } if let summary { - core.output.summary = summary + core.summary = summary } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 90a0ef07..4da85ecf 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -365,7 +365,7 @@ public final class CodexReviewStore { let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in return CodexReviewStoreDiagnosticsSnapshot.Run( status: runRecord.core.lifecycle.status.rawValue, - summary: runRecord.core.output.summary + summary: runRecord.core.summary ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 11c1b0a0..25be4e50 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -49,7 +49,7 @@ extension CodexReviewStore { runRecord.cancellationRequested = false runRecord.core.lifecycle.cancellation = cancellation runRecord.core.lifecycle.status = .cancelled - runRecord.core.output.summary = cancellation.message + runRecord.core.summary = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty ?? runRecord.core.lifecycle.errorMessage @@ -75,13 +75,13 @@ extension CodexReviewStore { runRecord.core.lifecycle.cancellation = nil if let message = message.nilIfEmpty { if message == "Failed to cancel review." { - runRecord.core.output.summary = message + runRecord.core.summary = message } else { - runRecord.core.output.summary = "Failed to cancel review: \(message)" + runRecord.core.summary = "Failed to cancel review: \(message)" } runRecord.core.lifecycle.errorMessage = message } else { - runRecord.core.output.summary = "Failed to cancel review." + runRecord.core.summary = "Failed to cancel review." } writeDiagnosticsIfNeeded() } @@ -267,9 +267,9 @@ extension CodexReviewStore { runRecord.core.lifecycle.cancellation = nil runRecord.core.lifecycle.status = .failed if let resolvedError { - runRecord.core.output.summary = "Failed to cancel review: \(resolvedError)" + runRecord.core.summary = "Failed to cancel review: \(resolvedError)" } else { - runRecord.core.output.summary = "Failed to cancel review." + runRecord.core.summary = "Failed to cancel review." } runRecord.core.lifecycle.errorMessage = resolvedError diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index de81bd3e..6c6189d4 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -72,7 +72,7 @@ extension CodexReviewStore { targetSummary: validatedRequest.target.displaySummary, core: .init( lifecycle: .init(status: .queued), - output: .init(summary: "Queued.") + summary: "Queued." ) ) insertReviewRun(runRecord) @@ -201,7 +201,7 @@ extension CodexReviewStore { let cancellation = runRecord.core.lifecycle.cancellation ?? .system() runRecord.cancellationRequested = true runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.output.summary = cancellation.message + runRecord.core.summary = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message do { try await backend.interruptReview( @@ -235,7 +235,7 @@ extension CodexReviewStore { } private func appendRecoveryProgress(_ message: String, to runRecord: ReviewRunRecord) { - runRecord.core.output.summary = message + runRecord.core.summary = message writeDiagnosticsIfNeeded() } @@ -377,7 +377,7 @@ extension CodexReviewStore { runRecord.cancellationRequested = true runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.output.summary = cancellation.message + runRecord.core.summary = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message if runRecord.core.lifecycle.status == .queued { @@ -535,7 +535,7 @@ extension CodexReviewStore { private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { runRecord.core.lifecycle.status = .running runRecord.core.lifecycle.startedAt = startedAt - runRecord.core.output.summary = "Review started." + runRecord.core.summary = "Review started." writeDiagnosticsIfNeeded() } @@ -547,7 +547,7 @@ extension CodexReviewStore { runRecord.core.lifecycle.status = .failed runRecord.core.lifecycle.endedAt = endedAt runRecord.core.lifecycle.errorMessage = message - runRecord.core.output.summary = message + runRecord.core.summary = message writeDiagnosticsIfNeeded() } @@ -676,7 +676,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.output.summary + summary: runRecord.core.summary ) } return recoveryState.currentRun @@ -700,7 +700,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.output.summary + summary: runRecord.core.summary ) } return true @@ -818,12 +818,12 @@ extension CodexReviewStore { updatedRun.reviewThreadID = reviewThreadID ?? updatedRun.reviewThreadID updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) - runRecord.core.output.summary = "Review started." + runRecord.core.summary = "Review started." case .message, .messageDelta: break case .log(let text): - if runRecord.core.output.summary.isEmpty || runRecord.core.output.summary == "Review started." { - runRecord.core.output.summary = text + if runRecord.core.summary.isEmpty || runRecord.core.summary == "Review started." { + runRecord.core.summary = text } case .completed(let summary, _): completeReview(runRecord, summary: summary) @@ -864,7 +864,7 @@ extension CodexReviewStore { let endedAt = clock.now() runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.output.summary = summary + runRecord.core.summary = summary writeDiagnosticsIfNeeded() } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index e0a91ac0..21136baf 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -89,7 +89,7 @@ private extension CodexReviewAPI.Read.Result { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.output.structuredContent( + "output": core.structuredOutputContent( review: log.finalResult?.nilIfEmpty ?? core.lifecycleText, finalReview: log.finalResult?.nilIfEmpty ), @@ -327,7 +327,7 @@ private extension CodexReviewAPI.Run.ListItem { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.output.structuredContent( + "output": core.structuredOutputContent( review: core.lifecycleText, finalReview: nil ), @@ -361,7 +361,7 @@ private extension CodexReviewAPI.Cancel.Outcome { elapsedSeconds: nil, cancellable: false ), - "output": core.output.structuredContent( + "output": core.structuredOutputContent( review: core.lifecycleText, finalReview: nil ), @@ -400,12 +400,10 @@ private extension ReviewRunCore.Lifecycle { private extension ReviewRunCore { var lifecycleText: String { - lifecycle.errorMessage?.nilIfEmpty ?? output.summary.nilIfEmpty ?? lifecycle.status.rawValue + lifecycle.errorMessage?.nilIfEmpty ?? summary.nilIfEmpty ?? lifecycle.status.rawValue } -} -private extension ReviewRunCore.Output { - func structuredContent(review: String, finalReview: String?) -> Value { + func structuredOutputContent(review: String, finalReview: String?) -> Value { let finalReview = finalReview?.nilIfEmpty return .object([ "summary": .string(summary), diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index ae616d79..7a0332ba 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -41,13 +41,13 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { private init(result: CodexReviewAPI.Read.Result) { let lifecycle = result.core.lifecycle - let output = result.core.output + let summary = result.core.summary let status = lifecycle.status self.revision = [ result.runID, status.rawValue, lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", - output.summary, + summary, ].joined(separator: ":") self.items = [] @@ -55,7 +55,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.activeEntryIDs = [] self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last - self.finalSummary = status.isTerminal ? output.summary : nil + self.finalSummary = status.isTerminal ? summary : nil self.finalResult = nil } @@ -74,7 +74,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { threadItems: [CodexThreadItem] ) { let lifecycle = result.core.lifecycle - let output = result.core.output + let summary = result.core.summary let status = lifecycle.status let projectedItems = threadItems.compactMap { item -> Item? in guard let content = Content(threadItem: item) else { @@ -103,7 +103,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.activeEntryIDs = status.isTerminal ? [] : projectedItems.map(\.id) self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last - self.finalSummary = status.isTerminal ? output.summary : nil + self.finalSummary = status.isTerminal ? summary : nil self.finalResult = status == .succeeded ? projectedItems.lastAssistantMessageText diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index aef588d4..8ddbd4f3 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -547,7 +547,7 @@ package final class StoreSnapshotProbe { return StoreRunSnapshot( runID: runRecord.id, status: runRecord.core.lifecycle.status, - summary: runRecord.core.output.summary, + summary: runRecord.core.summary, run: runRecord.core.run, activeRun: runtimeState.activeRun, cancellationRequested: runRecord.cancellationRequested diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 5f724609..9f7f0236 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -118,7 +118,7 @@ struct CodexReviewStoreCommandTests { let final = try await awaited #expect(final.core.lifecycle.status == .cancelled) - #expect(final.core.output.summary == "Stop") + #expect(final.core.summary == "Stop") } } @@ -172,7 +172,7 @@ struct CodexReviewStoreCommandTests { let final = try await awaited #expect(final.core.lifecycle.status == .failed) - #expect(final.core.output.summary == "Failed to cancel review: Review runtime stopped.") + #expect(final.core.summary == "Failed to cancel review: Review runtime stopped.") } } @@ -231,11 +231,11 @@ struct CodexReviewStoreCommandTests { await backend.yield(.messageDelta("first", itemID: "message-1")) await backend.yield(.messageDelta("second", itemID: "message-2")) let running = try #require(store.reviewRun(id: "run-1")) - #expect(running.core.output.summary == "Review started.") + #expect(running.core.summary == "Review started.") await backend.yield(.completed(summary: "Succeeded.", result: nil)) let read = try await result - #expect(read.core.output.summary == "Succeeded.") + #expect(read.core.summary == "Succeeded.") } } @@ -448,7 +448,7 @@ struct CodexReviewStoreCommandTests { let running = try store.readReview(runID: "run-1") #expect(running.core.lifecycle.status == .running) - #expect(running.core.output.summary == "Network unavailable; waiting to reconnect.") + #expect(running.core.summary == "Network unavailable; waiting to reconnect.") _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) await backend.yield(.cancelled("Stop")) _ = try await result @@ -496,7 +496,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) #expect( await waitUntil { - store.reviewRun(id: "run-1")?.core.output.summary == "Network restored; restarting review." + store.reviewRun(id: "run-1")?.core.summary == "Network restored; restarting review." }) networkMonitor.yield(.satisfied()) await settleGate.open() @@ -1258,7 +1258,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.output.summary == "Stop") + #expect(read.core.summary == "Stop") } } @@ -1419,7 +1419,7 @@ struct CodexReviewStoreCommandTests { #expect(readAfterFailure.cancellable) #expect(readAfterFailure.core.lifecycle.cancellation == nil) - #expect(readAfterFailure.core.output.summary == "Failed to cancel review: Interrupt failed") + #expect(readAfterFailure.core.summary == "Failed to cancel review: Interrupt failed") await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -1446,7 +1446,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.output.summary == "Stop") + #expect(read.core.summary == "Stop") } } @@ -1471,7 +1471,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.output.summary == "Stop") + #expect(read.core.summary == "Stop") } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 52c3a683..3039bd62 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -8,31 +8,31 @@ struct ReviewRunCoreTests { @Test func coreKeepsLifecycleAndSummaryOnly() { let succeeded = ReviewRunCore( lifecycle: .init(status: .succeeded), - output: .init(summary: "Succeeded.") + summary: "Succeeded." ) #expect(succeeded.lifecycle.status == .succeeded) - #expect(succeeded.output.summary == "Succeeded.") + #expect(succeeded.summary == "Succeeded.") let failed = ReviewRunCore( lifecycle: .init( status: .failed, errorMessage: "Backend failed." ), - output: .init(summary: "Failed.") + summary: "Failed." ) #expect(failed.lifecycle.status == .failed) #expect(failed.lifecycle.errorMessage == "Backend failed.") - #expect(failed.output.summary == "Failed.") + #expect(failed.summary == "Failed.") let cancelled = ReviewRunCore( lifecycle: .init( status: .cancelled, cancellation: .mcpClient(message: "Session closed.") ), - output: .init(summary: "Cancelled.") + summary: "Cancelled." ) #expect(cancelled.lifecycle.status == .cancelled) #expect(cancelled.lifecycle.cancellation?.message == "Session closed.") - #expect(cancelled.output.summary == "Cancelled.") + #expect(cancelled.summary == "Cancelled.") } } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index f7ef6fe9..2f8da75b 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -11,7 +11,7 @@ struct ReviewMCPLogProjectionTests { runID: "run-1", core: .init( lifecycle: .init(status: .running), - output: .init(summary: "Review started.") + summary: "Review started." ), cancellable: true )) @@ -29,7 +29,7 @@ struct ReviewMCPLogProjectionTests { runID: "run-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - output: .init(summary: "Done.") + summary: "Done." ), cancellable: false )) @@ -48,7 +48,7 @@ struct ReviewMCPLogProjectionTests { core: .init( run: .init(threadID: "thread-1", turnID: "turn-1"), lifecycle: .init(status: .running), - output: .init(summary: "Running.") + summary: "Running." ), cancellable: true ), @@ -91,7 +91,7 @@ struct ReviewMCPLogProjectionTests { core: .init( run: .init(threadID: "thread-1", turnID: "turn-1"), lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - output: .init(summary: "Done.") + summary: "Done." ), cancellable: false ), From 17e6739cc0e89f650f03518f8fc3dd6ebd022ff8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:29:58 +0900 Subject: [PATCH 263/359] refactor(review): hide run lifecycle types --- .../Model/ReviewCancellation.swift | 18 ++++---- .../CodexReviewKit/Model/ReviewRunCore.swift | 42 +++++++++---------- .../CodexReviewKit/Model/ReviewRunState.swift | 6 +-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewCancellation.swift b/Sources/CodexReviewKit/Model/ReviewCancellation.swift index 1321f67d..19944fa6 100644 --- a/Sources/CodexReviewKit/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 index b9934662..253fb067 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -1,14 +1,14 @@ import Foundation -public struct ReviewRunCore: Codable, Sendable, Hashable { - public struct Run: Codable, Sendable, Hashable { - public internal(set) var attemptID: String? - public internal(set) var reviewThreadID: String? - public internal(set) var threadID: String? - public internal(set) var turnID: String? - public internal(set) var model: String? +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? - public init( + package init( attemptID: String? = nil, reviewThreadID: String? = nil, threadID: String? = nil, @@ -23,15 +23,15 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { } } - public struct Lifecycle: Codable, Sendable, Hashable { - public internal(set) var status: ReviewRunState - 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? + 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? - public init( + package init( status: ReviewRunState, exitCode: Int? = nil, startedAt: Date? = nil, @@ -48,11 +48,11 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { } } - public internal(set) var run: Run - public internal(set) var lifecycle: Lifecycle - public internal(set) var summary: String + package var run: Run + package var lifecycle: Lifecycle + package var summary: String - public init( + package init( run: Run = .init(), lifecycle: Lifecycle, summary: String @@ -62,7 +62,7 @@ public struct ReviewRunCore: Codable, Sendable, Hashable { self.summary = summary } - public var isTerminal: Bool { + package var isTerminal: Bool { lifecycle.status.isTerminal } } diff --git a/Sources/CodexReviewKit/Model/ReviewRunState.swift b/Sources/CodexReviewKit/Model/ReviewRunState.swift index 402760ee..815cdb48 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunState.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunState.swift @@ -1,11 +1,11 @@ -public enum ReviewRunState: 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 ReviewRunState: String, Codable, Sendable, Hashable { } } - public var displayText: String { + package var displayText: String { switch self { case .queued: "Queued" From db76701348b75cf5ed3c3918f2d436eb71d18e1b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:38:23 +0900 Subject: [PATCH 264/359] refactor(mcp): split lifecycle and review output --- .../CodexReviewKit/Model/ReviewRunCore.swift | 6 +- .../Model/ReviewRunRecordTesting.swift | 4 +- .../Store/CodexReviewStore.swift | 2 +- .../Store/CodexReviewStoreCancellation.swift | 12 ++-- .../Store/CodexReviewStoreDiagnostics.swift | 2 +- .../Store/CodexReviewStoreReviews.swift | 24 ++++---- .../CodexReviewMCPToolResults.swift | 58 ++++++++++--------- .../ReviewMCPLogProjection.swift | 12 ++-- Sources/CodexReviewTesting/TestSupport.swift | 2 +- .../CodexReviewStoreCommandTests.swift | 20 +++---- .../ReviewRunRecordTests.swift | 14 ++--- .../CodexReviewMCPHTTPServerTests.swift | 19 ++++-- .../CodexReviewMCPServerTests.swift | 2 +- .../ReviewMCPLogProjectionTests.swift | 12 ++-- 14 files changed, 101 insertions(+), 88 deletions(-) diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 253fb067..412deef1 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -50,16 +50,16 @@ package struct ReviewRunCore: Codable, Sendable, Hashable { package var run: Run package var lifecycle: Lifecycle - package var summary: String + package var lifecycleMessage: String package init( run: Run = .init(), lifecycle: Lifecycle, - summary: String + lifecycleMessage: String ) { self.run = run self.lifecycle = lifecycle - self.summary = summary + self.lifecycleMessage = lifecycleMessage } package var isTerminal: Bool { diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index cd539721..49c8f5ea 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -38,7 +38,7 @@ extension ReviewRunRecord { cancellation: cancellation, errorMessage: errorMessage ), - summary: summary + lifecycleMessage: summary ), cancellationRequested: cancellationRequested ) @@ -63,7 +63,7 @@ extension ReviewRunRecord { core.lifecycle.endedAt = nil } if let summary { - core.summary = summary + core.lifecycleMessage = summary } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 4da85ecf..940fd6a5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -365,7 +365,7 @@ public final class CodexReviewStore { let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in return CodexReviewStoreDiagnosticsSnapshot.Run( status: runRecord.core.lifecycle.status.rawValue, - summary: runRecord.core.summary + lifecycleMessage: runRecord.core.lifecycleMessage ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 25be4e50..6738aba6 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -49,7 +49,7 @@ extension CodexReviewStore { runRecord.cancellationRequested = false runRecord.core.lifecycle.cancellation = cancellation runRecord.core.lifecycle.status = .cancelled - runRecord.core.summary = cancellation.message + runRecord.core.lifecycleMessage = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty ?? runRecord.core.lifecycle.errorMessage @@ -75,13 +75,13 @@ extension CodexReviewStore { runRecord.core.lifecycle.cancellation = nil if let message = message.nilIfEmpty { if message == "Failed to cancel review." { - runRecord.core.summary = message + runRecord.core.lifecycleMessage = message } else { - runRecord.core.summary = "Failed to cancel review: \(message)" + runRecord.core.lifecycleMessage = "Failed to cancel review: \(message)" } runRecord.core.lifecycle.errorMessage = message } else { - runRecord.core.summary = "Failed to cancel review." + runRecord.core.lifecycleMessage = "Failed to cancel review." } writeDiagnosticsIfNeeded() } @@ -267,9 +267,9 @@ extension CodexReviewStore { runRecord.core.lifecycle.cancellation = nil runRecord.core.lifecycle.status = .failed if let resolvedError { - runRecord.core.summary = "Failed to cancel review: \(resolvedError)" + runRecord.core.lifecycleMessage = "Failed to cancel review: \(resolvedError)" } else { - runRecord.core.summary = "Failed to cancel review." + runRecord.core.lifecycleMessage = "Failed to cancel review." } runRecord.core.lifecycle.errorMessage = resolvedError diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index 9a1fdf76..dd6d55c6 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -14,7 +14,7 @@ public enum CodexReviewStoreTestEnvironment { struct CodexReviewStoreDiagnosticsSnapshot: Encodable { struct Run: Encodable { var status: String - var summary: String + var lifecycleMessage: String } var serverState: String diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 6c6189d4..8075f72a 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -72,7 +72,7 @@ extension CodexReviewStore { targetSummary: validatedRequest.target.displaySummary, core: .init( lifecycle: .init(status: .queued), - summary: "Queued." + lifecycleMessage: "Queued." ) ) insertReviewRun(runRecord) @@ -201,7 +201,7 @@ extension CodexReviewStore { let cancellation = runRecord.core.lifecycle.cancellation ?? .system() runRecord.cancellationRequested = true runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.summary = cancellation.message + runRecord.core.lifecycleMessage = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message do { try await backend.interruptReview( @@ -235,7 +235,7 @@ extension CodexReviewStore { } private func appendRecoveryProgress(_ message: String, to runRecord: ReviewRunRecord) { - runRecord.core.summary = message + runRecord.core.lifecycleMessage = message writeDiagnosticsIfNeeded() } @@ -377,7 +377,7 @@ extension CodexReviewStore { runRecord.cancellationRequested = true runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.summary = cancellation.message + runRecord.core.lifecycleMessage = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message if runRecord.core.lifecycle.status == .queued { @@ -535,7 +535,7 @@ extension CodexReviewStore { private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { runRecord.core.lifecycle.status = .running runRecord.core.lifecycle.startedAt = startedAt - runRecord.core.summary = "Review started." + runRecord.core.lifecycleMessage = "Review started." writeDiagnosticsIfNeeded() } @@ -547,7 +547,7 @@ extension CodexReviewStore { runRecord.core.lifecycle.status = .failed runRecord.core.lifecycle.endedAt = endedAt runRecord.core.lifecycle.errorMessage = message - runRecord.core.summary = message + runRecord.core.lifecycleMessage = message writeDiagnosticsIfNeeded() } @@ -676,7 +676,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.summary + summary: runRecord.core.lifecycleMessage ) } return recoveryState.currentRun @@ -700,7 +700,7 @@ extension CodexReviewStore { } completeReview( runRecord, - summary: runRecord.core.summary + summary: runRecord.core.lifecycleMessage ) } return true @@ -818,12 +818,12 @@ extension CodexReviewStore { updatedRun.reviewThreadID = reviewThreadID ?? updatedRun.reviewThreadID updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) - runRecord.core.summary = "Review started." + runRecord.core.lifecycleMessage = "Review started." case .message, .messageDelta: break case .log(let text): - if runRecord.core.summary.isEmpty || runRecord.core.summary == "Review started." { - runRecord.core.summary = text + if runRecord.core.lifecycleMessage.isEmpty || runRecord.core.lifecycleMessage == "Review started." { + runRecord.core.lifecycleMessage = text } case .completed(let summary, _): completeReview(runRecord, summary: summary) @@ -864,7 +864,7 @@ extension CodexReviewStore { let endedAt = clock.now() runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.summary = summary + runRecord.core.lifecycleMessage = summary writeDiagnosticsIfNeeded() } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 21136baf..4c1cbeb9 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -34,7 +34,7 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul private extension CodexReviewAPI.Read.Result { func textContent(log: ReviewMCPLogProjection) -> String { - log.finalResult?.nilIfEmpty ?? core.lifecycleText + log.finalResult?.nilIfEmpty ?? core.displayLifecycleMessage } func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String { @@ -85,14 +85,11 @@ private extension CodexReviewAPI.Read.Result { var object: [String: Value] = [ "runId": .string(runID), "run": core.run.structuredContent(), - "lifecycle": core.lifecycle.structuredContent( + "lifecycle": core.structuredLifecycleContent( elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.structuredOutputContent( - review: log.finalResult?.nilIfEmpty ?? core.lifecycleText, - finalReview: log.finalResult?.nilIfEmpty - ), + "review": core.structuredReviewContent(finalReview: log.finalResult), ] object["log"] = includeDetails @@ -117,9 +114,9 @@ private extension ReviewMCPLogProjection { "activeEntryIds": .array(activeEntryIDs.map(Value.string)), "activeEntryCount": .int(activeEntryCount), "latestEntryId": latestEntryID.map(Value.string) ?? .null, - "finalSummary": boundedLogString( - finalSummary, - field: "finalSummary", + "finalLifecycleMessage": boundedLogString( + finalLifecycleMessage, + field: "finalLifecycleMessage", truncatedFields: &truncatedFields ), "finalResult": boundedLogString( @@ -143,9 +140,9 @@ private extension ReviewMCPLogProjection { "activeEntryIds": .array(activeEntryIDs.map(Value.string)), "activeEntryCount": .int(activeEntryCount), "latestEntryId": latestEntryID.map(Value.string) ?? .null, - "finalSummary": boundedLogString( - finalSummary, - field: "finalSummary", + "finalLifecycleMessage": boundedLogString( + finalLifecycleMessage, + field: "finalLifecycleMessage", truncatedFields: &truncatedFields ), "finalResult": boundedLogString( @@ -323,14 +320,11 @@ private extension CodexReviewAPI.Run.ListItem { "cwd": .string(cwd), "targetSummary": .string(targetSummary), "run": core.run.structuredContent(), - "lifecycle": core.lifecycle.structuredContent( + "lifecycle": core.structuredLifecycleContent( elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "output": core.structuredOutputContent( - review: core.lifecycleText, - finalReview: nil - ), + "review": core.structuredReviewContent(finalReview: nil), ]) } } @@ -357,14 +351,11 @@ private extension CodexReviewAPI.Cancel.Outcome { "runId": .string(runID), "cancelled": .bool(cancelled), "run": core.run.structuredContent(), - "lifecycle": core.lifecycle.structuredContent( + "lifecycle": core.structuredLifecycleContent( elapsedSeconds: nil, cancellable: false ), - "output": core.structuredOutputContent( - review: core.lifecycleText, - finalReview: nil - ), + "review": core.structuredReviewContent(finalReview: nil), ]) } } @@ -383,10 +374,12 @@ private extension ReviewRunCore.Run { private extension ReviewRunCore.Lifecycle { func structuredContent( elapsedSeconds: Int?, - cancellable: Bool + 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, @@ -399,15 +392,24 @@ private extension ReviewRunCore.Lifecycle { } private extension ReviewRunCore { - var lifecycleText: String { - lifecycle.errorMessage?.nilIfEmpty ?? summary.nilIfEmpty ?? lifecycle.status.rawValue + 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 + ) } - func structuredOutputContent(review: String, finalReview: String?) -> Value { + func structuredReviewContent(finalReview: String?) -> Value { let finalReview = finalReview?.nilIfEmpty return .object([ - "summary": .string(summary), - "review": .string(review), "hasFinalReview": .bool(finalReview != nil), "finalReview": finalReview.map(Value.string) ?? .null, "reviewResult": ParsedReviewResult.parse(finalReviewText: finalReview).structuredContent(), diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 7a0332ba..7de569cd 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -31,7 +31,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { var activeEntryIDs: [String] var activeEntryCount: Int var latestEntryID: String? - var finalSummary: String? + var finalLifecycleMessage: String? var finalResult: String? var items: [Item] @@ -41,13 +41,13 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { private init(result: CodexReviewAPI.Read.Result) { let lifecycle = result.core.lifecycle - let summary = result.core.summary + let lifecycleMessage = result.core.lifecycleMessage let status = lifecycle.status self.revision = [ result.runID, status.rawValue, lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", - summary, + lifecycleMessage, ].joined(separator: ":") self.items = [] @@ -55,7 +55,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.activeEntryIDs = [] self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last - self.finalSummary = status.isTerminal ? summary : nil + self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil self.finalResult = nil } @@ -74,7 +74,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { threadItems: [CodexThreadItem] ) { let lifecycle = result.core.lifecycle - let summary = result.core.summary + let lifecycleMessage = result.core.lifecycleMessage let status = lifecycle.status let projectedItems = threadItems.compactMap { item -> Item? in guard let content = Content(threadItem: item) else { @@ -103,7 +103,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.activeEntryIDs = status.isTerminal ? [] : projectedItems.map(\.id) self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last - self.finalSummary = status.isTerminal ? summary : nil + self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil self.finalResult = status == .succeeded ? projectedItems.lastAssistantMessageText diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 8ddbd4f3..685738e1 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -547,7 +547,7 @@ package final class StoreSnapshotProbe { return StoreRunSnapshot( runID: runRecord.id, status: runRecord.core.lifecycle.status, - summary: runRecord.core.summary, + summary: runRecord.core.lifecycleMessage, run: runRecord.core.run, activeRun: runtimeState.activeRun, cancellationRequested: runRecord.cancellationRequested diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 9f7f0236..4a62e142 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -118,7 +118,7 @@ struct CodexReviewStoreCommandTests { let final = try await awaited #expect(final.core.lifecycle.status == .cancelled) - #expect(final.core.summary == "Stop") + #expect(final.core.lifecycleMessage == "Stop") } } @@ -172,7 +172,7 @@ struct CodexReviewStoreCommandTests { let final = try await awaited #expect(final.core.lifecycle.status == .failed) - #expect(final.core.summary == "Failed to cancel review: Review runtime stopped.") + #expect(final.core.lifecycleMessage == "Failed to cancel review: Review runtime stopped.") } } @@ -231,11 +231,11 @@ struct CodexReviewStoreCommandTests { await backend.yield(.messageDelta("first", itemID: "message-1")) await backend.yield(.messageDelta("second", itemID: "message-2")) let running = try #require(store.reviewRun(id: "run-1")) - #expect(running.core.summary == "Review started.") + #expect(running.core.lifecycleMessage == "Review started.") await backend.yield(.completed(summary: "Succeeded.", result: nil)) let read = try await result - #expect(read.core.summary == "Succeeded.") + #expect(read.core.lifecycleMessage == "Succeeded.") } } @@ -448,7 +448,7 @@ struct CodexReviewStoreCommandTests { let running = try store.readReview(runID: "run-1") #expect(running.core.lifecycle.status == .running) - #expect(running.core.summary == "Network unavailable; waiting to reconnect.") + #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 @@ -496,7 +496,7 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) #expect( await waitUntil { - store.reviewRun(id: "run-1")?.core.summary == "Network restored; restarting review." + store.reviewRun(id: "run-1")?.core.lifecycleMessage == "Network restored; restarting review." }) networkMonitor.yield(.satisfied()) await settleGate.open() @@ -1258,7 +1258,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.summary == "Stop") + #expect(read.core.lifecycleMessage == "Stop") } } @@ -1419,7 +1419,7 @@ struct CodexReviewStoreCommandTests { #expect(readAfterFailure.cancellable) #expect(readAfterFailure.core.lifecycle.cancellation == nil) - #expect(readAfterFailure.core.summary == "Failed to cancel review: Interrupt failed") + #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed") await backend.yield(.completed(summary: "Succeeded.", result: "review text")) _ = try await result @@ -1446,7 +1446,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.summary == "Stop") + #expect(read.core.lifecycleMessage == "Stop") } } @@ -1471,7 +1471,7 @@ struct CodexReviewStoreCommandTests { let read = try await result #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.summary == "Stop") + #expect(read.core.lifecycleMessage == "Stop") } } diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index 3039bd62..ea05c236 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -5,34 +5,34 @@ import Testing @Suite("Review run core") @MainActor struct ReviewRunCoreTests { - @Test func coreKeepsLifecycleAndSummaryOnly() { + @Test func coreKeepsLifecycleMessageOnly() { let succeeded = ReviewRunCore( lifecycle: .init(status: .succeeded), - summary: "Succeeded." + lifecycleMessage: "Succeeded." ) #expect(succeeded.lifecycle.status == .succeeded) - #expect(succeeded.summary == "Succeeded.") + #expect(succeeded.lifecycleMessage == "Succeeded.") let failed = ReviewRunCore( lifecycle: .init( status: .failed, errorMessage: "Backend failed." ), - summary: "Failed." + lifecycleMessage: "Failed." ) #expect(failed.lifecycle.status == .failed) #expect(failed.lifecycle.errorMessage == "Backend failed.") - #expect(failed.summary == "Failed.") + #expect(failed.lifecycleMessage == "Failed.") let cancelled = ReviewRunCore( lifecycle: .init( status: .cancelled, cancellation: .mcpClient(message: "Session closed.") ), - summary: "Cancelled." + lifecycleMessage: "Cancelled." ) #expect(cancelled.lifecycle.status == .cancelled) #expect(cancelled.lifecycle.cancellation?.message == "Session closed.") - #expect(cancelled.summary == "Cancelled.") + #expect(cancelled.lifecycleMessage == "Cancelled.") } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 1c2acc82..78b210e3 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -170,7 +170,10 @@ struct CodexReviewMCPHTTPServerTests { #expect( resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - resolved.value(for: ["result", "structuredContent", "output", "review"]) as? String == "Done") + resolved.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Done") + #expect( + resolved.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) + #expect(resolved.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) let commands = await backend.recordedCommands() #expect( commands.contains( @@ -247,9 +250,12 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - awaited.value(for: ["result", "structuredContent", "output", "review"]) as? String == "Done") + awaited.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Done") + #expect( + awaited.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) + #expect(awaited.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) #expect( - awaited.value(for: ["result", "structuredContent", "log", "finalSummary"]) as? String + awaited.value(for: ["result", "structuredContent", "log", "finalLifecycleMessage"]) as? String == "Done") #expect(awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) is NSNull) #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) @@ -511,7 +517,12 @@ struct CodexReviewMCPHTTPServerTests { defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - defaultResponse.value(for: ["result", "structuredContent", "output", "summary"]) as? String == "Done") + 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) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index c20a8722..e97ca86a 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -46,7 +46,7 @@ struct CodexReviewMCPServerTests { let log = snapshot.log #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) - #expect(log.finalSummary == "Done") + #expect(log.finalLifecycleMessage == "Done") #expect(log.finalResult == nil) #expect(log.items.isEmpty) } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 2f8da75b..b8cd1049 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -11,7 +11,7 @@ struct ReviewMCPLogProjectionTests { runID: "run-1", core: .init( lifecycle: .init(status: .running), - summary: "Review started." + lifecycleMessage: "Review started." ), cancellable: true )) @@ -29,14 +29,14 @@ struct ReviewMCPLogProjectionTests { runID: "run-2", core: .init( lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - summary: "Done." + lifecycleMessage: "Done." ), cancellable: false )) #expect(projection.activeEntryIDs == []) #expect(projection.activeEntryCount == 0) - #expect(projection.finalSummary == "Done.") + #expect(projection.finalLifecycleMessage == "Done.") #expect(projection.finalResult == nil) #expect(projection.items.isEmpty) } @@ -48,7 +48,7 @@ struct ReviewMCPLogProjectionTests { core: .init( run: .init(threadID: "thread-1", turnID: "turn-1"), lifecycle: .init(status: .running), - summary: "Running." + lifecycleMessage: "Running." ), cancellable: true ), @@ -91,7 +91,7 @@ struct ReviewMCPLogProjectionTests { core: .init( run: .init(threadID: "thread-1", turnID: "turn-1"), lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - summary: "Done." + lifecycleMessage: "Done." ), cancellable: false ), @@ -105,7 +105,7 @@ struct ReviewMCPLogProjectionTests { ] ) - #expect(projection.finalSummary == "Done.") + #expect(projection.finalLifecycleMessage == "Done.") #expect(projection.finalResult == "CodexChat final") } } From fd556d6b35b198326a57fa96fb3862473a24c031 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:57:26 +0900 Subject: [PATCH 265/359] refactor(review): keep backend events lifecycle-only --- Docs/architecture.md | 352 +++++++++--------- Docs/mcp.md | 67 ++-- README.md | 2 +- .../AppServerCodexReviewBackend.swift | 51 +-- .../ReviewBackendEventSession.swift | 118 +----- .../CodexReviewKit/CodexReviewBackend.swift | 4 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 6 +- .../Store/CodexReviewStoreReviews.swift | 10 +- .../AppServerClientTests.swift | 25 +- .../CodexReviewHostTests.swift | 2 +- .../ReviewBackendEventSessionTests.swift | 42 +-- .../CodexReviewStoreCommandTests.swift | 69 ++-- .../CodexReviewMCPHTTPServerTests.swift | 8 +- .../CodexReviewMCPServerTests.swift | 2 +- .../ReviewMCPLogProjectionTests.swift | 2 +- 15 files changed, 296 insertions(+), 464 deletions(-) diff --git a/Docs/architecture.md b/Docs/architecture.md index 124ab20a..36a6bce8 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -1,25 +1,36 @@ # CodexReviewKit Architecture CodexReviewKit provides ReviewMonitor, a native macOS app for running and -observing Codex review. The package is organized so the generic app-server -client, review-specific app-server behavior, semantic review events, observable -timeline state, UI rendering, and MCP responses each have a clear owner. +observing Codex review. The package is organized around one invariant: +generic Codex data comes from CodexKit, while ReviewMonitor owns only review +product behavior. -The important architectural invariant is that review progress flows in one -direction: +Review content and log content flow in one direction: ```text -raw app-server wire - -> high-level app-server review stream - -> domain review event - -> observable review timeline - -> UI / MCP / rendering 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 or MCP responses after they -have been normalized into high-level app-server review streams and 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 @@ -27,164 +38,157 @@ This table describes intended ownership. | Target | Responsibility | | --- | --- | -| `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server review notification schema, high-level review stream/session API, and Swift domain API for threads, turns, prompts, review sessions, models, accounts, and login. Raw review DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | -| `CodexReviewKit` | Review semantic core, identifiers, kinds, runs, jobs, timeline, parsing, application store/use-case primitives, `CodexReviewStore`, `CodexReviewStoreBackend`, and auth/settings/runtime product state. It has no app-server wire, UI, or MCP dependencies | -| `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level `CodexReviewSession` review streams into `CodexReviewKit` backend events | -| `CodexReviewMCPServer` | MCP server and projection over the review core/store contract, internal MCP protocol request/response conversion, and Streamable HTTP endpoint. It has no UI or app-server backend dependency | +| `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` | Concrete ReviewMonitor UI: AppKit views/controllers, existing hosted SwiftUI views, and monitor presentation state such as selection and sidebar filters | +| `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 | -`CodexDataKit` lives in the separate CodexKit repository and owns reusable -Codex-facing observable model state for generic app-server concepts. -ReviewMonitor should depend on those owners for basic Codex data concerns such -as workspace groups, workspaces, chats, account/login state, rate-limit -presentation, and model configuration once the corresponding `CodexDataKit` -public models exist. Review-specific jobs, review timelines, findings, -cancellation/restart state, and MCP result projections remain -`CodexReviewKit` responsibilities because they are product semantics rather -than generic Codex data state. - ReviewMonitor is the product entry point. The host target wires the concrete runtime together; lower targets do not import the host. ## Source Of Truth -`CodexReviewKit` 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. -Review parsing, store commands, backend contracts, and timeline mutation stay -in this target. - -`CodexAppServerKit` owns the app-server connection and schema boundary. It -starts the local `codex app-server` process, performs JSON-RPC framing, sends -`initialize`/`initialized`, retries overload responses, preserves unknown -notifications, owns app-server review notification schema, and exposes -`CodexAppServer` plus high-level review session streams as the Swift-facing -API for threads, turns, prompts, review sessions, messages, transcripts, log -entries, models, accounts, and login flows. Raw review DTOs may exist as -internal implementation detail, but they are not the public API boundary. It -must not import Review, UI, or MCP targets. - -`CodexReviewAppServer` owns the adapter from high-level app-server review -sessions into `CodexReviewKit` backend events. It consumes `CodexReviewSession` -values from `CodexAppServerKit` and hands application-facing review events to -the store backend. - -`ReviewTimeline` and `CodexDataKit` model state are the observable sources of -truth after conversion. ReviewMonitor UI should render selected chats through -the `CodexChat` snapshot/change stream and `ReviewTimelineDocument` projection. -Legacy log buffering is package-internal compatibility plumbing only. It is not -a ReviewMonitor rendering contract and must not rebuild or author semantic -timeline state. Rendering helpers and MCP server projections project from -semantic state; they do not parse raw app-server events and do not make string -logs authoritative again. - -Collection/list style UI observes source-of-truth models through -ObservationBridge. Large transcript/log surfaces consume stream APIs that emit -an initial snapshot followed by changes so the UI can apply append/replacement -updates without re-reading the entire model. +`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["CodexReviewKit domain layer"] - DomainEvents["ReviewDomainEvent"] - Timeline["ReviewTimeline"] - end - - subgraph Application["CodexReviewKit application layer"] - AppStore["ReviewStore"] - Awaiter["ReviewObservationAwaiter"] - end - - subgraph Product["CodexReviewKit product API"] - PublicStore["CodexReviewStore"] - end - - subgraph Kit["CodexAppServerKit"] - DomainAPI["CodexAppServer"] + subgraph AppServerKit["CodexAppServerKit"] + Server["CodexAppServer"] ReviewSession["CodexReviewSession"] Client["JSON-RPC client"] Process["codex app-server"] end - subgraph GenericData["CodexDataKit"] - GenericOwners["Generic Codex model owners"] + subgraph DataKit["CodexDataKit"] + Context["CodexModelContext"] + Fetch["CodexFetchedResults"] + Chat["CodexChat"] end - subgraph AppServer["CodexReviewAppServer"] - Runtime["Review runtime"] + subgraph ReviewCore["CodexReviewKit"] + Store["CodexReviewStore"] + Run["ReviewRunRecord"] + Awaiter["ReviewObservationAwaiter"] end - subgraph MCP["MCP"] - MCPServer["CodexReviewMCPServer"] + subgraph AppServerAdapter["CodexReviewAppServer"] + Adapter["Review lifecycle adapter"] end - subgraph Monitor["Monitor surfaces"] - Renderer["ReviewMonitorRendering"] - UI["ReviewUI"] + subgraph MCP["CodexReviewMCPServer"] + MCPServer["MCP tools"] + MCPProjection["CodexChat log projection"] + end + + subgraph UI["ReviewUI"] + Sidebar["CodexChat sidebar"] + Detail["CodexChat detail log"] + Presentation["Selection, filters, DnD, context menus"] end subgraph Host["CodexReviewHost"] Composition["Composition root"] end - ReviewSession --> Runtime - Runtime --> DomainEvents - DomainEvents --> Timeline - Timeline --> AppStore - Timeline --> PublicStore - Timeline --> MCPServer - Timeline --> Renderer - PublicStore --> UI - Renderer --> UI - GenericOwners --> UI - DomainAPI --> Client - DomainAPI --> GenericOwners + Server --> Client ReviewSession --> Client Client --> Process - Runtime --> PublicStore - 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. New -code should not move domain, application, rendering, UI, or MCP projection -responsibilities back into runtime owners. +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 `CodexReviewKit` 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. + 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 product surface used by existing ReviewMonitor code. -It owns review commands, auth/settings/runtime state, network recovery policy, -diagnostics, and log store projections through `CodexReviewStore`. +`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 @@ -198,33 +202,27 @@ the store. - `initialize` and `initialized` run once per connection. - `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`, `CodexThreadID`, - `CodexTurnID`, `CodexThread`, `CodexPrompt`, Foundation Models-style - `respond` / `streamResponse` / `CodexResponseStream.collect()` calls, - Codex-specific response stream controls, thread event streams, messages, - transcript/log values, high-level review sessions such as - `CodexReviewSession`, model values, account values, and login handles. -- App-server review notification schemas belong inside the Kit boundary. The - public review surface should be a high-level review stream/session API, not - raw notification DTOs. +- 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. - Notifications are routed by turn ID, early turn notifications are replayed to later consumers, and schema-new notifications are preserved as unknown domain - events. + values. - Cancellation is represented by typed control/cleanup requests, not by closing the transport. -`CodexReviewAppServer` adapts high-level `CodexReviewSession` output into -ReviewMonitor backend events and adds ReviewMonitor-specific cleanup and -recovery on top of that generic boundary. - -The intended ownership for review progress is that `CodexAppServerKit` supplies -generic app-server thread events, log entries, and high-level review session -events, while ReviewMonitor-specific targets adapt those values into -`ReviewDomainEvent` and `ReviewTimeline`. +`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. @@ -232,10 +230,10 @@ Fake and live tests use the same transport protocol. `CodexReviewMCPServer` knows MCP tool names, request arguments, response shape, session headers, Streamable HTTP behavior, and MCP-facing value snapshots from -domain/product state. It calls store commands and projects the review timeline -into MCP tool response shape. 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. +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 @@ -253,20 +251,20 @@ 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`. +- 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 CodexDataKit model exists. Today - CodexDataKit provides `CodexModelContainer`, `CodexModelContext`, - `CodexFetchRequest`, `CodexFetchedResults`, `CodexWorkspaceGroup`, - `CodexWorkspace`, and `CodexChat`; account/login/rate-limit and - model-configuration owners still need to be added before ReviewMonitor can - migrate those basic surfaces. -- UI code must not import app-server runtime, app-server wire, or - MCP server targets. + 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. @@ -274,35 +272,25 @@ The public tool surface is: The current ReviewMonitor UI model boundary is therefore: - `CodexDataKit`: generic observable app-server models and fetch/query owners. -- `CodexReviewKit`: review-job, review-timeline, review-auth/settings product - state, and product command state until the matching generic CodexDataKit - owners are available. +- `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, installed controllers, row views, and transient presentation. + filters, drag state, installed controllers, row views, context menus, and + transient presentation. CodexDataKit migration candidates should be split by owner: -- `CodexModelContext` and `CodexFetchedResults` should own generic - chat/workspace sidebar inputs. ReviewMonitor adapts those models through - `ReviewMonitorCodexSidebarLibrary` for generic Codex sidebar surfaces, while - keeping review jobs and review timelines in `CodexReviewKit`. -- `CodexChat` should be used for generic chat presentation and thread-level - send/refresh/archive/delete actions, not as a replacement for - `CodexReviewJob`. -- A future CodexDataKit account owner should own active-account loading, login - progress, login cancellation/completion, logout, account events, and - rate-limit windows over `CodexAppServer`. -- A future CodexDataKit model-configuration owner should own 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. - -`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. +- 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 @@ -311,11 +299,11 @@ Default tests are deterministic and do not start a live `codex app-server`. | Test area | Uses | Verifies | | --- | --- | --- | | `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, domain result aggregation, and high-level review stream behavior | -| `CodexReviewKitTests` | Domain timelines, ObservationBridge awaiters, and fake `CodexReviewStoreBackend` | Semantic timeline mutation, use-case observation behavior, review/auth/settings state machines, cancellation, result retention | -| `CodexReviewAppServerTests` | Fake app-server review sessions or transport as needed | Adapter behavior from high-level review sessions into backend events, cleanup, and recovery | -| `CodexReviewMCPServerTests` | Fake review store and domain timeline projections | MCP protocol conversion, response shape, and timeline projection snapshots | +| `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 | +| `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: @@ -327,5 +315,5 @@ Forbidden test patterns: - 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 41a2c250..f686c4b8 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,22 +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 Codex chat turn when available + - `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 Codex chat projection. If no chat projection is available, it is `null` even when the run has reached a terminal lifecycle state. +- `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 the detailed timeline for a job. +- Use `review_read` to fetch detailed Codex chat log projection for a run. If you are unsure how to build the `target` object, read: @@ -75,38 +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`. Use `review_read` when timeline item details 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. +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` -- `timeline` with ordered item IDs, active item IDs, terminal summary/result, - and item details. +- `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: @@ -117,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` @@ -139,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/README.md b/README.md index d1b60511..779390a4 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ 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. diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index fac9d573..82018281 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -489,10 +489,9 @@ private enum AppServerTypedReviewEventAdapter { .init(events: itemEvents(item, phase: .updated), controlThreadID: controlThreadID) case .itemCompleted(let item, _): .init(events: itemEvents(item, phase: .completed), controlThreadID: controlThreadID) - case .message(let message, _): - .init(events: messageEvents(message), controlThreadID: controlThreadID) - case .messageDelta(let delta, _): - .init(events: messageDeltaEvents(delta), controlThreadID: controlThreadID) + case .message, + .messageDelta: + .init(events: [], controlThreadID: controlThreadID) case .reasoningSummaryPartAdded: .init(events: [], controlThreadID: controlThreadID) case .reasoningDelta: @@ -530,12 +529,7 @@ private enum AppServerTypedReviewEventAdapter { ) } return [ - .completed( - summary: "Succeeded.", - result: response.finalAnswer?.nilIfEmpty - ?? response.transcript.finalAnswer?.nilIfEmpty - ?? response.transcript.responseText?.nilIfEmpty - ) + .completed(summary: "Succeeded.") ] } @@ -543,7 +537,7 @@ private enum AppServerTypedReviewEventAdapter { _ status: String, turnID: String ) -> [CodexReviewBackendModel.Review.Event] { - [.log("Review thread \(turnID) status changed: \(status).")] + [.progress("Review thread \(turnID) status changed: \(status).")] } private static func terminalFailureEvents( @@ -567,45 +561,16 @@ private enum AppServerTypedReviewEventAdapter { } if phase == .completed, item.kind.rawValue == "exitedReviewMode" { return [ - .completed( - summary: "Succeeded.", - result: item.text?.nilIfEmpty - ) + .completed(summary: "Succeeded.") ] } - guard item.kind == .agentMessage, - let text = item.text?.nilIfEmpty - else { - return [] - } - return [.message(text)] - } - - private static func messageEvents( - _ message: CodexMessage - ) -> [CodexReviewBackendModel.Review.Event] { - guard message.role != .user, - message.text.isEmpty == false - else { - return [] - } - return [.message(message.text)] - } - - private static func messageDeltaEvents( - _ delta: CodexMessageDelta - ) -> [CodexReviewBackendModel.Review.Event] { - guard delta.text.isEmpty == false else { - return [] - } - let itemID = delta.itemID?.nilIfEmpty ?? "agent-message-delta" - return [.messageDelta(delta.text, itemID: itemID)] + return [] } private static func unknownEvents( _ raw: CodexRawNotification ) -> [CodexReviewBackendModel.Review.Event] { - [.log("Unhandled app-server notification: \(raw.method)")] + [.progress("Unhandled app-server notification: \(raw.method)")] } } diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index dfb7f328..1e6bd495 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -40,12 +40,9 @@ package actor ReviewBackendEventSession { private let callbacks: ReviewBackendEventSessionCallbacks private var reviewThreadIDsForCleanup: [String] = [] private var cancellationRequestedMessage: String? - private let completionCoordinator = ReviewCompletionCoordinator() private let createdAt = Date() private var finished = false private var metrics = ReviewBackendEventSessionMetrics() - private var typedMessageTextByItemID: [String: String] = [:] - private var typedReviewResultText: String? package init( run: CodexReviewBackendModel.Review.Run, @@ -100,13 +97,8 @@ package actor ReviewBackendEventSession { } if let error { finished = true - completionCoordinator.cancelPendingCompletion() await mailbox.fail(error) } else { - if await flushPendingCompletion() { - finished = true - return - } finished = true await mailbox.finish() } @@ -117,7 +109,6 @@ package actor ReviewBackendEventSession { return } finished = true - completionCoordinator.cancelPendingCompletion() await mailbox.abandon() } @@ -139,51 +130,17 @@ package actor ReviewBackendEventSession { return } metrics.decoded += 1 - for event in eventsWithTypedResultFallback(events) { - if event.shouldDeferReviewBackendCompletion { - completionCoordinator.deferCompletion(event) - continue - } + for event in events { if await emit(event, controlThreadID: controlThreadID) { return } } } - private func eventsWithTypedResultFallback( - _ events: [CodexReviewBackendModel.Review.Event] - ) -> [CodexReviewBackendModel.Review.Event] { - events.map { event in - switch event { - case .message(let text): - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - return event - case .messageDelta(let delta, let itemID): - let text = (typedMessageTextByItemID[itemID] ?? "") + delta - typedMessageTextByItemID[itemID] = text - if let text = text.nilIfEmpty { - typedReviewResultText = text - } - return event - case .completed(let summary, nil): - return .completed(summary: summary, result: typedReviewResultText) - case .started, - .log, - .completed, - .failed, - .cancelled: - return event - } - } - } - private func finishSession(cancellationMessage: String?) async { guard finished == false else { return } - completionCoordinator.cancelPendingCompletion() if let cancellationMessage { _ = await emit(.cancelled(cancellationMessage)) } else { @@ -206,27 +163,10 @@ package actor ReviewBackendEventSession { _ event: CodexReviewBackendModel.Review.Event, controlThreadID: String? = nil ) async -> Bool { - noteEmission(event) - let didFinish = completionCoordinator.emit(event) - await mailbox.append(event) - await recordReviewEvent(event, controlThreadID: controlThreadID) - return didFinish - } - - private func flushPendingCompletion( - controlThreadID: String? = nil - ) async -> Bool { - guard let event = completionCoordinator.flushPendingCompletion() else { - return false - } - let events = eventsWithTypedResultFallback([event]) - guard let event = events.first else { - return false - } noteEmission(event) await mailbox.append(event) await recordReviewEvent(event, controlThreadID: controlThreadID) - return true + return event.isReviewBackendTerminal } private func recordReviewEvent( @@ -238,9 +178,7 @@ package actor ReviewBackendEventSession { await callbacks.recordTurnStarted(turnID) case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) - case .message, - .messageDelta, - .log: + case .progress: break } } @@ -270,56 +208,8 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .started, - .message, - .messageDelta, - .log: + .progress: false } } - - var shouldDeferReviewBackendCompletion: Bool { - guard case .completed(_, let result) = self else { - return false - } - return result?.nilIfEmpty == 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.isReviewBackendTerminal 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 { - return nil - } - pendingCompletion = nil - finished = true - return event - } - - func cancelPendingCompletion() { - pendingCompletion = nil - } } diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 3de49e43..98a63a6e 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -179,9 +179,7 @@ package actor BackendReviewEventMailbox { case .completed, .failed, .cancelled: return true case .started, - .message, - .messageDelta, - .log: + .progress: return false } } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 18eebf1c..8dcbb911 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -278,10 +278,8 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { case started(turnID: String, reviewThreadID: String?, model: String?) - case message(String) - case messageDelta(String, itemID: String) - case log(String) - case completed(summary: String, result: String?) + case progress(String) + case completed(summary: String) case failed(String) case cancelled(String) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 8075f72a..2c0f4e4e 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -819,13 +819,11 @@ extension CodexReviewStore { updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) runRecord.core.lifecycleMessage = "Review started." - case .message, .messageDelta: - break - case .log(let text): + case .progress(let text): if runRecord.core.lifecycleMessage.isEmpty || runRecord.core.lifecycleMessage == "Review started." { runRecord.core.lifecycleMessage = text } - case .completed(let summary, _): + case .completed(let summary): completeReview(runRecord, summary: summary) case .failed(let message): markReviewFailed(runRecord, message: message) @@ -891,9 +889,7 @@ private extension CodexReviewBackendModel.Review.Event { case .completed, .failed, .cancelled: true case .started, - .message, - .messageDelta, - .log: + .progress: false } } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index f27beaf5..52193ca1 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -99,11 +99,10 @@ struct AppServerClientTests { #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .messageDelta("Looks good.", itemID: "msg-1")) - #expect(try await iterator.next() == .completed(summary: "Succeeded.", result: "Looks good.")) + #expect(try await iterator.next() == .completed(summary: "Succeeded.")) } - @Test func backendScopesMessageDeltasByReviewThread() async throws { + @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") @@ -133,18 +132,32 @@ struct AppServerClientTests { delta: "second" ) ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread-1", + turn: .init(id: "turn-1", status: "completed") + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread-2", + turn: .init(id: "turn-2", status: "completed") + ) + ) 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() == .messageDelta("first", itemID: "msg-1")) + #expect(try await firstIterator.next() == .completed(summary: "Succeeded.")) 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() == .messageDelta("second", itemID: "msg-1")) + #expect(try await secondIterator.next() == .completed(summary: "Succeeded.")) } @Test func backendKeepsCommandOutputDeltasInCodexChat() async throws { @@ -189,7 +202,7 @@ struct AppServerClientTests { ) ) - #expect(try await nextEvent(from: attempt.events) == .completed(summary: "Succeeded.", result: nil)) + #expect(try await nextEvent(from: attempt.events) == .completed(summary: "Succeeded.")) } @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 68093537..9f7d4fe7 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -135,7 +135,7 @@ struct CodexReviewHostTests { }.first) #expect(startReview.model == "gpt-5.5") - await backend.yield(.completed(summary: "Succeeded.", result: nil)) + await backend.yield(.completed(summary: "Succeeded.")) await backend.finishEvents() _ = try await reviewTask.value } diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index dc33408e..504d13c4 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -22,7 +22,7 @@ struct ReviewBackendEventSessionTests { await session.receive( [ .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), - .completed(summary: "Succeeded.", result: "Looks good."), + .completed(summary: "Succeeded."), ], controlThreadID: "review-thread") #expect( @@ -34,10 +34,7 @@ struct ReviewBackendEventSessionTests { )) #expect( try await nextEvent(from: attempt.events) - == .completed( - summary: "Succeeded.", - result: "Looks good." - )) + == .completed(summary: "Succeeded.")) #expect(await recorder.startedTurnIDs() == ["turn-1"]) #expect(await recorder.finishedRun() == makeRun()) @@ -51,36 +48,18 @@ struct ReviewBackendEventSessionTests { #expect(await recorder.finishedMetrics() == metrics) } - @Test func emitsLogsWithoutDelayingLaterUpdates() async throws { + @Test func emitsProgressWithoutDelayingLaterUpdates() async throws { let session = ReviewBackendEventSession(run: makeRun()) let attempt = await session.attempt() - await session.receive([.log("first")], controlThreadID: "review-thread") - await session.receive([.log("second")], controlThreadID: "review-thread") + await session.receive([.progress("first")], controlThreadID: "review-thread") + await session.receive([.progress("second")], controlThreadID: "review-thread") await session.finish(throwing: nil) - #expect(try await nextEvent(from: attempt.events) == .log("first")) - #expect(try await nextEvent(from: attempt.events) == .log("second")) + #expect(try await nextEvent(from: attempt.events) == .progress("first")) + #expect(try await nextEvent(from: attempt.events) == .progress("second")) #expect(try await nextEvent(from: attempt.events) == nil) } - - @Test func fillsCompletionResultFromStreamedMessageText() async throws { - let session = ReviewBackendEventSession(run: makeRun()) - let attempt = await session.attempt() - - await session.receive([messageTextDelta("Looks ", itemID: "msg-1")]) - await session.receive([messageTextDelta("good.", itemID: "msg-1")]) - await session.receive([.completed(summary: "Succeeded.", result: nil)]) - - #expect(try await nextEvent(from: attempt.events) == messageTextDelta("Looks ", itemID: "msg-1")) - #expect(try await nextEvent(from: attempt.events) == messageTextDelta("good.", itemID: "msg-1")) - #expect( - try await nextEvent(from: attempt.events) - == .completed( - summary: "Succeeded.", - result: "Looks good." - )) - } } private actor ReviewBackendEventSessionRecorder { @@ -144,10 +123,3 @@ private func makeRun() -> CodexReviewBackendModel.Review.Run { model: "gpt-5" ) } - -private func messageTextDelta( - _ delta: String, - itemID: String -) -> CodexReviewBackendModel.Review.Event { - .messageDelta(delta, itemID: itemID) -} diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 4a62e142..985bc598 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -18,8 +18,8 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.log("started")) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.progress("started")) + await backend.yield(.completed(summary: "Succeeded.")) let read = try await result #expect(read.runID == "run-1") @@ -55,7 +55,7 @@ struct CodexReviewStoreCommandTests { #expect(running.runID == "run-1") #expect(running.core.lifecycle.status == .running) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) let final = try await store.awaitReview( sessionID: "session-1", runID: "run-1", @@ -85,7 +85,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", timeout: .seconds(1) ) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) let final = try await awaited #expect(final.core.lifecycle.status == .succeeded) @@ -146,7 +146,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func awaitReviewReturnsWhenLocalTerminationUpdatesRunOutput() async throws { + @Test func awaitReviewReturnsWhenLocalTerminationUpdatesRunLifecycle() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -203,7 +203,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await result let commands = await backend.recordedCommands() @@ -217,7 +217,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func reviewStartKeepsMessageDeltasOutOfRunOutput() async throws { + @Test func reviewStartUsesProgressAsLifecycleMessage() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -228,12 +228,15 @@ struct CodexReviewStoreCommandTests { 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")) - let running = try #require(store.reviewRun(id: "run-1")) - #expect(running.core.lifecycleMessage == "Review started.") + let probe = StoreSnapshotProbe(store: store) + try #require(await probe.waitUntilRunStatus(.running, runID: "run-1") != nil) + await backend.yield(.progress("Inspecting files.")) + let progressSnapshot = try #require(await probe.waitUntil { snapshot in + snapshot.run("run-1")?.summary == "Inspecting files." + }) + #expect(progressSnapshot.run("run-1")?.summary == "Inspecting files.") - await backend.yield(.completed(summary: "Succeeded.", result: nil)) + await backend.yield(.completed(summary: "Succeeded.")) let read = try await result #expect(read.core.lifecycleMessage == "Succeeded.") } @@ -249,7 +252,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/old-project", target: .baseBranch("main")) ) - await backend.yield(.completed(summary: "Succeeded.", result: "first")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await first await backend.finishEvents() @@ -257,7 +260,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.", result: "second")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await second #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"]) @@ -274,7 +277,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - await backend.yield(.completed(summary: "Succeeded.", result: "first")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await first await backend.finishEvents() @@ -282,7 +285,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.", result: "second")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await second #expect( @@ -311,7 +314,7 @@ struct CodexReviewStoreCommandTests { #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await result } } @@ -339,7 +342,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.", result: "new")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await result #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") @@ -422,7 +425,7 @@ struct CodexReviewStoreCommandTests { } } == false) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) let read = try await result #expect(read.core.lifecycle.status == .succeeded) } @@ -503,7 +506,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -574,7 +577,7 @@ struct CodexReviewStoreCommandTests { #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) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -617,8 +620,8 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(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) + await backend.yield(.completed(summary: "Succeeded."), for: initialRun) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -665,7 +668,7 @@ struct CodexReviewStoreCommandTests { await recoverGate.open() try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -709,7 +712,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1224,7 +1227,7 @@ struct CodexReviewStoreCommandTests { } #expect(try store.readReview(runID: "run-1").cancellable) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await result let commands = await backend.recordedCommands() @@ -1262,7 +1265,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func failedReviewKeepsMessageEventsOutOfRunOutput() async throws { + @Test func failedReviewDoesNotRequireReviewText() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1274,7 +1277,7 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) - await backend.yield(.message("partial review")) + await backend.yield(.progress("partial review")) await backend.finishEvents(throwing: StreamClosedError()) let read = try await result @@ -1336,7 +1339,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded.", result: "recovered review"), for: recoveredRun) + await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1421,7 +1424,7 @@ struct CodexReviewStoreCommandTests { #expect(readAfterFailure.core.lifecycle.cancellation == nil) #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed") - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) _ = try await result } } @@ -1442,7 +1445,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.completed(summary: "Succeeded.", result: "late result")) + await backend.yield(.completed(summary: "Succeeded.")) let read = try await result #expect(read.core.lifecycle.status == .cancelled) @@ -1465,7 +1468,7 @@ struct CodexReviewStoreCommandTests { ) async let cancel = store.cancelReview(runID: "run-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 backend.yield(.completed(summary: "Reviewer failed to output a response.")) await interruptGate.open() _ = try await cancel let read = try await result @@ -1560,7 +1563,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.", result: "review text")) + await backend.yield(.completed(summary: "Succeeded.")) let read = try await result #expect(read.runID == "run-1") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 78b210e3..49c19f10 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -160,7 +160,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(summary: "Done", result: "review text")) + await backend.yield(.completed(summary: "Done")) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == false) @@ -229,7 +229,7 @@ struct CodexReviewMCPHTTPServerTests { #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(summary: "Done")) let awaited = try await postJSONRPC( endpoint: endpoint, sessionID: sessionID, @@ -292,7 +292,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(summary: "Done", result: "review text")) + await backend.yield(.completed(summary: "Done")) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") @@ -851,7 +851,7 @@ 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(summary: "Done")) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index e97ca86a..44cdfb9b 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -35,7 +35,7 @@ struct CodexReviewMCPServerTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges), waitTimeout: nil )) - await backend.yield(.completed(summary: "Done", result: "review")) + await backend.yield(.completed(summary: "Done")) let resolved = try await response guard case .reviewStart(let snapshot) = resolved else { diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index b8cd1049..d3eecdb1 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -6,7 +6,7 @@ import CodexKit @Suite("Review MCP log projection") struct ReviewMCPLogProjectionTests { - @Test func unavailableProjectionDoesNotRebuildLogFromRunOutput() throws { + @Test func unavailableProjectionDoesNotRebuildLogFromRunLifecycle() throws { let projection = ReviewMCPLogProjection.unavailable(result: .init( runID: "run-1", core: .init( From 32dd416ba617838b0b3257885b463470eab5da5e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:03:19 +0900 Subject: [PATCH 266/359] test(ui): cover chat context menu cancellation --- .../ReviewMonitorSidebarViewController.swift | 16 +++++++++ Tests/ReviewUITests/ReviewUITests.swift | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index a63be381..a8a5ba10 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -1627,6 +1627,22 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return titles } + @discardableResult + func activateReviewChatContextMenuItemForTesting( + _ chatID: CodexThreadID, + title: String + ) -> Bool { + var didActivate = false + presentReviewChatContextMenuForTesting(chatID) { menu in + guard let itemIndex = menu.items.firstIndex(where: { $0.title == title }) else { + return + } + menu.performActionForItem(at: itemIndex) + didActivate = true + } + return didActivate + } + private func presentReviewChatContextMenuForTesting( _ chatID: CodexThreadID, presenter: @escaping (NSMenu) -> Void diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 1ea19084..f95ed713 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -849,6 +849,42 @@ struct ReviewUITests { #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChat.chatID).isEmpty) } + @Test func reviewChatContextMenuCancelActionCancelsRunningChat() async throws { + let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running) + let activeRun = ReviewRunRecord.makeForTesting( + id: "run-cancellable", + cwd: activeChat.cwd, + targetSummary: activeChat.chat.title, + threadID: activeChat.chatID.rawValue, + turnID: activeChat.turnID.rawValue, + status: .running, + summary: "Running review." + ) + let store = CodexReviewStore.makePreviewStore() + store.loadReviewCancellationStateForTesting( + serverState: .running, + reviewRuns: [activeRun] + ) + installPreviewChatLogSourceForTesting(on: store, fixtures: [activeChat]) + let backend = makeWindowHarness(store: store) + let viewController = backend.viewController + defer { backend.window.close() } + let sidebar = viewController.sidebarViewControllerForTesting + + #expect(sidebar.activateReviewChatContextMenuItemForTesting( + activeChat.chatID, + title: "Cancel Review" + )) + + _ = try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus( + .cancelled, + runID: "run-cancellable" + )) + let run = try #require(store.reviewRun(id: "run-cancellable")) + #expect(run.core.lifecycle.cancellation?.source == .userInterface) + #expect(run.core.lifecycle.cancellation?.message == "Cancelled by user from Review Monitor.") + } + @Test func selectingReviewChatUpdatesDetailPane() async throws { let activeChat = makeReviewChatFixtureForTesting( title: "Uncommitted changes", From 69e65d43ed373e63db49469f56fc31f330d90101 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:10:59 +0900 Subject: [PATCH 267/359] fix(review): unify cancellable run state --- .../Store/CodexReviewStoreCancellation.swift | 2 +- .../Store/CodexReviewStoreOrderQueries.swift | 6 ++- .../Store/CodexReviewStoreReviews.swift | 6 +-- .../CodexReviewStoreCommandTests.swift | 40 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 6738aba6..8b6835df 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -107,7 +107,7 @@ extension CodexReviewStore { let cancellation = ReviewCancellation.system( message: reason.nilIfEmpty ?? "Cancellation requested." ) - let cancellableReviewRuns = orderedReviewRuns.filter { $0.isTerminal == false } + let cancellableReviewRuns = orderedReviewRuns.filter(isCancellableReviewRun) var firstError: (any Error)? for runRecord in cancellableReviewRuns { do { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 914479e7..f324671f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -12,13 +12,17 @@ extension CodexReviewStore { 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 cancellableReviewRun(forChatID chatID: String) -> ReviewRunRecord? { orderedReviewRuns.first { runRecord in - guard runRecord.isTerminal == false else { + guard isCancellableReviewRun(runRecord) else { return false } return runRecord.matchesChatID(chatID) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 2c0f4e4e..903c8ba6 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -287,7 +287,7 @@ extension CodexReviewStore { runID: runRecord.id, core: runRecord.core, elapsedSeconds: elapsedSeconds(for: runRecord), - cancellable: runRecord.isTerminal == false && runRecord.cancellationRequested == false + cancellable: isCancellableReviewRun(runRecord) ) } @@ -371,7 +371,7 @@ extension CodexReviewStore { cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { let runRecord = try requireReviewRun(runID: runID) - guard runRecord.isTerminal == false else { + guard isCancellableReviewRun(runRecord) else { return .init(runID: runRecord.id, cancelled: false, core: runRecord.core) } @@ -515,7 +515,7 @@ extension CodexReviewStore { targetSummary: runRecord.targetSummary, core: runRecord.core, elapsedSeconds: elapsedSeconds(for: runRecord), - cancellable: runRecord.isTerminal == false && runRecord.cancellationRequested == false + cancellable: isCancellableReviewRun(runRecord) ) } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 985bc598..1d5d7be7 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -380,6 +380,46 @@ struct CodexReviewStoreCommandTests { } } + @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() From 38e5dca9c4b7c91ad70a512db46cca1e07905f24 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:13:18 +0900 Subject: [PATCH 268/359] refactor(ui): rename preview chat initial message --- .../ReviewMonitorPreviewContent.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 92084087..b9aa9131 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -17,7 +17,7 @@ public enum ReviewMonitorPreviewContent { let lifecycle: PreviewChatLifecycle let targetSummary: String let summary: String - let lastAgentMessage: String + let initialMessage: String let model: String let startedOffset: TimeInterval? let endedOffset: TimeInterval? @@ -30,7 +30,7 @@ public enum ReviewMonitorPreviewContent { let cwd: String let targetSummary: String let summary: String - let lastAgentMessage: String + let initialMessage: String let model: String let lifecycle: PreviewChatLifecycle let startedAt: Date? @@ -194,7 +194,7 @@ public enum ReviewMonitorPreviewContent { cwd: cwd, targetSummary: "Command output panel", summary: "A command output block is collapsed by default.", - lastAgentMessage: "Opened command output should stay bounded to a short embedded scroll view.", + initialMessage: "Opened command output should stay bounded to a short embedded scroll view.", model: "gpt-5.4", lifecycle: .running, startedAt: now.addingTimeInterval(-135), @@ -617,7 +617,7 @@ public enum ReviewMonitorPreviewContent { cwd: cwd, targetSummary: definition.targetSummary, summary: definition.summary, - lastAgentMessage: definition.lastAgentMessage, + initialMessage: definition.initialMessage, model: definition.model, lifecycle: definition.lifecycle, startedAt: definition.startedOffset.map { now.addingTimeInterval($0) }, @@ -642,7 +642,7 @@ public enum ReviewMonitorPreviewContent { rowID: .chat(chatFixture.chatID), id: chatFixture.chatID, title: chatFixture.targetSummary, - preview: chatFixture.lastAgentMessage.nilIfEmpty ?? chatFixture.summary.nilIfEmpty, + preview: chatFixture.initialMessage.nilIfEmpty ?? chatFixture.summary.nilIfEmpty, model: chatFixture.model, workspaceCWD: chatFixture.cwd, updatedAt: chatFixture.endedAt ?? chatFixture.startedAt, @@ -850,7 +850,7 @@ public enum ReviewMonitorPreviewContent { message: definition.summary ), messageItem( - "failed-message-\(workspaceName)-\(definition.targetSummary)", text: definition.lastAgentMessage), + "failed-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), ] case .cancelled: return [ @@ -865,7 +865,7 @@ public enum ReviewMonitorPreviewContent { message: definition.summary ), messageItem( - "cancelled-message-\(workspaceName)-\(definition.targetSummary)", text: definition.lastAgentMessage), + "cancelled-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), ] case .succeeded: let commandName = "preview-complete-command-\(workspaceName)-\(definition.targetSummary)" @@ -894,7 +894,7 @@ public enum ReviewMonitorPreviewContent { text: definition.summary ), messageItem( - "complete-message-\(workspaceName)-\(definition.targetSummary)", text: definition.lastAgentMessage), + "complete-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), ] } } @@ -962,7 +962,7 @@ public enum ReviewMonitorPreviewContent { "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.lastAgentMessage), + "preview-initial-agent-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), ] } @@ -972,7 +972,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .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.", + initialMessage: "Inspecting recent sidebar changes and collecting render timings.", model: "gpt-5.4", startedOffset: -420, endedOffset: nil @@ -981,7 +981,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .running, targetSummary: "Uncommitted changes", summary: "The working tree review is still in progress.", - lastAgentMessage: "Comparing row reuse behavior across the latest local edits.", + initialMessage: "Comparing row reuse behavior across the latest local edits.", model: "gpt-5.4-mini", startedOffset: -135, endedOffset: nil @@ -990,7 +990,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .queued, targetSummary: "Base branch: main", summary: "Queued behind another active review in this workspace.", - lastAgentMessage: "Waiting for an available backend slot.", + initialMessage: "Waiting for an available backend slot.", model: "gpt-5.3-codex", startedOffset: nil, endedOffset: nil @@ -999,7 +999,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .succeeded, targetSummary: "Commit: abc1234", summary: "Review completed without correctness findings.", - lastAgentMessage: "No correctness issues found in the touched files.", + initialMessage: "No correctness issues found in the touched files.", model: "gpt-5.4", startedOffset: -1_500, endedOffset: -1_260 @@ -1008,7 +1008,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .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.", + initialMessage: "Build failed before the model could finish evaluating the patch.", model: "gpt-5.3-codex", startedOffset: -2_400, endedOffset: -2_190 @@ -1017,7 +1017,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .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.", + initialMessage: "Stopped after the first pass to free the session for a retry.", model: "gpt-5.4-mini", startedOffset: -960, endedOffset: -840 @@ -1026,7 +1026,7 @@ public enum ReviewMonitorPreviewContent { lifecycle: .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.", + initialMessage: "Suggested simplifying duplicated state handling in the row view.", model: "gpt-5.4", startedOffset: -5_400, endedOffset: -5_040 From 7d79d4ed34ef137bc9134028d3baafe9412b5016 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:27:37 +0900 Subject: [PATCH 269/359] refactor(ui): bind sidebar rows to codex chat models --- .../CodexChats/ReviewMonitorChatRowView.swift | 4 + .../ReviewMonitorCodexSidebarLibrary.swift | 229 +++++++++++++++--- .../ReviewMonitorSidebarViewController.swift | 28 +++ .../ReviewMonitorChatLogTesting.swift | 3 +- ...eviewMonitorCodexSidebarLibraryTests.swift | 18 ++ Tests/ReviewUITests/ReviewUITests.swift | 1 + 6 files changed, 245 insertions(+), 38 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index 2fd1b0c1..5312a981 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI +@MainActor struct ReviewMonitorSidebarChatRow: Equatable { var id: String var title: String @@ -41,6 +42,7 @@ struct ReviewMonitorSidebarChatRow: Equatable { } } +@MainActor struct ReviewMonitorChatRowContentView: View { var row: ReviewMonitorSidebarChatRow @@ -87,6 +89,7 @@ struct ReviewMonitorChatRowContentView: View { } } +@MainActor struct ReviewMonitorChatRowView: View { var node: ReviewMonitorCodexSidebarOutlineNode @@ -104,6 +107,7 @@ struct ReviewMonitorChatRowView: View { } } +@MainActor struct ReviewMonitorChatRowTimerLabel: View { var row: ReviewMonitorSidebarChatRow diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index 75d4e0e8..a6a72725 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -27,8 +27,10 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC } } -package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { - package struct Section: Equatable, Sendable { +@MainActor +package struct ReviewMonitorCodexSidebarSnapshot: Equatable { + @MainActor + package struct Section: Equatable { package var rowID: ReviewMonitorCodexSidebarRowID package var id: String package var title: String @@ -57,28 +59,111 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { } } - package struct Workspace: Equatable, Sendable { + @MainActor + package struct Workspace: Equatable { + private struct Fixture: Equatable { + var cwd: String + var title: String + } + + private enum Source: Equatable { + case codex(CodexWorkspace) + case fixture(Fixture) + + static func == (lhs: Source, rhs: Source) -> Bool { + switch (lhs, rhs) { + case (.codex(let lhs), .codex(let rhs)): + lhs === rhs + case (.fixture(let lhs), .fixture(let rhs)): + lhs == rhs + default: + false + } + } + } + package var rowID: ReviewMonitorCodexSidebarRowID package var id: CodexWorkspaceID - package var cwd: String - package var title: String package var chats: [Chat] + private var source: Source package var rowIDs: [ReviewMonitorCodexSidebarRowID] { [rowID] + chats.map(\.rowID) } + + package var cwd: String { + switch source { + case .codex(let workspace): + workspace.url.path + case .fixture(let fixture): + fixture.cwd + } + } + + package var title: String { + switch source { + case .codex(let workspace): + workspace.name + case .fixture(let fixture): + fixture.title + } + } + + package init( + rowID: ReviewMonitorCodexSidebarRowID, + id: CodexWorkspaceID, + cwd: String, + title: String, + chats: [Chat] + ) { + self.rowID = rowID + self.id = id + self.chats = chats + self.source = .fixture(Fixture(cwd: cwd, title: title)) + } + + package init( + workspace: CodexWorkspace, + chats: [Chat] + ) { + self.rowID = .workspace(workspace.id) + self.id = workspace.id + self.chats = chats + self.source = .codex(workspace) + } } - package struct Chat: Equatable, Sendable { + @MainActor + package struct Chat: Equatable { + private struct Fixture: Equatable { + var title: String + var preview: String? + var model: String? + var workspaceCWD: String? + var updatedAt: Date? + var recencyAt: Date? + var status: CodexThreadStatus? + } + + private enum Source: Equatable { + case codex(CodexChat) + case fixture(Fixture) + + static func == (lhs: Source, rhs: Source) -> Bool { + switch (lhs, rhs) { + case (.codex(let lhs), .codex(let rhs)): + lhs === rhs + case (.fixture(let lhs), .fixture(let rhs)): + lhs == rhs + default: + false + } + } + } + package var rowID: ReviewMonitorCodexSidebarRowID package var id: CodexThreadID - package var title: String - package var preview: String? - package var model: String? - package var workspaceCWD: String? - package var updatedAt: Date? - package var recencyAt: Date? - package var status: CodexThreadStatus? + private var source: Source package init( rowID: ReviewMonitorCodexSidebarRowID, @@ -93,13 +178,94 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable, Sendable { ) { self.rowID = rowID self.id = id - self.title = title - self.preview = preview - self.model = model - self.workspaceCWD = workspaceCWD - self.updatedAt = updatedAt - self.recencyAt = recencyAt - self.status = status + self.source = .fixture( + Fixture( + title: title, + preview: preview, + model: model, + workspaceCWD: workspaceCWD, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status + )) + } + + package init(chat: CodexChat) { + self.rowID = .chat(chat.id) + self.id = chat.id + self.source = .codex(chat) + } + + package var codexChat: CodexChat? { + switch source { + case .codex(let chat): + chat + case .fixture: + nil + } + } + + package var title: String { + switch source { + case .codex(let chat): + chat.title + case .fixture(let fixture): + fixture.title + } + } + + package var preview: String? { + switch source { + case .codex(let chat): + chat.preview + case .fixture(let fixture): + fixture.preview + } + } + + package var model: String? { + switch source { + case .codex(let chat): + chat.modelProvider + case .fixture(let fixture): + fixture.model + } + } + + package var workspaceCWD: String? { + switch source { + case .codex(let chat): + chat.workspace?.url.path + case .fixture(let fixture): + fixture.workspaceCWD + } + } + + package var updatedAt: Date? { + switch source { + case .codex(let chat): + chat.updatedAt + case .fixture(let fixture): + fixture.updatedAt + } + } + + package var recencyAt: Date? { + switch source { + case .codex(let chat): + chat.recencyAt + case .fixture(let fixture): + fixture.recencyAt + } + } + + package var status: CodexThreadStatus? { + switch source { + case .codex(let chat): + chat.status + case .fixture(let fixture): + fixture.status + } } var activityDate: Date? { @@ -230,7 +396,8 @@ private extension ReviewMonitorCodexSidebarSnapshot.Section { } } -struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { +@MainActor +struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { private var sectionIDs: [String] = [] private var chatIDsByContainer: [ReviewMonitorCodexSidebarRowID: [CodexThreadID]] = [:] @@ -352,7 +519,8 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable, Sendable { } } -package enum ReviewMonitorCodexSidebarOutlineItem: Equatable, Sendable { +@MainActor +package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { case section(ReviewMonitorCodexSidebarSnapshot.Section) case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) case chat(ReviewMonitorCodexSidebarSnapshot.Chat) @@ -674,10 +842,7 @@ package final class ReviewMonitorCodexSidebarLibrary { title: section.title, workspaces: section.workspaces.map { workspace in ReviewMonitorCodexSidebarSnapshot.Workspace( - rowID: .workspace(workspace.id), - id: workspace.id, - cwd: workspace.cwd, - title: workspace.title, + workspace: workspace.workspace, chats: workspace.chats.map(Self.snapshotChat(_:)) ) }, @@ -688,16 +853,6 @@ package final class ReviewMonitorCodexSidebarLibrary { } private static func snapshotChat(_ chat: CodexChat) -> ReviewMonitorCodexSidebarSnapshot.Chat { - ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chat.id), - id: chat.id, - title: chat.title, - preview: chat.preview, - model: chat.modelProvider, - workspaceCWD: chat.workspace?.url.path, - updatedAt: chat.updatedAt, - recencyAt: chat.recencyAt, - status: chat.status - ) + ReviewMonitorCodexSidebarSnapshot.Chat(chat: chat) } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index a8a5ba10..6c215497 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -1464,6 +1464,34 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return cellView.isHostingReviewMonitorChatRowViewForTesting } + func codexSidebarChatCellHostingViewIdentityForTesting( + _ id: CodexThreadID + ) -> ObjectIdentifier? { + guard let row = row(forCodexSidebarSelectionID: .chat(id)), + let cellView = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: true + ) as? ReviewMonitorReviewChatCellView + else { + return nil + } + return cellView.hostingViewIdentityForTesting + } + + func codexSidebarChatCellBindingGenerationForTesting(_ id: CodexThreadID) -> Int? { + guard let row = row(forCodexSidebarSelectionID: .chat(id)), + let cellView = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: true + ) as? ReviewMonitorReviewChatCellView + else { + return nil + } + return cellView.bindingGenerationValueForTesting + } + func selectCodexSidebarRowForTesting(rowID: ReviewMonitorCodexSidebarRowID) { guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index e263f6fd..4f5f5609 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -87,7 +87,8 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { } } -struct ReviewChatFixtureForTesting: Sendable { +@MainActor +struct ReviewChatFixtureForTesting { var id: String var cwd: String var chat: ReviewMonitorCodexSidebarSnapshot.Chat diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 2b9a0ee4..5be9bd49 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -498,6 +498,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { window.title == "App review" } + let hostingViewIdentityBeforeContentUpdate = try #require( + sidebar.codexSidebarChatCellHostingViewIdentityForTesting(threadID) + ) + let bindingGenerationBeforeContentUpdate = try #require( + sidebar.codexSidebarChatCellBindingGenerationForTesting(threadID) + ) let fullReloadCountBeforeContentUpdate = sidebar.sidebarFullReloadCountForTesting let chat = context.model(for: threadID) @@ -523,6 +529,14 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { window.title == "App review renamed" } + #expect( + sidebar.codexSidebarChatCellHostingViewIdentityForTesting(threadID) + == hostingViewIdentityBeforeContentUpdate + ) + #expect( + sidebar.codexSidebarChatCellBindingGenerationForTesting(threadID) + == bindingGenerationBeforeContentUpdate + ) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate) #expect( sidebar.displayedCodexSidebarTitlesForTesting == [ @@ -704,6 +718,7 @@ private struct ThreadListParams: Decodable { var sourceKinds: [String]? } +@MainActor private func sidebarSnapshot( workspaceID: CodexWorkspaceID, threadID: CodexThreadID, @@ -721,6 +736,7 @@ private func sidebarSnapshot( ) } +@MainActor private func sidebarSnapshot( workspaceID: CodexWorkspaceID, chats: [ReviewMonitorCodexSidebarSnapshot.Chat] @@ -732,6 +748,7 @@ private func sidebarSnapshot( ) } +@MainActor private func sidebarSection( id: String, title: String, @@ -755,6 +772,7 @@ private func sidebarSection( ) } +@MainActor private func sidebarChat( id: CodexThreadID, title: String, diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index f95ed713..1922ca62 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5137,6 +5137,7 @@ func makeReviewChatFixtureForTesting( ) } +@MainActor func reviewChatCellTestChat( id: String, title: String, From f8b68300e2fc361639e810d457ff386944ad6905 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:35:15 +0900 Subject: [PATCH 270/359] refactor(review): make completion events lifecycle-only --- .../AppServerCodexReviewBackend.swift | 4 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 2 +- .../Store/CodexReviewStoreReviews.swift | 21 +++----- .../AppServerClientTests.swift | 8 +-- .../CodexReviewHostTests.swift | 2 +- .../ReviewBackendEventSessionTests.swift | 4 +- .../CodexReviewStoreCommandTests.swift | 50 +++++++++---------- .../CodexReviewMCPHTTPServerTests.swift | 18 ++++--- .../CodexReviewMCPServerTests.swift | 4 +- 9 files changed, 53 insertions(+), 60 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 82018281..8f78e5c7 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -529,7 +529,7 @@ private enum AppServerTypedReviewEventAdapter { ) } return [ - .completed(summary: "Succeeded.") + .completed ] } @@ -561,7 +561,7 @@ private enum AppServerTypedReviewEventAdapter { } if phase == .completed, item.kind.rawValue == "exitedReviewMode" { return [ - .completed(summary: "Succeeded.") + .completed ] } return [] diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 8dcbb911..4a214206 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -279,7 +279,7 @@ package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { case started(turnID: String, reviewThreadID: String?, model: String?) case progress(String) - case completed(summary: String) + case completed case failed(String) case cancelled(String) } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 903c8ba6..9f8eb0ec 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -674,10 +674,7 @@ extension CodexReviewStore { if completePendingCancellationIfNeeded(for: runRecord) { return recoveryState.currentRun } - completeReview( - runRecord, - summary: runRecord.core.lifecycleMessage - ) + completeReview(runRecord) } return recoveryState.currentRun } @@ -698,10 +695,7 @@ extension CodexReviewStore { if completePendingCancellationIfNeeded(for: runRecord) { return true } - completeReview( - runRecord, - summary: runRecord.core.lifecycleMessage - ) + completeReview(runRecord) } return true } @@ -823,8 +817,8 @@ extension CodexReviewStore { if runRecord.core.lifecycleMessage.isEmpty || runRecord.core.lifecycleMessage == "Review started." { runRecord.core.lifecycleMessage = text } - case .completed(let summary): - completeReview(runRecord, summary: summary) + case .completed: + completeReview(runRecord) case .failed(let message): markReviewFailed(runRecord, message: message) case .cancelled(let message): @@ -852,17 +846,14 @@ extension CodexReviewStore { return true } - private func completeReview( - _ runRecord: ReviewRunRecord, - summary: String - ) { + private func completeReview(_ runRecord: ReviewRunRecord) { guard runRecord.isTerminal == false else { return } let endedAt = clock.now() runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.lifecycleMessage = summary + runRecord.core.lifecycleMessage = "Review completed." writeDiagnosticsIfNeeded() } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 52193ca1..40f73757 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -99,7 +99,7 @@ struct AppServerClientTests { #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed(summary: "Succeeded.")) + #expect(try await iterator.next() == .completed) } @Test func backendIgnoresAgentMessageDeltasInLifecycleStream() async throws { @@ -151,13 +151,13 @@ struct AppServerClientTests { #expect( try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) - #expect(try await firstIterator.next() == .completed(summary: "Succeeded.")) + #expect(try await firstIterator.next() == .completed) 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(summary: "Succeeded.")) + #expect(try await secondIterator.next() == .completed) } @Test func backendKeepsCommandOutputDeltasInCodexChat() async throws { @@ -202,7 +202,7 @@ struct AppServerClientTests { ) ) - #expect(try await nextEvent(from: attempt.events) == .completed(summary: "Succeeded.")) + #expect(try await nextEvent(from: attempt.events) == .completed) } @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 9f7d4fe7..6122ea23 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -135,7 +135,7 @@ struct CodexReviewHostTests { }.first) #expect(startReview.model == "gpt-5.5") - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) await backend.finishEvents() _ = try await reviewTask.value } diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index 504d13c4..29974405 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -22,7 +22,7 @@ struct ReviewBackendEventSessionTests { await session.receive( [ .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), - .completed(summary: "Succeeded."), + .completed, ], controlThreadID: "review-thread") #expect( @@ -34,7 +34,7 @@ struct ReviewBackendEventSessionTests { )) #expect( try await nextEvent(from: attempt.events) - == .completed(summary: "Succeeded.")) + == .completed) #expect(await recorder.startedTurnIDs() == ["turn-1"]) #expect(await recorder.finishedRun() == makeRun()) diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 1d5d7be7..a4ea26a2 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -19,7 +19,7 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) await backend.yield(.progress("started")) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let read = try await result #expect(read.runID == "run-1") @@ -55,7 +55,7 @@ struct CodexReviewStoreCommandTests { #expect(running.runID == "run-1") #expect(running.core.lifecycle.status == .running) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let final = try await store.awaitReview( sessionID: "session-1", runID: "run-1", @@ -85,7 +85,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", timeout: .seconds(1) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let final = try await awaited #expect(final.core.lifecycle.status == .succeeded) @@ -203,7 +203,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await result let commands = await backend.recordedCommands() @@ -236,9 +236,9 @@ struct CodexReviewStoreCommandTests { }) #expect(progressSnapshot.run("run-1")?.summary == "Inspecting files.") - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let read = try await result - #expect(read.core.lifecycleMessage == "Succeeded.") + #expect(read.core.lifecycleMessage == "Review completed.") } } @@ -252,7 +252,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/old-project", target: .baseBranch("main")) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await first await backend.finishEvents() @@ -260,7 +260,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await second #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"]) @@ -277,7 +277,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await first await backend.finishEvents() @@ -285,7 +285,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await second #expect( @@ -314,7 +314,7 @@ struct CodexReviewStoreCommandTests { #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await result } } @@ -342,7 +342,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await result #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") @@ -465,7 +465,7 @@ struct CodexReviewStoreCommandTests { } } == false) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let read = try await result #expect(read.core.lifecycle.status == .succeeded) } @@ -546,7 +546,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -617,7 +617,7 @@ struct CodexReviewStoreCommandTests { #expect(recoveredFromRuns.last?.turnID == "turn-actual") try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -660,8 +660,8 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: initialRun) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: initialRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -708,7 +708,7 @@ struct CodexReviewStoreCommandTests { await recoverGate.open() try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -752,7 +752,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1267,7 +1267,7 @@ struct CodexReviewStoreCommandTests { } #expect(try store.readReview(runID: "run-1").cancellable) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await result let commands = await backend.recordedCommands() @@ -1379,7 +1379,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed(summary: "Succeeded."), for: recoveredRun) + await backend.yield(.completed, for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1464,7 +1464,7 @@ struct CodexReviewStoreCommandTests { #expect(readAfterFailure.core.lifecycle.cancellation == nil) #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed") - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) _ = try await result } } @@ -1485,7 +1485,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let read = try await result #expect(read.core.lifecycle.status == .cancelled) @@ -1508,7 +1508,7 @@ struct CodexReviewStoreCommandTests { ) async let cancel = store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) try await backend.waitForInterruptReview(timeout: .seconds(2)) - await backend.yield(.completed(summary: "Reviewer failed to output a response.")) + await backend.yield(.completed) await interruptGate.open() _ = try await cancel let read = try await result @@ -1603,7 +1603,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(summary: "Succeeded.")) + await backend.yield(.completed) let read = try await result #expect(read.runID == "run-1") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 49c19f10..ec5039f6 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -160,7 +160,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(summary: "Done")) + await backend.yield(.completed) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == false) @@ -170,7 +170,8 @@ struct CodexReviewMCPHTTPServerTests { #expect( resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - resolved.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Done") + resolved.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String + == "Review completed.") #expect( resolved.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) #expect(resolved.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) @@ -229,7 +230,7 @@ struct CodexReviewMCPHTTPServerTests { #expect( running.value(for: ["result", "structuredContent", "nextAction", "tool"]) as? String == "review_await") - await backend.yield(.completed(summary: "Done")) + await backend.yield(.completed) let awaited = try await postJSONRPC( endpoint: endpoint, sessionID: sessionID, @@ -250,13 +251,14 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "succeeded") #expect( - awaited.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Done") + awaited.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String + == "Review completed.") #expect( awaited.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) #expect(awaited.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) #expect( awaited.value(for: ["result", "structuredContent", "log", "finalLifecycleMessage"]) as? String - == "Done") + == "Review completed.") #expect(awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) is NSNull) #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) } @@ -292,7 +294,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(summary: "Done")) + await backend.yield(.completed) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") @@ -851,14 +853,14 @@ struct CodexReviewMCPHTTPServerTests { await backend.waitForStartReview() await server.runSessionCleanupForTesting(now: .distantFuture) await gate.open() - await backend.yield(.completed(summary: "Done")) + await backend.yield(.completed) let resolved = try decodeSSEJSON(from: try await responseData) #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 == "Done") + #expect(startText == "Review completed.") #expect(startText?.contains("rawLogText") == false) let tools = try await postJSONRPC( endpoint: endpoint, diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 44cdfb9b..319ffc44 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -35,7 +35,7 @@ struct CodexReviewMCPServerTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges), waitTimeout: nil )) - await backend.yield(.completed(summary: "Done")) + await backend.yield(.completed) let resolved = try await response guard case .reviewStart(let snapshot) = resolved else { @@ -46,7 +46,7 @@ struct CodexReviewMCPServerTests { let log = snapshot.log #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) - #expect(log.finalLifecycleMessage == "Done") + #expect(log.finalLifecycleMessage == "Review completed.") #expect(log.finalResult == nil) #expect(log.items.isEmpty) } From 5e28f62fe4a7ae730f82e13a769319e2d8fd9cb4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:41:25 +0900 Subject: [PATCH 271/359] refactor(review): remove progress from lifecycle events --- .../AppServerCodexReviewBackend.swift | 10 +++++----- .../Application/ReviewBackendEventSession.swift | 5 +---- Sources/CodexReviewKit/CodexReviewBackend.swift | 3 +-- Sources/CodexReviewKit/CodexReviewTypes.swift | 1 - .../Store/CodexReviewStoreReviews.swift | 7 +------ .../ReviewBackendEventSessionTests.swift | 13 ------------- .../CodexReviewStoreCommandTests.swift | 12 +++--------- 7 files changed, 11 insertions(+), 40 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 8f78e5c7..2bdd39b4 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -534,10 +534,10 @@ private enum AppServerTypedReviewEventAdapter { } private static func unknownStatusEvents( - _ status: String, - turnID: String + _: String, + turnID _: String ) -> [CodexReviewBackendModel.Review.Event] { - [.progress("Review thread \(turnID) status changed: \(status).")] + [] } private static func terminalFailureEvents( @@ -568,9 +568,9 @@ private enum AppServerTypedReviewEventAdapter { } private static func unknownEvents( - _ raw: CodexRawNotification + _: CodexRawNotification ) -> [CodexReviewBackendModel.Review.Event] { - [.progress("Unhandled app-server notification: \(raw.method)")] + [] } } diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 1e6bd495..6ddb464b 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -178,8 +178,6 @@ package actor ReviewBackendEventSession { await callbacks.recordTurnStarted(turnID) case .completed, .failed, .cancelled: await callbacks.recordFinished(run, metrics) - case .progress: - break } } @@ -207,8 +205,7 @@ private extension CodexReviewBackendModel.Review.Event { switch self { case .completed, .failed, .cancelled: true - case .started, - .progress: + case .started: false } } diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index 98a63a6e..f891574e 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -178,8 +178,7 @@ package actor BackendReviewEventMailbox { switch event { case .completed, .failed, .cancelled: return true - case .started, - .progress: + case .started: return false } } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 4a214206..87c5926f 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -278,7 +278,6 @@ package extension CodexReviewBackendModel.Review { package extension CodexReviewBackendModel.Review { enum Event: Equatable, Sendable { case started(turnID: String, reviewThreadID: String?, model: String?) - case progress(String) case completed case failed(String) case cancelled(String) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 9f8eb0ec..6ec4daaf 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -813,10 +813,6 @@ extension CodexReviewStore { updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) runRecord.core.lifecycleMessage = "Review started." - case .progress(let text): - if runRecord.core.lifecycleMessage.isEmpty || runRecord.core.lifecycleMessage == "Review started." { - runRecord.core.lifecycleMessage = text - } case .completed: completeReview(runRecord) case .failed(let message): @@ -879,8 +875,7 @@ private extension CodexReviewBackendModel.Review.Event { switch self { case .completed, .failed, .cancelled: true - case .started, - .progress: + case .started: false } } diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index 29974405..e12126ef 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -47,19 +47,6 @@ struct ReviewBackendEventSessionTests { #expect(metrics.terminalLatencyMs != nil) #expect(await recorder.finishedMetrics() == metrics) } - - @Test func emitsProgressWithoutDelayingLaterUpdates() async throws { - let session = ReviewBackendEventSession(run: makeRun()) - let attempt = await session.attempt() - - await session.receive([.progress("first")], controlThreadID: "review-thread") - await session.receive([.progress("second")], controlThreadID: "review-thread") - await session.finish(throwing: nil) - - #expect(try await nextEvent(from: attempt.events) == .progress("first")) - #expect(try await nextEvent(from: attempt.events) == .progress("second")) - #expect(try await nextEvent(from: attempt.events) == nil) - } } private actor ReviewBackendEventSessionRecorder { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index a4ea26a2..69678cca 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -18,7 +18,6 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.progress("started")) await backend.yield(.completed) let read = try await result @@ -217,7 +216,7 @@ struct CodexReviewStoreCommandTests { } } - @Test func reviewStartUsesProgressAsLifecycleMessage() async throws { + @Test func reviewStartDoesNotNeedProgressEventsForLifecycle() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -229,12 +228,8 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) let probe = StoreSnapshotProbe(store: store) - try #require(await probe.waitUntilRunStatus(.running, runID: "run-1") != nil) - await backend.yield(.progress("Inspecting files.")) - let progressSnapshot = try #require(await probe.waitUntil { snapshot in - snapshot.run("run-1")?.summary == "Inspecting files." - }) - #expect(progressSnapshot.run("run-1")?.summary == "Inspecting files.") + let runningSnapshot = try #require(await probe.waitUntilRunStatus(.running, runID: "run-1")) + #expect(runningSnapshot.run("run-1")?.summary == "Review started.") await backend.yield(.completed) let read = try await result @@ -1317,7 +1312,6 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) - await backend.yield(.progress("partial review")) await backend.finishEvents(throwing: StreamClosedError()) let read = try await result From 52e8ab46a3bffd368d1092dc029d968634ea1619 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:48:06 +0900 Subject: [PATCH 272/359] fix(ui): preserve codex workspace sources in sidebar snapshots --- .../ReviewMonitorCodexSidebarLibrary.swift | 79 ++++++++++--------- ...eviewMonitorCodexSidebarLibraryTests.swift | 49 ++++++++++++ 2 files changed, 89 insertions(+), 39 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index a6a72725..bf142edb 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -91,6 +91,15 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { [rowID] + chats.map(\.rowID) } + package var codexWorkspace: CodexWorkspace? { + switch source { + case .codex(let workspace): + workspace + case .fixture: + nil + } + } + package var cwd: String { switch source { case .codex(let workspace): @@ -109,6 +118,12 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { } } + func replacingChats(_ chats: [Chat]) -> Self { + var copy = self + copy.chats = chats + return copy + } + package init( rowID: ReviewMonitorCodexSidebarRowID, id: CodexWorkspaceID, @@ -336,25 +351,18 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { filter.contains(.latestFinished) ? Self.latestFinishedChat(in: section.allChats)?.id : nil - return Section( - rowID: section.rowID, - id: section.id, - title: section.title, - workspaces: section.workspaces.map { workspace in - Workspace( - rowID: workspace.rowID, - id: workspace.id, - cwd: workspace.cwd, - title: workspace.title, - chats: workspace.chats.filter { - Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) - } - ) - }, - uncategorizedChats: section.uncategorizedChats.filter { - Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) - } - ) + var filteredSection = section + filteredSection.workspaces = section.workspaces.map { workspace in + workspace.replacingChats( + workspace.chats.filter { + Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) + } + ) + } + filteredSection.uncategorizedChats = section.uncategorizedChats.filter { + Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) + } + return filteredSection } ) } @@ -404,29 +412,22 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { func applying(to snapshot: ReviewMonitorCodexSidebarSnapshot) -> ReviewMonitorCodexSidebarSnapshot { ReviewMonitorCodexSidebarSnapshot( sections: ordered(snapshot.sections, by: sectionIDs, id: \.id).map { section in - ReviewMonitorCodexSidebarSnapshot.Section( - rowID: section.rowID, - id: section.id, - title: section.title, - workspaces: section.workspaces.map { workspace in - ReviewMonitorCodexSidebarSnapshot.Workspace( - rowID: workspace.rowID, - id: workspace.id, - cwd: workspace.cwd, - title: workspace.title, - chats: ordered( - workspace.chats, - by: chatIDsByContainer[workspace.rowID] ?? [], - id: \.id - ) + var orderedSection = section + orderedSection.workspaces = section.workspaces.map { workspace in + workspace.replacingChats( + ordered( + workspace.chats, + by: chatIDsByContainer[workspace.rowID] ?? [], + id: \.id ) - }, - uncategorizedChats: ordered( - section.uncategorizedChats, - by: chatIDsByContainer[section.rowID] ?? [], - id: \.id ) + } + orderedSection.uncategorizedChats = ordered( + section.uncategorizedChats, + by: chatIDsByContainer[section.rowID] ?? [], + id: \.id ) + return orderedSection } ) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 5be9bd49..5d03dfd7 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -88,6 +88,55 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) } + @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 library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + let snapshot = library.snapshot + let originalWorkspace = try #require(snapshot.sections.first?.workspaces.first) + let originalCodexWorkspace = try #require(originalWorkspace.codexWorkspace) + + let filteredWorkspace = try #require(snapshot.filtered(by: .running).sections.first?.workspaces.first) + #expect(filteredWorkspace.codexWorkspace === originalCodexWorkspace) + #expect(filteredWorkspace.chats.map(\.id) == [runningThreadID]) + + var order = ReviewMonitorCodexSidebarPresentationOrder() + _ = order.reorderChat( + id: idleThreadID, + in: originalWorkspace.rowID, + currentOrder: [runningThreadID, idleThreadID], + before: runningThreadID + ) + let orderedWorkspace = try #require(order.applying(to: snapshot).sections.first?.workspaces.first) + #expect(orderedWorkspace.codexWorkspace === originalCodexWorkspace) + #expect(orderedWorkspace.chats.map(\.id) == [idleThreadID, runningThreadID]) + } + @Test func defaultRequestFetchesReviewThreadsOnly() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext From acc99a2949ba9d8374e815f28304f8e35b466340 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:52:08 +0900 Subject: [PATCH 273/359] refactor(mcp): keep unavailable log projections empty --- .../ReviewMCPLogProjection.swift | 13 ++----------- .../CodexReviewMCPHTTPServerTests.swift | 2 +- .../CodexReviewMCPServerTests.swift | 2 +- .../ReviewMCPLogProjectionTests.swift | 4 ++-- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 7de569cd..bf7ad8c8 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -40,22 +40,13 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } private init(result: CodexReviewAPI.Read.Result) { - let lifecycle = result.core.lifecycle - let lifecycleMessage = result.core.lifecycleMessage - let status = lifecycle.status - self.revision = [ - result.runID, - status.rawValue, - lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", - lifecycleMessage, - ].joined(separator: ":") - + self.revision = "\(result.runID):unavailable" self.items = [] self.orderedEntryIDs = [] self.activeEntryIDs = [] self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last - self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil + self.finalLifecycleMessage = nil self.finalResult = nil } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index ec5039f6..da2c3e33 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -258,7 +258,7 @@ struct CodexReviewMCPHTTPServerTests { #expect(awaited.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) #expect( awaited.value(for: ["result", "structuredContent", "log", "finalLifecycleMessage"]) as? String - == "Review completed.") + == nil) #expect(awaited.value(for: ["result", "structuredContent", "log", "finalResult"]) is NSNull) #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 319ffc44..44a0e048 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -46,7 +46,7 @@ struct CodexReviewMCPServerTests { let log = snapshot.log #expect(read.runID == "run-1") #expect(read.core.lifecycle.status == .succeeded) - #expect(log.finalLifecycleMessage == "Review completed.") + #expect(log.finalLifecycleMessage == nil) #expect(log.finalResult == nil) #expect(log.items.isEmpty) } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index d3eecdb1..074e62ab 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -24,7 +24,7 @@ struct ReviewMCPLogProjectionTests { #expect(projection.finalResult == nil) } - @Test func unavailableTerminalProjectionKeepsSummaryButNoFinalResult() throws { + @Test func unavailableTerminalProjectionDoesNotMirrorLifecycleAsLog() throws { let projection = ReviewMCPLogProjection.unavailable(result: .init( runID: "run-2", core: .init( @@ -36,7 +36,7 @@ struct ReviewMCPLogProjectionTests { #expect(projection.activeEntryIDs == []) #expect(projection.activeEntryCount == 0) - #expect(projection.finalLifecycleMessage == "Done.") + #expect(projection.finalLifecycleMessage == nil) #expect(projection.finalResult == nil) #expect(projection.items.isEmpty) } From 8854171f9367987a0e89dbbd94161dc00c2b3a0a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:34:15 +0900 Subject: [PATCH 274/359] fix(ui): restore chat context menu cancellation --- .../ReviewMonitorPreviewChatLogSource.swift | 57 +++++++++++---- .../ReviewMonitorChatContextMenuView.swift | 47 ++++++++++++ .../ReviewMonitorCodexSidebarLibrary.swift | 34 +++++++++ .../ReviewMonitorSidebarViewController.swift | 69 ++---------------- ...eviewMonitorCodexSidebarLibraryTests.swift | 19 +++++ Tests/ReviewUITests/ReviewUITests.swift | 73 ------------------- 6 files changed, 151 insertions(+), 148 deletions(-) create mode 100644 Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift diff --git a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift index edc5df9e..f1f32252 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift @@ -32,20 +32,20 @@ final class ReviewMonitorPreviewChatLogSource { let snapshot: ReviewMonitorCodexSidebarSnapshot let initialChat: ReviewMonitorCodexSidebarSnapshot.Chat? - private let previewChats: [PreviewReviewChat] - private let previewChatsByID: [CodexThreadID: PreviewReviewChat] + private let previewChats: [ReviewMonitorPreviewChat] + private let previewChatsByID: [CodexThreadID: ReviewMonitorPreviewChat] init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] var sectionIndexesByCWD: [String: Int] = [:] - var previewChats: [PreviewReviewChat] = [] - var previewChatsByID: [CodexThreadID: PreviewReviewChat] = [:] + var previewChats: [ReviewMonitorPreviewChat] = [] + var previewChatsByID: [CodexThreadID: ReviewMonitorPreviewChat] = [:] var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? for fixture in fixtures { - let previewChat = PreviewReviewChat(fixture: fixture) - let chat = previewChat.chat + let previewChat = ReviewMonitorPreviewChat(fixture: fixture) + let chat = ReviewMonitorCodexSidebarSnapshot.Chat(previewChat: previewChat) previewChats.append(previewChat) previewChatsByID[chat.id] = previewChat firstChat = firstChat ?? chat @@ -108,6 +108,10 @@ final class ReviewMonitorPreviewChatLogSource { previewChatsByID[chatID]?.appendTextDelta(delta, itemID: itemID, kind: kind, content: content) } + func previewChat(id: CodexThreadID) -> ReviewMonitorPreviewChat? { + previewChatsByID[id] + } + func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { previewChatsByID[chatID]?.snapshot() } @@ -135,23 +139,39 @@ final class ReviewMonitorPreviewChatLogSource { @MainActor @Observable -private final class PreviewReviewChat { - let chat: ReviewMonitorCodexSidebarSnapshot.Chat +package final class ReviewMonitorPreviewChat { + package let id: CodexThreadID + package let title: String + package let preview: String? + package let model: String? + package let workspaceCWD: String? + package private(set) var updatedAt: Date? + package private(set) var recencyAt: Date? + package private(set) var status: CodexThreadStatus? let cwd: String let streamID: String - let isRunning: Bool private var revision = 0 private var snapshotStorage: CodexChatSnapshot init(fixture: ReviewMonitorPreviewChatLogFixture) { - self.chat = fixture.chat + self.id = fixture.chat.id + self.title = fixture.chat.title + self.preview = fixture.chat.preview + self.model = fixture.chat.model + self.workspaceCWD = fixture.chat.workspaceCWD + self.updatedAt = fixture.chat.updatedAt + self.recencyAt = fixture.chat.recencyAt + self.status = fixture.chat.status self.cwd = fixture.cwd self.streamID = fixture.streamID - self.isRunning = fixture.isRunning self.snapshotStorage = fixture.initialSnapshot } + package var isRunning: Bool { + status?.isActive == true + } + func trackRevision() { _ = revision } @@ -188,6 +208,17 @@ private final class PreviewReviewChat { snapshotStorage.turns.last?.id } + package func cancel() { + guard isRunning else { + return + } + status = .idle + let now = Date() + updatedAt = now + recencyAt = now + updateTurnStatus(.cancelled) + } + func upsertItem( id: String, kind: CodexThreadItem.Kind, @@ -303,13 +334,13 @@ final class ReviewMonitorPreviewRuntimeSupport { @MainActor private final class PreviewChatLogSubscription { - private let previewChat: PreviewReviewChat + private let previewChat: ReviewMonitorPreviewChat private let continuation: AsyncStream.Continuation private var observation: PortableObservationTracking.Token? private var previousSnapshot: CodexChatSnapshot? init( - previewChat: PreviewReviewChat, + previewChat: ReviewMonitorPreviewChat, continuation: AsyncStream.Continuation ) { self.previewChat = previewChat diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift new file mode 100644 index 00000000..709dbc81 --- /dev/null +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -0,0 +1,47 @@ +import CodexKit +import SwiftUI + +@MainActor +struct ReviewMonitorChatContextMenuView: View { + private enum Chat { + case codex(CodexChat) + case preview(ReviewMonitorPreviewChat) + } + + private var chat: Chat + + init(chat: CodexChat) { + self.chat = .codex(chat) + } + + init(previewChat: ReviewMonitorPreviewChat) { + self.chat = .preview(previewChat) + } + + var body: some View { + Button("Cancel") { + cancel() + } + .disabled(isRunning == false) + } + + private var isRunning: Bool { + switch chat { + case .codex(let chat): + chat.status?.isActive == true + case .preview(let chat): + chat.isRunning + } + } + + private func cancel() { + switch chat { + case .codex(let chat): + Task { + try? await chat.cancel() + } + case .preview(let chat): + chat.cancel() + } + } +} diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index bf142edb..ec761c23 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -162,12 +162,15 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { private enum Source: Equatable { case codex(CodexChat) + case preview(ReviewMonitorPreviewChat) case fixture(Fixture) static func == (lhs: Source, rhs: Source) -> Bool { switch (lhs, rhs) { case (.codex(let lhs), .codex(let rhs)): lhs === rhs + case (.preview(let lhs), .preview(let rhs)): + lhs === rhs case (.fixture(let lhs), .fixture(let rhs)): lhs == rhs default: @@ -211,12 +214,29 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { self.source = .codex(chat) } + package init(previewChat: ReviewMonitorPreviewChat) { + self.rowID = .chat(previewChat.id) + self.id = previewChat.id + self.source = .preview(previewChat) + } + package var codexChat: CodexChat? { switch source { case .codex(let chat): chat + case .preview, .fixture: + nil + } + } + + package var previewChat: ReviewMonitorPreviewChat? { + switch source { + case .preview(let chat): + chat case .fixture: nil + case .codex: + nil } } @@ -224,6 +244,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.title + case .preview(let chat): + chat.title case .fixture(let fixture): fixture.title } @@ -233,6 +255,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.preview + case .preview(let chat): + chat.preview case .fixture(let fixture): fixture.preview } @@ -242,6 +266,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.modelProvider + case .preview(let chat): + chat.model case .fixture(let fixture): fixture.model } @@ -251,6 +277,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.workspace?.url.path + case .preview(let chat): + chat.workspaceCWD case .fixture(let fixture): fixture.workspaceCWD } @@ -260,6 +288,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.updatedAt + case .preview(let chat): + chat.updatedAt case .fixture(let fixture): fixture.updatedAt } @@ -269,6 +299,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.recencyAt + case .preview(let chat): + chat.recencyAt case .fixture(let fixture): fixture.recencyAt } @@ -278,6 +310,8 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { switch source { case .codex(let chat): chat.status + case .preview(let chat): + chat.status case .fixture(let fixture): fixture.status } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 6c215497..26348a53 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -691,34 +691,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } - let menu = NSMenu() - if store.hasCancellableReview(forChatID: chat.id.rawValue) { - let item = NSMenuItem( - title: "Cancel Review", - action: #selector(cancelReviewFromContextMenu(_:)), - keyEquivalent: "" + if let codexChat = chat.codexChat { + return NSHostingMenu( + rootView: ReviewMonitorChatContextMenuView(chat: codexChat) ) - item.target = self - item.representedObject = chat.id.rawValue - menu.addItem(item) } - return menu.items.isEmpty ? nil : menu - } - - @objc - private func cancelReviewFromContextMenu(_ sender: NSMenuItem) { - guard let chatID = sender.representedObject as? String else { - return - } - Task { @MainActor [weak self] in - guard let self else { - return - } - _ = try? await self.store.cancelReview( - chatID: chatID, - cancellation: .userInterface() + if let previewChat = chat.previewChat ?? previewChatLogSource?.previewChat(id: chat.id) { + return NSHostingMenu( + rootView: ReviewMonitorChatContextMenuView(previewChat: previewChat) ) } + return nil } private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -1647,44 +1630,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return cellView.isHostingReviewMonitorChatRowViewForTesting } - func reviewChatContextMenuTitlesForTesting(_ chatID: CodexThreadID) -> [String] { - var titles: [String] = [] - presentReviewChatContextMenuForTesting(chatID) { menu in - titles = menu.items.map(\.title) - } - return titles - } - - @discardableResult - func activateReviewChatContextMenuItemForTesting( - _ chatID: CodexThreadID, - title: String - ) -> Bool { - var didActivate = false - presentReviewChatContextMenuForTesting(chatID) { menu in - guard let itemIndex = menu.items.firstIndex(where: { $0.title == title }) else { - return - } - menu.performActionForItem(at: itemIndex) - didActivate = true - } - return didActivate - } - - private func presentReviewChatContextMenuForTesting( - _ chatID: CodexThreadID, - presenter: @escaping (NSMenu) -> Void - ) { - guard let row = row(for: chatID) else { - return - } - view.layoutSubtreeIfNeeded() - outlineView.layoutSubtreeIfNeeded() - let rowRect = outlineView.rect(ofRow: row) - let point = NSPoint(x: rowRect.midX, y: rowRect.midY) - outlineView.presentContextMenuForTesting(at: point, presenter: presenter) - } - func clickBlankAreaForTesting() { view.layoutSubtreeIfNeeded() guard outlineView.numberOfRows > 0 else { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 5d03dfd7..52151439 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -276,6 +276,25 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(filtered.chat(id: idleThreadID) == nil) } + @Test func sidebarChatRunningStateUsesThreadStatus() { + let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + let runningChat = sidebarChat( + id: CodexThreadID(rawValue: "thread-running"), + title: "Running review", + workspaceID: workspaceID, + status: .active(activeFlags: []) + ) + let idleChat = sidebarChat( + id: CodexThreadID(rawValue: "thread-idle"), + title: "Idle review", + workspaceID: workspaceID, + status: .idle + ) + + #expect(runningChat.isRunning) + #expect(idleChat.isRunning == false) + } + @Test func sidebarSnapshotLatestFinishedFilterUsesSectionActivityDate() throws { let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") let olderFinishedID = CodexThreadID(rawValue: "thread-older-finished") diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 1922ca62..391518fa 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -812,79 +812,6 @@ struct ReviewUITests { #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } - @Test func reviewChatContextMenuOffersCancellationForRunningChatOnly() throws { - let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running) - let recentChat = makeReviewChatFixtureForTesting(title: "Commit: abc123", status: .succeeded) - let activeRun = ReviewRunRecord.makeForTesting( - id: "run-cancellable", - cwd: activeChat.cwd, - targetSummary: activeChat.chat.title, - threadID: activeChat.chatID.rawValue, - turnID: activeChat.turnID.rawValue, - status: .running, - summary: "Running review." - ) - let recentRun = ReviewRunRecord.makeForTesting( - id: "run-terminal", - cwd: recentChat.cwd, - targetSummary: recentChat.chat.title, - threadID: recentChat.chatID.rawValue, - turnID: recentChat.turnID.rawValue, - status: .succeeded, - endedAt: Date(timeIntervalSince1970: 201), - summary: "Review complete." - ) - let store = CodexReviewStore.makePreviewStore() - store.loadReviewCancellationStateForTesting( - serverState: .running, - reviewRuns: [activeRun, recentRun] - ) - installPreviewChatLogSourceForTesting(on: store, fixtures: [activeChat, recentChat]) - let backend = makeWindowHarness(store: store) - let viewController = backend.viewController - defer { backend.window.close() } - let sidebar = viewController.sidebarViewControllerForTesting - - #expect(sidebar.reviewChatContextMenuTitlesForTesting(activeChat.chatID) == ["Cancel Review"]) - #expect(sidebar.reviewChatContextMenuTitlesForTesting(recentChat.chatID).isEmpty) - } - - @Test func reviewChatContextMenuCancelActionCancelsRunningChat() async throws { - let activeChat = makeReviewChatFixtureForTesting(title: "Uncommitted changes", status: .running) - let activeRun = ReviewRunRecord.makeForTesting( - id: "run-cancellable", - cwd: activeChat.cwd, - targetSummary: activeChat.chat.title, - threadID: activeChat.chatID.rawValue, - turnID: activeChat.turnID.rawValue, - status: .running, - summary: "Running review." - ) - let store = CodexReviewStore.makePreviewStore() - store.loadReviewCancellationStateForTesting( - serverState: .running, - reviewRuns: [activeRun] - ) - installPreviewChatLogSourceForTesting(on: store, fixtures: [activeChat]) - let backend = makeWindowHarness(store: store) - let viewController = backend.viewController - defer { backend.window.close() } - let sidebar = viewController.sidebarViewControllerForTesting - - #expect(sidebar.activateReviewChatContextMenuItemForTesting( - activeChat.chatID, - title: "Cancel Review" - )) - - _ = try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus( - .cancelled, - runID: "run-cancellable" - )) - let run = try #require(store.reviewRun(id: "run-cancellable")) - #expect(run.core.lifecycle.cancellation?.source == .userInterface) - #expect(run.core.lifecycle.cancellation?.message == "Cancelled by user from Review Monitor.") - } - @Test func selectingReviewChatUpdatesDetailPane() async throws { let activeChat = makeReviewChatFixtureForTesting( title: "Uncommitted changes", From 8a820ebc44e11dff7c1659e8793e25a7e093c544 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:20:21 +0900 Subject: [PATCH 275/359] refactor(review-ui): migrate monitor to CodexDataKit chat flow --- Package.swift | 1 + ...ReviewMonitorTransportViewController.swift | 91 +-- .../ReviewMonitorCodexChatLogProjection.swift | 9 +- .../ReviewUI/ReviewMonitorContentView.swift | 35 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 685 +++++++++++++++++ .../ReviewMonitorPreviewChatLogSource.swift | 493 ------------ .../ReviewMonitorPreviewContent.swift | 55 +- .../ReviewMonitorSelectedCodexChat.swift | 6 +- Sources/ReviewUI/ReviewMonitorSelection.swift | 16 +- .../ReviewMonitorSplitViewController.swift | 51 +- Sources/ReviewUI/ReviewMonitorUIState.swift | 12 - .../ReviewMonitorWindowController.swift | 20 +- .../ReviewMonitorWorkspaceGrouping.swift | 10 - .../ReviewMonitorChatContextMenuView.swift | 29 +- .../CodexChats/ReviewMonitorChatRowView.swift | 149 ++-- .../ReviewMonitorCodexSidebarLibrary.swift | 719 +++++------------- .../ReviewMonitorSidebarViewController.swift | 308 ++++---- .../ReviewMonitorChatLogTesting.swift | 96 ++- ...eviewMonitorCodexSidebarLibraryTests.swift | 522 +++++-------- Tests/ReviewUITests/ReviewUIShellTests.swift | 130 ++-- Tests/ReviewUITests/ReviewUITests.swift | 96 ++- .../CodexReviewMonitorApp.swift | 1 - .../CodexReviewMonitorCITests.swift | 21 + 23 files changed, 1636 insertions(+), 1919 deletions(-) create mode 100644 Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift delete mode 100644 Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift diff --git a/Package.swift b/Package.swift index cce44a9d..3d104dff 100644 --- a/Package.swift +++ b/Package.swift @@ -97,6 +97,7 @@ let package = Package( "CodexReviewKit", "TextTransitions", .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 17182e57..1f80f11b 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -9,7 +9,6 @@ final class ReviewMonitorTransportViewController: NSViewController { } private let codexModelSource: ReviewMonitorCodexModelSource? - private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let uiState: ReviewMonitorUIState private let selectedCodexChat: ReviewMonitorSelectedCodexChat private let logScrollView = ReviewMonitorLogScrollView() @@ -28,25 +27,23 @@ final class ReviewMonitorTransportViewController: NSViewController { convenience init( uiState: ReviewMonitorUIState, - modelContext: CodexModelContext, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil + modelContext: CodexModelContext ) { self.init( uiState: uiState, - codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), - previewChatLogSource: previewChatLogSource + codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext) ) } init( uiState: ReviewMonitorUIState, - codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil + codexModelSource: ReviewMonitorCodexModelSource? = nil ) { self.codexModelSource = codexModelSource - self.previewChatLogSource = previewChatLogSource self.uiState = uiState - self.selectedCodexChat = ReviewMonitorSelectedCodexChat(modelSource: codexModelSource) + self.selectedCodexChat = ReviewMonitorSelectedCodexChat( + modelSource: codexModelSource + ) super.init(nibName: nil, bundle: nil) } @@ -121,7 +118,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func selectionRequiresPresentationUpdate(_ selection: ReviewMonitorSelection?) -> Bool { switch selection { - case .workspaceSection: + case .workspaceGroup: return displayedSelection != selection?.id case .workspace: return displayedSelection != selection?.id @@ -135,7 +132,7 @@ final class ReviewMonitorTransportViewController: NSViewController { private func updatePresentation(selection: ReviewMonitorSelection?) { switch selection { - case .workspaceSection: + case .workspaceGroup: clearDisplayedLogSelection() displayPlaceholder(.noFindings) logScrollView.isHidden = true @@ -175,15 +172,6 @@ final class ReviewMonitorTransportViewController: NSViewController { boundChatID = selectedChatID let restorationTarget = restorationTarget(chatID: selectedChatID) selectedCodexChat.bind(toChatID: selectedChatID) - if let previewStream = previewChatLogSource?.chatChangeStream(for: selectedChatID) { - startPreviewChatChangeStream( - previewStream, - chatUpdatedAt: previewChatLogSource?.snapshot.chat(id: selectedChatID)?.updatedAt, - target: .chat(selectedChatID), - initialRestorationTarget: restorationTarget - ) - return - } startSelectedCodexChatLogStream( target: .chat(selectedChatID), initialRestorationTarget: restorationTarget @@ -259,43 +247,6 @@ final class ReviewMonitorTransportViewController: NSViewController { ) } - private func startPreviewChatChangeStream( - _ stream: AsyncStream, - chatUpdatedAt: Date?, - target: LogRenderTarget, - initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget - ) { - selectedChatLogTask?.cancel() - selectedChatLogTask = Task { @MainActor [weak self] in - var projection = ReviewMonitorSelectedCodexChatLogProjection() - for await change in stream { - guard let self, - self.isCurrentLogRenderTarget(target) - else { - return - } - guard - let logChange = projection.apply( - change, - chatCreatedAt: nil, - chatUpdatedAt: chatUpdatedAt - ) - else { - continue - } - let hasAppliedInitialDocument = self.hasAppliedBoundLog - self.applySelectedCodexChatLogChange( - logChange, - target: target, - restorationTarget: hasAppliedInitialDocument - ? self.logScrollView.currentScrollRestorationTarget - : initialRestorationTarget, - allowIncrementalUpdate: hasAppliedInitialDocument && logChange.allowsIncrementalRender - ) - } - } - } - private func startLogSourceChangeStream( _ stream: AsyncStream, target: LogRenderTarget, @@ -375,7 +326,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch displayedSelection { case .chat: return logScrollView.performDisplayedTextFinderAction(sender) - case .workspaceSection, .workspace, nil: + case .workspaceGroup, .workspace, nil: return false } } @@ -384,7 +335,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch displayedSelection { case .chat: return logScrollView.validateDisplayedTextFinderAction(item) - case .workspaceSection, .workspace, nil: + case .workspaceGroup, .workspace, nil: return false } } @@ -402,7 +353,7 @@ final class ReviewMonitorTransportViewController: NSViewController { } enum DisplayedSelectionForTesting: Sendable, Equatable { - case workspaceSection(String) + case workspaceGroup(String) case workspace(String) case chat(String) } @@ -438,7 +389,7 @@ final class ReviewMonitorTransportViewController: NSViewController { return selectionObservation } switch expectedSelection { - case .workspaceSection, .workspace, .chat: + case .workspaceGroup, .workspace, .chat: return selectionObservation case nil: return selectionObservation @@ -790,7 +741,7 @@ final class ReviewMonitorTransportViewController: NSViewController { var expectedRenderSnapshotForTesting: RenderSnapshotForTesting { switch uiState.selection { - case .workspaceSection: + case .workspaceGroup: .init( title: nil, summary: nil, @@ -830,8 +781,8 @@ final class ReviewMonitorTransportViewController: NSViewController { private var displayedSelectionForTesting: DisplayedSelectionForTesting? { switch displayedSelection { - case .workspaceSection(let id): - .workspaceSection(id) + case .workspaceGroup(let id): + .workspaceGroup(id.rawValue) case .workspace(let id): .workspace(id.rawValue) case .chat(let id): @@ -843,10 +794,10 @@ final class ReviewMonitorTransportViewController: NSViewController { private var expectedDisplayedSelectionForTesting: DisplayedSelectionForTesting? { switch uiState.selection { - case .workspaceSection(let section): - .workspaceSection(section.id) - case .workspace(let workspace): - .workspace(workspace.id.rawValue) + case .workspaceGroup(let id): + .workspaceGroup(id.rawValue) + case .workspace(let id): + .workspace(id.rawValue) case .chat(let id): .chat(id.rawValue) case nil: @@ -990,7 +941,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch target ?? displayedSelectionForTesting { case .chat(let id): resolvedTarget = .chat(CodexThreadID(rawValue: id)) - case .workspaceSection, .workspace, nil: + case .workspaceGroup, .workspace, nil: return false } let resolvedRestorationTarget = @@ -1030,7 +981,7 @@ final class ReviewMonitorTransportViewController: NSViewController { hidePlaceholder() logScrollView.isHidden = false displayedSelection = .chat(chatID) - case .workspaceSection, .workspace: + case .workspaceGroup, .workspace: break } } diff --git a/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift index 9f1041b1..1deb582d 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift @@ -148,7 +148,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { projectedBlock( item, kind: logKind(for: item, fallback: .event), - text: [title, raw.text].compactMap { $0 }.joined(separator: "\n"), + text: unknownText(title: title, detail: raw.text), metadata: metadata( for: item, sourceType: item.kind.rawValue, @@ -161,6 +161,13 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { } } + private func unknownText(title: String, detail: String?) -> String { + guard let detail, detail != title else { + return title + } + return "\(title)\n\(detail)" + } + private func commandBlocks( item: CodexChatItemSnapshot, command: CodexCommand, diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 98c32efc..f9e62e8d 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -15,7 +15,6 @@ final class ReviewMonitorRootViewController: NSViewController { private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let codexModelSource: ReviewMonitorCodexModelSource? - private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let contentTransitionAnimator: ReviewMonitorContentTransitionAnimator private let showSettings: (@MainActor () -> Void)? private var observation: PortableObservationTracking.Token? @@ -26,7 +25,6 @@ final class ReviewMonitorRootViewController: NSViewController { store: store, uiState: uiState, codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource, showSettings: showSettings ) @@ -36,7 +34,6 @@ final class ReviewMonitorRootViewController: NSViewController { store: CodexReviewStore, uiState: ReviewMonitorUIState, modelContext: CodexModelContext, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil ) { @@ -44,7 +41,6 @@ final class ReviewMonitorRootViewController: NSViewController { store: store, uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), - previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings ) @@ -54,14 +50,12 @@ final class ReviewMonitorRootViewController: NSViewController { store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil ) { self.store = store self.uiState = uiState self.codexModelSource = codexModelSource - self.previewChatLogSource = previewChatLogSource self.contentTransitionAnimator = contentTransitionAnimator self.showSettings = showSettings super.init(nibName: nil, bundle: nil) @@ -259,8 +253,11 @@ extension ReviewMonitorRootViewController { } @discardableResult - func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) -> Int? { - previewChatLogSource?.appendPreviewStreamTick(after: tick) + func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? { + guard let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) else { + return nil + } + return await previewRuntime.appendPreviewStreamTick(after: tick) } var splitViewControllerForTesting: ReviewMonitorSplitViewController { @@ -320,26 +317,24 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:))) store.auth.selectPersistedAccount(resolvedAccount?.id) let uiState = ReviewMonitorUIState(auth: store.auth) - let previewChatLogSource: ReviewMonitorPreviewChatLogSource? = - if case .running = serverState { - ReviewMonitorPreviewContent.makeChatLogSource(from: store) - } else { - nil - } - if let initialChat = previewChatLogSource?.initialChat { - uiState.selection = .chat(initialChat.id) + if uiState.selection == nil, + let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { + uiState.selection = previewRuntime.initialSelection } - if ownsPreviewStore, let previewChatLogSource { - ReviewMonitorPreviewContent.retainChatLogStreamer( - source: previewChatLogSource, + if ownsPreviewStore, + ReviewMonitorPreviewContent.previewRuntime(from: store) != nil { + ReviewMonitorPreviewContent.retainPreviewRuntimeStreamer( in: store, interval: .milliseconds(40) ) + } else { + ReviewMonitorPreviewContent.previewRuntime(from: store)?.start() } + let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) return ReviewMonitorRootViewController( store: store, uiState: uiState, - previewChatLogSource: previewChatLogSource + codexModelSource: previewRuntime?.modelSource ) } #endif diff --git a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift new file mode 100644 index 00000000..3cccb152 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift @@ -0,0 +1,685 @@ +import CodexKit +import CodexAppServerKitTesting +import CodexReviewKit +import Foundation +import Observation +import ObservationBridge + +@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 initialSnapshot: CodexChatSnapshot + + init( + chatID: CodexThreadID, + title: String, + preview: String?, + model: String?, + workspaceCWD: String?, + updatedAt: Date?, + recencyAt: Date?, + status: CodexThreadStatus?, + cwd: String, + streamID: String, + isRunning: Bool, + initialSnapshot: CodexChatSnapshot + ) { + 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.initialSnapshot = initialSnapshot + } +} + +@MainActor +final class ReviewMonitorPreviewAppServerRuntime { + let modelSource = ReviewMonitorCodexModelSource() + + private let fixtures: [ReviewMonitorPreviewChatLogFixture] + private let fixturesByChatID: [CodexThreadID: ReviewMonitorPreviewChatLogFixture] + private var snapshotStorageByChatID: [CodexThreadID: CodexChatSnapshot] + private var runtime: CodexAppServerTestRuntime? + private var container: CodexModelContainer? + private var startTask: Task? + private var streamTask: Task? + private var notificationTask: Task? + private var tick = 0 + + init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { + self.fixtures = fixtures + self.fixturesByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { ($0.chatID, $0) }) + self.snapshotStorageByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { + ($0.chatID, $0.initialSnapshot) + }) + } + + var initialSelection: ReviewMonitorSelection? { + fixtures.first.map { .chat($0.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) -> CodexChatSnapshot? { + snapshotStorageByChatID[chatID] + } + + func upsertPreviewItem( + id: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, + to chatID: CodexThreadID + ) { + guard let fixture = fixturesByChatID[chatID] else { + return + } + let turnID = snapshotStorageByChatID[chatID]?.turns.last?.id + let item = CodexChatItemSnapshot( + id: id, + turnID: turnID, + kind: kind, + content: content + ) + upsertStoredItem(item, in: chatID) + 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 + ) { + guard delta.isEmpty == false, + fixturesByChatID[chatID] != nil else { + return + } + let turnID = snapshotStorageByChatID[chatID]?.turns.last?.id + appendStoredText( + delta, + itemID: itemID, + turnID: turnID, + kind: kind, + content: content, + in: chatID + ) + 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: turnID ?? CodexTurnID(rawValue: "preview-turn"), + chatID: chatID, + kind: kind, + content: 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) + } + } + } + + @discardableResult + func appendPreviewStreamTick(after currentTick: Int = 0) async -> Int { + do { + try await ensureStarted() + } catch { + return currentTick + } + + let runningFixtures = fixtures.filter(\.isRunning) + guard runningFixtures.isEmpty == false else { + return currentTick + } + + let nextTick = currentTick + 1 + for (index, fixture) in runningFixtures.enumerated() { + guard let frame = ReviewMonitorPreviewContent.streamFrame( + forRunningChatAt: index, + tick: nextTick + ) else { + continue + } + apply(frame.step, cycle: frame.cycle, for: fixture) + await emit(frame.step, cycle: frame.cycle, 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( + threads: fixtures.map(\.threadSnapshot) + ) + let container = CodexModelContainer(appServer: runtime.server) + self.runtime = runtime + self.container = container + modelSource.install(container: container) + } + + private func emit( + _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, + cycle: Int, + for fixture: ReviewMonitorPreviewChatLogFixture + ) async { + guard let runtime else { + return + } + let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( + itemName: step.itemName, + streamID: fixture.streamID, + cycle: cycle + ) + let turnID = fixture.initialSnapshot.turns.last?.id ?? CodexTurnID(rawValue: "preview-turn") + do { + switch step.mode { + case .textDelta: + try await emitTextDelta( + step.deltaText ?? "", + itemID: itemID, + turnID: turnID, + chatID: fixture.chatID, + kind: step.kind, + content: step.content, + runtime: runtime + ) + case .update, .complete: + await emitItem( + step.itemSnapshot(id: itemID, turnID: turnID), + for: fixture + ) + } + } catch { + } + } + + private func apply( + _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, + cycle: Int, + for fixture: ReviewMonitorPreviewChatLogFixture + ) { + let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( + itemName: step.itemName, + streamID: fixture.streamID, + cycle: cycle + ) + switch step.mode { + case .update, .complete: + upsertStoredItem( + step.itemSnapshot(id: itemID, turnID: snapshotStorageByChatID[fixture.chatID]?.turns.last?.id), + in: fixture.chatID + ) + case .textDelta: + appendStoredText( + step.deltaText ?? "", + itemID: itemID, + turnID: snapshotStorageByChatID[fixture.chatID]?.turns.last?.id, + kind: step.kind, + content: step.content, + in: fixture.chatID + ) + } + } + + private func upsertStoredItem(_ item: CodexChatItemSnapshot, in chatID: CodexThreadID) { + guard var snapshot = snapshotStorageByChatID[chatID] else { + return + } + if let index = snapshot.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { + snapshot.items[index] = item + } else { + snapshot.items.append(item) + } + snapshotStorageByChatID[chatID] = snapshot + } + + private func appendStoredText( + _ delta: String, + itemID: String, + turnID: CodexTurnID?, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, + in chatID: CodexThreadID + ) { + guard delta.isEmpty == false, + var snapshot = snapshotStorageByChatID[chatID] else { + return + } + if let index = snapshot.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { + snapshot.items[index].content.appendPreviewText(delta) + } else { + var item = CodexChatItemSnapshot( + id: itemID, + turnID: turnID, + kind: kind, + content: content + ) + item.content.appendPreviewText(delta) + snapshot.items.append(item) + } + snapshotStorageByChatID[chatID] = snapshot + } + + private func emitItem( + _ item: CodexChatItemSnapshot, + 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: item.turnID?.rawValue ?? fixture.initialSnapshot.turns.last?.id.rawValue ?? "preview-turn", + item: makePreviewNotificationItem(id: item.id, kind: item.kind, content: 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 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 output: String? + var exitCode: Int? + var status: String? + } +} + +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), + output: previewNotificationOutput(content), + exitCode: previewNotificationExitCode(content), + status: previewNotificationStatus(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 previewNotificationOutput(_ content: CodexThreadItem.Content) -> String? { + if case .command(let command) = content { + return command.output + } + 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 .toolCall(let toolCall): + toolCall.status?.rawValue + default: + nil + } +} + +private extension ReviewMonitorPreviewChatLogFixture { + var threadSnapshot: CodexThreadSnapshot { + CodexThreadSnapshot( + id: chatID, + workspace: workspaceCWD.map { URL(fileURLWithPath: $0, isDirectory: true) }, + name: title, + preview: preview, + modelProvider: model, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + turns: initialSnapshot.turns.map { turn in + CodexTurnSnapshot( + id: turn.id, + status: turn.status, + errorMessage: turn.errorDescription, + items: initialSnapshot.items + .filter { $0.turnID == turn.id } + .map(\.threadItem) + ) + } + ) + } +} + +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/ReviewUI/ReviewMonitorPreviewChatLogSource.swift b/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift deleted file mode 100644 index f1f32252..00000000 --- a/Sources/ReviewUI/ReviewMonitorPreviewChatLogSource.swift +++ /dev/null @@ -1,493 +0,0 @@ -import CodexKit -import CodexReviewKit -import Foundation -import Observation -import ObservationBridge - -@MainActor -struct ReviewMonitorPreviewChatLogFixture { - let chat: ReviewMonitorCodexSidebarSnapshot.Chat - let cwd: String - let streamID: String - let isRunning: Bool - let initialSnapshot: CodexChatSnapshot - - init( - chat: ReviewMonitorCodexSidebarSnapshot.Chat, - cwd: String, - streamID: String, - isRunning: Bool, - initialSnapshot: CodexChatSnapshot - ) { - self.chat = chat - self.cwd = cwd - self.streamID = streamID - self.isRunning = isRunning - self.initialSnapshot = initialSnapshot - } -} - -@MainActor -final class ReviewMonitorPreviewChatLogSource { - let snapshot: ReviewMonitorCodexSidebarSnapshot - let initialChat: ReviewMonitorCodexSidebarSnapshot.Chat? - - private let previewChats: [ReviewMonitorPreviewChat] - private let previewChatsByID: [CodexThreadID: ReviewMonitorPreviewChat] - - init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { - var sections: [ReviewMonitorCodexSidebarSnapshot.Section] = [] - var sectionIndexesByCWD: [String: Int] = [:] - var previewChats: [ReviewMonitorPreviewChat] = [] - var previewChatsByID: [CodexThreadID: ReviewMonitorPreviewChat] = [:] - var initialRunningChat: ReviewMonitorCodexSidebarSnapshot.Chat? - var firstChat: ReviewMonitorCodexSidebarSnapshot.Chat? - - for fixture in fixtures { - let previewChat = ReviewMonitorPreviewChat(fixture: fixture) - let chat = ReviewMonitorCodexSidebarSnapshot.Chat(previewChat: previewChat) - previewChats.append(previewChat) - previewChatsByID[chat.id] = previewChat - firstChat = firstChat ?? chat - if initialRunningChat == nil, previewChat.isRunning { - initialRunningChat = chat - } - - if let sectionIndex = sectionIndexesByCWD[previewChat.cwd] { - sections[sectionIndex].uncategorizedChats.append(chat) - } else { - sectionIndexesByCWD[previewChat.cwd] = sections.count - sections.append( - ReviewMonitorCodexSidebarSnapshot.Section( - rowID: .section(previewChat.cwd), - id: previewChat.cwd, - title: URL(fileURLWithPath: previewChat.cwd).lastPathComponent, - workspaces: [], - uncategorizedChats: [chat] - )) - } - } - - self.snapshot = ReviewMonitorCodexSidebarSnapshot(sections: sections) - self.initialChat = initialRunningChat ?? firstChat - self.previewChats = previewChats - self.previewChatsByID = previewChatsByID - } - - func chatChangeStream(for chatID: CodexThreadID) -> AsyncStream? { - guard let previewChat = previewChatsByID[chatID] else { - return nil - } - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - let subscription = PreviewChatLogSubscription(previewChat: previewChat, continuation: pair.continuation) - subscription.start() - pair.continuation.onTermination = { _ in - Task { @MainActor in - subscription.cancel() - } - } - return pair.stream - } - - func upsertPreviewItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, - to chatID: CodexThreadID - ) { - previewChatsByID[chatID]?.upsertItem(id: id, kind: kind, content: content) - } - - func appendPreviewText( - _ delta: String, - to chatID: CodexThreadID, - itemID: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content - ) { - previewChatsByID[chatID]?.appendTextDelta(delta, itemID: itemID, kind: kind, content: content) - } - - func previewChat(id: CodexThreadID) -> ReviewMonitorPreviewChat? { - previewChatsByID[id] - } - - func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { - previewChatsByID[chatID]?.snapshot() - } - - @discardableResult - func appendPreviewStreamTick(after currentTick: Int = 0) -> Int { - let runningChats = previewChats.filter(\.isRunning) - guard runningChats.isEmpty == false else { - return currentTick - } - - let nextTick = currentTick + 1 - for (index, previewChat) in runningChats.enumerated() { - guard let frame = ReviewMonitorPreviewContent.streamFrame( - forRunningChatAt: index, - tick: nextTick - ) else { - continue - } - previewChat.applyPreviewStreamStep(frame.step, cycle: frame.cycle) - } - return nextTick - } -} - -@MainActor -@Observable -package final class ReviewMonitorPreviewChat { - package let id: CodexThreadID - package let title: String - package let preview: String? - package let model: String? - package let workspaceCWD: String? - package private(set) var updatedAt: Date? - package private(set) var recencyAt: Date? - package private(set) var status: CodexThreadStatus? - let cwd: String - let streamID: String - - private var revision = 0 - private var snapshotStorage: CodexChatSnapshot - - init(fixture: ReviewMonitorPreviewChatLogFixture) { - self.id = fixture.chat.id - self.title = fixture.chat.title - self.preview = fixture.chat.preview - self.model = fixture.chat.model - self.workspaceCWD = fixture.chat.workspaceCWD - self.updatedAt = fixture.chat.updatedAt - self.recencyAt = fixture.chat.recencyAt - self.status = fixture.chat.status - self.cwd = fixture.cwd - self.streamID = fixture.streamID - self.snapshotStorage = fixture.initialSnapshot - } - - package var isRunning: Bool { - status?.isActive == true - } - - func trackRevision() { - _ = revision - } - - func snapshot() -> CodexChatSnapshot { - snapshotStorage - } - - func applyPreviewStreamStep( - _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, - cycle: Int - ) { - let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( - itemName: step.itemName, - streamID: streamID, - cycle: cycle - ) - switch step.mode { - case .update: - upsertItem(step.itemSnapshot(id: itemID, turnID: currentTurnID)) - case .complete: - upsertItem(step.itemSnapshot(id: itemID, turnID: currentTurnID)) - case .textDelta: - appendTextDelta( - step.deltaText ?? "", - itemID: itemID, - kind: step.kind, - content: step.content - ) - } - } - - private var currentTurnID: CodexTurnID? { - snapshotStorage.turns.last?.id - } - - package func cancel() { - guard isRunning else { - return - } - status = .idle - let now = Date() - updatedAt = now - recencyAt = now - updateTurnStatus(.cancelled) - } - - func upsertItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content - ) { - let item = CodexChatItemSnapshot( - id: id, - turnID: currentTurnID, - kind: kind, - content: content - ) - upsertItem(item) - } - - private func upsertItem(_ item: CodexChatItemSnapshot) { - if let index = snapshotStorage.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { - snapshotStorage.items[index] = item - } else { - snapshotStorage.items.append(item) - } - bumpRevision() - } - - func appendTextDelta( - _ delta: String, - itemID: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content - ) { - guard delta.isEmpty == false else { - return - } - let turnID = currentTurnID - if let index = snapshotStorage.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { - snapshotStorage.items[index].content.appendPreviewText(delta) - } else { - var item = CodexChatItemSnapshot( - id: itemID, - turnID: turnID, - kind: kind, - content: content - ) - item.content.appendPreviewText(delta) - snapshotStorage.items.append(item) - } - bumpRevision() - } - - private func updateTurnStatus(_ status: CodexTurnStatus) { - guard snapshotStorage.turns.isEmpty == false else { - return - } - snapshotStorage.turns[snapshotStorage.turns.count - 1].status = status - snapshotStorage.phase = .loaded - bumpRevision() - } - - private func bumpRevision() { - revision += 1 - } -} - -@MainActor -final class ReviewMonitorPreviewChatLogStreamer { - private weak var source: ReviewMonitorPreviewChatLogSource? - private let interval: Duration - private var task: Task? - private var tick = 0 - - init(source: ReviewMonitorPreviewChatLogSource, interval: Duration) { - self.source = source - 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 source else { - task?.cancel() - return - } - tick = source.appendPreviewStreamTick(after: tick) - } -} - -@MainActor -final class ReviewMonitorPreviewRuntimeSupport { - let chatLogSource: ReviewMonitorPreviewChatLogSource - private var streamer: ReviewMonitorPreviewChatLogStreamer? - - init(chatLogSource: ReviewMonitorPreviewChatLogSource) { - self.chatLogSource = chatLogSource - } - - func startStreaming(interval: Duration) { - streamer = ReviewMonitorPreviewChatLogStreamer( - source: chatLogSource, - interval: interval - ) - } -} - -@MainActor -private final class PreviewChatLogSubscription { - private let previewChat: ReviewMonitorPreviewChat - private let continuation: AsyncStream.Continuation - private var observation: PortableObservationTracking.Token? - private var previousSnapshot: CodexChatSnapshot? - - init( - previewChat: ReviewMonitorPreviewChat, - continuation: AsyncStream.Continuation - ) { - self.previewChat = previewChat - self.continuation = continuation - } - - func start() { - publish() - observation = withPortableContinuousObservation { [weak self] _ in - guard let self else { - return - } - previewChat.trackRevision() - publish() - } - } - - func cancel() { - observation?.cancel() - observation = nil - } - - private func publish() { - let snapshot = previewChat.snapshot() - let changes = CodexChatChange.previewChanges(from: previousSnapshot, to: snapshot) - previousSnapshot = snapshot - for change in changes { - continuation.yield(change) - } - } -} - -private extension CodexChatChange { - static func previewChanges(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { - guard let previous else { - return [.snapshot(current)] - } - - var changes: [Self] = [] - let previousTurnsByID = Dictionary(uniqueKeysWithValues: previous.turns.map { ($0.id, $0) }) - for turn in current.turns where previousTurnsByID[turn.id] != turn { - changes.append(.turnUpdated(turn)) - } - - let previousItemsByID = Dictionary(uniqueKeysWithValues: previous.items.map { ($0.id, $0) }) - let currentItemIDs = Set(current.items.map(\.id)) - for removedItem in previous.items where currentItemIDs.contains(removedItem.id) == false { - changes.append(.itemRemoved(id: removedItem.id, turnID: removedItem.turnID)) - } - for item in current.items { - guard let previousItem = previousItemsByID[item.id] else { - changes.append(.itemInserted(item)) - continue - } - guard previousItem != item else { - continue - } - if let delta = previousItem.textDelta(to: item) { - changes.append(.itemTextAppended(id: item.id, turnID: item.turnID, delta: delta, item: item)) - } else { - changes.append(.itemUpdated(item)) - } - } - - if previous.phase != current.phase { - changes.append(.phaseChanged(current.phase)) - } - return changes - } -} - -extension CodexChatItemSnapshot { - func textDelta(to item: CodexChatItemSnapshot) -> String? { - guard - turnID == item.turnID, - kind == item.kind, - sameContentShape(as: 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 sameContentShape(as item: CodexChatItemSnapshot) -> 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 - } - } -} - -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/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index b9aa9131..66927ade 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -144,36 +144,19 @@ public enum ReviewMonitorPreviewContent { persistedAccounts: accounts, serverURL: URL(string: "http://localhost:9417/mcp") ) - let chatLogSource = ReviewMonitorPreviewChatLogSource( + let previewRuntime = ReviewMonitorPreviewAppServerRuntime( fixtures: previewContent.chatLogFixtures ) - store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( - chatLogSource: chatLogSource - ) + store.previewSupportRetainer = previewRuntime return store } - static func makeChatLogSource(from store: CodexReviewStore) -> ReviewMonitorPreviewChatLogSource { - if let previewSupport = store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport { - return previewSupport.chatLogSource - } - return ReviewMonitorPreviewChatLogSource(fixtures: []) + static func previewRuntime(from store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? { + store.previewSupportRetainer as? ReviewMonitorPreviewAppServerRuntime } - static func retainChatLogStreamer( - source: ReviewMonitorPreviewChatLogSource, - in store: CodexReviewStore, - interval: Duration - ) { - let previewSupport: ReviewMonitorPreviewRuntimeSupport - if let existingSupport = store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport, - existingSupport.chatLogSource === source { - previewSupport = existingSupport - } else { - previewSupport = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) - store.previewSupportRetainer = previewSupport - } - previewSupport.startStreaming(interval: interval) + static func retainPreviewRuntimeStreamer(in store: CodexReviewStore, interval: Duration) { + previewRuntime(from: store)?.startStreaming(interval: interval) } @_spi(PreviewSupport) @@ -210,9 +193,7 @@ public enum ReviewMonitorPreviewContent { let fixture = makeChatLogFixture( for: chatFixture ) - store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport( - chatLogSource: ReviewMonitorPreviewChatLogSource(fixtures: [fixture]) - ) + store.previewSupportRetainer = ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) return store } @@ -638,17 +619,6 @@ public enum ReviewMonitorPreviewContent { private static func makeChatLogFixture( for chatFixture: PreviewChatFixture ) -> ReviewMonitorPreviewChatLogFixture { - let chat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatFixture.chatID), - id: 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) - ) let turn = CodexChatTurnStateSnapshot( id: chatFixture.turnID, status: CodexTurnStatus(chatFixture.lifecycle), @@ -656,7 +626,7 @@ public enum ReviewMonitorPreviewContent { usage: nil ) let initialSnapshot = CodexChatSnapshot( - chatID: chat.id, + chatID: chatFixture.chatID, phase: CodexDataPhase( chatFixture.lifecycle, errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil @@ -669,7 +639,14 @@ public enum ReviewMonitorPreviewContent { ) ) return ReviewMonitorPreviewChatLogFixture( - chat: chat, + 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, diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 3cab7c87..1ab16fcd 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -95,7 +95,11 @@ final class ReviewMonitorSelectedCodexChat { logProjection.reset() boundModelContext = nextModelContext - guard let nextChatID, let modelContext = nextModelContext else { + guard let nextChatID else { + return + } + + guard let modelContext = nextModelContext else { return } diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 5e3dea88..b8967a14 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -2,23 +2,23 @@ import CodexKit import CodexReviewKit enum ReviewMonitorSelectionID: Hashable, Sendable { - case workspaceSection(String) + case workspaceGroup(CodexWorkspaceGroupID) case workspace(CodexWorkspaceID) case chat(CodexThreadID) } @MainActor -enum ReviewMonitorSelection { - case workspaceSection(ReviewMonitorWorkspaceSectionSelection) - case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) +enum ReviewMonitorSelection: Hashable, Sendable { + case workspaceGroup(CodexWorkspaceGroupID) + case workspace(CodexWorkspaceID) case chat(CodexThreadID) var id: ReviewMonitorSelectionID { switch self { - case .workspaceSection(let section): - return .workspaceSection(section.id) - case .workspace(let workspace): - return .workspace(workspace.id) + case .workspaceGroup(let id): + return .workspaceGroup(id) + case .workspace(let id): + return .workspace(id) case .chat(let id): return .chat(id) } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index f4b9cfb4..6a5382be 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -21,7 +21,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe private let store: CodexReviewStore private let uiState: ReviewMonitorUIState private let codexModelSource: ReviewMonitorCodexModelSource? - private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let showSettings: (@MainActor () -> Void)? private var sidebarViewController: ReviewMonitorSidebarViewController? private var transportViewController: ReviewMonitorTransportViewController? @@ -42,14 +41,12 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe store: CodexReviewStore, uiState: ReviewMonitorUIState, modelContext: CodexModelContext, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { self.init( store: store, uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), - previewChatLogSource: previewChatLogSource, showSettings: showSettings ) } @@ -58,14 +55,22 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { self.store = store self.uiState = uiState - self.codexModelSource = codexModelSource - self.previewChatLogSource = previewChatLogSource - ?? (store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport)?.chatLogSource + #if DEBUG + if let codexModelSource { + self.codexModelSource = codexModelSource + } else if let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { + previewRuntime.start() + self.codexModelSource = previewRuntime.modelSource + } else { + self.codexModelSource = nil + } + #else + self.codexModelSource = codexModelSource + #endif self.showSettings = showSettings super.init(nibName: nil, bundle: nil) } @@ -85,13 +90,17 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe let sidebarViewController = ReviewMonitorSidebarViewController( store: store, uiState: uiState, - codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource + codexModelSource: codexModelSource ) + sidebarViewController.codexSidebarContentDidChange = { [weak self] in + guard let self else { + return + } + self.applyWindowTitle(self.windowTitlePresentation(for: self.uiState.selection)) + } let transportViewController = ReviewMonitorTransportViewController( uiState: uiState, - codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource + codexModelSource: codexModelSource ) let statusAccessoryViewController = ReviewMonitorServerStatusAccessoryViewController( store: store, @@ -247,16 +256,16 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe for selection: ReviewMonitorSelection? ) -> WindowTitlePresentation { switch selection { - case .workspaceSection(let section): - return WindowTitlePresentation( - title: section.title, - subtitle: section.subtitle - ) - case .workspace(let workspace): - return WindowTitlePresentation( - title: workspace.title, - subtitle: workspace.cwd - ) + case .workspaceGroup(let id): + guard let presentation = sidebarViewController?.codexWorkspaceGroupTitlePresentation(id: id) else { + return WindowTitlePresentation(title: "", subtitle: "") + } + return WindowTitlePresentation(title: presentation.title, subtitle: presentation.subtitle) + case .workspace(let id): + guard let presentation = sidebarViewController?.codexWorkspaceTitlePresentation(id: id) else { + return WindowTitlePresentation(title: "", subtitle: "") + } + return WindowTitlePresentation(title: presentation.title, subtitle: presentation.subtitle) case .chat(let id): guard let presentation = sidebarViewController?.codexChatTitlePresentation(id: id) else { return WindowTitlePresentation(title: "", subtitle: "") diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index 061f2ae0..d171001c 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -27,18 +27,6 @@ final class ReviewMonitorUIState { self.persistSidebarReviewChatFilter = persistSidebarReviewChatFilter } - 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 } diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index bed7c791..d8ca1c61 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -70,16 +70,16 @@ public final class ReviewMonitorWindowController: NSWindowController { convenience init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, + initialSelection: ReviewMonitorSelection? = nil, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil ) { self.init( store: store, codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, + initialSelection: initialSelection, frameAutosaveName: Self.frameAutosaveName, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults, showSettings: showSettings @@ -89,8 +89,8 @@ public final class ReviewMonitorWindowController: NSWindowController { init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, + initialSelection: ReviewMonitorSelection? = nil, frameAutosaveName: NSWindow.FrameAutosaveName, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil @@ -99,14 +99,13 @@ public final class ReviewMonitorWindowController: NSWindowController { auth: store.auth, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) - if let initialChat = previewChatLogSource?.initialChat { - uiState.selection = .chat(initialChat.id) + if uiState.selection == nil { + uiState.selection = initialSelection } let rootViewController = ReviewMonitorRootViewController( store: store, uiState: uiState, codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings ) @@ -156,17 +155,16 @@ public extension ReviewMonitorWindowController { codexModelSource: ReviewMonitorCodexModelSource? = nil, showSettings: (@MainActor () -> Void)? = nil ) { - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) - ReviewMonitorPreviewContent.retainChatLogStreamer( - source: previewChatLogSource, + let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) + ReviewMonitorPreviewContent.retainPreviewRuntimeStreamer( in: store, interval: .milliseconds(40) ) self.init( store: store, - codexModelSource: codexModelSource, - previewChatLogSource: previewChatLogSource, + codexModelSource: codexModelSource ?? previewRuntime?.modelSource, contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + initialSelection: previewRuntime?.initialSelection, showSettings: showSettings ) } diff --git a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift b/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift index d84b85c2..c3e0f0b6 100644 --- a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift +++ b/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift @@ -1,15 +1,5 @@ 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 diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 709dbc81..0bd51981 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -3,19 +3,10 @@ import SwiftUI @MainActor struct ReviewMonitorChatContextMenuView: View { - private enum Chat { - case codex(CodexChat) - case preview(ReviewMonitorPreviewChat) - } - - private var chat: Chat + private var chat: CodexChat init(chat: CodexChat) { - self.chat = .codex(chat) - } - - init(previewChat: ReviewMonitorPreviewChat) { - self.chat = .preview(previewChat) + self.chat = chat } var body: some View { @@ -26,22 +17,12 @@ struct ReviewMonitorChatContextMenuView: View { } private var isRunning: Bool { - switch chat { - case .codex(let chat): - chat.status?.isActive == true - case .preview(let chat): - chat.isRunning - } + chat.status?.isActive == true } private func cancel() { - switch chat { - case .codex(let chat): - Task { - try? await chat.cancel() - } - case .preview(let chat): - chat.cancel() + Task { + try? await chat.cancel() } } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index 5312a981..9b74d2b7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -1,68 +1,51 @@ import Foundation import SwiftUI +import CodexKit @MainActor -struct ReviewMonitorSidebarChatRow: Equatable { - var id: String - var title: String - var model: String? - var subtitle: String? - var startedAt: Date? - var endedAt: Date? - var isRunning: Bool +private struct ReviewMonitorChatRowContent: View { + var chat: CodexChat - init( - id: String, - title: String, - model: String?, - subtitle: String?, - startedAt: Date?, - endedAt: Date?, - isRunning: Bool - ) { - self.id = id - self.title = title - self.model = model - self.subtitle = subtitle - self.startedAt = startedAt - self.endedAt = endedAt - self.isRunning = isRunning - } - - init(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { - self.init( + var body: some View { + ReviewMonitorChatRowLayout( id: chat.id.rawValue, title: chat.title, - model: chat.model, + model: chat.modelProvider, subtitle: chat.preview?.trimmedNonEmpty, - startedAt: chat.isRunning ? chat.activityDate : nil, - endedAt: chat.isRunning ? nil : chat.activityDate, - isRunning: chat.isRunning + startedAt: chat.status?.isActive == true ? chat.activityDate : nil, + endedAt: nil, + isRunning: chat.status?.isActive == true ) } } @MainActor -struct ReviewMonitorChatRowContentView: View { - var row: ReviewMonitorSidebarChatRow +private struct ReviewMonitorChatRowLayout: View { + var id: String + var title: String + var model: String? + var subtitle: String? + var startedAt: Date? + var endedAt: Date? + var isRunning: Bool var body: some View { Label { VStack { HStack { - Text(row.title) + Text(title) .truncationMode(.tail) Spacer(minLength: 0) - ReviewMonitorChatRowTimerLabel(row: row) + ReviewMonitorChatRowTimerLabel(startedAt: startedAt, endedAt: endedAt) .foregroundStyle(.secondary) .layoutPriority(1) } .lineLimit(1) HStack { - if let model = row.model { + if let model { Text(model) } - if let subtitle = row.subtitle { + if let subtitle { Text(subtitle) } Spacer(minLength: 0) @@ -75,15 +58,15 @@ struct ReviewMonitorChatRowContentView: View { ZStack { Image(systemName: "circle.fill") .foregroundStyle(.clear) - if row.isRunning { + if isRunning { ProgressView() .controlSize(.mini) } } - .animation(.default, value: row.isRunning) + .animation(.default, value: isRunning) .padding(.leading, SidebarLayout.disclosureGutterWidth) } - .transaction(value: row.id) { transaction in + .transaction(value: id) { transaction in transaction.disablesAnimations = true } } @@ -93,29 +76,30 @@ struct ReviewMonitorChatRowContentView: View { struct ReviewMonitorChatRowView: View { var node: ReviewMonitorCodexSidebarOutlineNode - var row: ReviewMonitorSidebarChatRow? { + var chat: CodexChat? { guard case .chat(let chat) = node.item else { return nil } - return ReviewMonitorSidebarChatRow(chat: chat) + return chat } var body: some View { - if let row { - ReviewMonitorChatRowContentView(row: row) + if let chat { + ReviewMonitorChatRowContent(chat: chat) } } } @MainActor struct ReviewMonitorChatRowTimerLabel: View { - var row: ReviewMonitorSidebarChatRow + var startedAt: Date? + var endedAt: Date? var body: some View { - if let startedAt = row.startedAt { + if let startedAt { Text( - timerInterval: startedAt...(row.endedAt ?? .distantFuture), - pauseTime: row.endedAt, + timerInterval: startedAt...(endedAt ?? .distantFuture), + pauseTime: endedAt, countsDown: false, showsHours: true ) @@ -124,47 +108,38 @@ struct ReviewMonitorChatRowTimerLabel: View { } } -#if DEBUG - #Preview { - NavigationSplitView { - List { - Section("workspace-alpha") { - ReviewMonitorChatRowContentView( - row: ReviewMonitorSidebarChatRow( - id: "preview-running", - title: "Uncommitted changes", - model: "gpt-5.5", - subtitle: "Inspecting recent changes", - startedAt: Date(timeIntervalSinceNow: -640), - endedAt: nil, - isRunning: true - ) - ) - ReviewMonitorChatRowContentView( - row: ReviewMonitorSidebarChatRow( - id: "preview-completed", - title: "Base branch: main", - model: "gpt-5.5-codex", - subtitle: "No findings", - startedAt: Date(timeIntervalSinceNow: -3_600), - endedAt: Date(timeIntervalSinceNow: -2_900), - isRunning: false - ) - ) - } - } - .frame(minWidth: 320) - } detail: { - ContentUnavailableView { - Text(verbatim: "Preview") - } - } - } -#endif - 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 { + ReviewMonitorChatRowLayout.measureMeasuredHeight() +} + +@MainActor +extension ReviewMonitorChatRowLayout { + static func measureMeasuredHeight() -> CGFloat { + let hostingView = NSHostingView( + rootView: ReviewMonitorChatRowLayout( + id: "row-height-measurement", + title: "Uncommitted changes", + model: "gpt-5.5", + subtitle: "Review output preview", + startedAt: Date(timeIntervalSince1970: 0), + endedAt: nil, + isRunning: true + ) + ) + return ceil(hostingView.fittingSize.height) + } +} diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index ec761c23..28a5b0f6 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -10,8 +10,8 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC self.rawValue = rawValue } - package static func section(_ id: String) -> Self { - .init(rawValue: "section:\(id)") + package static func workspaceGroup(_ id: CodexWorkspaceGroupID) -> Self { + .init(rawValue: "workspaceGroup:\(id.rawValue)") } package static func workspace(_ id: CodexWorkspaceID) -> Self { @@ -28,385 +28,119 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC } @MainActor -package struct ReviewMonitorCodexSidebarSnapshot: Equatable { - @MainActor - package struct Section: Equatable { - package var rowID: ReviewMonitorCodexSidebarRowID - package var id: String - package var title: String - package var workspaces: [Workspace] - package var uncategorizedChats: [Chat] - - package var rowIDs: [ReviewMonitorCodexSidebarRowID] { - [rowID] + workspaces.flatMap(\.rowIDs) + uncategorizedChats.map(\.rowID) - } - - var selection: ReviewMonitorWorkspaceSectionSelection { - var workspaceCWDs = workspaces.map(\.cwd) - for chat in uncategorizedChats { - guard let cwd = chat.workspaceCWD, - workspaceCWDs.contains(cwd) == false - else { - continue - } - workspaceCWDs.append(cwd) - } - return ReviewMonitorWorkspaceSectionSelection( - id: id, - title: title, - workspaceCWDs: workspaceCWDs - ) +extension CodexFetchSection where Model == CodexChat { + package var workspaceGroupID: CodexWorkspaceGroupID { + switch id { + case .workspaceGroup(let id): + id + default: + CodexWorkspaceGroupID(rawValue: id.description) } } - @MainActor - package struct Workspace: Equatable { - private struct Fixture: Equatable { - var cwd: String - var title: String - } - - private enum Source: Equatable { - case codex(CodexWorkspace) - case fixture(Fixture) - - static func == (lhs: Source, rhs: Source) -> Bool { - switch (lhs, rhs) { - case (.codex(let lhs), .codex(let rhs)): - lhs === rhs - case (.fixture(let lhs), .fixture(let rhs)): - lhs == rhs - default: - false - } - } - } - - package var rowID: ReviewMonitorCodexSidebarRowID - package var id: CodexWorkspaceID - package var chats: [Chat] - private var source: Source - - package var rowIDs: [ReviewMonitorCodexSidebarRowID] { - [rowID] + chats.map(\.rowID) - } - - package var codexWorkspace: CodexWorkspace? { - switch source { - case .codex(let workspace): - workspace - case .fixture: - nil - } - } + package var displayTitle: String { + title ?? "Unknown" + } - package var cwd: String { - switch source { - case .codex(let workspace): - workspace.url.path - case .fixture(let fixture): - fixture.cwd - } - } + package var rowID: ReviewMonitorCodexSidebarRowID { + .workspaceGroup(workspaceGroupID) + } - package var title: String { - switch source { - case .codex(let workspace): - workspace.name - case .fixture(let fixture): - fixture.title + package var workspaces: [CodexWorkspace] { + var workspaces: [CodexWorkspace] = [] + var workspaceIDs = Set() + for chat in items { + guard let workspace = chat.workspace, + workspaceIDs.contains(workspace.id) == false + else { + continue } + workspaceIDs.insert(workspace.id) + workspaces.append(workspace) } + return workspaces + } - func replacingChats(_ chats: [Chat]) -> Self { - var copy = self - copy.chats = chats - return copy - } - - package init( - rowID: ReviewMonitorCodexSidebarRowID, - id: CodexWorkspaceID, - cwd: String, - title: String, - chats: [Chat] - ) { - self.rowID = rowID - self.id = id - self.chats = chats - self.source = .fixture(Fixture(cwd: cwd, title: title)) - } + package var chats: [CodexChat] { + items + } - package init( - workspace: CodexWorkspace, - chats: [Chat] - ) { - self.rowID = .workspace(workspace.id) - self.id = workspace.id - self.chats = chats - self.source = .codex(workspace) - } + package var uncategorizedChats: [CodexChat] { + items.filter { $0.workspace == nil } } - @MainActor - package struct Chat: Equatable { - private struct Fixture: Equatable { - var title: String - var preview: String? - var model: String? - var workspaceCWD: String? - var updatedAt: Date? - var recencyAt: Date? - var status: CodexThreadStatus? - } + package var displaysWorkspaceNodes: Bool { + workspaces.count > 1 || (workspaces.isEmpty == false && uncategorizedChats.isEmpty == false) + } - private enum Source: Equatable { - case codex(CodexChat) - case preview(ReviewMonitorPreviewChat) - case fixture(Fixture) - - static func == (lhs: Source, rhs: Source) -> Bool { - switch (lhs, rhs) { - case (.codex(let lhs), .codex(let rhs)): - lhs === rhs - case (.preview(let lhs), .preview(let rhs)): - lhs === rhs - case (.fixture(let lhs), .fixture(let rhs)): - lhs == rhs - default: - false + package var rowIDs: [ReviewMonitorCodexSidebarRowID] { + if displaysWorkspaceNodes { + return [rowID] + + workspaces.flatMap { workspace in + [ReviewMonitorCodexSidebarRowID.workspace(workspace.id)] + + chats(in: workspace.id).map { .chat($0.id) } } - } - } - - package var rowID: ReviewMonitorCodexSidebarRowID - package var id: CodexThreadID - private var source: Source - - package init( - rowID: ReviewMonitorCodexSidebarRowID, - id: CodexThreadID, - title: String, - preview: String?, - model: String? = nil, - workspaceCWD: String?, - updatedAt: Date?, - recencyAt: Date? = nil, - status: CodexThreadStatus? = nil - ) { - self.rowID = rowID - self.id = id - self.source = .fixture( - Fixture( - title: title, - preview: preview, - model: model, - workspaceCWD: workspaceCWD, - updatedAt: updatedAt, - recencyAt: recencyAt, - status: status - )) - } - - package init(chat: CodexChat) { - self.rowID = .chat(chat.id) - self.id = chat.id - self.source = .codex(chat) - } - - package init(previewChat: ReviewMonitorPreviewChat) { - self.rowID = .chat(previewChat.id) - self.id = previewChat.id - self.source = .preview(previewChat) - } - - package var codexChat: CodexChat? { - switch source { - case .codex(let chat): - chat - case .preview, .fixture: - nil - } - } - - package var previewChat: ReviewMonitorPreviewChat? { - switch source { - case .preview(let chat): - chat - case .fixture: - nil - case .codex: - nil - } - } - - package var title: String { - switch source { - case .codex(let chat): - chat.title - case .preview(let chat): - chat.title - case .fixture(let fixture): - fixture.title - } - } - - package var preview: String? { - switch source { - case .codex(let chat): - chat.preview - case .preview(let chat): - chat.preview - case .fixture(let fixture): - fixture.preview - } - } - - package var model: String? { - switch source { - case .codex(let chat): - chat.modelProvider - case .preview(let chat): - chat.model - case .fixture(let fixture): - fixture.model - } - } - - package var workspaceCWD: String? { - switch source { - case .codex(let chat): - chat.workspace?.url.path - case .preview(let chat): - chat.workspaceCWD - case .fixture(let fixture): - fixture.workspaceCWD - } - } - - package var updatedAt: Date? { - switch source { - case .codex(let chat): - chat.updatedAt - case .preview(let chat): - chat.updatedAt - case .fixture(let fixture): - fixture.updatedAt - } - } - - package var recencyAt: Date? { - switch source { - case .codex(let chat): - chat.recencyAt - case .preview(let chat): - chat.recencyAt - case .fixture(let fixture): - fixture.recencyAt - } - } - - package var status: CodexThreadStatus? { - switch source { - case .codex(let chat): - chat.status - case .preview(let chat): - chat.status - case .fixture(let fixture): - fixture.status - } - } - - var activityDate: Date? { - recencyAt ?? updatedAt - } - - var isRunning: Bool { - status?.isActive == true - } - - var isFinished: Bool { - status.map { $0.isActive == false } ?? false + + uncategorizedChats.map { .chat($0.id) } } + return [rowID] + items.map { .chat($0.id) } } - package var sections: [Section] + package func chats(in workspaceID: CodexWorkspaceID) -> [CodexChat] { + items.filter { $0.workspace?.id == workspaceID } + } - package var rowIDs: [ReviewMonitorCodexSidebarRowID] { - sections.flatMap(\.rowIDs) + package func chat(id: CodexThreadID) -> CodexChat? { + items.first { $0.id == id } } - package var outlineItems: [ReviewMonitorCodexSidebarOutlineItem] { - sections.map(ReviewMonitorCodexSidebarOutlineItem.section) + package func hasSameIdentity(as other: Self) -> Bool { + workspaceGroupID == other.workspaceGroupID + && displayTitle == other.displayTitle + && items.elementsEqual(other.items) { $0 === $1 } } +} - package func chat(id: CodexThreadID) -> Chat? { - for section in sections { - for workspace in section.workspaces { - if let chat = workspace.chats.first(where: { $0.id == id }) { - return chat - } - } - if let chat = section.uncategorizedChats.first(where: { $0.id == id }) { - return chat - } - } - return nil +@MainActor +extension Array where Element == CodexFetchSection { + var rowIDs: [ReviewMonitorCodexSidebarRowID] { + flatMap(\.rowIDs) } - package func outlineItem(rowID: ReviewMonitorCodexSidebarRowID) -> ReviewMonitorCodexSidebarOutlineItem? { - for section in sections { - if section.rowID == rowID { - return .section(section) - } - for workspace in section.workspaces { - if workspace.rowID == rowID { - return .workspace(workspace) - } - if let chat = workspace.chats.first(where: { $0.rowID == rowID }) { - return .chat(chat) - } - } - if let chat = section.uncategorizedChats.first(where: { $0.rowID == rowID }) { - return .chat(chat) + 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) -> ReviewMonitorCodexSidebarSnapshot { + func filtered(by filter: SidebarReviewChatFilter) -> [CodexFetchSection] { guard filter.isActive else { return self } - return ReviewMonitorCodexSidebarSnapshot( - sections: sections.map { section in - let latestFinishedChatID = - filter.contains(.latestFinished) - ? Self.latestFinishedChat(in: section.allChats)?.id - : nil - var filteredSection = section - filteredSection.workspaces = section.workspaces.map { workspace in - workspace.replacingChats( - workspace.chats.filter { - Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) - } - ) - } - filteredSection.uncategorizedChats = section.uncategorizedChats.filter { + return map { section in + let latestFinishedChatID = + filter.contains(.latestFinished) + ? Self.latestFinishedChat(in: section.chats)?.id + : nil + return CodexFetchSection( + id: section.id, + title: section.title, + items: section.items.filter { Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) } - return filteredSection - } - ) + ) + } } private static func includes( - _ chat: Chat, + _ chat: CodexChat, filter: SidebarReviewChatFilter, latestFinishedChatID: CodexThreadID? ) -> Bool { - if filter.contains(.running), chat.isRunning { + if filter.contains(.running), chat.status?.isActive == true { return true } if filter.contains(.latestFinished), chat.id == latestFinishedChatID { @@ -415,11 +149,11 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { return false } - private static func latestFinishedChat(in chats: [Chat]) -> Chat? { - var latestChat: Chat? + private static func latestFinishedChat(in chats: [CodexChat]) -> CodexChat? { + var latestChat: CodexChat? var latestDate = Date.distantPast for chat in chats { - guard chat.isFinished else { + guard chat.status.map({ $0.isActive == false }) ?? false else { continue } let finishedAt = chat.activityDate ?? .distantPast @@ -432,42 +166,55 @@ package struct ReviewMonitorCodexSidebarSnapshot: Equatable { } } -private extension ReviewMonitorCodexSidebarSnapshot.Section { - var allChats: [ReviewMonitorCodexSidebarSnapshot.Chat] { - workspaces.flatMap(\.chats) + uncategorizedChats +private extension CodexChat { + var activityDate: Date? { + recencyAt ?? updatedAt } } @MainActor struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { - private var sectionIDs: [String] = [] + private var workspaceGroupIDs: [CodexWorkspaceGroupID] = [] private var chatIDsByContainer: [ReviewMonitorCodexSidebarRowID: [CodexThreadID]] = [:] - func applying(to snapshot: ReviewMonitorCodexSidebarSnapshot) -> ReviewMonitorCodexSidebarSnapshot { - ReviewMonitorCodexSidebarSnapshot( - sections: ordered(snapshot.sections, by: sectionIDs, id: \.id).map { section in - var orderedSection = section - orderedSection.workspaces = section.workspaces.map { workspace in - workspace.replacingChats( - ordered( - workspace.chats, - by: chatIDsByContainer[workspace.rowID] ?? [], - id: \.id - ) + func applying( + to sections: [CodexFetchSection] + ) -> [CodexFetchSection] { + ordered(sections, by: workspaceGroupIDs, id: \.workspaceGroupID).map { section in + if section.displaysWorkspaceNodes == false { + return CodexFetchSection( + id: section.id, + title: section.title, + items: ordered( + section.items, + by: chatIDsByContainer[section.rowID] ?? [], + id: \.id ) - } - orderedSection.uncategorizedChats = ordered( + ) + } + + var items: [CodexChat] = [] + for workspace in section.workspaces { + let container = ReviewMonitorCodexSidebarRowID.workspace(workspace.id) + items.append( + contentsOf: ordered( + section.chats(in: workspace.id), + by: chatIDsByContainer[container] ?? [], + id: \.id + )) + } + items.append( + contentsOf: ordered( section.uncategorizedChats, by: chatIDsByContainer[section.rowID] ?? [], id: \.id - ) - return orderedSection - } - ) + )) + return CodexFetchSection(id: section.id, title: section.title, items: items) + } } - mutating func reorderSection(id: String, before targetID: String?) -> Bool { - Self.reorder(id: id, before: targetID, in: §ionIDs) + mutating func reorderWorkspaceGroup(id: CodexWorkspaceGroupID, before targetID: CodexWorkspaceGroupID?) -> Bool { + Self.reorder(id: id, before: targetID, in: &workspaceGroupIDs) } mutating func reorderChat( @@ -482,15 +229,19 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { return didChange } - mutating func prune(to snapshot: ReviewMonitorCodexSidebarSnapshot) { - let activeSectionIDs = snapshot.sections.map(\.id) - sectionIDs = sectionIDs.filter { activeSectionIDs.contains($0) } + mutating func prune(to sections: [CodexFetchSection]) { + let activeWorkspaceGroupIDs = sections.map(\.workspaceGroupID) + workspaceGroupIDs = workspaceGroupIDs.filter { activeWorkspaceGroupIDs.contains($0) } var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:] - for section in snapshot.sections { - activeChatIDsByContainer[section.rowID] = Set(section.uncategorizedChats.map(\.id)) - for workspace in section.workspaces { - activeChatIDsByContainer[workspace.rowID] = Set(workspace.chats.map(\.id)) + for section in sections { + if section.displaysWorkspaceNodes { + activeChatIDsByContainer[section.rowID] = Set(section.uncategorizedChats.map(\.id)) + for workspace in section.workspaces { + activeChatIDsByContainer[.workspace(workspace.id)] = Set(section.chats(in: workspace.id).map(\.id)) + } + } else { + activeChatIDsByContainer[section.rowID] = Set(section.items.map(\.id)) } } let currentChatIDsByContainer = chatIDsByContainer @@ -555,28 +306,28 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } @MainActor -package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { - case section(ReviewMonitorCodexSidebarSnapshot.Section) - case workspace(ReviewMonitorCodexSidebarSnapshot.Workspace) - case chat(ReviewMonitorCodexSidebarSnapshot.Chat) +package enum ReviewMonitorCodexSidebarOutlineItem { + case section(CodexFetchSection) + case workspace(CodexWorkspace) + case chat(CodexChat) package var rowID: ReviewMonitorCodexSidebarRowID { switch self { case .section(let section): section.rowID case .workspace(let workspace): - workspace.rowID + .workspace(workspace.id) case .chat(let chat): - chat.rowID + .chat(chat.id) } } package var title: String { switch self { case .section(let section): - section.title + section.displayTitle case .workspace(let workspace): - workspace.title + workspace.name case .chat(let chat): chat.title } @@ -585,7 +336,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { var selectionID: ReviewMonitorSelectionID { switch self { case .section(let section): - .workspaceSection(section.id) + .workspaceGroup(section.workspaceGroupID) case .workspace(let workspace): .workspace(workspace.id) case .chat(let chat): @@ -593,21 +344,19 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { } } - package var children: [ReviewMonitorCodexSidebarOutlineItem] { - switch self { - case .section(let section): - section.workspaces.map(ReviewMonitorCodexSidebarOutlineItem.workspace) - + section.uncategorizedChats.map(ReviewMonitorCodexSidebarOutlineItem.chat) - case .workspace(let workspace): - workspace.chats.map(ReviewMonitorCodexSidebarOutlineItem.chat) - case .chat: - [] + package func hasSameIdentity(as other: Self) -> Bool { + switch (self, other) { + case (.section(let lhs), .section(let rhs)): + lhs.hasSameIdentity(as: rhs) + case (.workspace(let lhs), .workspace(let rhs)): + lhs === rhs + case (.chat(let lhs), .chat(let rhs)): + lhs === rhs + default: + false } } - package var isExpandable: Bool { - children.isEmpty == false - } } @MainActor @@ -620,10 +369,10 @@ final class ReviewMonitorCodexSidebarOutlineTree { private var nodesByRowID: [ReviewMonitorCodexSidebarRowID: ReviewMonitorCodexSidebarOutlineNode] = [:] private(set) var roots: [ReviewMonitorCodexSidebarOutlineNode] = [] - func apply(snapshot: ReviewMonitorCodexSidebarSnapshot) -> ApplyResult { + func apply(sections: [CodexFetchSection]) -> ApplyResult { let oldTopology = topology var activeRowIDs: Set = [] - roots = snapshot.outlineItems.map { node(for: $0, activeRowIDs: &activeRowIDs) } + roots = sections.map { sectionNode(for: $0, activeRowIDs: &activeRowIDs) } nodesByRowID = nodesByRowID.filter { activeRowIDs.contains($0.key) } let newTopology = topology return ApplyResult( @@ -678,6 +427,47 @@ final class ReviewMonitorCodexSidebarOutlineTree { return changes } + private func sectionNode( + for section: CodexFetchSection, + activeRowIDs: inout Set + ) -> ReviewMonitorCodexSidebarOutlineNode { + let item = ReviewMonitorCodexSidebarOutlineItem.section(section) + let node = node(for: item, activeRowIDs: &activeRowIDs) + let children: [ReviewMonitorCodexSidebarOutlineNode] + if section.displaysWorkspaceNodes { + children = + section.workspaces.map { workspace in + workspaceNode( + for: workspace, + chats: section.chats(in: workspace.id), + activeRowIDs: &activeRowIDs + ) + } + + section.uncategorizedChats.map { chat in + self.node(for: .chat(chat), activeRowIDs: &activeRowIDs) + } + } else { + children = section.items.map { chat in + self.node(for: .chat(chat), activeRowIDs: &activeRowIDs) + } + } + updateChildren(of: node, to: children) + return node + } + + private func workspaceNode( + for workspace: CodexWorkspace, + chats: [CodexChat], + activeRowIDs: inout Set + ) -> ReviewMonitorCodexSidebarOutlineNode { + let node = node(for: .workspace(workspace), activeRowIDs: &activeRowIDs) + let children = chats.map { child in + self.node(for: .chat(child), activeRowIDs: &activeRowIDs) + } + updateChildren(of: node, to: children) + return node + } + private func node( for item: ReviewMonitorCodexSidebarOutlineItem, activeRowIDs: inout Set @@ -685,16 +475,19 @@ final class ReviewMonitorCodexSidebarOutlineTree { activeRowIDs.insert(item.rowID) let node = nodesByRowID[item.rowID] ?? ReviewMonitorCodexSidebarOutlineNode(item: item) nodesByRowID[item.rowID] = node - if node.item != item { + if node.item.hasSameIdentity(as: item) == false { node.item = item } - let children = item.children.map { child in - self.node(for: child, activeRowIDs: &activeRowIDs) - } + return node + } + + private func updateChildren( + of node: ReviewMonitorCodexSidebarOutlineNode, + to children: [ReviewMonitorCodexSidebarOutlineNode] + ) { if node.children.map(\.rowID) != children.map(\.rowID) { node.children = children } - return node } } @@ -738,51 +531,12 @@ final class ReviewMonitorCodexSidebarOutlineNode { } } -@MainActor -package struct ReviewMonitorCodexSidebarWorkspace { - package var workspace: CodexWorkspace - package var chats: [CodexChat] - - package var id: CodexWorkspaceID { - workspace.id - } - - package var cwd: String { - workspace.url.path - } - - package var title: String { - workspace.name - } -} - -@MainActor -package struct ReviewMonitorCodexSidebarSection { - package var id: String - package var title: String - package var workspaces: [ReviewMonitorCodexSidebarWorkspace] - package var uncategorizedChats: [CodexChat] - - package var chats: [CodexChat] { - workspaces.flatMap(\.chats) + uncategorizedChats - } - - var selection: ReviewMonitorWorkspaceSectionSelection { - ReviewMonitorWorkspaceSectionSelection( - id: id, - title: title, - workspaceCWDs: workspaces.map(\.cwd) - ) - } -} - @MainActor package final class ReviewMonitorCodexSidebarLibrary { - package static var defaultRequest: CodexFetchRequest { - CodexFetchRequest( - filter: .init(sourceKinds: [.subAgentReview]), - sortDescriptors: [.updatedAt(.reverse)], - sectionDescriptor: .workspaceGroup + package static var defaultDescriptor: CodexFetchDescriptor { + CodexFetchDescriptor( + predicate: .init(sourceKinds: [.subAgentReview]), + sortBy: [.updatedAt(.reverse)] ) } @@ -790,9 +544,13 @@ package final class ReviewMonitorCodexSidebarLibrary { package init( modelContext: CodexModelContext, - request: CodexFetchRequest = ReviewMonitorCodexSidebarLibrary.defaultRequest + descriptor: CodexFetchDescriptor = + ReviewMonitorCodexSidebarLibrary.defaultDescriptor ) { - self.fetchedResults = modelContext.fetchedResults(for: request) + self.fetchedResults = modelContext.fetchedResults( + for: descriptor, + sectionedBy: .workspaceGroup + ) } package var phase: CodexDataPhase { @@ -807,12 +565,8 @@ package final class ReviewMonitorCodexSidebarLibrary { fetchedResults.nextCursor } - package var sections: [ReviewMonitorCodexSidebarSection] { - Self.sidebarSections(from: fetchedResults.sections) - } - - package var snapshot: ReviewMonitorCodexSidebarSnapshot { - Self.sidebarSnapshot(from: sections) + package var sections: [CodexFetchSection] { + fetchedResults.sections } package func performFetch() async throws { @@ -831,63 +585,4 @@ package final class ReviewMonitorCodexSidebarLibrary { fetchedResults.items.first { $0.id == id } } - private static func sidebarSections( - from fetchSections: [CodexFetchSection] - ) -> [ReviewMonitorCodexSidebarSection] { - fetchSections.map { fetchSection in - var workspaces: [ReviewMonitorCodexSidebarWorkspace] = [] - var workspaceIndexesByID: [CodexWorkspaceID: Int] = [:] - var uncategorizedChats: [CodexChat] = [] - - for chat in fetchSection.items { - guard let workspace = chat.workspace else { - uncategorizedChats.append(chat) - continue - } - - if let index = workspaceIndexesByID[workspace.id] { - workspaces[index].chats.append(chat) - } else { - workspaceIndexesByID[workspace.id] = workspaces.count - workspaces.append( - ReviewMonitorCodexSidebarWorkspace( - workspace: workspace, - chats: [chat] - )) - } - } - - return ReviewMonitorCodexSidebarSection( - id: fetchSection.id, - title: fetchSection.title ?? "Unknown", - workspaces: workspaces, - uncategorizedChats: uncategorizedChats - ) - } - } - - private static func sidebarSnapshot( - from sections: [ReviewMonitorCodexSidebarSection] - ) -> ReviewMonitorCodexSidebarSnapshot { - ReviewMonitorCodexSidebarSnapshot( - sections: sections.map { section in - ReviewMonitorCodexSidebarSnapshot.Section( - rowID: .section(section.id), - id: section.id, - title: section.title, - workspaces: section.workspaces.map { workspace in - ReviewMonitorCodexSidebarSnapshot.Workspace( - workspace: workspace.workspace, - chats: workspace.chats.map(Self.snapshotChat(_:)) - ) - }, - uncategorizedChats: section.uncategorizedChats.map(Self.snapshotChat(_:)) - ) - } - ) - } - - private static func snapshotChat(_ chat: CodexChat) -> ReviewMonitorCodexSidebarSnapshot.Chat { - ReviewMonitorCodexSidebarSnapshot.Chat(chat: chat) - } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 26348a53..d375eef1 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -35,7 +35,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD static func measure() -> SidebarRowHeights { SidebarRowHeights( workspace: measuredWorkspaceRowHeight(), - reviewChat: measuredReviewChatRowHeight() + reviewChat: measuredReviewMonitorChatRowHeight() ) } @@ -45,34 +45,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD cellView.configure(title: "workspace-alpha", toolTip: "/tmp/workspace-alpha") return ceil(cellView.fittingSize.height) } - - @MainActor - private static func measuredReviewChatRowHeight() -> CGFloat { - let chatID = CodexThreadID(rawValue: "row-height-measurement") - let chat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: "Uncommitted changes", - preview: "Review output preview", - model: "gpt-5.5", - workspaceCWD: "/tmp/workspace", - updatedAt: Date(timeIntervalSince1970: 0) - ) - let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: ReviewMonitorCodexSidebarOutlineNode(item: .chat(chat))) - return ceil(cellView.fittingSize.height) - } } private enum SidebarDragPayload: Codable, Equatable { - case codexSection(id: String) + case codexWorkspaceGroup(id: String) case codexChat(id: String, containerRowID: String) } private struct SidebarResolvedDrop { enum Operation { case none - case reorderCodexSection(id: String, beforeID: String?) + case reorderCodexWorkspaceGroup( + id: CodexWorkspaceGroupID, + beforeID: CodexWorkspaceGroupID? + ) case reorderCodexChat( id: CodexThreadID, container: ReviewMonitorCodexSidebarRowID, @@ -96,7 +82,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let store: CodexReviewStore private let uiState: ReviewMonitorUIState private let codexModelSource: ReviewMonitorCodexModelSource? - private let previewChatLogSource: ReviewMonitorPreviewChatLogSource? private let scrollView = NSScrollView() private let outlineView = ReviewMonitorSidebarOutlineView() private let accountsViewController: ReviewMonitorAccountsViewController @@ -108,15 +93,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarFilterObservation: PortableObservationTracking.Token? private var sidebarSelectionObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? - private var codexSidebarSnapshotObservation: PortableObservationTracking.Token? + private var codexSidebarSectionsObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? private var codexSidebarModelContext: CodexModelContext? - private var codexSidebarUnfilteredSnapshot = ReviewMonitorCodexSidebarSnapshot(sections: []) + private var codexSidebarUnfilteredSections: [CodexFetchSection] = [] private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var appliedSidebarKind: SidebarKind? private var isReconcilingSelection = false + var codexSidebarContentDidChange: (@MainActor () -> Void)? #if DEBUG private var fullReloadCountForTesting = 0 #endif @@ -124,13 +110,22 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD init( store: CodexReviewStore, uiState: ReviewMonitorUIState, - codexModelSource: ReviewMonitorCodexModelSource? = nil, - previewChatLogSource: ReviewMonitorPreviewChatLogSource? = nil + codexModelSource: ReviewMonitorCodexModelSource? = nil ) { self.store = store self.uiState = uiState - self.codexModelSource = codexModelSource - self.previewChatLogSource = previewChatLogSource + #if DEBUG + if let codexModelSource { + self.codexModelSource = codexModelSource + } else if let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { + previewRuntime.start() + self.codexModelSource = previewRuntime.modelSource + } else { + self.codexModelSource = nil + } + #else + self.codexModelSource = codexModelSource + #endif self.accountsViewController = ReviewMonitorAccountsViewController(store: store) self.unavailableView = NSHostingView(rootView: MCPServerUnavailableView(store: store)) self.rowHeights = SidebarRowHeights.measure() @@ -147,7 +142,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarFilterObservation?.cancel() sidebarSelectionObservation?.cancel() codexSidebarObservation?.cancel() - codexSidebarSnapshotObservation?.cancel() + codexSidebarSectionsObservation?.cancel() codexSidebarFetchTask?.cancel() } @@ -159,7 +154,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD super.viewDidLoad() configureHierarchy() configureOutlineView() - applyPreviewCodexSidebarSnapshotIfNeeded() + applyCodexSidebarSections([]) bindObservation() bindCodexSidebarLibrary() } @@ -273,7 +268,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard event.kind != .initial else { return } - self?.applyFilteredCodexSidebarSnapshot() + self?.applyFilteredCodexSidebarSections() } sidebarSelectionObservation = withPortableContinuousObservation { [weak self, uiState] event in @@ -309,29 +304,29 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } codexSidebarFetchTask?.cancel() codexSidebarFetchTask = nil - codexSidebarSnapshotObservation?.cancel() - codexSidebarSnapshotObservation = nil + codexSidebarSectionsObservation?.cancel() + codexSidebarSectionsObservation = nil guard let modelContext else { codexSidebarModelContext = nil codexSidebarLibrary = nil - applyPreviewCodexSidebarSnapshotIfNeeded() + applyCodexSidebarSections([]) return } let library = ReviewMonitorCodexSidebarLibrary(modelContext: modelContext) codexSidebarModelContext = modelContext codexSidebarLibrary = library - codexSidebarSnapshotObservation = withPortableContinuousObservation { [weak self, library] event in + codexSidebarSectionsObservation = withPortableContinuousObservation { [weak self, library] event in guard let self, self.codexSidebarLibrary === library else { return } - let snapshot = library.snapshot + let sections = library.sections guard event.kind != .initial else { return } - self.applyCodexSidebarSnapshot(snapshot) + self.applyCodexSidebarSections(sections) } codexSidebarFetchTask = Task { @MainActor [weak self, library] in do { @@ -342,32 +337,24 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard self?.codexSidebarLibrary === library else { return } - self?.applyCodexSidebarSnapshot(library.snapshot) + self?.applyCodexSidebarSections(library.sections) self?.codexSidebarFetchTask = nil } } - private func applyPreviewCodexSidebarSnapshotIfNeeded() { - if let previewChatLogSource { - applyCodexSidebarSnapshot(previewChatLogSource.snapshot) - } else { - applyCodexSidebarSnapshot(ReviewMonitorCodexSidebarSnapshot(sections: [])) - } + private func applyCodexSidebarSections(_ sections: [CodexFetchSection]) { + codexSidebarUnfilteredSections = sections + codexSidebarPresentationOrder.prune(to: sections) + applyFilteredCodexSidebarSections() } - private func applyCodexSidebarSnapshot(_ snapshot: ReviewMonitorCodexSidebarSnapshot) { - codexSidebarUnfilteredSnapshot = snapshot - codexSidebarPresentationOrder.prune(to: snapshot) - applyFilteredCodexSidebarSnapshot() - } - - private func applyFilteredCodexSidebarSnapshot() { - let snapshot = + private func applyFilteredCodexSidebarSections() { + let sections = codexSidebarPresentationOrder - .applying(to: codexSidebarUnfilteredSnapshot) + .applying(to: codexSidebarUnfilteredSections) .filtered(by: uiState.sidebarReviewChatFilter) let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline - let applyResult = codexSidebarOutlineTree.apply(snapshot: snapshot) + let applyResult = codexSidebarOutlineTree.apply(sections: sections) applySidebarKind(sidebarKind) guard wasUsingCodexSidebarOutline || isUsingCodexSidebarOutline else { return @@ -377,6 +364,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } else if isUsingCodexSidebarOutline { reconcileOutlineSelection() } + codexSidebarContentDidChange?() } private func applyCodexSidebarOutlineTopologyChanges( @@ -600,37 +588,35 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } switch selection { - case .workspaceSection(let selectedSection): - guard let currentSection = codexWorkspaceSectionSelection(id: selectedSection.id), - let row = row(forCodexSidebarSelectionID: .workspaceSection(selectedSection.id)) + case .workspaceGroup(let selectedWorkspaceGroupID): + guard codexWorkspaceGroupSelection(id: selectedWorkspaceGroupID) != nil, + let row = row(forCodexSidebarSelectionID: .workspaceGroup(selectedWorkspaceGroupID)) else { + guard codexSidebarContentIsAuthoritative else { + return + } uiState.selection = nil outlineView.deselectAll(nil) return } - if currentSection.selection != selectedSection { - uiState.selection = .workspaceSection(currentSection.selection) - } - guard outlineView.selectedRow != row else { return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - case .workspace(let selectedWorkspace): - guard let currentWorkspace = codexWorkspaceSelection(id: selectedWorkspace.id), - let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspace.id)) + case .workspace(let selectedWorkspaceID): + guard codexWorkspaceSelection(id: selectedWorkspaceID) != nil, + let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspaceID)) else { + guard codexSidebarContentIsAuthoritative else { + return + } uiState.selection = nil outlineView.deselectAll(nil) return } - if currentWorkspace != selectedWorkspace { - uiState.selection = .workspace(currentWorkspace) - } - guard outlineView.selectedRow != row else { return } @@ -638,6 +624,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .chat(let selectedChatID): guard currentChatSelection(id: selectedChatID) != nil else { + guard codexSidebarContentIsAuthoritative else { + return + } uiState.selection = nil outlineView.deselectAll(nil) return @@ -671,9 +660,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let node = codexSidebarNode(from: item) { switch node.item { case .section(let section): - uiState.selection = .workspaceSection(section.selection) + uiState.selection = .workspaceGroup(section.workspaceGroupID) case .workspace(let workspace): - uiState.selection = .workspace(workspace) + uiState.selection = .workspace(workspace.id) case .chat(let chat): uiState.selection = .chat(chat.id) } @@ -691,17 +680,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } - if let codexChat = chat.codexChat { - return NSHostingMenu( - rootView: ReviewMonitorChatContextMenuView(chat: codexChat) - ) - } - if let previewChat = chat.previewChat ?? previewChatLogSource?.previewChat(id: chat.id) { - return NSHostingMenu( - rootView: ReviewMonitorChatContextMenuView(previewChat: previewChat) - ) - } - return nil + return NSHostingMenu( + rootView: ReviewMonitorChatContextMenuView(chat: chat) + ) } private func codexSidebarNode(from item: Any?) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -717,7 +698,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func row(forWorkspaceCWD cwd: String) -> Int? { row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: cwd))) - ?? row(forCodexSidebarSelectionID: .workspaceSection(cwd)) + ?? row(forCodexSidebarSelectionID: .workspaceGroup(CodexWorkspaceGroupID(rawValue: cwd))) } private func row(for chatID: CodexThreadID) -> Int? { @@ -735,8 +716,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD rowID = .workspace(id) case .chat(let id): rowID = .chat(id) - case .workspaceSection(let id): - rowID = .section(id) + case .workspaceGroup(let id): + rowID = .workspaceGroup(id) } guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return nil @@ -749,23 +730,38 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let selection else { return true } + guard codexSidebarContentIsAuthoritative else { + return true + } switch selection { - case .workspaceSection(let section): - return codexWorkspaceSectionSelection(id: section.id) != nil - case .workspace(let workspace): - return codexWorkspaceSelection(id: workspace.id) != nil + case .workspaceGroup(let id): + return codexWorkspaceGroupSelection(id: id) != nil + case .workspace(let id): + return codexWorkspaceSelection(id: id) != nil case .chat(let id): return currentChatSelection(id: id) != nil } } - private func currentChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { - codexSidebarUnfilteredSnapshot.chat(id: id) + private var codexSidebarContentIsAuthoritative: Bool { + guard let codexSidebarLibrary else { + return false + } + switch codexSidebarLibrary.phase { + case .loaded, .failed: + return true + case .idle, .loading: + return false + } + } + + private func currentChatSelection(id: CodexThreadID) -> CodexChat? { + codexSidebarUnfilteredSections.chat(id: id) } private func codexWorkspaceSelection( id: CodexWorkspaceID - ) -> ReviewMonitorCodexSidebarSnapshot.Workspace? { + ) -> CodexWorkspace? { guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), case .workspace(let workspace) = node.item else { @@ -774,10 +770,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return workspace } - private func codexWorkspaceSectionSelection( - id: String - ) -> ReviewMonitorCodexSidebarSnapshot.Section? { - guard let node = codexSidebarOutlineTree.node(rowID: .section(id)), + private func codexWorkspaceGroupSelection( + id: CodexWorkspaceGroupID + ) -> CodexFetchSection? { + guard let node = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)), case .section(let section) = node.item else { return nil @@ -785,7 +781,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return section } - private func codexChatSelection(id: CodexThreadID) -> ReviewMonitorCodexSidebarSnapshot.Chat? { + private func codexChatSelection(id: CodexThreadID) -> CodexChat? { guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), case .chat(let chat) = node.item else { @@ -806,17 +802,39 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } return ( title: chat.title, - subtitle: chat.workspaceCWD ?? "" + subtitle: chat.workspace?.url.path ?? "" ) } + func codexWorkspaceGroupTitlePresentation( + id: CodexWorkspaceGroupID + ) -> (title: String, subtitle: String)? { + guard let section = codexWorkspaceGroupSelection(id: id) else { + return nil + } + let workspaceCWDs = section.workspaces.map(\.url.path) + return ( + title: section.displayTitle, + subtitle: workspaceCWDs.count == 1 ? (workspaceCWDs.first ?? "") : "\(workspaceCWDs.count) workspaces" + ) + } + + func codexWorkspaceTitlePresentation( + id: CodexWorkspaceID + ) -> (title: String, subtitle: String)? { + guard let workspace = codexWorkspaceSelection(id: id) else { + return nil + } + return (title: workspace.name, subtitle: workspace.url.path) + } + private func dragPayload(for item: Any) -> SidebarDragPayload? { guard let node = codexSidebarNode(from: item) else { return nil } switch node.item { case .section(let section): - return .codexSection(id: section.id) + return .codexWorkspaceGroup(id: section.workspaceGroupID.rawValue) case .workspace: return nil case .chat(let chat): @@ -857,9 +875,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD proposedChildIndex index: Int ) -> SidebarResolvedDrop? { switch payload { - case .codexSection(let id): - resolvedCodexSectionDrop( - id: id, + case .codexWorkspaceGroup(let id): + resolvedCodexWorkspaceGroupDrop( + id: CodexWorkspaceGroupID(rawValue: id), draggingLocation: draggingLocation, proposedItem: proposedItem, proposedChildIndex: index @@ -875,13 +893,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func resolvedCodexSectionDrop( - id: String, + private func resolvedCodexWorkspaceGroupDrop( + id: CodexWorkspaceGroupID, draggingLocation: NSPoint?, proposedItem: Any?, proposedChildIndex index: Int ) -> SidebarResolvedDrop? { - guard let sourceNode = codexSidebarOutlineTree.node(rowID: .section(id)), + guard let sourceNode = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)), let sourceIndex = codexRootIndex(for: sourceNode), let destinationIndex = codexRootInsertionIndex( draggingLocation: draggingLocation, @@ -902,17 +920,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } - let beforeID: String? + let beforeID: CodexWorkspaceGroupID? if displayDestinationIndex < remainingRoots.count, case .section(let section) = remainingRoots[displayDestinationIndex].item { - beforeID = section.id + beforeID = section.workspaceGroupID } else { beforeID = nil } return SidebarResolvedDrop( - operation: .reorderCodexSection(id: id, beforeID: beforeID), + operation: .reorderCodexWorkspaceGroup(id: id, beforeID: beforeID), dropItem: nil, dropChildIndex: clampedDestinationIndex ) @@ -1092,8 +1110,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func isCodexChatContainer(_ node: ReviewMonitorCodexSidebarOutlineNode) -> Bool { switch node.item { - case .section(let section): - return section.uncategorizedChats.isEmpty == false + case .section: + return node.children.contains { child in + if case .chat = child.item { + return true + } + return false + } case .workspace: return true case .chat: @@ -1114,12 +1137,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { - for section in codexSidebarUnfilteredSnapshot.sections { + for section in codexSidebarUnfilteredSections { if section.rowID == container { + if section.displaysWorkspaceNodes == false { + return section.items.map(\.id) + } return section.uncategorizedChats.map(\.id) } - for workspace in section.workspaces where workspace.rowID == container { - return workspace.chats.map(\.id) + for workspace in section.workspaces where ReviewMonitorCodexSidebarRowID.workspace(workspace.id) == container { + return section.chats(in: workspace.id).map(\.id) } } return [] @@ -1154,11 +1180,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch resolvedDrop.operation { case .none: return false - case .reorderCodexSection(let id, let beforeID): - guard codexSidebarPresentationOrder.reorderSection(id: id, before: beforeID) else { + case .reorderCodexWorkspaceGroup(let id, let beforeID): + guard codexSidebarPresentationOrder.reorderWorkspaceGroup(id: id, before: beforeID) else { return false } - applyFilteredCodexSidebarSnapshot() + applyFilteredCodexSidebarSections() return true case .reorderCodexChat(let id, let container, let currentOrder, let beforeID): guard @@ -1171,7 +1197,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return false } - applyFilteredCodexSidebarSnapshot() + applyFilteredCodexSidebarSections() return true } } @@ -1369,12 +1395,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return id } - var selectedWorkspaceSectionForTesting: ReviewMonitorWorkspaceSectionSelection? { - uiState.selectedWorkspaceSectionEntry + var selectedWorkspaceGroupIDForTesting: CodexWorkspaceGroupID? { + guard case .workspaceGroup(let id) = uiState.selection else { + return nil + } + return id } - var codexSidebarSnapshotForTesting: ReviewMonitorCodexSidebarSnapshot? { - codexSidebarLibrary?.snapshot + var codexSidebarSectionsForTesting: [CodexFetchSection] { + codexSidebarLibrary?.sections ?? [] } var codexSidebarRootTitlesForTesting: [String] { @@ -1403,10 +1432,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } @discardableResult - func performCodexSectionDropForTesting(id: String, toIndex index: Int) -> Bool { + func performCodexWorkspaceGroupDropForTesting(id: CodexWorkspaceGroupID, toIndex index: Int) -> Bool { guard let resolvedDrop = resolvedDrop( - for: .codexSection(id: id), + for: .codexWorkspaceGroup(id: id.rawValue), proposedItem: nil, proposedChildIndex: index ) @@ -1486,6 +1515,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } + func refreshCodexSidebarForTesting() async throws { + try await codexSidebarLibrary?.refresh() + applyCodexSidebarSections(codexSidebarLibrary?.sections ?? []) + } + var sidebarFullReloadCountForTesting: Int { fullReloadCountForTesting } @@ -1494,7 +1528,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD emptyStateViewController.view.isHidden == false } - func selectReviewChatForTesting(chat: ReviewMonitorCodexSidebarSnapshot.Chat) { + func selectReviewChatForTesting(chat: CodexChat) { guard let row = row(forCodexSidebarSelectionID: .chat(chat.id)) else { uiState.selection = .chat(chat.id) return @@ -1516,7 +1550,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func selectWorkspaceForTesting(cwd: String) { guard let row = row(forWorkspaceCWD: cwd) else { - uiState.selection = fallbackWorkspaceSectionSelection(cwd: cwd) + uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -1645,7 +1679,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func clickWorkspaceHeaderForTesting(cwd: String) { view.layoutSubtreeIfNeeded() guard let row = row(forWorkspaceCWD: cwd) else { - uiState.selection = fallbackWorkspaceSectionSelection(cwd: cwd) + uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd) return } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) @@ -1724,18 +1758,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return rowView.isFloating } - private func fallbackWorkspaceSectionSelection(cwd: String) -> ReviewMonitorSelection { - .workspaceSection( - .init( - id: cwd, - title: workspaceDisplayTitle(cwd: cwd), - workspaceCWDs: [cwd] - )) - } - - private func workspaceDisplayTitle(cwd: String) -> String { - let title = (cwd as NSString).lastPathComponent - return title.isEmpty ? cwd : title + private func fallbackWorkspaceGroupSelection(cwd: String) -> ReviewMonitorSelection { + .workspaceGroup(CodexWorkspaceGroupID(rawValue: cwd)) } func scrollSidebarToOffsetForTesting(_ yOffset: CGFloat) { @@ -2048,7 +2072,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { else { return } - self.toolTip = chat.workspaceCWD ?? chat.preview ?? chat.title + self.toolTip = chat.workspace?.url.path ?? chat.preview ?? chat.title } if let hostingView { hostingView.rootView.node = node @@ -2079,7 +2103,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } var hostedRowIDForTesting: String? { - hostingView?.rootView.row?.id + hostingView?.rootView.chat?.id.rawValue } var hostingViewIdentityForTesting: ObjectIdentifier? { @@ -2111,7 +2135,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { #if DEBUG @MainActor func makeReviewMonitorReviewChatCellViewForTesting( - chat: ReviewMonitorCodexSidebarSnapshot.Chat + chat: CodexChat ) -> NSTableCellView { let cellView = ReviewMonitorReviewChatCellView() cellView.configure( @@ -2133,7 +2157,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { @MainActor func configureReviewMonitorReviewChatCellViewForTesting( _ cellView: NSTableCellView, - chat: ReviewMonitorCodexSidebarSnapshot.Chat + chat: CodexChat ) { guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { fatalError("Expected ReviewMonitorReviewChatCellView.") @@ -2247,9 +2271,9 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private func render(_ item: ReviewMonitorCodexSidebarOutlineItem) { switch item { case .section(let section): - configure(title: section.title, toolTip: section.title, systemSymbolName: "folder") + configure(title: section.displayTitle, toolTip: section.displayTitle, systemSymbolName: "folder") case .workspace(let workspace): - configure(title: workspace.title, toolTip: workspace.cwd, systemSymbolName: "folder") + configure(title: workspace.name, toolTip: workspace.url.path, systemSymbolName: "folder") case .chat(let chat): configure( title: chat.title, diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 4f5f5609..e4153d63 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -89,9 +89,21 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { @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: ReviewMonitorCodexSidebarSnapshot.Chat + var chat: Chat var turnID: CodexTurnID var streamID: String var isRunning: Bool @@ -152,7 +164,7 @@ func makeReviewChatFixtureForTesting( let resolvedChatID = chatID ?? CodexThreadID(rawValue: id) let resolvedTurnID = turnID ?? CodexTurnID(rawValue: "\(id):preview-turn") let resolvedUpdatedAt = updatedAt ?? startedAt - let chat = ReviewMonitorCodexSidebarSnapshot.Chat( + let chat = ReviewChatFixtureForTesting.Chat( rowID: .chat(resolvedChatID), id: resolvedChatID, title: title, @@ -212,12 +224,12 @@ func installPreviewChatLogSourceForTesting( fixtures: [ReviewChatFixtureForTesting] ) { let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting) - let source = ReviewMonitorPreviewChatLogSource(fixtures: fixtures) - store.previewSupportRetainer = ReviewMonitorPreviewRuntimeSupport(chatLogSource: source) + let runtime = ReviewMonitorPreviewAppServerRuntime(fixtures: fixtures) + store.previewSupportRetainer = runtime runStorePreviewSupportRetainers.append( ReviewChatLogFixtureRetainer( - source: source, - chatIDs: Set(fixtures.map(\.chat.id)) + runtime: runtime, + chatIDs: Set(fixtures.map(\.chatID)) )) } @@ -330,14 +342,14 @@ func reviewChatRenderedLogMatches(_ actual: String, _ expected: String) -> Bool } @MainActor -func makePreviewChatLogSourceForTesting( - chat: ReviewMonitorCodexSidebarSnapshot.Chat, +func makePreviewAppServerRuntimeForTesting( + chat: ReviewChatFixtureForTesting.Chat, cwd: String, streamID: String, isRunning: Bool, initialSnapshot: CodexChatSnapshot -) -> ReviewMonitorPreviewChatLogSource { - ReviewMonitorPreviewChatLogSource( +) -> ReviewMonitorPreviewAppServerRuntime { + ReviewMonitorPreviewAppServerRuntime( fixtures: [ makePreviewChatLogFixtureForTesting( chat: chat, @@ -452,14 +464,21 @@ func makeCodexChatSnapshotForTesting( @MainActor func makePreviewChatLogFixtureForTesting( - chat: ReviewMonitorCodexSidebarSnapshot.Chat, + chat: ReviewChatFixtureForTesting.Chat, cwd: String, streamID: String, isRunning: Bool, initialSnapshot: CodexChatSnapshot ) -> ReviewMonitorPreviewChatLogFixture { ReviewMonitorPreviewChatLogFixture( - chat: chat, + 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, @@ -519,11 +538,11 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { - let source: ReviewMonitorPreviewChatLogSource + let runtime: ReviewMonitorPreviewAppServerRuntime private var chatIDs: Set - init(source: ReviewMonitorPreviewChatLogSource, chatIDs: Set) { - self.source = source + init(runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { + self.runtime = runtime self.chatIDs = chatIDs } @@ -532,8 +551,33 @@ private final class ReviewChatLogFixtureRetainer { } func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) { + let previousItemsByID = Dictionary( + uniqueKeysWithValues: runtime.snapshotForTesting(chatID: chatID)?.items.map { ($0.id, $0) } ?? [] + ) for item in items { - source.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + guard let previousItem = previousItemsByID[item.id] else { + runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + continue + } + if let outputDelta = previousItem.outputDeltaForTesting(to: item) { + runtime.appendPreviewText( + outputDelta, + to: chatID, + itemID: item.id, + kind: item.kind, + content: previousItem.content + ) + } else if let textDelta = previousItem.textDeltaForTesting(to: item) { + runtime.appendPreviewText( + textDelta, + to: chatID, + itemID: item.id, + kind: item.kind, + content: previousItem.content + ) + } else { + runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + } } } } @@ -815,6 +859,19 @@ private extension CodexChatChange { } private extension CodexChatItemSnapshot { + func outputDeltaForTesting(to item: CodexChatItemSnapshot) -> String? { + guard turnID == item.turnID, + kind == item.kind, + 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: CodexChatItemSnapshot) -> String? { guard turnID == item.turnID, @@ -830,6 +887,13 @@ private extension CodexChatItemSnapshot { return String(nextText.dropFirst(previousText.count)) } + private var commandOutputForTesting: String? { + if case .command(let command) = content { + return command.output + } + return nil + } + private func sameContentShapeForTesting(as item: CodexChatItemSnapshot) -> Bool { switch (content, item.content) { case (.message, .message), diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 52151439..e5ee89a5 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -38,49 +38,42 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await library.performFetch() let section = try #require(library.sections.first) - let snapshotSection = try #require(library.snapshot.sections.first) let appWorkspace = try #require(section.workspaces.first) - let appChat = try #require(appWorkspace.chats.first) - let snapshotAppWorkspace = try #require(snapshotSection.workspaces.first) - let snapshotAppChat = try #require(snapshotAppWorkspace.chats.first) + let appChat = try #require(section.chats(in: appWorkspace.id).first) let resolvedAppPath = app.standardizedFileURL.resolvingSymlinksInPath().path let resolvedToolsPath = tools.standardizedFileURL.resolvingSymlinksInPath().path #expect(library.sections.count == 1) - #expect(section.title == repo.lastPathComponent) - #expect(section.selection.workspaceCWDs == [resolvedAppPath, resolvedToolsPath]) - #expect(section.workspaces.map(\.title) == ["App", "Tools"]) + #expect(section.displayTitle == repo.lastPathComponent) + #expect(section.workspaces.map(\.url.path) == [resolvedAppPath, resolvedToolsPath]) + #expect(section.workspaces.map(\.name) == ["App", "Tools"]) #expect(section.chats.map(\.title) == ["App chat", "Tools chat"]) #expect(appChat === library.chat(id: CodexThreadID(rawValue: "thread-app"))) - #expect(snapshotSection.selection.workspaceCWDs == [resolvedAppPath, resolvedToolsPath]) - #expect(snapshotAppWorkspace.cwd == resolvedAppPath) - #expect(snapshotAppChat.id == CodexThreadID(rawValue: "thread-app")) - #expect(snapshotAppChat.title == "App chat") - #expect(snapshotAppChat.workspaceCWD == resolvedAppPath) - #expect(library.snapshot.chat(id: CodexThreadID(rawValue: "thread-app")) == snapshotAppChat) - let outlineSection = try #require(library.snapshot.outlineItems.first) - let outlineAppWorkspace = try #require(outlineSection.children.first) - #expect(outlineSection.rowID == snapshotSection.rowID) - #expect(outlineSection.title == section.title) - #expect(outlineSection.selectionID == .workspaceSection(section.id)) + #expect(appChat.workspace?.url.path == resolvedAppPath) + + let tree = ReviewMonitorCodexSidebarOutlineTree() + #expect(tree.apply(sections: library.sections).topologyChanged) + let outlineSection = try #require(tree.roots.first) + let outlineAppWorkspace = try #require(tree.node(rowID: .workspace(appWorkspace.id))) + let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) + + #expect(outlineSection.rowID == section.rowID) + #expect(outlineSection.title == section.displayTitle) + #expect(outlineSection.selectionID == .workspaceGroup(section.workspaceGroupID)) #expect(outlineSection.isExpandable) #expect( outlineSection.children.map(\.rowID.rawValue) == [ "workspace:\(resolvedAppPath)", "workspace:\(resolvedToolsPath)", ]) - #expect(outlineAppWorkspace.rowID == snapshotAppWorkspace.rowID) #expect(outlineAppWorkspace.title == "App") - #expect(outlineAppWorkspace.selectionID == .workspace(snapshotAppWorkspace.id)) + #expect(outlineAppWorkspace.selectionID == .workspace(appWorkspace.id)) #expect(outlineAppWorkspace.isExpandable) - let outlineAppChat = try #require(outlineAppWorkspace.children.first) - #expect(outlineAppChat.selectionID == .chat(snapshotAppChat.id)) #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) + #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( - library.snapshot.outlineItem(rowID: .chat(CodexThreadID(rawValue: "thread-app"))) == .chat(snapshotAppChat)) - #expect( - library.snapshot.rowIDs.map(\.rawValue) == [ - "section:\(section.id)", + library.sections.rowIDs.map(\.rawValue) == [ + "workspaceGroup:\(section.workspaceGroupID.rawValue)", "workspace:\(resolvedAppPath)", "chat:thread-app", "workspace:\(resolvedToolsPath)", @@ -117,27 +110,29 @@ struct ReviewMonitorCodexSidebarLibraryTests { let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) try await library.performFetch() - let snapshot = library.snapshot - let originalWorkspace = try #require(snapshot.sections.first?.workspaces.first) - let originalCodexWorkspace = try #require(originalWorkspace.codexWorkspace) + let sections = library.sections + let originalWorkspace = try #require(sections.first?.workspaces.first) - let filteredWorkspace = try #require(snapshot.filtered(by: .running).sections.first?.workspaces.first) - #expect(filteredWorkspace.codexWorkspace === originalCodexWorkspace) - #expect(filteredWorkspace.chats.map(\.id) == [runningThreadID]) + 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]) var order = ReviewMonitorCodexSidebarPresentationOrder() _ = order.reorderChat( id: idleThreadID, - in: originalWorkspace.rowID, + in: sections[0].rowID, currentOrder: [runningThreadID, idleThreadID], before: runningThreadID ) - let orderedWorkspace = try #require(order.applying(to: snapshot).sections.first?.workspaces.first) - #expect(orderedWorkspace.codexWorkspace === originalCodexWorkspace) - #expect(orderedWorkspace.chats.map(\.id) == [idleThreadID, 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 defaultRequestFetchesReviewThreadsOnly() async throws { + @Test func defaultDescriptorFetchesReviewThreadsOnly() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -151,7 +146,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(params.sourceKinds == ["subAgentReview"]) } - @Test func sidebarSnapshotIncludesUncategorizedChatsWithStableRowIDs() async throws { + @Test func sidebarIncludesUncategorizedChatsWithStableRowIDs() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -170,220 +165,108 @@ struct ReviewMonitorCodexSidebarLibraryTests { let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) try await library.performFetch() - let section = try #require(library.snapshot.sections.first) + let section = try #require(library.sections.first) let chat = try #require(section.uncategorizedChats.first) #expect(section.workspaces.isEmpty) #expect(chat.id == CodexThreadID(rawValue: "thread-uncategorized")) - #expect(chat.rowID.rawValue == "chat:thread-uncategorized") #expect(chat.title == "Floating review") #expect(chat.preview == "Uncategorized preview") - #expect(chat.workspaceCWD == nil) - let outlineSection = try #require(library.snapshot.outlineItems.first) - let outlineChat = try #require(outlineSection.children.first) - #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) - #expect(outlineChat == .chat(chat)) - #expect(outlineChat.selectionID == .chat(chat.id)) - #expect(outlineChat.isExpandable == false) - #expect(library.snapshot.outlineItem(rowID: chat.rowID) == .chat(chat)) + #expect(chat.workspace == nil) #expect( section.rowIDs.map(\.rawValue) == [ section.rowID.rawValue, "chat:thread-uncategorized", ]) + + let tree = ReviewMonitorCodexSidebarOutlineTree() + #expect(tree.apply(sections: library.sections).topologyChanged) + let outlineSection = try #require(tree.roots.first) + let outlineChat = try #require(tree.node(rowID: .chat(chat.id))) + #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) + #expect(outlineChat.selectionID == .chat(chat.id)) + #expect(outlineChat.isExpandable == false) } - @Test func sidebarOutlineTreePreservesNodeIdentityAcrossSnapshots() throws { - let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + @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") - let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect( - tree.apply( - snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Initial review", - includesChat: true - ) - ).topologyChanged) + try await runtime.transport.enqueueThreadList( + .init( + threads: [ + .init( + id: threadID, + workspace: repo, + name: "Initial review", + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + ] + )) - let root = try #require(tree.roots.first) - let workspace = try #require(tree.node(rowID: .workspace(workspaceID))) - let chat = try #require(tree.node(rowID: .chat(threadID))) + let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + let section = try #require(library.sections.first) + let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect( - tree.apply( - snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Updated review", - includesChat: true - ) - ).topologyChanged == false) - - let updatedRoot = try #require(tree.roots.first) - let updatedWorkspace = try #require(tree.node(rowID: .workspace(workspaceID))) - let updatedChat = try #require(tree.node(rowID: .chat(threadID))) - #expect(updatedRoot === root) - #expect(updatedWorkspace === workspace) - #expect(updatedChat === chat) - #expect(chat.title == "Updated review") - #expect(root.children.first === workspace) - #expect(workspace.children.first === chat) + #expect(tree.apply(sections: library.sections).topologyChanged) + let root = try #require(tree.roots.first) + let chatNode = try #require(tree.node(rowID: .chat(threadID))) - #expect( - tree.apply( - snapshot: sidebarSnapshot( - workspaceID: workspaceID, - threadID: threadID, - chatTitle: "Removed review", - includesChat: false - ) - ).topologyChanged) - - #expect(tree.node(rowID: .chat(threadID)) == nil) - #expect(workspace.children.isEmpty) + 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.model(for: threadID).refresh(includeTurns: false) + + #expect(tree.apply(sections: library.sections).topologyChanged == false) + #expect(tree.roots.first === root) + #expect(tree.node(rowID: .chat(threadID)) === chatNode) + #expect(chatNode.title == "Updated review") + #expect(root.children.map(\.rowID) == [.chat(threadID)]) + #expect(root.children.first === chatNode) } - @Test func sidebarSnapshotRunningFilterUsesThreadStatus() throws { - let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") + @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") - let snapshot = sidebarSnapshot( - workspaceID: workspaceID, - chats: [ - sidebarChat( - id: runningThreadID, - title: "Running review", - workspaceID: workspaceID, - updatedAt: Date(timeIntervalSince1970: 20), - status: .active(activeFlags: []) - ), - sidebarChat( - id: idleThreadID, - title: "Idle review", - workspaceID: workspaceID, - updatedAt: Date(timeIntervalSince1970: 30), - status: .idle - ), - ] - ) - - let filtered = snapshot.filtered(by: .running) - let workspace = try #require(filtered.sections.first?.workspaces.first) - #expect(workspace.chats.map(\.id) == [runningThreadID]) - #expect(filtered.chat(id: idleThreadID) == nil) - } - - @Test func sidebarChatRunningStateUsesThreadStatus() { - let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") - let runningChat = sidebarChat( - id: CodexThreadID(rawValue: "thread-running"), - title: "Running review", - workspaceID: workspaceID, - status: .active(activeFlags: []) - ) - let idleChat = sidebarChat( - id: CodexThreadID(rawValue: "thread-idle"), - title: "Idle review", - workspaceID: workspaceID, - status: .idle - ) - #expect(runningChat.isRunning) - #expect(idleChat.isRunning == false) - } - - @Test func sidebarSnapshotLatestFinishedFilterUsesSectionActivityDate() throws { - let workspaceID = CodexWorkspaceID(rawValue: "/tmp/App") - let olderFinishedID = CodexThreadID(rawValue: "thread-older-finished") - let newerFinishedID = CodexThreadID(rawValue: "thread-newer-finished") - let runningThreadID = CodexThreadID(rawValue: "thread-running") - let snapshot = sidebarSnapshot( - workspaceID: workspaceID, - chats: [ - sidebarChat( - id: olderFinishedID, - title: "Older finished", - workspaceID: workspaceID, - updatedAt: Date(timeIntervalSince1970: 100), - status: .idle - ), - sidebarChat( - id: newerFinishedID, - title: "Newer finished", - workspaceID: workspaceID, - updatedAt: Date(timeIntervalSince1970: 300), - status: .idle - ), - sidebarChat( - id: runningThreadID, - title: "Running review", - workspaceID: workspaceID, - updatedAt: Date(timeIntervalSince1970: 400), - status: .active(activeFlags: []) - ), - ] - ) - - let latestFinished = snapshot.filtered(by: .latestFinished) - let combined: SidebarReviewChatFilter = [.running, .latestFinished] - let combinedFiltered = snapshot.filtered(by: combined) - - #expect(latestFinished.sections.first?.workspaces.first?.chats.map(\.id) == [newerFinishedID]) - #expect( - combinedFiltered.sections.first?.workspaces.first?.chats.map(\.id) == [ - newerFinishedID, - runningThreadID, - ]) - } - - @Test func sidebarPresentationOrderReordersSectionsAndChatsLocally() throws { - let alphaWorkspaceID = CodexWorkspaceID(rawValue: "/tmp/Alpha") - let betaWorkspaceID = CodexWorkspaceID(rawValue: "/tmp/Beta") - let alphaFirstID = CodexThreadID(rawValue: "thread-alpha-first") - let alphaSecondID = CodexThreadID(rawValue: "thread-alpha-second") - let betaThreadID = CodexThreadID(rawValue: "thread-beta") - let alpha = sidebarSection( - id: "alpha", - title: "Alpha", - workspaceID: alphaWorkspaceID, - chats: [ - sidebarChat(id: alphaFirstID, title: "Alpha first", workspaceID: alphaWorkspaceID), - sidebarChat(id: alphaSecondID, title: "Alpha second", workspaceID: alphaWorkspaceID), - ] - ) - let beta = sidebarSection( - id: "beta", - title: "Beta", - workspaceID: betaWorkspaceID, - chats: [ - sidebarChat(id: betaThreadID, title: "Beta", workspaceID: betaWorkspaceID) - ] - ) - var order = ReviewMonitorCodexSidebarPresentationOrder() - - let didReorderSection = order.reorderSection(id: "beta", before: "alpha") - let didReorderChat = order.reorderChat( - id: alphaSecondID, - in: .workspace(alphaWorkspaceID), - currentOrder: [alphaFirstID, alphaSecondID], - before: alphaFirstID - ) - - #expect(didReorderSection) - #expect(didReorderChat) + 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 ordered = order.applying(to: ReviewMonitorCodexSidebarSnapshot(sections: [alpha, beta])) + let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) + try await library.performFetch() + let section = try #require(library.sections.filtered(by: .running).first) + let workspace = try #require(section.workspaces.first) - #expect(ordered.sections.map(\.id) == ["beta", "alpha"]) - #expect( - ordered.sections[1].workspaces.first?.chats.map(\.id) == [ - alphaSecondID, - alphaFirstID, - ]) - #expect(ordered.sections[0].workspaces.first?.chats.map(\.id) == [betaThreadID]) + #expect(section.chats(in: workspace.id).map(\.id) == [runningThreadID]) + #expect(section.chats(in: workspace.id).contains { $0.id == idleThreadID } == false) } @Test func sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() async throws { @@ -405,9 +288,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running - ) + store.loadForTesting(serverState: .running) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( store: store, @@ -418,9 +299,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { let sidebar = viewController.sidebarViewControllerForTesting try await waitForCondition { - sidebar.codexSidebarSnapshotForTesting? - .chat(id: threadID)? - .title == "App review" + sidebar.codexSidebarSectionsForTesting.first?.chat(id: threadID)?.title == "App review" } try await waitForCondition { sidebar.codexSidebarRootTitlesForTesting == [repo.lastPathComponent] @@ -428,10 +307,13 @@ struct ReviewMonitorCodexSidebarLibraryTests { } #expect( sidebar.displayedCodexSidebarTitlesForTesting == [ - repo.lastPathComponent, repo.lastPathComponent, "App review", ]) + let workspace = try #require(sidebar.codexSidebarSectionsForTesting.first?.workspaces.first) + #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .workspace(workspace.id)) == nil) + #expect(sidebar.workspaceRowHeightForTesting(cwd: repo.path) == sidebar.expectedWorkspaceRowRectHeightForTesting) + #expect(sidebar.reviewChatRowHeightForTesting(threadID) == sidebar.expectedReviewChatRowRectHeightForTesting) #expect(sidebar.codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(threadID)) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) @@ -472,14 +354,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { startedAt: Date(timeIntervalSince1970: 4_000), summary: "Running review." ) - let runBackedChat = ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(hiddenRunThreadID), - id: hiddenRunThreadID, - title: "Run-backed review row", - preview: "Running review.", - workspaceCWD: repo.path, - updatedAt: Date(timeIntervalSince1970: 4_000) - ) let store = CodexReviewStore.makePreviewStore() store.loadReviewCancellationStateForTesting( serverState: .running, @@ -497,9 +371,9 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(visibleThreadID)) == "App review" } - #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(runBackedChat.id)) == nil) + #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(hiddenRunThreadID)) == nil) - uiState.selection = .chat(runBackedChat.id) + uiState.selection = .chat(hiddenRunThreadID) try await waitForCondition { uiState.selection == nil && sidebar.selectedReviewChatIDForTesting == nil @@ -512,6 +386,46 @@ struct ReviewMonitorCodexSidebarLibraryTests { 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 sidebarViewControllerDoesNotRebindCodexChatCellWhenContentChanges() 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: [ @@ -525,9 +439,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) let store = CodexReviewStore.makePreviewStore() - store.loadForTesting( - serverState: .running - ) + store.loadForTesting(serverState: .running) let uiState = ReviewMonitorUIState(auth: store.auth) let viewController = ReviewMonitorSplitViewController( store: store, @@ -539,11 +451,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { viewController.loadViewIfNeeded() let sidebar = viewController.sidebarViewControllerForTesting - try await waitForCondition(timeout: .milliseconds(500)) { - sidebar.codexSidebarSnapshotForTesting? - .chat(id: threadID)? - .title == "App review" - } try await waitForCondition(timeout: .milliseconds(500)) { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review" } @@ -585,11 +492,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) try await chat.refresh(includeTurns: false) - try await waitForCondition { - sidebar.codexSidebarSnapshotForTesting? - .chat(id: threadID)? - .title == "App review renamed" - } try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review renamed" } @@ -608,13 +510,12 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate) #expect( sidebar.displayedCodexSidebarTitlesForTesting == [ - repo.lastPathComponent, repo.lastPathComponent, "App review renamed", ]) } - @Test func sidebarViewControllerReordersCodexSectionsLocally() async throws { + @Test func sidebarViewControllerReordersCodexWorkspaceGroupsLocally() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let firstRepo = try makeGitRepository() @@ -654,22 +555,22 @@ struct ReviewMonitorCodexSidebarLibraryTests { secondRepo.lastPathComponent, ] } - let snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) - let firstSection = try #require(snapshot.sections.first) - let secondSection = try #require(snapshot.sections.dropFirst().first) + let sections = sidebar.codexSidebarSectionsForTesting + let firstSection = try #require(sections.first) + let secondSection = try #require(sections.dropFirst().first) let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID)) - #expect(sidebar.performCodexSectionDropForTesting(id: secondSection.id, toIndex: 0)) + #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondSection.workspaceGroupID, toIndex: 0)) #expect( sidebar.codexSidebarRootTitlesForTesting == [ - secondSection.title, - firstSection.title, + secondSection.displayTitle, + firstSection.displayTitle, ]) #expect( - sidebar.codexSidebarSnapshotForTesting?.sections.map(\.id) == [ - firstSection.id, - secondSection.id, + sidebar.codexSidebarSectionsForTesting.map(\.workspaceGroupID) == [ + firstSection.workspaceGroupID, + secondSection.workspaceGroupID, ]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } @@ -712,19 +613,16 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(secondThreadID)) == "Second review" } - let snapshot = try #require(sidebar.codexSidebarSnapshotForTesting) - let container = try #require(snapshot.sections.first?.workspaces.first?.rowID) + let section = try #require(sidebar.codexSidebarSectionsForTesting.first) + let workspace = try #require(section.workspaces.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( - sidebar.codexSidebarSnapshotForTesting?.sections.first?.workspaces.first?.chats.map(\.id) == [ - firstThreadID, - secondThreadID, - ]) + #expect(section.chats(in: workspace.id).map(\.id) == [firstThreadID, secondThreadID]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } @@ -786,80 +684,6 @@ private struct ThreadListParams: Decodable { var sourceKinds: [String]? } -@MainActor -private func sidebarSnapshot( - workspaceID: CodexWorkspaceID, - threadID: CodexThreadID, - chatTitle: String, - includesChat: Bool -) -> ReviewMonitorCodexSidebarSnapshot { - let chat = sidebarChat( - id: threadID, - title: chatTitle, - workspaceID: workspaceID - ) - return sidebarSnapshot( - workspaceID: workspaceID, - chats: includesChat ? [chat] : [] - ) -} - -@MainActor -private func sidebarSnapshot( - workspaceID: CodexWorkspaceID, - chats: [ReviewMonitorCodexSidebarSnapshot.Chat] -) -> ReviewMonitorCodexSidebarSnapshot { - return ReviewMonitorCodexSidebarSnapshot( - sections: [ - sidebarSection(id: "repo", title: "Repo", workspaceID: workspaceID, chats: chats) - ] - ) -} - -@MainActor -private func sidebarSection( - id: String, - title: String, - workspaceID: CodexWorkspaceID, - chats: [ReviewMonitorCodexSidebarSnapshot.Chat] -) -> ReviewMonitorCodexSidebarSnapshot.Section { - ReviewMonitorCodexSidebarSnapshot.Section( - rowID: .section(id), - id: id, - title: title, - workspaces: [ - ReviewMonitorCodexSidebarSnapshot.Workspace( - rowID: .workspace(workspaceID), - id: workspaceID, - cwd: workspaceID.rawValue, - title: URL(fileURLWithPath: workspaceID.rawValue).lastPathComponent, - chats: chats - ) - ], - uncategorizedChats: [] - ) -} - -@MainActor -private func sidebarChat( - id: CodexThreadID, - title: String, - workspaceID: CodexWorkspaceID, - updatedAt: Date? = nil, - status: CodexThreadStatus? = nil -) -> ReviewMonitorCodexSidebarSnapshot.Chat { - ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(id), - id: id, - title: title, - preview: nil, - workspaceCWD: workspaceID.rawValue, - updatedAt: updatedAt, - recencyAt: updatedAt, - status: status - ) -} - 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) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 7c7ee55a..303a2b0f 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -23,8 +23,7 @@ extension ReviewUITests { @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) - let selectedChat = try #require(previewChatLogSource.initialChat) + let selectedChatID = try #require(previewSelectedChatID(in: store)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -42,16 +41,32 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) + #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 previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) - let selectedChat = try #require(previewChatLogSource.initialChat) - let selectedSnapshot = try #require(previewChatLogSource.snapshotForTesting(chatID: selectedChat.id)) - let expectedLogText = try #require(selectedSnapshot.items.compactMap(\.text).first) + let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let selectedChatID = try #require(previewSelectedChatID(in: store)) + let selectedSnapshot = try #require(previewRuntime.snapshotForTesting(chatID: selectedChatID)) + let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -64,7 +79,7 @@ extension ReviewUITests { snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) + #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue)) #expect(snapshot.log.isEmpty == false) } @@ -82,7 +97,7 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - let nextTick = try #require(viewController.appendPreviewChatLogStreamTickForTesting()) + let nextTick = try #require(await viewController.appendPreviewChatLogStreamTickForTesting()) #expect(nextTick == 1) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.count > initialSnapshot.log.count @@ -754,13 +769,16 @@ 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() } window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() window.layoutIfNeeded() + try await waitForCondition { + window.titleVisibility == .visible && window.title.isEmpty == false + } #expect(window.toolbar != nil) #expect(window.styleMask.contains(.fullSizeContentView)) @@ -775,10 +793,10 @@ extension ReviewUITests { @Test func previewContentViewControllerRendersSelectedChatLog() async throws { let store = ReviewMonitorPreviewContent.makeStore() - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) - let selectedChat = try #require(previewChatLogSource.initialChat) - let selectedSnapshot = try #require(previewChatLogSource.snapshotForTesting(chatID: selectedChat.id)) - let expectedLogText = try #require(selectedSnapshot.items.compactMap(\.text).first) + let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let selectedChatID = try #require(previewSelectedChatID(in: store)) + let selectedSnapshot = try #require(previewRuntime.snapshotForTesting(chatID: selectedChatID)) + let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewStore: store ) @@ -793,7 +811,7 @@ extension ReviewUITests { snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } - #expect(transport.renderedStateForTesting.selection == .chat(selectedChat.id.rawValue)) + #expect(transport.renderedStateForTesting.selection == .chat(selectedChatID.rawValue)) #expect(snapshot.log.isEmpty == false) } @@ -813,7 +831,7 @@ extension ReviewUITests { snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } - let nextTick = try #require(viewController.appendPreviewChatLogStreamTickForTesting()) + let nextTick = try #require(await viewController.appendPreviewChatLogStreamTickForTesting()) #expect(nextTick == 1) let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in snapshot.log.count > initialSnapshot.log.count @@ -1562,19 +1580,19 @@ extension ReviewUITests { #expect(backend.startCallCount() == 0) } - @Test func previewRunningChatsAppendPseudoStreamWhenTicked() throws { - let source = ReviewMonitorPreviewContent.makeChatLogSource( + @Test func previewRunningChatsAppendPseudoStreamWhenTicked() async throws { + let source = try #require(ReviewMonitorPreviewContent.previewRuntime( from: ReviewMonitorPreviewContent.makeStore() - ) - let runningChat = try #require(source.initialChat) - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + )) + let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count - source.appendPreviewStreamTick() + await source.appendPreviewStreamTick() - let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) - let appendedText = appendedItems.compactMap(\.text).joined(separator: "\n") + let appendedText = appendedItems.compactMap { $0.text }.joined(separator: "\n") #expect(updatedSnapshot.items != initialSnapshot.items) #expect(appendedText.contains("Turn started")) #expect(appendedText.contains("delta/") == false) @@ -1584,22 +1602,22 @@ extension ReviewUITests { #expect(appendedItems.first.map(diagnosticMessage)?.contains("preview-turn") == true) } - @Test func previewChatStreamUsesMixedLogKinds() throws { - let source = ReviewMonitorPreviewContent.makeChatLogSource( + @Test func previewChatStreamUsesMixedLogKinds() async throws { + let source = try #require(ReviewMonitorPreviewContent.previewRuntime( from: ReviewMonitorPreviewContent.makeStore() - ) - let runningChat = try #require(source.initialChat) - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + )) + let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 for _ in 0..<720 { - tick = source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewStreamTick(after: tick) } - let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) - let appendedKinds = appendedItems.map(\.kind.rawValue) + let appendedKinds = appendedItems.map { $0.kind.rawValue } #expect(appendedKinds.contains("event")) #expect(appendedKinds.contains("commandExecution")) #expect(appendedKinds.contains("mcpToolCall")) @@ -1608,46 +1626,46 @@ extension ReviewUITests { #expect(appendedKinds.contains("reasoning")) #expect(appendedKinds.contains("agentMessage")) #expect(Set(appendedKinds).count >= 6) - #expect(Set(appendedItems.map(\.id)).count == appendedItems.count) + #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(\.text).joined(separator: "\n") + 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 previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() throws { - let source = ReviewMonitorPreviewContent.makeChatLogSource( + @Test func previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() async throws { + let source = try #require(ReviewMonitorPreviewContent.previewRuntime( from: ReviewMonitorPreviewContent.makeStore() - ) - let runningChat = try #require(source.initialChat) - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + )) + let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") + let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 - tick = source.appendPreviewStreamTick(after: tick) - var snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + tick = await source.appendPreviewStreamTick(after: tick) + var snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) #expect(snapshot.items.last?.kind.rawValue == "event") for _ in 0..<38 { - tick = source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewStreamTick(after: tick) } - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) - tick = source.appendPreviewStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + tick = await source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 2) #expect(snapshot.items.last?.kind.rawValue == "plan") for _ in 0..<180 where snapshot.items.last?.kind.rawValue != "reasoning" { - tick = source.appendPreviewStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + tick = await source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) } let firstReasoning = try #require(snapshot.items.last) #expect(firstReasoning.kind.rawValue == "reasoning") @@ -1656,8 +1674,8 @@ extension ReviewUITests { var latestReasoningText = initialReasoningText for _ in 0..<80 { - tick = source.appendPreviewStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + tick = await source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let item = try #require(snapshot.items.first { $0.id == reasoningID }) let text = reasoningText(item) if text == latestReasoningText { @@ -1669,19 +1687,25 @@ extension ReviewUITests { #expect(latestReasoningText.count > initialReasoningText.count) let countAfterReasoning = snapshot.items.count for _ in 0..<37 { - tick = source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewStreamTick(after: tick) } - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning) - tick = source.appendPreviewStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChat.id)) + tick = await source.appendPreviewStreamTick(after: tick) + snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning + 1) #expect(snapshot.items.last?.kind.rawValue == "commandExecution") } } +@MainActor +private func previewSelectedChatID(in store: CodexReviewStore) -> CodexThreadID? { + _ = store + return CodexThreadID(rawValue: "preview-thread-0-0") +} + @MainActor private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { if case .diagnostic(let message) = item.content { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 391518fa..81692326 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -345,13 +345,13 @@ struct ReviewUITests { ]) } - @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() throws { - let placeholderChat = reviewChatCellTestChat( + @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() async throws { + let placeholderChat = try await reviewChatCellTestChat( id: "chat-placeholder", title: "Queued review", workspaceCWD: "/tmp/placeholder" ) - let loadedChat = reviewChatCellTestChat( + let loadedChat = try await reviewChatCellTestChat( id: "chat-loaded", title: "Uncommitted changes", workspaceCWD: "/tmp/loaded" @@ -379,11 +379,11 @@ struct ReviewUITests { return } #expect(objectChat.id == loadedChat.id) - #expect(cellView.toolTip == loadedChat.workspaceCWD) + #expect(cellView.toolTip == (loadedChat.workspace?.url.path ?? loadedChat.preview ?? loadedChat.title)) } - @Test func reviewChatCellViewSkipsSameNodeConfigureWithoutRebindingHostedContent() throws { - let chat = reviewChatCellTestChat( + @Test func reviewChatCellViewSkipsSameNodeConfigureWithoutRebindingHostedContent() async throws { + let chat = try await reviewChatCellTestChat( id: "chat-stable", title: "Stable review", workspaceCWD: "/tmp/stable" @@ -834,9 +834,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: recentChat.chat - ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) let selectedSnapshot = try await awaitChatRenderForTesting( recentChat, @@ -925,9 +923,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: chat.chat - ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let selectedSnapshot = try await awaitChatRenderForTesting( chat, @@ -1667,9 +1663,7 @@ struct ReviewUITests { let window = backend.window defer { window.close() } let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: activeChat.chat - ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) let activeSnapshot = try await awaitChatRenderForTesting( activeChat, @@ -1680,9 +1674,7 @@ struct ReviewUITests { #expect(activeSnapshot.summary == nil) #expect(window.title == activeChat.chat.title) #expect(window.subtitle == activeChat.cwd) - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: recentChat.chat - ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) let recentSnapshot = try await awaitChatRenderForTesting( recentChat, @@ -2168,9 +2160,7 @@ struct ReviewUITests { _ = try await awaitTransportRender(transport) #expect( - viewController.sidebarViewControllerForTesting.selectedWorkspaceSectionForTesting?.workspaceCWDs == [ - chat.cwd - ]) + viewController.sidebarViewControllerForTesting.selectedWorkspaceGroupIDForTesting?.rawValue == chat.cwd) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(transport.isShowingNoFindingsStateForTesting) } @@ -2283,9 +2273,7 @@ struct ReviewUITests { defer { window.close() } let contentPane = viewController.contentPaneViewControllerForTesting let transport = viewController.transportViewControllerForTesting - viewController.sidebarViewControllerForTesting.selectReviewChatForTesting( - chat: chat.chat - ) + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) let selectedSnapshot = try await awaitChatRenderForTesting( chat, @@ -2367,11 +2355,10 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth), - previewChatLogSource: previewChatLogSource + uiState: ReviewMonitorUIState(auth: store.auth) ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2385,7 +2372,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - previewChatLogSource.appendPreviewText( + previewRuntime.appendPreviewText( " log", to: chatID, itemID: "msg_1", @@ -2413,11 +2400,10 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth), - previewChatLogSource: previewChatLogSource + uiState: ReviewMonitorUIState(auth: store.auth) ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2429,7 +2415,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting - previewChatLogSource.upsertPreviewItem( + previewRuntime.upsertPreviewItem( id: "progress_1", kind: CodexThreadItem.Kind(rawValue: "progress"), content: .diagnostic("stream.tick 001"), @@ -2855,11 +2841,10 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewChatLogSource = ReviewMonitorPreviewContent.makeChatLogSource(from: store) + let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth), - previewChatLogSource: previewChatLogSource + uiState: ReviewMonitorUIState(auth: store.auth) ) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2871,7 +2856,7 @@ struct ReviewUITests { ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - previewChatLogSource.upsertPreviewItem( + previewRuntime.upsertPreviewItem( id: "fixture-log-\(chat.id)", kind: .agentMessage, content: .message(.init(id: "fixture-log-\(chat.id)", role: .assistant, text: "Initial log")), @@ -4611,11 +4596,8 @@ func makeWindowHarness( .defaultContentTransitionAnimator ) -> ReviewMonitorWindowHarness { applyTestAuthState(auth: store.auth, state: authState) - let previewChatLogSource = - (store.previewSupportRetainer as? ReviewMonitorPreviewRuntimeSupport)?.chatLogSource let windowController = ReviewMonitorWindowController( store: store, - previewChatLogSource: previewChatLogSource, contentTransitionAnimator: contentTransitionAnimator, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) @@ -5069,18 +5051,34 @@ func reviewChatCellTestChat( id: String, title: String, workspaceCWD: String -) -> ReviewMonitorCodexSidebarSnapshot.Chat { +) async throws -> CodexChat { let chatID = CodexThreadID(rawValue: id) - return ReviewMonitorCodexSidebarSnapshot.Chat( - rowID: .chat(chatID), - id: chatID, - title: title, - preview: nil, - workspaceCWD: workspaceCWD, - updatedAt: Date(timeIntervalSince1970: 200), - recencyAt: Date(timeIntervalSince1970: 200), - status: .idle + 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 chat } struct LinkedWorktreeFixtureForTesting { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 7c03aff9..b680d3da 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -298,7 +298,6 @@ struct ReviewMonitorAppComposition { if previewStore === store { return ReviewMonitorWindowController( previewStore: store, - codexModelSource: codexModelSource, showSettings: showSettings ) } diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index b402c049..405f03a2 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -278,6 +278,14 @@ struct CodexReviewMonitorCITests { ) 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 @@ -824,6 +832,19 @@ private func awaitPreviewTransportRender( 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 From f15e0787f10fd7b6cb12aaa218b9abd270bc13e6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:28:55 +0900 Subject: [PATCH 276/359] test(review-ui): remove sidebar hosting implementation assertions --- .../ReviewMonitorSidebarViewController.swift | 152 ------------------ ...eviewMonitorCodexSidebarLibraryTests.swift | 20 +-- Tests/ReviewUITests/ReviewUITests.swift | 64 ++------ 3 files changed, 12 insertions(+), 224 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index d375eef1..b2319492 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -1463,47 +1463,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return applyResolvedDrop(resolvedDrop) } - func codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(_ id: CodexThreadID) -> Bool { - guard let row = row(forCodexSidebarSelectionID: .chat(id)), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return false - } - return cellView.isHostingReviewMonitorChatRowViewForTesting - } - - func codexSidebarChatCellHostingViewIdentityForTesting( - _ id: CodexThreadID - ) -> ObjectIdentifier? { - guard let row = row(forCodexSidebarSelectionID: .chat(id)), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return nil - } - return cellView.hostingViewIdentityForTesting - } - - func codexSidebarChatCellBindingGenerationForTesting(_ id: CodexThreadID) -> Int? { - guard let row = row(forCodexSidebarSelectionID: .chat(id)), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return nil - } - return cellView.bindingGenerationValueForTesting - } - func selectCodexSidebarRowForTesting(rowID: ReviewMonitorCodexSidebarRowID) { guard let node = codexSidebarOutlineTree.node(rowID: rowID) else { return @@ -1651,19 +1610,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return cellView.contentMinXForTesting(relativeTo: outlineView) } - func reviewChatRowUsesReviewMonitorChatRowViewForTesting(_ chatID: CodexThreadID) -> Bool { - guard let row = row(for: chatID), - let cellView = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: true - ) as? ReviewMonitorReviewChatCellView - else { - return false - } - return cellView.isHostingReviewMonitorChatRowViewForTesting - } - func clickBlankAreaForTesting() { view.layoutSubtreeIfNeeded() guard outlineView.numberOfRows > 0 else { @@ -2038,9 +1984,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? private var nodeObservation: PortableObservationTracking.Token? - #if DEBUG - private var bindingGenerationForTesting = 0 - #endif override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2062,9 +2005,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } objectValue = node boundNode = node - #if DEBUG - bindingGenerationForTesting += 1 - #endif nodeObservation?.cancel() nodeObservation = withPortableContinuousObservation { [weak self, node] _ in guard let self, @@ -2098,30 +2038,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } #if DEBUG - var isHostingReviewMonitorChatRowViewForTesting: Bool { - hostingView != nil - } - - var hostedRowIDForTesting: String? { - hostingView?.rootView.chat?.id.rawValue - } - - var hostingViewIdentityForTesting: ObjectIdentifier? { - hostingView.map(ObjectIdentifier.init) - } - - var hostedNodeIdentityForTesting: ObjectIdentifier? { - hostingView.map { ObjectIdentifier($0.rootView.node) } - } - - var nodeObservationForTesting: PortableObservationTracking.Token? { - nodeObservation - } - - var bindingGenerationValueForTesting: Int { - bindingGenerationForTesting - } - func contentMinXForTesting(relativeTo view: NSView) -> CGFloat? { guard let hostingView else { return nil @@ -2145,15 +2061,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { return cellView } - @MainActor - func makeReviewMonitorReviewChatCellViewForTesting( - node: ReviewMonitorCodexSidebarOutlineNode - ) -> NSTableCellView { - let cellView = ReviewMonitorReviewChatCellView() - cellView.configure(with: node) - return cellView - } - @MainActor func configureReviewMonitorReviewChatCellViewForTesting( _ cellView: NSTableCellView, @@ -2167,65 +2074,6 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { item: .chat(chat) )) } - - @MainActor - func configureReviewMonitorReviewChatCellViewForTesting( - _ cellView: NSTableCellView, - node: ReviewMonitorCodexSidebarOutlineNode - ) { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - fatalError("Expected ReviewMonitorReviewChatCellView.") - } - cellView.configure(with: node) - } - - @MainActor - func reviewMonitorReviewChatCellHostedRowIDForTesting(_ cellView: NSTableCellView) -> String? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil - } - return cellView.hostedRowIDForTesting - } - - @MainActor - func reviewMonitorReviewChatCellHostingViewIdentityForTesting( - _ cellView: NSTableCellView - ) -> ObjectIdentifier? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil - } - return cellView.hostingViewIdentityForTesting - } - - @MainActor - func reviewMonitorReviewChatCellHostedNodeIdentityForTesting( - _ cellView: NSTableCellView - ) -> ObjectIdentifier? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil - } - return cellView.hostedNodeIdentityForTesting - } - - @MainActor - func reviewMonitorReviewChatCellNodeObservationForTesting( - _ cellView: NSTableCellView - ) -> PortableObservationTracking.Token? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil - } - return cellView.nodeObservationForTesting - } - - @MainActor - func reviewMonitorReviewChatCellBindingGenerationForTesting( - _ cellView: NSTableCellView - ) -> Int? { - guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { - return nil - } - return cellView.bindingGenerationValueForTesting - } #endif @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index e5ee89a5..74af4297 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -314,7 +314,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .workspace(workspace.id)) == nil) #expect(sidebar.workspaceRowHeightForTesting(cwd: repo.path) == sidebar.expectedWorkspaceRowRectHeightForTesting) #expect(sidebar.reviewChatRowHeightForTesting(threadID) == sidebar.expectedReviewChatRowRectHeightForTesting) - #expect(sidebar.codexSidebarChatRowUsesReviewMonitorChatRowViewForTesting(threadID)) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) guard case .chat(let selectedChatID) = uiState.selection else { @@ -420,7 +419,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { } } - @Test func sidebarViewControllerDoesNotRebindCodexChatCellWhenContentChanges() async throws { + @Test func sidebarViewControllerPreservesSelectionAndAvoidsFullReloadWhenCodexChatContentChanges() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -473,15 +472,9 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { window.title == "App review" } - let hostingViewIdentityBeforeContentUpdate = try #require( - sidebar.codexSidebarChatCellHostingViewIdentityForTesting(threadID) - ) - let bindingGenerationBeforeContentUpdate = try #require( - sidebar.codexSidebarChatCellBindingGenerationForTesting(threadID) - ) 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( @@ -499,14 +492,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { try await waitForCondition { window.title == "App review renamed" } - #expect( - sidebar.codexSidebarChatCellHostingViewIdentityForTesting(threadID) - == hostingViewIdentityBeforeContentUpdate - ) - #expect( - sidebar.codexSidebarChatCellBindingGenerationForTesting(threadID) - == bindingGenerationBeforeContentUpdate - ) + #expect(ObjectIdentifier(context.model(for: threadID)) == chatIdentityBeforeContentUpdate) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeContentUpdate) #expect( sidebar.displayedCodexSidebarTitlesForTesting == [ diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 81692326..cab08183 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -345,7 +345,7 @@ struct ReviewUITests { ]) } - @Test func reviewChatCellViewUpdatesHostedObservationReferenceWithoutReplacingHostingView() async throws { + @Test func reviewChatCellViewUpdatesNativeOwnerStateWhenConfiguredWithNewChat() async throws { let placeholderChat = try await reviewChatCellTestChat( id: "chat-placeholder", title: "Queued review", @@ -358,21 +358,17 @@ struct ReviewUITests { ) let cellView = makeReviewMonitorReviewChatCellViewForTesting(chat: placeholderChat) - let initialHostingViewIdentity = try #require( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - ) - let initialHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) + let initialObjectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) + guard case .chat(let initialObjectChat) = initialObjectNode.item else { + Issue.record("Expected cell to bind a Codex chat node.") + return + } - configureReviewMonitorReviewChatCellViewForTesting(cellView, chat: loadedChat) + #expect(initialObjectChat.id == placeholderChat.id) + #expect(cellView.toolTip == (placeholderChat.workspace?.url.path ?? placeholderChat.preview ?? placeholderChat.title)) - let updatedHostingViewIdentity = try #require( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - ) - let updatedHostedRowID = reviewMonitorReviewChatCellHostedRowIDForTesting(cellView) + configureReviewMonitorReviewChatCellViewForTesting(cellView, chat: loadedChat) - #expect(initialHostedRowID == placeholderChat.id.rawValue) - #expect(updatedHostedRowID == loadedChat.id.rawValue) - #expect(initialHostingViewIdentity == updatedHostingViewIdentity) let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) guard case .chat(let objectChat) = objectNode.item else { Issue.record("Expected cell to bind a Codex chat node.") @@ -382,48 +378,6 @@ struct ReviewUITests { #expect(cellView.toolTip == (loadedChat.workspace?.url.path ?? loadedChat.preview ?? loadedChat.title)) } - @Test func reviewChatCellViewSkipsSameNodeConfigureWithoutRebindingHostedContent() async throws { - let chat = try await reviewChatCellTestChat( - id: "chat-stable", - title: "Stable review", - workspaceCWD: "/tmp/stable" - ) - let node = ReviewMonitorCodexSidebarOutlineNode(item: .chat(chat)) - - let cellView = makeReviewMonitorReviewChatCellViewForTesting(node: node) - let initialHostingViewIdentity = try #require( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - ) - let initialHostedNodeIdentity = try #require( - reviewMonitorReviewChatCellHostedNodeIdentityForTesting(cellView) - ) - let initialObservation = try #require( - reviewMonitorReviewChatCellNodeObservationForTesting(cellView) - ) - let initialBindingGeneration = try #require( - reviewMonitorReviewChatCellBindingGenerationForTesting(cellView) - ) - - configureReviewMonitorReviewChatCellViewForTesting(cellView, node: node) - - let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) - #expect(objectNode === node) - #expect( - reviewMonitorReviewChatCellHostingViewIdentityForTesting(cellView) - == initialHostingViewIdentity - ) - #expect( - reviewMonitorReviewChatCellHostedNodeIdentityForTesting(cellView) - == initialHostedNodeIdentity - ) - #expect( - reviewMonitorReviewChatCellBindingGenerationForTesting(cellView) - == initialBindingGeneration - ) - #expect(initialObservation.isActive) - #expect(reviewMonitorReviewChatCellNodeObservationForTesting(cellView)?.isActive == true) - } - @Test func accountContextMenuPresentationRestoresResponderStateAfterClosing() throws { let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro") let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus") From 6551f7b40e7696ccd34b493698e7f0de29913f66 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:40:13 +0900 Subject: [PATCH 277/359] test(review-ui): align sidebar row height assertion with flattened sections --- Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 74af4297..8bd1176c 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -208,7 +208,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) try await library.performFetch() - let section = try #require(library.sections.first) let tree = ReviewMonitorCodexSidebarOutlineTree() #expect(tree.apply(sections: library.sections).topologyChanged) @@ -312,7 +311,6 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) let workspace = try #require(sidebar.codexSidebarSectionsForTesting.first?.workspaces.first) #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .workspace(workspace.id)) == nil) - #expect(sidebar.workspaceRowHeightForTesting(cwd: repo.path) == sidebar.expectedWorkspaceRowRectHeightForTesting) #expect(sidebar.reviewChatRowHeightForTesting(threadID) == sidebar.expectedReviewChatRowRectHeightForTesting) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) From 44c3b6de888e81c4fecc909635c2ae94d254c3ec Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:06:26 +0900 Subject: [PATCH 278/359] fix(review-ui): route preview chat cancellation through review store --- .../Store/CodexReviewStoreOrderQueries.swift | 10 +++ .../ReviewMonitorPreviewContent.swift | 66 ++++++++++++++++++- .../ReviewMonitorChatContextMenuView.swift | 21 +++++- .../ReviewMonitorSidebarViewController.swift | 17 ++++- Tests/ReviewUITests/ReviewUIShellTests.swift | 48 ++++++++++++++ Tests/ReviewUITests/ReviewUITests.swift | 6 +- 6 files changed, 160 insertions(+), 8 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index f324671f..51009fab 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -20,6 +20,16 @@ extension CodexReviewStore { cancellableReviewRun(forChatID: chatID) != nil } + package func hasReviewRun(forChatID chatID: String) -> Bool { + reviewRun(forChatID: chatID) != nil + } + + 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 { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 66927ade..322f34f0 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -129,6 +129,7 @@ public enum ReviewMonitorPreviewContent { private struct PreviewSidebarContent { var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] + var reviewRuns: [ReviewRunRecord] } @_spi(PreviewSupport) @@ -142,7 +143,8 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp") + serverURL: URL(string: "http://localhost:9417/mcp"), + reviewRuns: previewContent.reviewRuns ) let previewRuntime = ReviewMonitorPreviewAppServerRuntime( fixtures: previewContent.chatLogFixtures @@ -188,7 +190,13 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp") + serverURL: URL(string: "http://localhost:9417/mcp"), + reviewRuns: [ + makeReviewRunRecord( + for: chatFixture, + runIndex: 0 + ) + ] ) let fixture = makeChatLogFixture( for: chatFixture @@ -585,6 +593,7 @@ public enum ReviewMonitorPreviewContent { ] var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] = [] + var reviewRuns: [ReviewRunRecord] = [] for (workspaceIndex, cwd) in workspacePaths.enumerated() { let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() { @@ -609,10 +618,44 @@ public enum ReviewMonitorPreviewContent { makeChatLogFixture( for: chatFixture )) + reviewRuns.append( + makeReviewRunRecord( + for: chatFixture, + runIndex: workspaceIndex * 100 + chatIndex + )) } } return PreviewSidebarContent( - chatLogFixtures: chatLogFixtures + chatLogFixtures: chatLogFixtures, + reviewRuns: reviewRuns + ) + } + + private static func makeReviewRunRecord( + for chatFixture: PreviewChatFixture, + runIndex: Int + ) -> ReviewRunRecord { + ReviewRunRecord( + id: "preview-run-\(runIndex)", + sessionID: "preview-session", + cwd: chatFixture.cwd, + targetSummary: chatFixture.targetSummary, + core: .init( + run: .init( + reviewThreadID: chatFixture.chatID.rawValue, + threadID: chatFixture.chatID.rawValue, + turnID: chatFixture.turnID.rawValue, + model: chatFixture.model + ), + lifecycle: .init( + status: ReviewRunState(chatFixture.lifecycle), + startedAt: chatFixture.startedAt, + endedAt: chatFixture.endedAt, + cancellation: chatFixture.lifecycle == .cancelled ? .system() : nil, + errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil + ), + lifecycleMessage: chatFixture.summary + ) ) } @@ -1038,6 +1081,23 @@ private extension CodexTurnStatus { } } +private extension ReviewRunState { + init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { + switch lifecycle { + case .queued: + self = .queued + case .running: + self = .running + case .succeeded: + self = .succeeded + case .failed: + self = .failed + case .cancelled: + self = .cancelled + } + } +} + private extension CodexDataPhase { init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) { switch lifecycle { diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 0bd51981..e9be6b78 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -1,12 +1,15 @@ import CodexKit +import CodexReviewKit import SwiftUI @MainActor struct ReviewMonitorChatContextMenuView: View { private var chat: CodexChat + private var store: CodexReviewStore - init(chat: CodexChat) { + init(chat: CodexChat, store: CodexReviewStore) { self.chat = chat + self.store = store } var body: some View { @@ -17,12 +20,24 @@ struct ReviewMonitorChatContextMenuView: View { } private var isRunning: Bool { - chat.status?.isActive == true + let chatID = chat.id.rawValue + if store.hasCancellableReview(forChatID: chatID) { + return true + } + if store.hasReviewRun(forChatID: chatID) { + return false + } + return chat.status?.isActive == true } private func cancel() { + let chatID = chat.id.rawValue Task { - try? await chat.cancel() + if store.hasCancellableReview(forChatID: chatID) { + _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface()) + } else if store.hasReviewRun(forChatID: chatID) == false { + _ = try? await chat.cancel() + } } } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index b2319492..d520b88c 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -681,7 +681,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } return NSHostingMenu( - rootView: ReviewMonitorChatContextMenuView(chat: chat) + rootView: ReviewMonitorChatContextMenuView(chat: chat, store: store) ) } @@ -1507,6 +1507,21 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) } + 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 selectWorkspaceForTesting(cwd: String) { guard let row = row(forWorkspaceCWD: cwd) else { uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 303a2b0f..5db059d6 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -61,6 +61,54 @@ extension ReviewUITests { #expect(sidebar.sidebarKindForTesting == .chatList) } + @Test func previewChatContextMenuCancelCancelsMatchingReviewRun() async throws { + let store = ReviewMonitorPreviewContent.makeStore() + let selectedChatID = try #require(previewSelectedChatID(in: store)) + let run = try #require(store.cancellableReviewRun(forChatID: selectedChatID.rawValue)) + let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( + previewStore: store + ) + 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.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar") + } + + 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 waitForCondition { + store.reviewRun(id: run.id)?.core.lifecycle.status == .cancelled + } + #expect(store.reviewRun(id: run.id)?.core.lifecycle.cancellation?.source == .userInterface) + #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) + + var cancelItemDisabledAfterCancellation = false + sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in + guard let cancelItem = menu.items.first(where: { $0.title == "Cancel" }) else { + return + } + cancelItemDisabledAfterCancellation = cancelItem.isEnabled == false + } + #expect(cancelItemDisabledAfterCancellation) + } + @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { let store = ReviewMonitorPreviewContent.makeStore() let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index cab08183..81e2d051 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -973,7 +973,11 @@ struct ReviewUITests { let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "cmd-direct") #expect(transport.clickLogCommandOutputPanelHeaderForTesting(blockID: panelBlockID)) - await awaitNativeLayoutTurn() + try await waitForCondition { + transport.logRenderIsIdleForTesting + && transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)? + .contains("Tests passed") == true + } #expect( transport.logCommandOutputPanelTerminalTextForTesting(blockID: panelBlockID)?.contains("$ swift test") == true) From 71007e23d272c0c04ed9a22475032991b20a063f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:20:12 +0900 Subject: [PATCH 279/359] refactor(review-ui): bind sidebar rows to codex models --- .../CodexChats/ReviewMonitorChatRowView.swift | 13 +-- .../ReviewMonitorCodexSidebarLibrary.swift | 53 +++++++-- .../ReviewMonitorSidebarViewController.swift | 109 ++++++++++-------- 3 files changed, 102 insertions(+), 73 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index 9b74d2b7..f9969408 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -74,19 +74,10 @@ private struct ReviewMonitorChatRowLayout: View { @MainActor struct ReviewMonitorChatRowView: View { - var node: ReviewMonitorCodexSidebarOutlineNode - - var chat: CodexChat? { - guard case .chat(let chat) = node.item else { - return nil - } - return chat - } + var chat: CodexChat var body: some View { - if let chat { - ReviewMonitorChatRowContent(chat: chat) - } + ReviewMonitorChatRowContent(chat: chat) } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index 28a5b0f6..ec4d93fe 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -39,13 +39,18 @@ extension CodexFetchSection where Model == CodexChat { } package var displayTitle: String { - title ?? "Unknown" + workspaceGroup?.name ?? title ?? "Unknown" } package var rowID: ReviewMonitorCodexSidebarRowID { .workspaceGroup(workspaceGroupID) } + package var workspaceGroup: CodexWorkspaceGroup? { + items.lazy.compactMap { $0.workspace?.workspaceGroup }.first { $0.id == workspaceGroupID } + ?? items.lazy.compactMap { $0.workspace?.workspaceGroup }.first + } + package var workspaces: [CodexWorkspace] { var workspaces: [CodexWorkspace] = [] var workspaceIDs = Set() @@ -95,6 +100,7 @@ extension CodexFetchSection where Model == CodexChat { package func hasSameIdentity(as other: Self) -> Bool { workspaceGroupID == other.workspaceGroupID + && workspaceGroup === other.workspaceGroup && displayTitle == other.displayTitle && items.elementsEqual(other.items) { $0 === $1 } } @@ -307,14 +313,17 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { @MainActor package enum ReviewMonitorCodexSidebarOutlineItem { - case section(CodexFetchSection) + case workspaceGroup(CodexWorkspaceGroup) + case fallbackWorkspaceGroup(id: CodexWorkspaceGroupID, title: String) case workspace(CodexWorkspace) case chat(CodexChat) package var rowID: ReviewMonitorCodexSidebarRowID { switch self { - case .section(let section): - section.rowID + case .workspaceGroup(let workspaceGroup): + .workspaceGroup(workspaceGroup.id) + case .fallbackWorkspaceGroup(let id, _): + .workspaceGroup(id) case .workspace(let workspace): .workspace(workspace.id) case .chat(let chat): @@ -324,8 +333,10 @@ package enum ReviewMonitorCodexSidebarOutlineItem { package var title: String { switch self { - case .section(let section): - section.displayTitle + case .workspaceGroup(let workspaceGroup): + workspaceGroup.name + case .fallbackWorkspaceGroup(_, let title): + title case .workspace(let workspace): workspace.name case .chat(let chat): @@ -335,8 +346,10 @@ package enum ReviewMonitorCodexSidebarOutlineItem { var selectionID: ReviewMonitorSelectionID { switch self { - case .section(let section): - .workspaceGroup(section.workspaceGroupID) + case .workspaceGroup(let workspaceGroup): + .workspaceGroup(workspaceGroup.id) + case .fallbackWorkspaceGroup(let id, _): + .workspaceGroup(id) case .workspace(let workspace): .workspace(workspace.id) case .chat(let chat): @@ -346,8 +359,10 @@ package enum ReviewMonitorCodexSidebarOutlineItem { package func hasSameIdentity(as other: Self) -> Bool { switch (self, other) { - case (.section(let lhs), .section(let rhs)): - lhs.hasSameIdentity(as: rhs) + case (.workspaceGroup(let lhs), .workspaceGroup(let rhs)): + lhs === rhs + case (.fallbackWorkspaceGroup(let lhsID, let lhsTitle), .fallbackWorkspaceGroup(let rhsID, let rhsTitle)): + lhsID == rhsID && lhsTitle == rhsTitle case (.workspace(let lhs), .workspace(let rhs)): lhs === rhs case (.chat(let lhs), .chat(let rhs)): @@ -431,7 +446,12 @@ final class ReviewMonitorCodexSidebarOutlineTree { for section: CodexFetchSection, activeRowIDs: inout Set ) -> ReviewMonitorCodexSidebarOutlineNode { - let item = ReviewMonitorCodexSidebarOutlineItem.section(section) + let item: ReviewMonitorCodexSidebarOutlineItem = + if let workspaceGroup = section.workspaceGroup { + .workspaceGroup(workspaceGroup) + } else { + .fallbackWorkspaceGroup(id: section.workspaceGroupID, title: section.displayTitle) + } let node = node(for: item, activeRowIDs: &activeRowIDs) let children: [ReviewMonitorCodexSidebarOutlineNode] if section.displaysWorkspaceNodes { @@ -526,6 +546,17 @@ final class ReviewMonitorCodexSidebarOutlineNode { item.selectionID } + var workspaceGroupID: CodexWorkspaceGroupID? { + switch item { + case .workspaceGroup(let workspaceGroup): + workspaceGroup.id + case .fallbackWorkspaceGroup(let id, _): + id + case .workspace, .chat: + nil + } + } + var isExpandable: Bool { children.isEmpty == false } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index d520b88c..24187607 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -114,18 +114,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) { self.store = store self.uiState = uiState - #if DEBUG - if let codexModelSource { - self.codexModelSource = codexModelSource - } else if let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { - previewRuntime.start() - self.codexModelSource = previewRuntime.modelSource - } else { - self.codexModelSource = nil - } - #else - self.codexModelSource = codexModelSource - #endif + self.codexModelSource = codexModelSource self.accountsViewController = ReviewMonitorAccountsViewController(store: store) self.unavailableView = NSHostingView(rootView: MCPServerUnavailableView(store: store)) self.rowHeights = SidebarRowHeights.measure() @@ -659,8 +648,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let item = outlineView.item(atRow: outlineView.selectedRow) if let node = codexSidebarNode(from: item) { switch node.item { - case .section(let section): - uiState.selection = .workspaceGroup(section.workspaceGroupID) + case .workspaceGroup(let workspaceGroup): + uiState.selection = .workspaceGroup(workspaceGroup.id) + case .fallbackWorkspaceGroup(let id, _): + uiState.selection = .workspaceGroup(id) case .workspace(let workspace): uiState.selection = .workspace(workspace.id) case .chat(let chat): @@ -772,13 +763,22 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func codexWorkspaceGroupSelection( id: CodexWorkspaceGroupID - ) -> CodexFetchSection? { - guard let node = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)), - case .section(let section) = node.item - else { + ) -> ReviewMonitorCodexSidebarOutlineNode? { + guard let node = codexSidebarOutlineTree.node(rowID: .workspaceGroup(id)) else { return nil } - return section + switch node.item { + case .workspaceGroup, .fallbackWorkspaceGroup: + return node + case .workspace, .chat: + return nil + } + } + + private func codexWorkspaceGroupSection( + id: CodexWorkspaceGroupID + ) -> CodexFetchSection? { + codexSidebarUnfilteredSections.first { $0.workspaceGroupID == id } } private func codexChatSelection(id: CodexThreadID) -> CodexChat? { @@ -809,12 +809,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func codexWorkspaceGroupTitlePresentation( id: CodexWorkspaceGroupID ) -> (title: String, subtitle: String)? { - guard let section = codexWorkspaceGroupSelection(id: id) else { + guard let node = codexWorkspaceGroupSelection(id: id) else { return nil } - let workspaceCWDs = section.workspaces.map(\.url.path) + let title = node.title + let workspaceCWDs = codexWorkspaceGroupSection(id: id)?.workspaces.map(\.url.path) ?? [] return ( - title: section.displayTitle, + title: title, subtitle: workspaceCWDs.count == 1 ? (workspaceCWDs.first ?? "") : "\(workspaceCWDs.count) workspaces" ) } @@ -833,8 +834,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } switch node.item { - case .section(let section): - return .codexWorkspaceGroup(id: section.workspaceGroupID.rawValue) + case .workspaceGroup(let workspaceGroup): + return .codexWorkspaceGroup(id: workspaceGroup.id.rawValue) + case .fallbackWorkspaceGroup(let id, _): + return .codexWorkspaceGroup(id: id.rawValue) case .workspace: return nil case .chat(let chat): @@ -922,9 +925,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } let beforeID: CodexWorkspaceGroupID? if displayDestinationIndex < remainingRoots.count, - case .section(let section) = remainingRoots[displayDestinationIndex].item + let workspaceGroupID = remainingRoots[displayDestinationIndex].workspaceGroupID { - beforeID = section.workspaceGroupID + beforeID = workspaceGroupID } else { beforeID = nil } @@ -1110,7 +1113,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func isCodexChatContainer(_ node: ReviewMonitorCodexSidebarOutlineNode) -> Bool { switch node.item { - case .section: + case .workspaceGroup, .fallbackWorkspaceGroup: return node.children.contains { child in if case .chat = child.item { return true @@ -1997,8 +2000,8 @@ private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { @MainActor private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? - private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? - private var nodeObservation: PortableObservationTracking.Token? + private weak var boundChat: CodexChat? + private var chatObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2006,7 +2009,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } isolated deinit { - nodeObservation?.cancel() + chatObservation?.cancel() } @available(*, unavailable) @@ -2015,25 +2018,24 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - guard boundNode !== node else { + guard case .chat(let chat) = node.item else { + return + } + guard boundChat !== chat else { return } objectValue = node - boundNode = node - nodeObservation?.cancel() - nodeObservation = withPortableContinuousObservation { [weak self, node] _ in - guard let self, - case .chat(let chat) = node.item - else { - return - } + boundChat = chat + chatObservation?.cancel() + chatObservation = withPortableContinuousObservation { [weak self, chat] _ in + guard let self else { return } self.toolTip = chat.workspace?.url.path ?? chat.preview ?? chat.title } if let hostingView { - hostingView.rootView.node = node + hostingView.rootView.chat = chat } else { let hostingView = NSHostingView( - rootView: ReviewMonitorChatRowView(node: node) + rootView: ReviewMonitorChatRowView(chat: chat) ) hostingView.translatesAutoresizingMaskIntoConstraints = false hostingView.setAccessibilityIdentifier("review-monitor.review-chat-row") @@ -2096,8 +2098,8 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private let iconView = NSImageView() private let titleLabel = NSTextField(labelWithString: "") private let contentStack = NSStackView() - private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? - private var nodeObservation: PortableObservationTracking.Token? + private var boundItem: ReviewMonitorCodexSidebarOutlineItem? + private var itemObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2105,7 +2107,7 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } isolated deinit { - nodeObservation?.cancel() + itemObservation?.cancel() } @available(*, unavailable) @@ -2114,14 +2116,17 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - guard boundNode !== node else { + let item = node.item + if let boundItem, + boundItem.hasSameIdentity(as: item) + { return } objectValue = node - boundNode = node - nodeObservation?.cancel() - nodeObservation = withPortableContinuousObservation { [weak self, node] _ in - self?.render(node.item) + boundItem = item + itemObservation?.cancel() + itemObservation = withPortableContinuousObservation { [weak self, item] _ in + self?.render(item) } } @@ -2133,8 +2138,10 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private func render(_ item: ReviewMonitorCodexSidebarOutlineItem) { switch item { - case .section(let section): - configure(title: section.displayTitle, toolTip: section.displayTitle, systemSymbolName: "folder") + case .workspaceGroup(let workspaceGroup): + configure(title: workspaceGroup.name, toolTip: workspaceGroup.name, systemSymbolName: "folder") + case .fallbackWorkspaceGroup(_, let title): + configure(title: title, toolTip: title, systemSymbolName: "folder") case .workspace(let workspace): configure(title: workspace.name, toolTip: workspace.url.path, systemSymbolName: "folder") case .chat(let chat): From 827ce53238be120fdb8459acdee701f86c09eb54 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:27:07 +0900 Subject: [PATCH 280/359] fix(review-ui): rebind sidebar cells on item replacement --- .../ReviewMonitorSidebarViewController.swift | 83 ++++++++++--------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 24187607..55c08c0f 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -2000,8 +2000,8 @@ private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { @MainActor private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? - private weak var boundChat: CodexChat? - private var chatObservation: PortableObservationTracking.Token? + private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? + private var nodeObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2009,7 +2009,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } isolated deinit { - chatObservation?.cancel() + nodeObservation?.cancel() } @available(*, unavailable) @@ -2018,35 +2018,17 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - guard case .chat(let chat) = node.item else { - return - } - guard boundChat !== chat else { + guard boundNode !== node else { return } objectValue = node - boundChat = chat - chatObservation?.cancel() - chatObservation = withPortableContinuousObservation { [weak self, chat] _ in - guard let self else { return } - self.toolTip = chat.workspace?.url.path ?? chat.preview ?? chat.title - } - if let hostingView { - hostingView.rootView.chat = chat - } else { - let hostingView = NSHostingView( - rootView: ReviewMonitorChatRowView(chat: chat) - ) - 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 + boundNode = node + nodeObservation?.cancel() + nodeObservation = withPortableContinuousObservation { [weak self, node] _ in + guard case .chat(let chat) = node.item else { + return + } + self?.render(chat) } } @@ -2054,6 +2036,30 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { 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.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 { @@ -2098,8 +2104,8 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private let iconView = NSImageView() private let titleLabel = NSTextField(labelWithString: "") private let contentStack = NSStackView() - private var boundItem: ReviewMonitorCodexSidebarOutlineItem? - private var itemObservation: PortableObservationTracking.Token? + private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? + private var nodeObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2107,7 +2113,7 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } isolated deinit { - itemObservation?.cancel() + nodeObservation?.cancel() } @available(*, unavailable) @@ -2116,17 +2122,14 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - let item = node.item - if let boundItem, - boundItem.hasSameIdentity(as: item) - { + guard boundNode !== node else { return } objectValue = node - boundItem = item - itemObservation?.cancel() - itemObservation = withPortableContinuousObservation { [weak self, item] _ in - self?.render(item) + boundNode = node + nodeObservation?.cancel() + nodeObservation = withPortableContinuousObservation { [weak self, node] _ in + self?.render(node.item) } } From 17ff9ee6c69394be2ff8cb1c886d95a5297fccfc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:59:54 +0900 Subject: [PATCH 281/359] fix(review-ui): use codex chat key-path sort --- .../Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index ec4d93fe..3d47abf2 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -567,7 +567,8 @@ package final class ReviewMonitorCodexSidebarLibrary { package static var defaultDescriptor: CodexFetchDescriptor { CodexFetchDescriptor( predicate: .init(sourceKinds: [.subAgentReview]), - sortBy: [.updatedAt(.reverse)] + sort: \.updatedAt, + order: .reverse ) } From bfe736a2d9ecd778b3e347336a021201be8c0e3b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:28:44 +0900 Subject: [PATCH 282/359] refactor(reviewui): remove unused workspace grouping wrappers --- .../ReviewMonitorWorkspaceGrouping.swift | 140 ------------------ 1 file changed, 140 deletions(-) delete mode 100644 Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift diff --git a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift b/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift deleted file mode 100644 index c3e0f0b6..00000000 --- a/Sources/ReviewUI/ReviewMonitorWorkspaceGrouping.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -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() - } -} From 708392c3b4bb0b92c10b54fd0e0fb3068e6cdba9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:36:05 +0900 Subject: [PATCH 283/359] refactor(review-ui): inject preview app server source --- .../Store/CodexReviewStore.swift | 1 - .../ReviewMonitorContentPreview.swift | 6 +- .../ReviewUI/ReviewMonitorContentView.swift | 65 +++++++++++----- .../ReviewMonitorPreviewContent.swift | 78 +++++++++++++++---- .../ReviewMonitorSplitViewController.swift | 13 +--- .../ReviewMonitorWindowController.swift | 45 ++++++++--- .../ReviewMonitorChatLogTesting.swift | 23 ++++-- Tests/ReviewUITests/ReviewUIShellTests.swift | 64 +++++++-------- Tests/ReviewUITests/ReviewUITests.swift | 18 +++-- .../CodexReviewMonitorApp.swift | 15 ++-- 10 files changed, 213 insertions(+), 115 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 940fd6a5..b2c5d158 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -18,7 +18,6 @@ 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 let runtimeState = CodexReviewStoreRuntimeState() diff --git a/Sources/ReviewUI/ReviewMonitorContentPreview.swift b/Sources/ReviewUI/ReviewMonitorContentPreview.swift index 8641f88f..c36a95b6 100644 --- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift +++ b/Sources/ReviewUI/ReviewMonitorContentPreview.swift @@ -34,7 +34,7 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable { authPhase: authPhase, account: account, serverState: serverState, - previewStore: previewStore() + previewContent: previewContent() ) viewController.prepareForSwiftUIPreviewRendering() return viewController @@ -62,7 +62,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 } @@ -70,7 +70,7 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable { case .normal: return nil case .commandOutput: - return ReviewMonitorPreviewContent.makeCommandOutputStore() + return ReviewMonitorPreviewContent.makeCommandOutputContentSource() } } } diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index f9e62e8d..9ab01ba4 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -17,6 +17,8 @@ final class ReviewMonitorRootViewController: NSViewController { private let codexModelSource: ReviewMonitorCodexModelSource? private let contentTransitionAnimator: ReviewMonitorContentTransitionAnimator private let showSettings: (@MainActor () -> Void)? + private let dependencyRetainer: AnyObject? + private let appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? private var observation: PortableObservationTracking.Token? private var windowCancellable: AnyCancellable? private var presentedContentKind: ReviewMonitorContentKind? @@ -42,7 +44,9 @@ final class ReviewMonitorRootViewController: NSViewController { uiState: uiState, codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), contentTransitionAnimator: contentTransitionAnimator, - showSettings: showSettings + showSettings: showSettings, + dependencyRetainer: nil, + appendPreviewChatLogStreamTickHandler: nil ) } @@ -51,13 +55,17 @@ final class ReviewMonitorRootViewController: NSViewController { uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, - showSettings: (@MainActor () -> Void)? = nil + showSettings: (@MainActor () -> Void)? = nil, + dependencyRetainer: AnyObject? = nil, + appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil ) { self.store = store self.uiState = uiState self.codexModelSource = codexModelSource self.contentTransitionAnimator = contentTransitionAnimator self.showSettings = showSettings + self.dependencyRetainer = dependencyRetainer + self.appendPreviewChatLogStreamTickHandler = appendPreviewChatLogStreamTickHandler super.init(nibName: nil, bundle: nil) } @@ -254,10 +262,7 @@ extension ReviewMonitorRootViewController { @discardableResult func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? { - guard let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) else { - return nil - } - return await previewRuntime.appendPreviewStreamTick(after: tick) + await appendPreviewChatLogStreamTickHandler?(tick) } var splitViewControllerForTesting: ReviewMonitorSplitViewController { @@ -299,14 +304,27 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( authPhase: CodexReviewAuthModel.Phase = .signedOut, account: CodexReviewAccount? = nil, serverState: CodexReviewServerState = .running, + previewContent: ReviewMonitorPreviewContentSource? = nil, previewStore: CodexReviewStore? = nil ) -> ReviewMonitorRootViewController { let store: CodexReviewStore - let ownsPreviewStore = previewStore == nil + let resolvedPreviewContent: ReviewMonitorPreviewContentSource? + let ownsPreviewContent = previewContent == nil && previewStore == nil switch serverState { case .running: - store = previewStore ?? ReviewMonitorPreviewContent.makeStore() + if let previewContent { + resolvedPreviewContent = previewContent + store = previewContent.store + } else if let previewStore { + resolvedPreviewContent = nil + store = previewStore + } 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 @@ -317,24 +335,31 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:))) store.auth.selectPersistedAccount(resolvedAccount?.id) let uiState = ReviewMonitorUIState(auth: store.auth) - if uiState.selection == nil, - let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { - uiState.selection = previewRuntime.initialSelection + if uiState.selection == nil { + uiState.selection = resolvedPreviewContent?.initialSelection } - if ownsPreviewStore, - ReviewMonitorPreviewContent.previewRuntime(from: store) != nil { - ReviewMonitorPreviewContent.retainPreviewRuntimeStreamer( - in: store, - interval: .milliseconds(40) - ) + if let resolvedPreviewContent { + if ownsPreviewContent { + resolvedPreviewContent.startStreaming(interval: .milliseconds(40)) + } else { + resolvedPreviewContent.start() + } + } + let appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? + if let previewContent = resolvedPreviewContent { + appendPreviewChatLogStreamTickHandler = { tick in + let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) + return nextTick + } } else { - ReviewMonitorPreviewContent.previewRuntime(from: store)?.start() + appendPreviewChatLogStreamTickHandler = nil } - let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) return ReviewMonitorRootViewController( store: store, uiState: uiState, - codexModelSource: previewRuntime?.modelSource + codexModelSource: resolvedPreviewContent?.codexModelSource, + dependencyRetainer: resolvedPreviewContent, + appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler ) } #endif diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 322f34f0..e5327599 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -2,6 +2,44 @@ import Foundation import CodexKit @_spi(Testing) import CodexReviewKit +@_spi(PreviewSupport) +@MainActor +public final class ReviewMonitorPreviewContentSource { + public let store: CodexReviewStore + public let codexModelSource: ReviewMonitorCodexModelSource + let runtime: ReviewMonitorPreviewAppServerRuntime + + init( + store: CodexReviewStore, + runtime: ReviewMonitorPreviewAppServerRuntime + ) { + self.store = store + self.runtime = runtime + self.codexModelSource = runtime.modelSource + } + + var initialSelection: ReviewMonitorSelection? { + runtime.initialSelection + } + + func start() { + runtime.start() + } + + func startStreaming(interval: Duration) { + runtime.startStreaming(interval: interval) + } + + @discardableResult + func appendPreviewChatLogStreamTick(after tick: Int = 0) async -> Int { + await runtime.appendPreviewStreamTick(after: tick) + } + + func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { + runtime.snapshotForTesting(chatID: chatID) + } +} + @_spi(PreviewSupport) @MainActor public enum ReviewMonitorPreviewContent { @@ -134,11 +172,28 @@ public enum ReviewMonitorPreviewContent { @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { + let previewContent = makeSidebarContent() + return makeStore(previewContent: previewContent) + } + + @_spi(PreviewSupport) + public static func makeContentSource() -> ReviewMonitorPreviewContentSource { + let previewContent = makeSidebarContent() + let store = makeStore(previewContent: previewContent) + let previewRuntime = ReviewMonitorPreviewAppServerRuntime( + fixtures: previewContent.chatLogFixtures + ) + return ReviewMonitorPreviewContentSource( + store: store, + runtime: previewRuntime + ) + } + + private static func makeStore(previewContent: PreviewSidebarContent) -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) ) let accounts = makePreviewAccounts() - let previewContent = makeSidebarContent() store.loadForTesting( serverState: .running, account: accounts.first, @@ -146,23 +201,16 @@ public enum ReviewMonitorPreviewContent { serverURL: URL(string: "http://localhost:9417/mcp"), reviewRuns: previewContent.reviewRuns ) - let previewRuntime = ReviewMonitorPreviewAppServerRuntime( - fixtures: previewContent.chatLogFixtures - ) - store.previewSupportRetainer = previewRuntime return store } - static func previewRuntime(from store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? { - store.previewSupportRetainer as? ReviewMonitorPreviewAppServerRuntime - } - - static func retainPreviewRuntimeStreamer(in store: CodexReviewStore, interval: Duration) { - previewRuntime(from: store)?.startStreaming(interval: interval) + @_spi(PreviewSupport) + public static func makeCommandOutputStore() -> CodexReviewStore { + makeCommandOutputContentSource().store } @_spi(PreviewSupport) - public static func makeCommandOutputStore() -> CodexReviewStore { + public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { let store = CodexReviewStore.makePreviewStore( seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) ) @@ -201,8 +249,10 @@ public enum ReviewMonitorPreviewContent { let fixture = makeChatLogFixture( for: chatFixture ) - store.previewSupportRetainer = ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) - return store + return ReviewMonitorPreviewContentSource( + store: store, + runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) + ) } package static func streamFrame( diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 6a5382be..80dc03e9 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -59,18 +59,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe ) { self.store = store self.uiState = uiState - #if DEBUG - if let codexModelSource { - self.codexModelSource = codexModelSource - } else if let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) { - previewRuntime.start() - self.codexModelSource = previewRuntime.modelSource - } else { - self.codexModelSource = nil - } - #else - self.codexModelSource = codexModelSource - #endif + self.codexModelSource = codexModelSource self.showSettings = showSettings super.init(nibName: nil, bundle: nil) } diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index d8ca1c61..50bb311f 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -73,7 +73,9 @@ public final class ReviewMonitorWindowController: NSWindowController { contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator, initialSelection: ReviewMonitorSelection? = nil, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, - showSettings: (@MainActor () -> Void)? = nil + showSettings: (@MainActor () -> Void)? = nil, + dependencyRetainer: AnyObject? = nil, + appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil ) { self.init( store: store, @@ -82,7 +84,9 @@ public final class ReviewMonitorWindowController: NSWindowController { initialSelection: initialSelection, frameAutosaveName: Self.frameAutosaveName, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults, - showSettings: showSettings + showSettings: showSettings, + dependencyRetainer: dependencyRetainer, + appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler ) } @@ -93,7 +97,9 @@ public final class ReviewMonitorWindowController: NSWindowController { initialSelection: ReviewMonitorSelection? = nil, frameAutosaveName: NSWindow.FrameAutosaveName, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, - showSettings: (@MainActor () -> Void)? = nil + showSettings: (@MainActor () -> Void)? = nil, + dependencyRetainer: AnyObject? = nil, + appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil ) { let uiState = Self.makeUIState( auth: store.auth, @@ -107,7 +113,9 @@ public final class ReviewMonitorWindowController: NSWindowController { uiState: uiState, codexModelSource: codexModelSource, contentTransitionAnimator: contentTransitionAnimator, - showSettings: showSettings + showSettings: showSettings, + dependencyRetainer: dependencyRetainer, + appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler ) let window = NSWindow( contentRect: NSRect(origin: .zero, size: Self.defaultContentSize), @@ -150,21 +158,36 @@ public final class ReviewMonitorWindowController: NSWindowController { @_spi(PreviewSupport) public extension ReviewMonitorWindowController { + convenience init( + previewContent: ReviewMonitorPreviewContentSource, + showSettings: (@MainActor () -> Void)? = nil + ) { + previewContent.startStreaming(interval: .milliseconds(40)) + self.init( + store: previewContent.store, + codexModelSource: previewContent.codexModelSource, + contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + initialSelection: previewContent.initialSelection, + showSettings: showSettings, + dependencyRetainer: previewContent, + appendPreviewChatLogStreamTickHandler: { tick in + let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) + return nextTick + } + ) + } + convenience init( previewStore store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource? = nil, + initialChatID: CodexThreadID? = nil, showSettings: (@MainActor () -> Void)? = nil ) { - let previewRuntime = ReviewMonitorPreviewContent.previewRuntime(from: store) - ReviewMonitorPreviewContent.retainPreviewRuntimeStreamer( - in: store, - interval: .milliseconds(40) - ) self.init( store: store, - codexModelSource: codexModelSource ?? previewRuntime?.modelSource, + codexModelSource: codexModelSource, contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, - initialSelection: previewRuntime?.initialSelection, + initialSelection: initialChatID.map(ReviewMonitorSelection.chat), showSettings: showSettings ) } diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index e4153d63..087c62de 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -219,18 +219,25 @@ func replaceChatLogTextForTesting( } @MainActor +@discardableResult func installPreviewChatLogSourceForTesting( on store: CodexReviewStore, fixtures: [ReviewChatFixtureForTesting] -) { +) -> ReviewMonitorPreviewAppServerRuntime { let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting) let runtime = ReviewMonitorPreviewAppServerRuntime(fixtures: fixtures) - store.previewSupportRetainer = runtime - runStorePreviewSupportRetainers.append( - ReviewChatLogFixtureRetainer( - runtime: runtime, - chatIDs: Set(fixtures.map(\.chatID)) - )) + let retainer = ReviewChatLogFixtureRetainer( + runtime: runtime, + chatIDs: Set(fixtures.map(\.chatID)) + ) + runStorePreviewSupportRetainers.append(retainer) + previewSupportRetainersByStore[ObjectIdentifier(store)] = retainer + return runtime +} + +@MainActor +func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? { + previewSupportRetainersByStore[ObjectIdentifier(store)]?.runtime } @MainActor @@ -584,6 +591,8 @@ private final class ReviewChatLogFixtureRetainer { @MainActor private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] +@MainActor +private var previewSupportRetainersByStore: [ObjectIdentifier: ReviewChatLogFixtureRetainer] = [:] @MainActor private func makeChatItems( diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 5db059d6..1d465b7f 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -22,10 +22,11 @@ extension ReviewUITests { } @Test func previewPreparationLoadsSelectedChatStreamBeforeWindowAttachment() async throws { - let store = ReviewMonitorPreviewContent.makeStore() + let previewContent = ReviewMonitorPreviewContent.makeContentSource() + let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) #expect(viewController.isViewLoaded == false) @@ -62,11 +63,12 @@ extension ReviewUITests { } @Test func previewChatContextMenuCancelCancelsMatchingReviewRun() async throws { - let store = ReviewMonitorPreviewContent.makeStore() + let previewContent = ReviewMonitorPreviewContent.makeContentSource() + let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) let run = try #require(store.cancellableReviewRun(forChatID: selectedChatID.rawValue)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -110,13 +112,13 @@ extension ReviewUITests { } @Test func previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { - let store = ReviewMonitorPreviewContent.makeStore() - let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let previewContent = ReviewMonitorPreviewContent.makeContentSource() + let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) - let selectedSnapshot = try #require(previewRuntime.snapshotForTesting(chatID: selectedChatID)) + let selectedSnapshot = try #require(previewContent.snapshotForTesting(chatID: selectedChatID)) let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) viewController.loadViewIfNeeded() @@ -132,9 +134,9 @@ extension ReviewUITests { } @Test func previewContentViewControllerStreamsSelectedChatLogDuringViewLifecycle() async throws { - let store = ReviewMonitorPreviewContent.makeStore() + let previewContent = ReviewMonitorPreviewContent.makeContentSource() let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) viewController.loadViewIfNeeded() @@ -840,13 +842,13 @@ extension ReviewUITests { } @Test func previewContentViewControllerRendersSelectedChatLog() async throws { - let store = ReviewMonitorPreviewContent.makeStore() - let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let previewContent = ReviewMonitorPreviewContent.makeContentSource() + let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) - let selectedSnapshot = try #require(previewRuntime.snapshotForTesting(chatID: selectedChatID)) + let selectedSnapshot = try #require(previewContent.snapshotForTesting(chatID: selectedChatID)) let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -864,9 +866,9 @@ extension ReviewUITests { } @Test func previewContentViewControllerStreamsSelectedChatLogTicks() async throws { - let store = ReviewMonitorPreviewContent.makeStore() + let previewContent = ReviewMonitorPreviewContent.makeContentSource() let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( - previewStore: store + previewContent: previewContent ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1629,14 +1631,12 @@ extension ReviewUITests { } @Test func previewRunningChatsAppendPseudoStreamWhenTicked() async throws { - let source = try #require(ReviewMonitorPreviewContent.previewRuntime( - from: ReviewMonitorPreviewContent.makeStore() - )) + let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count - await source.appendPreviewStreamTick() + await source.appendPreviewChatLogStreamTick() let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) @@ -1651,16 +1651,14 @@ extension ReviewUITests { } @Test func previewChatStreamUsesMixedLogKinds() async throws { - let source = try #require(ReviewMonitorPreviewContent.previewRuntime( - from: ReviewMonitorPreviewContent.makeStore() - )) + let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 for _ in 0..<720 { - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) } let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) @@ -1687,32 +1685,30 @@ extension ReviewUITests { } @Test func previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() async throws { - let source = try #require(ReviewMonitorPreviewContent.previewRuntime( - from: ReviewMonitorPreviewContent.makeStore() - )) + let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) var snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) #expect(snapshot.items.last?.kind.rawValue == "event") for _ in 0..<38 { - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) } snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 2) #expect(snapshot.items.last?.kind.rawValue == "plan") for _ in 0..<180 where snapshot.items.last?.kind.rawValue != "reasoning" { - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) } let firstReasoning = try #require(snapshot.items.last) @@ -1722,7 +1718,7 @@ extension ReviewUITests { var latestReasoningText = initialReasoningText for _ in 0..<80 { - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) let item = try #require(snapshot.items.first { $0.id == reasoningID }) let text = reasoningText(item) @@ -1735,12 +1731,12 @@ extension ReviewUITests { #expect(latestReasoningText.count > initialReasoningText.count) let countAfterReasoning = snapshot.items.count for _ in 0..<37 { - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) } snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning) - tick = await source.appendPreviewStreamTick(after: tick) + tick = await source.appendPreviewChatLogStreamTick(after: tick) snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning + 1) #expect(snapshot.items.last?.kind.rawValue == "commandExecution") diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 81e2d051..a80db275 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2313,10 +2313,12 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let previewRuntime = try #require(previewRuntimeForTesting(on: store)) + previewRuntime.start() let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth) + uiState: ReviewMonitorUIState(auth: store.auth), + codexModelSource: previewRuntime.modelSource ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2358,10 +2360,12 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let previewRuntime = try #require(previewRuntimeForTesting(on: store)) + previewRuntime.start() let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth) + uiState: ReviewMonitorUIState(auth: store.auth), + codexModelSource: previewRuntime.modelSource ) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2799,10 +2803,12 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let previewRuntime = try #require(ReviewMonitorPreviewContent.previewRuntime(from: store)) + let previewRuntime = try #require(previewRuntimeForTesting(on: store)) + previewRuntime.start() let viewController = ReviewMonitorSplitViewController( store: store, - uiState: ReviewMonitorUIState(auth: store.auth) + uiState: ReviewMonitorUIState(auth: store.auth), + codexModelSource: previewRuntime.modelSource ) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index b680d3da..baa5e519 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -269,15 +269,15 @@ struct ReviewMonitorAppComposition { } ) -> ReviewMonitorAppComposition { let codexModelSource = ReviewMonitorCodexModelSource() - var previewStore: CodexReviewStore? + var previewContent: ReviewMonitorPreviewContentSource? return ReviewMonitorAppComposition( makeStore: { context, presentationAnchorProvider in if context.requestsPreviewContent { - let store = ReviewMonitorPreviewContent.makeStore() - previewStore = store - return store + let content = ReviewMonitorPreviewContent.makeContentSource() + previewContent = content + return content.store } - previewStore = nil + previewContent = nil return makeLiveStore( runtimePreferencesStore.load(), .init( @@ -295,9 +295,10 @@ struct ReviewMonitorAppComposition { ) }, makeWindowController: { store, showSettings in - if previewStore === store { + if let content = previewContent, + content.store === store { return ReviewMonitorWindowController( - previewStore: store, + previewContent: content, showSettings: showSettings ) } From e9a7e518934ec19e1f79ffb86c2bb21029a4a8c4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:45:32 +0900 Subject: [PATCH 284/359] fix(review-ui): preserve preview store source routing --- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 25 ++++++++++++++----- .../ReviewMonitorWindowController.swift | 10 ++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 9ab01ba4..83f658fe 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -316,7 +316,7 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( resolvedPreviewContent = previewContent store = previewContent.store } else if let previewStore { - resolvedPreviewContent = nil + resolvedPreviewContent = ReviewMonitorPreviewContent.contentSource(for: previewStore) store = previewStore } else { let previewContent = ReviewMonitorPreviewContent.makeContentSource() diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index e5327599..724bde69 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -172,8 +172,7 @@ public enum ReviewMonitorPreviewContent { @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { - let previewContent = makeSidebarContent() - return makeStore(previewContent: previewContent) + makeContentSource().store } @_spi(PreviewSupport) @@ -183,10 +182,10 @@ public enum ReviewMonitorPreviewContent { let previewRuntime = ReviewMonitorPreviewAppServerRuntime( fixtures: previewContent.chatLogFixtures ) - return ReviewMonitorPreviewContentSource( + return registerContentSource(ReviewMonitorPreviewContentSource( store: store, runtime: previewRuntime - ) + )) } private static func makeStore(previewContent: PreviewSidebarContent) -> CodexReviewStore { @@ -249,12 +248,26 @@ public enum ReviewMonitorPreviewContent { let fixture = makeChatLogFixture( for: chatFixture ) - return ReviewMonitorPreviewContentSource( + return registerContentSource(ReviewMonitorPreviewContentSource( store: store, runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) - ) + )) + } + + static func contentSource(for store: CodexReviewStore) -> ReviewMonitorPreviewContentSource? { + contentSourcesByStoreID[ObjectIdentifier(store)] + } + + private static func registerContentSource( + _ contentSource: ReviewMonitorPreviewContentSource + ) -> ReviewMonitorPreviewContentSource { + contentSourcesByStoreID[ObjectIdentifier(contentSource.store)] = contentSource + return contentSource } + @MainActor + private static var contentSourcesByStoreID: [ObjectIdentifier: ReviewMonitorPreviewContentSource] = [:] + package static func streamFrame( forRunningChatAt runningChatIndex: Int, tick: Int diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 50bb311f..106b6571 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -183,6 +183,16 @@ public extension ReviewMonitorWindowController { initialChatID: CodexThreadID? = nil, showSettings: (@MainActor () -> Void)? = nil ) { + if codexModelSource == nil, + initialChatID == nil, + let previewContent = ReviewMonitorPreviewContent.contentSource(for: store) + { + self.init( + previewContent: previewContent, + showSettings: showSettings + ) + return + } self.init( store: store, codexModelSource: codexModelSource, From 5e240a68ace9695484c2d53f38597a2f628cd30b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:53:22 +0900 Subject: [PATCH 285/359] fix(review-ui): keep preview runtime for selected chat --- .../ReviewMonitorWindowController.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 106b6571..6f2b9e0d 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -183,13 +183,19 @@ public extension ReviewMonitorWindowController { initialChatID: CodexThreadID? = nil, showSettings: (@MainActor () -> Void)? = nil ) { - if codexModelSource == nil, - initialChatID == nil, - let previewContent = ReviewMonitorPreviewContent.contentSource(for: store) - { + if let previewContent = ReviewMonitorPreviewContent.contentSource(for: store) { + previewContent.startStreaming(interval: .milliseconds(40)) self.init( - previewContent: previewContent, - showSettings: showSettings + store: store, + codexModelSource: codexModelSource ?? previewContent.codexModelSource, + contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, + initialSelection: initialChatID.map(ReviewMonitorSelection.chat) ?? previewContent.initialSelection, + showSettings: showSettings, + dependencyRetainer: previewContent, + appendPreviewChatLogStreamTickHandler: { tick in + let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) + return nextTick + } ) return } From 9e6a42991d9ab88e31604fcc47db20b8fde32a5e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:00:25 +0900 Subject: [PATCH 286/359] fix(review-ui): avoid retaining preview sources globally --- .../ReviewMonitorPreviewContent.swift | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 724bde69..98b633fd 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -170,9 +170,28 @@ public enum ReviewMonitorPreviewContent { var reviewRuns: [ReviewRunRecord] } + private final class PreviewContentRegistryEntry { + weak var store: CodexReviewStore? + weak var contentSource: ReviewMonitorPreviewContentSource? + let fixtures: [ReviewMonitorPreviewChatLogFixture] + + init( + store: CodexReviewStore, + fixtures: [ReviewMonitorPreviewChatLogFixture], + contentSource: ReviewMonitorPreviewContentSource? = nil + ) { + self.store = store + self.fixtures = fixtures + self.contentSource = contentSource + } + } + @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { - makeContentSource().store + let previewContent = makeSidebarContent() + let store = makeStore(previewContent: previewContent) + registerPreviewStore(store, fixtures: previewContent.chatLogFixtures) + return store } @_spi(PreviewSupport) @@ -185,7 +204,7 @@ public enum ReviewMonitorPreviewContent { return registerContentSource(ReviewMonitorPreviewContentSource( store: store, runtime: previewRuntime - )) + ), fixtures: previewContent.chatLogFixtures) } private static func makeStore(previewContent: PreviewSidebarContent) -> CodexReviewStore { @@ -251,22 +270,57 @@ public enum ReviewMonitorPreviewContent { return registerContentSource(ReviewMonitorPreviewContentSource( store: store, runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) - )) + ), fixtures: [fixture]) } static func contentSource(for store: CodexReviewStore) -> ReviewMonitorPreviewContentSource? { - contentSourcesByStoreID[ObjectIdentifier(store)] + pruneDeadContentSources() + guard let entry = contentSourcesByStoreID[ObjectIdentifier(store)] else { + return nil + } + if let contentSource = entry.contentSource { + return contentSource + } + let contentSource = ReviewMonitorPreviewContentSource( + store: store, + runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: entry.fixtures) + ) + entry.contentSource = contentSource + return contentSource + } + + private static func registerPreviewStore( + _ store: CodexReviewStore, + fixtures: [ReviewMonitorPreviewChatLogFixture] + ) { + pruneDeadContentSources() + contentSourcesByStoreID[ObjectIdentifier(store)] = PreviewContentRegistryEntry( + store: store, + fixtures: fixtures + ) } private static func registerContentSource( - _ contentSource: ReviewMonitorPreviewContentSource + _ contentSource: ReviewMonitorPreviewContentSource, + fixtures: [ReviewMonitorPreviewChatLogFixture] ) -> ReviewMonitorPreviewContentSource { - contentSourcesByStoreID[ObjectIdentifier(contentSource.store)] = contentSource + pruneDeadContentSources() + contentSourcesByStoreID[ObjectIdentifier(contentSource.store)] = PreviewContentRegistryEntry( + store: contentSource.store, + fixtures: fixtures, + contentSource: contentSource + ) return contentSource } + private static func pruneDeadContentSources() { + contentSourcesByStoreID = contentSourcesByStoreID.filter { _, entry in + entry.store != nil + } + } + @MainActor - private static var contentSourcesByStoreID: [ObjectIdentifier: ReviewMonitorPreviewContentSource] = [:] + private static var contentSourcesByStoreID: [ObjectIdentifier: PreviewContentRegistryEntry] = [:] package static func streamFrame( forRunningChatAt runningChatIndex: Int, From c07aafff5543947037baac6f5179142598a42eb7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:09:50 +0900 Subject: [PATCH 287/359] test(review-ui): inject fixture model source in previews --- .../ReviewMonitorChatLogTesting.swift | 32 +++++ Tests/ReviewUITests/ReviewUIShellTests.swift | 28 ++-- Tests/ReviewUITests/ReviewUITests.swift | 135 +++++++++--------- .../CodexReviewMonitorCITests.swift | 2 +- 4 files changed, 116 insertions(+), 81 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 087c62de..21d33ea9 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -240,6 +240,38 @@ func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPrevie previewSupportRetainersByStore[ObjectIdentifier(store)]?.runtime } +@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( diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 1d465b7f..2bc09247 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -160,7 +160,7 @@ extension ReviewUITests { @Test func bindingStoreAppliesInitialState() { let store = CodexReviewStore.makePreviewStore() - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -177,7 +177,7 @@ extension ReviewUITests { serverState: .running, serverURL: URL(string: "http://localhost:9417/mcp") ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -194,7 +194,7 @@ extension ReviewUITests { store.loadForTesting( serverState: .failed("Embedded server is unavailable in preview mode.") ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -209,7 +209,7 @@ extension ReviewUITests { store.loadForTesting( serverState: .starting ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -221,7 +221,7 @@ extension ReviewUITests { store.loadForTesting( serverState: .stopped ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -234,7 +234,7 @@ extension ReviewUITests { serverState: .running, serverURL: URL(string: "http://localhost:9417/mcp") ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -250,7 +250,7 @@ extension ReviewUITests { 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 == .chatList) @@ -365,7 +365,7 @@ extension ReviewUITests { serverState: .running, serverURL: URL(string: "http://localhost:9417/mcp") ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -707,7 +707,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() } @@ -730,7 +730,7 @@ extension ReviewUITests { 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) @@ -773,7 +773,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)) @@ -799,7 +799,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)) @@ -980,7 +980,7 @@ extension ReviewUITests { ) 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() @@ -1615,7 +1615,7 @@ extension ReviewUITests { @Test func splitViewAttachIsIdempotentForSameWindow() { let backend = CountingStartBackend() let store = makeStore(backend: backend) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index a80db275..a42dc152 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -42,7 +42,7 @@ struct ReviewUITests { 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)) @@ -68,7 +68,7 @@ struct ReviewUITests { 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)) @@ -107,7 +107,7 @@ struct ReviewUITests { 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)) @@ -151,7 +151,7 @@ struct ReviewUITests { let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .workspace - 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)) @@ -182,7 +182,7 @@ struct ReviewUITests { let uiState = ReviewMonitorUIState(auth: store.auth) uiState.sidebarSelection = .workspace - 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)) @@ -213,7 +213,7 @@ struct ReviewUITests { ) 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 @@ -245,7 +245,7 @@ struct ReviewUITests { ) 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 @@ -300,7 +300,7 @@ struct ReviewUITests { ) 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 @@ -389,7 +389,7 @@ struct ReviewUITests { ) 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)) @@ -444,7 +444,7 @@ struct ReviewUITests { ) 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)) @@ -486,7 +486,7 @@ struct ReviewUITests { ) 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 @@ -520,7 +520,7 @@ struct ReviewUITests { ) 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)) @@ -557,7 +557,7 @@ struct ReviewUITests { ) 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)) @@ -610,7 +610,7 @@ struct ReviewUITests { ) 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 @@ -649,7 +649,7 @@ struct ReviewUITests { ) 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)) @@ -719,7 +719,7 @@ struct ReviewUITests { ) 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)) @@ -757,7 +757,7 @@ struct ReviewUITests { serverState: .running, fixtures: [activeChat, recentChat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -1663,7 +1663,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1703,7 +1703,7 @@ struct ReviewUITests { serverState: .running, fixtures: [activeChat, recentChat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1763,7 +1763,7 @@ struct ReviewUITests { serverState: .running, fixtures: [activeChat, recentChat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1816,7 +1816,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1866,7 +1866,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -1957,7 +1957,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [shortChat, recentChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2013,7 +2013,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [activeChat, recentChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2064,7 +2064,7 @@ struct ReviewUITests { serverState: .running, fixtures: [chat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2099,7 +2099,7 @@ struct ReviewUITests { serverState: .running, fixtures: [chat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -2127,7 +2127,7 @@ struct ReviewUITests { let activeChat = makeReviewChatFixtureForTesting(status: .running, targetSummary: "Uncommitted changes") let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() @@ -2175,7 +2175,7 @@ struct ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth), modelContext: context @@ -2274,7 +2274,7 @@ struct ReviewUITests { serverState: .running, fixtures: [chat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2315,7 +2315,7 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, fixtures: [chat]) let previewRuntime = try #require(previewRuntimeForTesting(on: store)) previewRuntime.start() - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth), codexModelSource: previewRuntime.modelSource @@ -2362,7 +2362,7 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, fixtures: [chat]) let previewRuntime = try #require(previewRuntimeForTesting(on: store)) previewRuntime.start() - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth), codexModelSource: previewRuntime.modelSource @@ -2404,7 +2404,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2437,7 +2437,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2475,7 +2475,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2513,7 +2513,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2606,7 +2606,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2712,7 +2712,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2751,7 +2751,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2805,7 +2805,7 @@ struct ReviewUITests { store.loadForTesting(serverState: .running, fixtures: [chat]) let previewRuntime = try #require(previewRuntimeForTesting(on: store)) previewRuntime.start() - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth), codexModelSource: previewRuntime.modelSource @@ -2845,7 +2845,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -2911,7 +2911,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -3047,7 +3047,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3146,7 +3146,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3194,7 +3194,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3235,7 +3235,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3274,7 +3274,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3320,7 +3320,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3371,7 +3371,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3411,7 +3411,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3450,7 +3450,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -3558,7 +3558,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -3688,7 +3688,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3845,7 +3845,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3909,7 +3909,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -3972,7 +3972,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [firstChat, secondChat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4020,7 +4020,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4065,7 +4065,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4121,7 +4121,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4179,7 +4179,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4223,7 +4223,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4270,7 +4270,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4319,7 +4319,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4367,7 +4367,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4437,7 +4437,7 @@ struct ReviewUITests { ) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running, fixtures: [chat]) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) let window = NSWindow(contentViewController: viewController) defer { window.close() } @@ -4482,7 +4482,7 @@ struct ReviewUITests { authState: .signedOut, fixtures: [chat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -4511,7 +4511,7 @@ struct ReviewUITests { authState: .signedIn(accountID: "review@example.com"), fixtures: [chat] ) - let viewController = ReviewMonitorSplitViewController( + let viewController = makeReviewMonitorSplitViewControllerForTesting( store: store, uiState: ReviewMonitorUIState(auth: store.auth)) viewController.loadViewIfNeeded() let transport = viewController.transportViewControllerForTesting @@ -4560,8 +4560,11 @@ func makeWindowHarness( .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, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults ) diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index 405f03a2..e91a8d8b 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -298,7 +298,7 @@ struct CodexReviewMonitorCITests { } #expect(initialSnapshot.log.isEmpty == false) - let nextTick = try #require(rootViewController.appendPreviewChatLogStreamTickForTesting()) + let nextTick = try #require(await rootViewController.appendPreviewChatLogStreamTickForTesting()) #expect(nextTick == 1) let updatedSnapshot = try await awaitPreviewTransportRender(transport) { snapshot in snapshot.log.count > initialSnapshot.log.count From f1457e1b12709c4211007d712e34955a3f593c5f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:17:26 +0900 Subject: [PATCH 288/359] fix(review-ui): prune stale preview test runtimes --- .../ReviewMonitorChatLogTesting.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 21d33ea9..2eb12e01 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -227,6 +227,7 @@ func installPreviewChatLogSourceForTesting( let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting) let runtime = ReviewMonitorPreviewAppServerRuntime(fixtures: fixtures) let retainer = ReviewChatLogFixtureRetainer( + store: store, runtime: runtime, chatIDs: Set(fixtures.map(\.chatID)) ) @@ -237,7 +238,21 @@ func installPreviewChatLogSourceForTesting( @MainActor func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? { - previewSupportRetainersByStore[ObjectIdentifier(store)]?.runtime + 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 @@ -569,6 +584,7 @@ private enum ReviewChatLogFixtureStore { } private static func updateRetainedPreviewSource(for chatID: CodexThreadID, turnID: CodexTurnID) { + prunePreviewSupportRetainersByStore() for support in runStorePreviewSupportRetainers where support.contains(chatID: chatID) { support.upsert(chatID: chatID, items: items(for: chatID, turnID: turnID)) } @@ -577,10 +593,12 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { + weak var store: CodexReviewStore? let runtime: ReviewMonitorPreviewAppServerRuntime private var chatIDs: Set - init(runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { + init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { + self.store = store self.runtime = runtime self.chatIDs = chatIDs } From 836cca366b40e21a37fe5ffb7abb5f4da2808b2b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:27:50 +0900 Subject: [PATCH 289/359] refactor(review-ui): remove preview store runtime fallback --- .../ReviewUI/ReviewMonitorContentView.swift | 8 +- .../ReviewMonitorPreviewContent.swift | 76 +------------------ .../ReviewMonitorWindowController.swift | 31 -------- 3 files changed, 6 insertions(+), 109 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 83f658fe..b011b8a7 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -304,20 +304,16 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( authPhase: CodexReviewAuthModel.Phase = .signedOut, account: CodexReviewAccount? = nil, serverState: CodexReviewServerState = .running, - previewContent: ReviewMonitorPreviewContentSource? = nil, - previewStore: CodexReviewStore? = nil + previewContent: ReviewMonitorPreviewContentSource? = nil ) -> ReviewMonitorRootViewController { let store: CodexReviewStore let resolvedPreviewContent: ReviewMonitorPreviewContentSource? - let ownsPreviewContent = previewContent == nil && previewStore == nil + let ownsPreviewContent = previewContent == nil switch serverState { case .running: if let previewContent { resolvedPreviewContent = previewContent store = previewContent.store - } else if let previewStore { - resolvedPreviewContent = ReviewMonitorPreviewContent.contentSource(for: previewStore) - store = previewStore } else { let previewContent = ReviewMonitorPreviewContent.makeContentSource() resolvedPreviewContent = previewContent diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index 98b633fd..f51a1754 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -170,28 +170,9 @@ public enum ReviewMonitorPreviewContent { var reviewRuns: [ReviewRunRecord] } - private final class PreviewContentRegistryEntry { - weak var store: CodexReviewStore? - weak var contentSource: ReviewMonitorPreviewContentSource? - let fixtures: [ReviewMonitorPreviewChatLogFixture] - - init( - store: CodexReviewStore, - fixtures: [ReviewMonitorPreviewChatLogFixture], - contentSource: ReviewMonitorPreviewContentSource? = nil - ) { - self.store = store - self.fixtures = fixtures - self.contentSource = contentSource - } - } - @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { - let previewContent = makeSidebarContent() - let store = makeStore(previewContent: previewContent) - registerPreviewStore(store, fixtures: previewContent.chatLogFixtures) - return store + makeStore(previewContent: makeSidebarContent()) } @_spi(PreviewSupport) @@ -201,10 +182,10 @@ public enum ReviewMonitorPreviewContent { let previewRuntime = ReviewMonitorPreviewAppServerRuntime( fixtures: previewContent.chatLogFixtures ) - return registerContentSource(ReviewMonitorPreviewContentSource( + return ReviewMonitorPreviewContentSource( store: store, runtime: previewRuntime - ), fixtures: previewContent.chatLogFixtures) + ) } private static func makeStore(previewContent: PreviewSidebarContent) -> CodexReviewStore { @@ -267,61 +248,12 @@ public enum ReviewMonitorPreviewContent { let fixture = makeChatLogFixture( for: chatFixture ) - return registerContentSource(ReviewMonitorPreviewContentSource( + return ReviewMonitorPreviewContentSource( store: store, runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) - ), fixtures: [fixture]) - } - - static func contentSource(for store: CodexReviewStore) -> ReviewMonitorPreviewContentSource? { - pruneDeadContentSources() - guard let entry = contentSourcesByStoreID[ObjectIdentifier(store)] else { - return nil - } - if let contentSource = entry.contentSource { - return contentSource - } - let contentSource = ReviewMonitorPreviewContentSource( - store: store, - runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: entry.fixtures) - ) - entry.contentSource = contentSource - return contentSource - } - - private static func registerPreviewStore( - _ store: CodexReviewStore, - fixtures: [ReviewMonitorPreviewChatLogFixture] - ) { - pruneDeadContentSources() - contentSourcesByStoreID[ObjectIdentifier(store)] = PreviewContentRegistryEntry( - store: store, - fixtures: fixtures ) } - private static func registerContentSource( - _ contentSource: ReviewMonitorPreviewContentSource, - fixtures: [ReviewMonitorPreviewChatLogFixture] - ) -> ReviewMonitorPreviewContentSource { - pruneDeadContentSources() - contentSourcesByStoreID[ObjectIdentifier(contentSource.store)] = PreviewContentRegistryEntry( - store: contentSource.store, - fixtures: fixtures, - contentSource: contentSource - ) - return contentSource - } - - private static func pruneDeadContentSources() { - contentSourcesByStoreID = contentSourcesByStoreID.filter { _, entry in - entry.store != nil - } - } - - @MainActor - private static var contentSourcesByStoreID: [ObjectIdentifier: PreviewContentRegistryEntry] = [:] - package static func streamFrame( forRunningChatAt runningChatIndex: Int, tick: Int diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 6f2b9e0d..2b31e1ab 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -176,37 +176,6 @@ public extension ReviewMonitorWindowController { } ) } - - convenience init( - previewStore store: CodexReviewStore, - codexModelSource: ReviewMonitorCodexModelSource? = nil, - initialChatID: CodexThreadID? = nil, - showSettings: (@MainActor () -> Void)? = nil - ) { - if let previewContent = ReviewMonitorPreviewContent.contentSource(for: store) { - previewContent.startStreaming(interval: .milliseconds(40)) - self.init( - store: store, - codexModelSource: codexModelSource ?? previewContent.codexModelSource, - contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, - initialSelection: initialChatID.map(ReviewMonitorSelection.chat) ?? previewContent.initialSelection, - showSettings: showSettings, - dependencyRetainer: previewContent, - appendPreviewChatLogStreamTickHandler: { tick in - let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) - return nextTick - } - ) - return - } - self.init( - store: store, - codexModelSource: codexModelSource, - contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, - initialSelection: initialChatID.map(ReviewMonitorSelection.chat), - showSettings: showSettings - ) - } } enum ReviewMonitorSidebar {} From d7b7330f0a38447dca0585cf280524d3913e105d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:42:08 +0900 Subject: [PATCH 290/359] fix(review-ui): avoid waiting for preview subscribers on source ticks --- .../ReviewUI/ReviewMonitorContentView.swift | 5 +++- ...ReviewMonitorPreviewAppServerRuntime.swift | 30 +++++++++++++------ .../ReviewMonitorPreviewContent.swift | 10 +++++-- .../ReviewMonitorWindowController.swift | 5 +++- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index b011b8a7..e5c7d5f3 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -344,7 +344,10 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( let appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? if let previewContent = resolvedPreviewContent { appendPreviewChatLogStreamTickHandler = { tick in - let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) + let nextTick = await previewContent.appendPreviewChatLogStreamTick( + after: tick, + emitsNotifications: true + ) return nextTick } } else { diff --git a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift index 3cccb152..e85a05dd 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift @@ -181,24 +181,32 @@ final class ReviewMonitorPreviewAppServerRuntime { guard let self, Task.isCancelled == false else { return } - _ = await self.appendPreviewStreamTick(after: self.tick) + _ = await self.appendPreviewStreamTick( + after: self.tick, + emitsNotifications: true + ) } } } @discardableResult - func appendPreviewStreamTick(after currentTick: Int = 0) async -> Int { - do { - try await ensureStarted() - } catch { - return currentTick - } - + func appendPreviewStreamTick( + after currentTick: Int = 0, + emitsNotifications: Bool = false + ) async -> Int { let runningFixtures = fixtures.filter(\.isRunning) 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( @@ -208,7 +216,11 @@ final class ReviewMonitorPreviewAppServerRuntime { continue } apply(frame.step, cycle: frame.cycle, for: fixture) - await emit(frame.step, cycle: frame.cycle, for: fixture) + if emitsNotifications { + enqueueNotification { [weak self] in + await self?.emit(frame.step, cycle: frame.cycle, for: fixture) + } + } } tick = nextTick return nextTick diff --git a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift index f51a1754..656cad24 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewContent.swift @@ -31,8 +31,14 @@ public final class ReviewMonitorPreviewContentSource { } @discardableResult - func appendPreviewChatLogStreamTick(after tick: Int = 0) async -> Int { - await runtime.appendPreviewStreamTick(after: tick) + func appendPreviewChatLogStreamTick( + after tick: Int = 0, + emitsNotifications: Bool = false + ) async -> Int { + await runtime.appendPreviewStreamTick( + after: tick, + emitsNotifications: emitsNotifications + ) } func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 2b31e1ab..e3aab1a7 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -171,7 +171,10 @@ public extension ReviewMonitorWindowController { showSettings: showSettings, dependencyRetainer: previewContent, appendPreviewChatLogStreamTickHandler: { tick in - let nextTick = await previewContent.appendPreviewChatLogStreamTick(after: tick) + let nextTick = await previewContent.appendPreviewChatLogStreamTick( + after: tick, + emitsNotifications: true + ) return nextTick } ) From 9ff01b292b794de26e80acdea0f43d2cd6a162e1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:17:02 +0900 Subject: [PATCH 291/359] fix(review-ui): restore selected chat log updates --- ...ReviewMonitorPreviewAppServerRuntime.swift | 59 +++++++++++++++++-- .../ReviewMonitorSelectedCodexChat.swift | 41 ++++++++++++- .../ReviewMonitorSidebarViewController.swift | 26 +++++++- .../ReviewMonitorChatLogTesting.swift | 48 ++++++++++++++- Tests/ReviewUITests/ReviewUITests.swift | 6 +- 5 files changed, 169 insertions(+), 11 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift index e85a05dd..f81e927c 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift @@ -152,12 +152,31 @@ final class ReviewMonitorPreviewAppServerRuntime { private func enqueueNotification(_ operation: @escaping @MainActor () async -> Void) { let previousTask = notificationTask - notificationTask = Task { @MainActor in + notificationTask = Task { @MainActor [weak self] in await previousTask?.value + await self?.refreshRuntimeThreadStubs() await operation() } } + private func refreshRuntimeThreadStubs() async { + do { + try await ensureStarted() + guard let runtime else { + return + } + try await runtime.transport.stubThreads(currentThreadSnapshots()) + } catch { + } + } + + private func currentThreadSnapshots() -> [CodexThreadSnapshot] { + fixtures.map { fixture in + let snapshot = snapshotStorageByChatID[fixture.chatID] ?? fixture.initialSnapshot + return fixture.threadSnapshot(snapshot) + } + } + func start() { guard startTask == nil, runtime == nil else { return @@ -515,9 +534,11 @@ private struct PreviewThreadItemParams: Encodable, Sendable { var text: String? var phase: String? var command: String? + var cwd: String? var output: String? var exitCode: Int? var status: String? + var path: String? } } @@ -552,9 +573,11 @@ private func makePreviewNotificationItem( text: previewNotificationText(content), phase: previewNotificationPhase(content), command: previewNotificationCommand(content), + cwd: previewNotificationCWD(content), output: previewNotificationOutput(content), exitCode: previewNotificationExitCode(content), - status: previewNotificationStatus(content) + status: previewNotificationStatus(content), + path: previewNotificationPath(content) ) } @@ -601,13 +624,24 @@ private func previewNotificationCommand(_ content: CodexThreadItem.Content) -> S return nil } -private func previewNotificationOutput(_ content: CodexThreadItem.Content) -> String? { +private func previewNotificationCWD(_ content: CodexThreadItem.Content) -> String? { if case .command(let command) = content { - return command.output + 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 @@ -619,6 +653,8 @@ private func previewNotificationStatus(_ content: CodexThreadItem.Content) -> St switch content { case .command(let command): command.status?.rawValue + case .fileChange(let fileChange): + fileChange.status?.rawValue case .toolCall(let toolCall): toolCall.status?.rawValue default: @@ -626,8 +662,19 @@ private func previewNotificationStatus(_ content: CodexThreadItem.Content) -> St } } +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(initialSnapshot) + } + + func threadSnapshot(_ snapshot: CodexChatSnapshot) -> CodexThreadSnapshot { CodexThreadSnapshot( id: chatID, workspace: workspaceCWD.map { URL(fileURLWithPath: $0, isDirectory: true) }, @@ -637,12 +684,12 @@ private extension ReviewMonitorPreviewChatLogFixture { updatedAt: updatedAt, recencyAt: recencyAt, status: status, - turns: initialSnapshot.turns.map { turn in + turns: snapshot.turns.map { turn in CodexTurnSnapshot( id: turn.id, status: turn.status, errorMessage: turn.errorDescription, - items: initialSnapshot.items + items: snapshot.items .filter { $0.turnID == turn.id } .map(\.threadItem) ) diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 1ab16fcd..428b7fff 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -6,6 +6,8 @@ import ObservationBridge @MainActor @Observable final class ReviewMonitorSelectedCodexChat { + private static let incrementalLogUpdateCoalescingDelay: Duration = .milliseconds(10) + private(set) var chatID: CodexThreadID? private(set) var chat: CodexChat? var phase: CodexDataPhase { @@ -28,6 +30,10 @@ final class ReviewMonitorSelectedCodexChat { @ObservationIgnored private var observationTask: Task? @ObservationIgnored + private var pendingLogSourceChange: ReviewMonitorLogSourceChange? + @ObservationIgnored + private var pendingLogSourceChangeTask: Task? + @ObservationIgnored private var logProjection = ReviewMonitorSelectedCodexChatLogProjection() @ObservationIgnored private var modelSourceObservation: PortableObservationTracking.Token? @@ -43,6 +49,7 @@ final class ReviewMonitorSelectedCodexChat { isolated deinit { modelSourceObservation?.cancel() + pendingLogSourceChangeTask?.cancel() cancelObservation() } @@ -91,7 +98,8 @@ final class ReviewMonitorSelectedCodexChat { targetChatID = nextChatID chatID = nextChatID chat = nil - publishLogSourceChange(.clear) + cancelPendingLogSourceChange() + publishLogSourceChangeNow(.clear) logProjection.reset() boundModelContext = nextModelContext @@ -146,6 +154,37 @@ final class ReviewMonitorSelectedCodexChat { } private func publishLogSourceChange(_ change: ReviewMonitorLogSourceChange?) { + guard let change else { + return + } + guard change.allowsIncrementalRender else { + cancelPendingLogSourceChange() + publishLogSourceChangeNow(change) + return + } + pendingLogSourceChange = change + 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 change = self.pendingLogSourceChange + self.pendingLogSourceChange = nil + self.pendingLogSourceChangeTask = nil + self.publishLogSourceChangeNow(change) + } + } + + private func cancelPendingLogSourceChange() { + pendingLogSourceChangeTask?.cancel() + pendingLogSourceChangeTask = nil + pendingLogSourceChange = nil + } + + private func publishLogSourceChangeNow(_ change: ReviewMonitorLogSourceChange?) { guard let change else { return } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 55c08c0f..5bbd19d7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -692,6 +692,21 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ?? row(forCodexSidebarSelectionID: .workspaceGroup(CodexWorkspaceGroupID(rawValue: cwd))) } + private func workspaceGroupID(forWorkspaceCWD cwd: String) -> CodexWorkspaceGroupID? { + let workspaceID = CodexWorkspaceID(rawValue: cwd) + for section in codexSidebarUnfilteredSections { + if section.workspaces.contains(where: { $0.id == workspaceID }) + || section.items.contains(where: { $0.workspaceID == workspaceID }) + { + return section.workspaceGroupID + } + if section.workspaceGroupID.rawValue == cwd || section.workspaceGroupID.rawValue == "cwd:\(cwd)" { + return section.workspaceGroupID + } + } + return nil + } + private func row(for chatID: CodexThreadID) -> Int? { row(forCodexSidebarSelectionID: .chat(chatID)) } @@ -1405,6 +1420,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return id } + func workspaceGroupIDForTesting(cwd: String) -> CodexWorkspaceGroupID? { + workspaceGroupID(forWorkspaceCWD: cwd) + } + var codexSidebarSectionsForTesting: [CodexFetchSection] { codexSidebarLibrary?.sections ?? [] } @@ -1642,11 +1661,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func clickWorkspaceHeaderForTesting(cwd: String) { view.layoutSubtreeIfNeeded() - guard let row = row(forWorkspaceCWD: cwd) else { + 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.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + updateSelectionFromOutlineView() } func focusSidebarForTesting() { diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 2eb12e01..10200292 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -616,6 +616,9 @@ private final class ReviewChatLogFixtureRetainer { 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) { runtime.appendPreviewText( outputDelta, @@ -921,6 +924,7 @@ private extension CodexChatItemSnapshot { func outputDeltaForTesting(to item: CodexChatItemSnapshot) -> String? { guard turnID == item.turnID, kind == item.kind, + preservesOutputDeltaMetadata(for: item), let previousOutput = commandOutputForTesting, let nextOutput = item.commandOutputForTesting, nextOutput.hasPrefix(previousOutput), @@ -936,6 +940,7 @@ private extension CodexChatItemSnapshot { turnID == item.turnID, kind == item.kind, sameContentShapeForTesting(as: item), + preservesTextDeltaMetadata(for: item), let previousText = text, let nextText = item.text, nextText.hasPrefix(previousText), @@ -946,11 +951,50 @@ private extension CodexChatItemSnapshot { return String(nextText.dropFirst(previousText.count)) } + private func preservesTextDeltaMetadata(for item: CodexChatItemSnapshot) -> Bool { + switch (content, item.content) { + case (.command, .command), + (.fileChange, .fileChange), + (.toolCall, .toolCall): + preservesOutputDeltaMetadata(for: item) + default: + true + } + } + + private func preservesOutputDeltaMetadata(for item: CodexChatItemSnapshot) -> 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? { - if case .command(let command) = content { + 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 } - return nil } private func sameContentShapeForTesting(as item: CodexChatItemSnapshot) -> Bool { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index a42dc152..003c3113 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2114,11 +2114,15 @@ struct ReviewUITests { 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.selectedWorkspaceGroupIDForTesting?.rawValue == chat.cwd) + viewController.sidebarViewControllerForTesting.selectedWorkspaceGroupIDForTesting + == expectedWorkspaceGroupID) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(transport.isShowingNoFindingsStateForTesting) } From f04828504d0692b60fff7d2de6f526e380383360 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:23:05 +0900 Subject: [PATCH 292/359] refactor(review-ui): use explicit codex sort descriptors --- .../Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index 3d47abf2..1d35acfc 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -567,8 +567,7 @@ package final class ReviewMonitorCodexSidebarLibrary { package static var defaultDescriptor: CodexFetchDescriptor { CodexFetchDescriptor( predicate: .init(sourceKinds: [.subAgentReview]), - sort: \.updatedAt, - order: .reverse + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] ) } From d7c0e4a63fcad1091a4df88d372e6e813eb65181 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:55:24 +0900 Subject: [PATCH 293/359] refactor(review-ui): move preview support to separate target --- Package.swift | 18 ++- .../ReviewUI/ReviewMonitorContentView.swift | 111 ++++------------ .../ReviewMonitorSplitViewController.swift | 2 +- Sources/ReviewUI/ReviewMonitorUIState.swift | 13 +- .../ReviewMonitorWindowController.swift | 70 +++++----- Sources/ReviewUI/SignIn/SignInView.swift | 30 ----- ...rServerStatusAccessoryViewController.swift | 39 ------ .../ReviewMonitorContentPreview.swift | 7 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 7 +- .../ReviewMonitorPreviewComposition.swift | 123 ++++++++++++++++++ .../ReviewMonitorPreviewContent.swift | 17 +-- .../ReviewMonitorChatLogTesting.swift | 20 +++ Tests/ReviewUITests/ReviewUIShellTests.swift | 1 + Tests/ReviewUITests/ReviewUITests.swift | 1 + .../project.pbxproj | 8 ++ .../xcshareddata/swiftpm/Package.resolved | 10 +- .../CodexReviewMonitorApp.swift | 1 + .../CodexReviewMonitorCITests.swift | 1 + 18 files changed, 261 insertions(+), 218 deletions(-) rename Sources/{ReviewUI => ReviewUIPreviewSupport}/ReviewMonitorContentPreview.swift (90%) rename Sources/{ReviewUI => ReviewUIPreviewSupport}/ReviewMonitorPreviewAppServerRuntime.swift (99%) create mode 100644 Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift rename Sources/{ReviewUI => ReviewUIPreviewSupport}/ReviewMonitorPreviewContent.swift (99%) diff --git a/Package.swift b/Package.swift index 3d104dff..4dd00454 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,10 @@ let package = Package( name: "ReviewUI", targets: ["ReviewUI"] ), + .library( + name: "ReviewUIPreviewSupport", + targets: ["ReviewUIPreviewSupport"] + ), .library( name: "TextTransitions", targets: ["TextTransitions"] @@ -97,13 +101,24 @@ let package = Package( "CodexReviewKit", "TextTransitions", .product(name: "CodexKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ .swiftLanguageMode(.v6), ] ), + .target( + name: "ReviewUIPreviewSupport", + dependencies: [ + .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexReviewKit", + "ReviewUI", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .target( name: "TextTransitions", swiftSettings: [ @@ -163,6 +178,7 @@ let package = Package( "CodexReviewKit", "CodexReviewTesting", "ReviewUI", + "ReviewUIPreviewSupport", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index e5c7d5f3..900aa07e 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -11,14 +11,14 @@ typealias ReviewMonitorContentTransitionAnimator = @MainActor ( ) -> Void @MainActor -final class ReviewMonitorRootViewController: NSViewController { +@_spi(PreviewSupport) +public 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 let appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? private var observation: PortableObservationTracking.Token? private var windowCancellable: AnyCancellable? private var presentedContentKind: ReviewMonitorContentKind? @@ -45,8 +45,25 @@ final class ReviewMonitorRootViewController: NSViewController { codexModelSource: ReviewMonitorCodexModelSource(modelContext: modelContext), contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings, - dependencyRetainer: nil, - appendPreviewChatLogStreamTickHandler: nil + dependencyRetainer: nil + ) + } + + @_spi(PreviewSupport) + public 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 ) } @@ -56,8 +73,7 @@ final class ReviewMonitorRootViewController: NSViewController { codexModelSource: ReviewMonitorCodexModelSource? = nil, contentTransitionAnimator: @escaping ReviewMonitorContentTransitionAnimator = ReviewMonitorRootViewController.defaultContentTransitionAnimator, showSettings: (@MainActor () -> Void)? = nil, - dependencyRetainer: AnyObject? = nil, - appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil + dependencyRetainer: AnyObject? = nil ) { self.store = store self.uiState = uiState @@ -65,7 +81,6 @@ final class ReviewMonitorRootViewController: NSViewController { self.contentTransitionAnimator = contentTransitionAnimator self.showSettings = showSettings self.dependencyRetainer = dependencyRetainer - self.appendPreviewChatLogStreamTickHandler = appendPreviewChatLogStreamTickHandler super.init(nibName: nil, bundle: nil) } @@ -78,7 +93,7 @@ final class ReviewMonitorRootViewController: NSViewController { observation?.cancel() } - override func loadView() { + public override func loadView() { let backgroundView = NSVisualEffectView() backgroundView.material = .underWindowBackground backgroundView.blendingMode = .behindWindow @@ -86,7 +101,7 @@ final class ReviewMonitorRootViewController: NSViewController { view = backgroundView } - override func viewDidLoad() { + public override func viewDidLoad() { super.viewDidLoad() bindWindowState() bindWindowAttachment() @@ -254,17 +269,13 @@ final class ReviewMonitorRootViewController: NSViewController { #if DEBUG @MainActor extension ReviewMonitorRootViewController { - func prepareForSwiftUIPreviewRendering() { + @_spi(PreviewSupport) + public func prepareForImmediateRenderingForTesting() { loadViewIfNeeded() - splitViewController.prepareForSwiftUIPreviewRendering() + splitViewController.prepareForImmediateRenderingForTesting() view.layoutSubtreeIfNeeded() } - @discardableResult - func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? { - await appendPreviewChatLogStreamTickHandler?(tick) - } - var splitViewControllerForTesting: ReviewMonitorSplitViewController { splitViewController } @@ -293,72 +304,4 @@ extension ReviewMonitorRootViewController { view.subviews.count } } - -@MainActor -func makeReviewMonitorPreviewContentViewController() -> NSViewController { - makeReviewMonitorPreviewContentViewControllerForPreview() -} - -@MainActor -func makeReviewMonitorPreviewContentViewControllerForPreview( - authPhase: CodexReviewAuthModel.Phase = .signedOut, - account: CodexReviewAccount? = nil, - serverState: CodexReviewServerState = .running, - previewContent: ReviewMonitorPreviewContentSource? = nil -) -> ReviewMonitorRootViewController { - 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) - if uiState.selection == nil { - uiState.selection = resolvedPreviewContent?.initialSelection - } - if let resolvedPreviewContent { - if ownsPreviewContent { - resolvedPreviewContent.startStreaming(interval: .milliseconds(40)) - } else { - resolvedPreviewContent.start() - } - } - let appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? - if let previewContent = resolvedPreviewContent { - appendPreviewChatLogStreamTickHandler = { tick in - let nextTick = await previewContent.appendPreviewChatLogStreamTick( - after: tick, - emitsNotifications: true - ) - return nextTick - } - } else { - appendPreviewChatLogStreamTickHandler = nil - } - return ReviewMonitorRootViewController( - store: store, - uiState: uiState, - codexModelSource: resolvedPreviewContent?.codexModelSource, - dependencyRetainer: resolvedPreviewContent, - appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler - ) -} #endif diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 80dc03e9..e3729c1f 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -518,7 +518,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe #if DEBUG @MainActor extension ReviewMonitorSplitViewController { - func prepareForSwiftUIPreviewRendering() { + func prepareForImmediateRenderingForTesting() { loadViewIfNeeded() sidebarViewController?.loadViewIfNeeded() transportViewController?.loadViewIfNeeded() diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index d171001c..6940252c 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -1,9 +1,10 @@ import Observation +import CodexKit import CodexReviewKit @MainActor @Observable -final class ReviewMonitorUIState { +public final class ReviewMonitorUIState { let auth: CodexReviewAuthModel private let persistSidebarReviewChatFilter: (SidebarReviewChatFilter) -> Void var selection: ReviewMonitorSelection? @@ -27,6 +28,16 @@ final class ReviewMonitorUIState { self.persistSidebarReviewChatFilter = persistSidebarReviewChatFilter } + @_spi(PreviewSupport) + public convenience init(previewSupportAuth auth: CodexReviewAuthModel) { + self.init(auth: auth) + } + + @_spi(PreviewSupport) + public func selectChat(id: CodexThreadID?) { + selection = id.map(ReviewMonitorSelection.chat) + } + var selectionID: ReviewMonitorSelectionID? { selection?.id } diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index e3aab1a7..524d563f 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -74,8 +74,7 @@ public final class ReviewMonitorWindowController: NSWindowController { initialSelection: ReviewMonitorSelection? = nil, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil, - dependencyRetainer: AnyObject? = nil, - appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil + dependencyRetainer: AnyObject? = nil ) { self.init( store: store, @@ -85,12 +84,32 @@ public final class ReviewMonitorWindowController: NSWindowController { frameAutosaveName: Self.frameAutosaveName, sidebarReviewChatFilterDefaults: sidebarReviewChatFilterDefaults, showSettings: showSettings, - dependencyRetainer: dependencyRetainer, - appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler + dependencyRetainer: dependencyRetainer ) } - init( + @_spi(PreviewSupport) + public 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, @@ -98,8 +117,7 @@ public final class ReviewMonitorWindowController: NSWindowController { frameAutosaveName: NSWindow.FrameAutosaveName, sidebarReviewChatFilterDefaults: UserDefaults? = .standard, showSettings: (@MainActor () -> Void)? = nil, - dependencyRetainer: AnyObject? = nil, - appendPreviewChatLogStreamTickHandler: (@MainActor (Int) async -> Int?)? = nil + dependencyRetainer: AnyObject? = nil ) { let uiState = Self.makeUIState( auth: store.auth, @@ -114,9 +132,18 @@ public final class ReviewMonitorWindowController: NSWindowController { codexModelSource: codexModelSource, contentTransitionAnimator: contentTransitionAnimator, showSettings: showSettings, - dependencyRetainer: dependencyRetainer, - appendPreviewChatLogStreamTickHandler: appendPreviewChatLogStreamTickHandler + 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], @@ -156,31 +183,6 @@ public final class ReviewMonitorWindowController: NSWindowController { } } -@_spi(PreviewSupport) -public extension ReviewMonitorWindowController { - convenience init( - previewContent: ReviewMonitorPreviewContentSource, - showSettings: (@MainActor () -> Void)? = nil - ) { - previewContent.startStreaming(interval: .milliseconds(40)) - self.init( - store: previewContent.store, - codexModelSource: previewContent.codexModelSource, - contentTransitionAnimator: ReviewMonitorRootViewController.defaultContentTransitionAnimator, - initialSelection: previewContent.initialSelection, - showSettings: showSettings, - dependencyRetainer: previewContent, - appendPreviewChatLogStreamTickHandler: { tick in - let nextTick = await previewContent.appendPreviewChatLogStreamTick( - after: tick, - emitsNotifications: true - ) - return nextTick - } - ) - } -} - enum ReviewMonitorSidebar {} extension ReviewMonitorSidebar { diff --git a/Sources/ReviewUI/SignIn/SignInView.swift b/Sources/ReviewUI/SignIn/SignInView.swift index 737a9a7b..caa2d2b8 100644 --- a/Sources/ReviewUI/SignIn/SignInView.swift +++ b/Sources/ReviewUI/SignIn/SignInView.swift @@ -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/ReviewMonitorServerStatusAccessoryViewController.swift b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift index 3cf5f613..d3541d74 100644 --- a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift +++ b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift @@ -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: CodexReviewAccount? = 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() -> CodexReviewAccount { - ReviewMonitorPreviewContent.makePreviewAccount() -} -#endif diff --git a/Sources/ReviewUI/ReviewMonitorContentPreview.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift similarity index 90% rename from Sources/ReviewUI/ReviewMonitorContentPreview.swift rename to Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift index c36a95b6..6f5a7881 100644 --- a/Sources/ReviewUI/ReviewMonitorContentPreview.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorContentPreview.swift @@ -1,6 +1,7 @@ #if DEBUG import AppKit import CodexReviewKit +import ReviewUI import SwiftUI #Preview("Normal") { @@ -29,7 +30,7 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable { var account: CodexReviewAccount? var serverState: CodexReviewServerState = .running - func makeNSViewController(context: Context) -> ReviewMonitorRootViewController { + func makeNSViewController(context: Context) -> NSViewController { let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( authPhase: authPhase, account: account, @@ -41,14 +42,14 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable { } func updateNSViewController( - _ nsViewController: ReviewMonitorRootViewController, + _ nsViewController: NSViewController, context: Context ) { } func sizeThatFits( _ proposal: ProposedViewSize, - nsViewController: ReviewMonitorRootViewController, + nsViewController: NSViewController, context: Context ) -> CGSize? { guard diff --git a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift similarity index 99% rename from Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift rename to Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index f81e927c..e1ed59d5 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -2,8 +2,7 @@ import CodexKit import CodexAppServerKitTesting import CodexReviewKit import Foundation -import Observation -import ObservationBridge +import ReviewUI @MainActor struct ReviewMonitorPreviewChatLogFixture { @@ -71,8 +70,8 @@ final class ReviewMonitorPreviewAppServerRuntime { }) } - var initialSelection: ReviewMonitorSelection? { - fixtures.first.map { .chat($0.chatID) } + var initialChatID: CodexThreadID? { + fixtures.first?.chatID } func chatPresentation(id: CodexThreadID) -> (title: String, subtitle: String)? { diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift new file mode 100644 index 00000000..15c119e0 --- /dev/null +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -0,0 +1,123 @@ +import AppKit +import CodexKit +import CodexReviewKit +import Foundation +import ObjectiveC +@_spi(PreviewSupport) 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(previewSupportAuth: 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( + previewContent: ReviewMonitorPreviewContentSource, + showSettings: (@MainActor () -> Void)? = nil + ) { + previewContent.startStreaming(interval: .milliseconds(40)) + let uiState = ReviewMonitorUIState(previewSupportAuth: previewContent.store.auth) + uiState.selectChat(id: previewContent.initialChatID) + self.init( + store: previewContent.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.prepareForImmediateRenderingForTesting() + } + + @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/ReviewUI/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift similarity index 99% rename from Sources/ReviewUI/ReviewMonitorPreviewContent.swift rename to Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 656cad24..65131507 100644 --- a/Sources/ReviewUI/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,8 +1,8 @@ import Foundation import CodexKit @_spi(Testing) import CodexReviewKit +import ReviewUI -@_spi(PreviewSupport) @MainActor public final class ReviewMonitorPreviewContentSource { public let store: CodexReviewStore @@ -18,8 +18,8 @@ public final class ReviewMonitorPreviewContentSource { self.codexModelSource = runtime.modelSource } - var initialSelection: ReviewMonitorSelection? { - runtime.initialSelection + var initialChatID: CodexThreadID? { + runtime.initialChatID } func start() { @@ -31,7 +31,7 @@ public final class ReviewMonitorPreviewContentSource { } @discardableResult - func appendPreviewChatLogStreamTick( + public func appendPreviewChatLogStreamTick( after tick: Int = 0, emitsNotifications: Bool = false ) async -> Int { @@ -41,12 +41,11 @@ public final class ReviewMonitorPreviewContentSource { ) } - func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { + public func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { runtime.snapshotForTesting(chatID: chatID) } } -@_spi(PreviewSupport) @MainActor public enum ReviewMonitorPreviewContent { fileprivate enum PreviewChatLifecycle { @@ -176,12 +175,10 @@ public enum ReviewMonitorPreviewContent { var reviewRuns: [ReviewRunRecord] } - @_spi(PreviewSupport) public static func makeStore() -> CodexReviewStore { makeStore(previewContent: makeSidebarContent()) } - @_spi(PreviewSupport) public static func makeContentSource() -> ReviewMonitorPreviewContentSource { let previewContent = makeSidebarContent() let store = makeStore(previewContent: previewContent) @@ -209,12 +206,10 @@ public enum ReviewMonitorPreviewContent { return store } - @_spi(PreviewSupport) public static func makeCommandOutputStore() -> CodexReviewStore { makeCommandOutputContentSource().store } - @_spi(PreviewSupport) public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { let store = CodexReviewStore.makePreviewStore( seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) @@ -546,7 +541,6 @@ public enum ReviewMonitorPreviewContent { ] } - @_spi(PreviewSupport) public static func makePreviewAccounts() -> [CodexReviewAccount] { [ makePreviewAccount( @@ -564,7 +558,6 @@ public enum ReviewMonitorPreviewContent { ] } - @_spi(PreviewSupport) public static func makePreviewAccount( email: String = "review@example.com", usedPercents: (short: Int, long: Int) = (short: 34, long: 61) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 10200292..80292a92 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -3,6 +3,26 @@ import Testing import CodexKit @_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @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 { diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 2bc09247..142a939b 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -6,6 +6,7 @@ import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI +import ReviewUIPreviewSupport import CodexReviewTesting @MainActor diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 003c3113..946d0fd7 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -8,6 +8,7 @@ import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit @_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUIPreviewSupport import CodexReviewTesting @MainActor diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj index 48be6374..84624022 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ F0A100000000000000000004 /* ReviewUI in Frameworks */ = {isa = PBXBuildFile; productRef = F0A100000000000000000005 /* ReviewUI */; }; F0A10000000000000000000A /* CodexReviewKit in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000B /* CodexReviewKit */; }; F0A10000000000000000000C /* CodexReviewHost in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000D /* CodexReviewHost */; }; + F0A10000000000000000000E /* ReviewUIPreviewSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000F /* ReviewUIPreviewSupport */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +62,7 @@ buildActionMask = 2147483647; files = ( F0A100000000000000000004 /* ReviewUI in Frameworks */, + F0A10000000000000000000E /* ReviewUIPreviewSupport in Frameworks */, F0A10000000000000000000A /* CodexReviewKit in Frameworks */, F0A10000000000000000000C /* CodexReviewHost in Frameworks */, ); @@ -116,6 +118,7 @@ name = CodexReviewMonitor; packageProductDependencies = ( F0A100000000000000000005 /* ReviewUI */, + F0A10000000000000000000F /* ReviewUIPreviewSupport */, F0A10000000000000000000B /* CodexReviewKit */, F0A10000000000000000000D /* CodexReviewHost */, ); @@ -575,6 +578,11 @@ 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 798083ef..9f05de89 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,6 @@ { - "originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76", + "originHash" : "208a11147004ff156a8f88d60010ca83a080f5ac9a772429fe0fcd768b73dbd9", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "77340166f494e16077a156b4a2a832d2cd88527d" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index baa5e519..11057385 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -10,6 +10,7 @@ import CodexDataKit import CodexReviewKit import CodexReviewHost @_spi(PreviewSupport) import ReviewUI +import ReviewUIPreviewSupport enum ReviewMonitorLaunchMode: Sendable { case application diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index e91a8d8b..bfc25280 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -3,6 +3,7 @@ import Foundation import CodexReviewKit import CodexReviewHost @_spi(PreviewSupport) @testable import ReviewUI +import ReviewUIPreviewSupport import Testing @testable import CodexReviewMonitor From 6eeebefcc59d45865d2181185137d6b06ddf55bc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:03:20 +0900 Subject: [PATCH 294/359] fix(review-ui): keep preview rendering helper available --- Sources/ReviewUI/ReviewMonitorContentView.swift | 9 ++++++--- Sources/ReviewUI/ReviewMonitorSplitViewController.swift | 7 +++++-- .../ReviewMonitorPreviewComposition.swift | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index 900aa07e..dc47c616 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -266,16 +266,19 @@ public final class ReviewMonitorRootViewController: NSViewController { } } -#if DEBUG @MainActor extension ReviewMonitorRootViewController { @_spi(PreviewSupport) - public func prepareForImmediateRenderingForTesting() { + public func prepareForImmediateRenderingForPreviewSupport() { loadViewIfNeeded() - splitViewController.prepareForImmediateRenderingForTesting() + splitViewController.prepareForImmediateRenderingForPreviewSupport() view.layoutSubtreeIfNeeded() } +} +#if DEBUG +@MainActor +extension ReviewMonitorRootViewController { var splitViewControllerForTesting: ReviewMonitorSplitViewController { splitViewController } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index e3729c1f..1599c3fc 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -515,16 +515,19 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe } -#if DEBUG @MainActor extension ReviewMonitorSplitViewController { - func prepareForImmediateRenderingForTesting() { + func prepareForImmediateRenderingForPreviewSupport() { loadViewIfNeeded() sidebarViewController?.loadViewIfNeeded() transportViewController?.loadViewIfNeeded() view.layoutSubtreeIfNeeded() } +} +#if DEBUG +@MainActor +extension ReviewMonitorSplitViewController { enum AddAccountToolbarItemModeForTesting: Equatable { case add case progress diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 15c119e0..37e04912 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -89,7 +89,7 @@ public extension NSViewController { view.layoutSubtreeIfNeeded() return } - rootViewController.prepareForImmediateRenderingForTesting() + rootViewController.prepareForImmediateRenderingForPreviewSupport() } @discardableResult From 998d7d79dc394c903ea7771e873b1614bb880b72 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:50:40 +0900 Subject: [PATCH 295/359] refactor(review-ui): narrow preview support API access --- Sources/ReviewUI/ReviewMonitorContentView.swift | 13 +++++-------- Sources/ReviewUI/ReviewMonitorUIState.swift | 6 ++---- .../ReviewUI/ReviewMonitorWindowController.swift | 9 +++------ .../ReviewMonitorPreviewComposition.swift | 14 +++++++++++++- .../ReviewMonitorChatLogTesting.swift | 2 +- .../ReviewMonitorCodexSidebarLibraryTests.swift | 2 +- Tests/ReviewUITests/ReviewUISettingsTests.swift | 2 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 2 +- Tests/ReviewUITests/ReviewUITests.swift | 2 +- .../CodexReviewMonitor/CodexReviewMonitorApp.swift | 4 ++-- .../CodexReviewMonitorCITests.swift | 2 +- 11 files changed, 31 insertions(+), 27 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index dc47c616..a7602512 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -11,8 +11,7 @@ typealias ReviewMonitorContentTransitionAnimator = @MainActor ( ) -> Void @MainActor -@_spi(PreviewSupport) -public final class ReviewMonitorRootViewController: NSViewController { +package final class ReviewMonitorRootViewController: NSViewController { private let uiState: ReviewMonitorUIState private let store: CodexReviewStore private let codexModelSource: ReviewMonitorCodexModelSource? @@ -49,8 +48,7 @@ public final class ReviewMonitorRootViewController: NSViewController { ) } - @_spi(PreviewSupport) - public convenience init( + package convenience init( store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, @@ -93,7 +91,7 @@ public final class ReviewMonitorRootViewController: NSViewController { observation?.cancel() } - public override func loadView() { + package override func loadView() { let backgroundView = NSVisualEffectView() backgroundView.material = .underWindowBackground backgroundView.blendingMode = .behindWindow @@ -101,7 +99,7 @@ public final class ReviewMonitorRootViewController: NSViewController { view = backgroundView } - public override func viewDidLoad() { + package override func viewDidLoad() { super.viewDidLoad() bindWindowState() bindWindowAttachment() @@ -268,8 +266,7 @@ public final class ReviewMonitorRootViewController: NSViewController { @MainActor extension ReviewMonitorRootViewController { - @_spi(PreviewSupport) - public func prepareForImmediateRenderingForPreviewSupport() { + package func prepareForImmediateRenderingForPreviewSupport() { loadViewIfNeeded() splitViewController.prepareForImmediateRenderingForPreviewSupport() view.layoutSubtreeIfNeeded() diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index 6940252c..f925e1ae 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -28,13 +28,11 @@ public final class ReviewMonitorUIState { self.persistSidebarReviewChatFilter = persistSidebarReviewChatFilter } - @_spi(PreviewSupport) - public convenience init(previewSupportAuth auth: CodexReviewAuthModel) { + package convenience init(previewSupportAuth auth: CodexReviewAuthModel) { self.init(auth: auth) } - @_spi(PreviewSupport) - public func selectChat(id: CodexThreadID?) { + package func selectChat(id: CodexThreadID?) { selection = id.map(ReviewMonitorSelection.chat) } diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 524d563f..870526bb 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -40,8 +40,7 @@ public final class ReviewMonitorWindowController: NSWindowController { ) } - @_spi(PreviewSupport) - public convenience init( + package convenience init( store: CodexReviewStore, showSettings: @escaping @MainActor () -> Void ) { @@ -53,8 +52,7 @@ public final class ReviewMonitorWindowController: NSWindowController { ) } - @_spi(PreviewSupport) - public convenience init( + package convenience init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource, showSettings: @escaping @MainActor () -> Void @@ -88,8 +86,7 @@ public final class ReviewMonitorWindowController: NSWindowController { ) } - @_spi(PreviewSupport) - public convenience init( + package convenience init( store: CodexReviewStore, uiState: ReviewMonitorUIState, codexModelSource: ReviewMonitorCodexModelSource? = nil, diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 37e04912..9a1d739a 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -3,7 +3,7 @@ import CodexKit import CodexReviewKit import Foundation import ObjectiveC -@_spi(PreviewSupport) import ReviewUI +import ReviewUI nonisolated(unsafe) private var previewContentSourceAssociationKey: UInt8 = 0 @@ -64,6 +64,18 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( } public extension ReviewMonitorWindowController { + convenience init( + previewSupportStore store: CodexReviewStore, + codexModelSource: ReviewMonitorCodexModelSource, + showSettings: @escaping @MainActor () -> Void + ) { + self.init( + store: store, + codexModelSource: codexModelSource, + showSettings: showSettings + ) + } + convenience init( previewContent: ReviewMonitorPreviewContentSource, showSettings: (@MainActor () -> Void)? = nil diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 80292a92..3b995d21 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -2,7 +2,7 @@ import Foundation import Testing import CodexKit @_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI @testable import ReviewUIPreviewSupport @MainActor diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 8bd1176c..4a60d0fc 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -4,7 +4,7 @@ import CodexAppServerKitTesting import Foundation import Testing @_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI @Suite("ReviewMonitor Codex sidebar library") @MainActor diff --git a/Tests/ReviewUITests/ReviewUISettingsTests.swift b/Tests/ReviewUITests/ReviewUISettingsTests.swift index 7a10623f..fd4aa5f5 100644 --- a/Tests/ReviewUITests/ReviewUISettingsTests.swift +++ b/Tests/ReviewUITests/ReviewUISettingsTests.swift @@ -2,7 +2,7 @@ import AppKit import Foundation import Testing @_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI import CodexReviewTesting @Suite(.serialized) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 142a939b..d9b5fe1f 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -5,7 +5,7 @@ import ObservationBridge import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI import ReviewUIPreviewSupport import CodexReviewTesting diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 946d0fd7..eb0d56b1 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -7,7 +7,7 @@ import CodexReviewKit import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI @testable import ReviewUIPreviewSupport import CodexReviewTesting diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 11057385..e8f7d0c1 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -9,7 +9,7 @@ import AppKit import CodexDataKit import CodexReviewKit import CodexReviewHost -@_spi(PreviewSupport) import ReviewUI +import ReviewUI import ReviewUIPreviewSupport enum ReviewMonitorLaunchMode: Sendable { @@ -304,7 +304,7 @@ struct ReviewMonitorAppComposition { ) } return ReviewMonitorWindowController( - store: store, + previewSupportStore: store, codexModelSource: codexModelSource, showSettings: showSettings ) diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index bfc25280..74879e9f 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -2,7 +2,7 @@ import AppKit import Foundation import CodexReviewKit import CodexReviewHost -@_spi(PreviewSupport) @testable import ReviewUI +@testable import ReviewUI import ReviewUIPreviewSupport import Testing @testable import CodexReviewMonitor From 9945afcf62dca4c2c7cac9d73af245d87b353824 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:43:03 +0900 Subject: [PATCH 296/359] refactor(preview): use app-server thread store for preview snapshots --- ...ReviewMonitorPreviewAppServerRuntime.swift | 193 ++++++++++-------- .../ReviewMonitorPreviewContent.swift | 4 +- .../ReviewMonitorChatLogTesting.swift | 63 +++--- Tests/ReviewUITests/ReviewUIShellTests.swift | 28 +-- Tests/ReviewUITests/ReviewUITests.swift | 6 +- 5 files changed, 164 insertions(+), 130 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index e1ed59d5..0905bf88 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -54,7 +54,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private let fixtures: [ReviewMonitorPreviewChatLogFixture] private let fixturesByChatID: [CodexThreadID: ReviewMonitorPreviewChatLogFixture] - private var snapshotStorageByChatID: [CodexThreadID: CodexChatSnapshot] + private let threadStore: CodexAppServerTestThreadStore private var runtime: CodexAppServerTestRuntime? private var container: CodexModelContainer? private var startTask: Task? @@ -65,9 +65,9 @@ final class ReviewMonitorPreviewAppServerRuntime { init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { self.fixtures = fixtures self.fixturesByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { ($0.chatID, $0) }) - self.snapshotStorageByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { - ($0.chatID, $0.initialSnapshot) - }) + self.threadStore = CodexAppServerTestThreadStore( + threads: fixtures.map(\.threadSnapshot) + ) } var initialChatID: CodexThreadID? { @@ -81,8 +81,8 @@ final class ReviewMonitorPreviewAppServerRuntime { return (title: fixture.title, subtitle: fixture.cwd) } - func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { - snapshotStorageByChatID[chatID] + func snapshotForTesting(chatID: CodexThreadID) async -> CodexChatSnapshot? { + await threadStore.snapshot(id: chatID)?.chatSnapshot } func upsertPreviewItem( @@ -90,18 +90,18 @@ final class ReviewMonitorPreviewAppServerRuntime { kind: CodexThreadItem.Kind, content: CodexThreadItem.Content, to chatID: CodexThreadID - ) { + ) async { guard let fixture = fixturesByChatID[chatID] else { return } - let turnID = snapshotStorageByChatID[chatID]?.turns.last?.id - let item = CodexChatItemSnapshot( + guard let item = await upsertStoredItem( id: id, - turnID: turnID, kind: kind, - content: content - ) - upsertStoredItem(item, in: chatID) + content: content, + in: fixture + ) else { + return + } start() enqueueNotification { [weak self] in await self?.emitItem(item, for: fixture) @@ -114,20 +114,20 @@ final class ReviewMonitorPreviewAppServerRuntime { itemID: String, kind: CodexThreadItem.Kind, content: CodexThreadItem.Content - ) { + ) async { guard delta.isEmpty == false, fixturesByChatID[chatID] != nil else { return } - let turnID = snapshotStorageByChatID[chatID]?.turns.last?.id - appendStoredText( + guard let item = await appendStoredText( delta, itemID: itemID, - turnID: turnID, kind: kind, content: content, in: chatID - ) + ) else { + return + } start() enqueueNotification { [weak self] in do { @@ -138,10 +138,10 @@ final class ReviewMonitorPreviewAppServerRuntime { try await self?.emitTextDelta( delta, itemID: itemID, - turnID: turnID ?? CodexTurnID(rawValue: "preview-turn"), + turnID: item.turnID ?? CodexTurnID(rawValue: "preview-turn"), chatID: chatID, kind: kind, - content: content, + content: item.content, runtime: runtime ) } catch { @@ -151,31 +151,12 @@ final class ReviewMonitorPreviewAppServerRuntime { private func enqueueNotification(_ operation: @escaping @MainActor () async -> Void) { let previousTask = notificationTask - notificationTask = Task { @MainActor [weak self] in + notificationTask = Task { @MainActor in await previousTask?.value - await self?.refreshRuntimeThreadStubs() await operation() } } - private func refreshRuntimeThreadStubs() async { - do { - try await ensureStarted() - guard let runtime else { - return - } - try await runtime.transport.stubThreads(currentThreadSnapshots()) - } catch { - } - } - - private func currentThreadSnapshots() -> [CodexThreadSnapshot] { - fixtures.map { fixture in - let snapshot = snapshotStorageByChatID[fixture.chatID] ?? fixture.initialSnapshot - return fixture.threadSnapshot(snapshot) - } - } - func start() { guard startTask == nil, runtime == nil else { return @@ -233,10 +214,12 @@ final class ReviewMonitorPreviewAppServerRuntime { ) else { continue } - apply(frame.step, cycle: frame.cycle, for: fixture) + guard let item = await apply(frame.step, cycle: frame.cycle, for: fixture) else { + continue + } if emitsNotifications { enqueueNotification { [weak self] in - await self?.emit(frame.step, cycle: frame.cycle, for: fixture) + await self?.emit(frame.step, item: item, for: fixture) } } } @@ -259,9 +242,7 @@ final class ReviewMonitorPreviewAppServerRuntime { if runtime != nil { return } - let runtime = try await CodexAppServerTestRuntime.start( - threads: fixtures.map(\.threadSnapshot) - ) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore) let container = CodexModelContainer(appServer: runtime.server) self.runtime = runtime self.container = container @@ -270,35 +251,27 @@ final class ReviewMonitorPreviewAppServerRuntime { private func emit( _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, - cycle: Int, + item: CodexChatItemSnapshot, for fixture: ReviewMonitorPreviewChatLogFixture ) async { guard let runtime else { return } - let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( - itemName: step.itemName, - streamID: fixture.streamID, - cycle: cycle - ) - let turnID = fixture.initialSnapshot.turns.last?.id ?? CodexTurnID(rawValue: "preview-turn") + let turnID = item.turnID ?? fixture.initialSnapshot.turns.last?.id ?? CodexTurnID(rawValue: "preview-turn") do { switch step.mode { case .textDelta: try await emitTextDelta( step.deltaText ?? "", - itemID: itemID, + itemID: item.id, turnID: turnID, chatID: fixture.chatID, - kind: step.kind, - content: step.content, + kind: item.kind, + content: item.content, runtime: runtime ) case .update, .complete: - await emitItem( - step.itemSnapshot(id: itemID, turnID: turnID), - for: fixture - ) + await emitItem(item, for: fixture) } } catch { } @@ -308,7 +281,7 @@ final class ReviewMonitorPreviewAppServerRuntime { _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, cycle: Int, for fixture: ReviewMonitorPreviewChatLogFixture - ) { + ) async -> CodexChatItemSnapshot? { let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( itemName: step.itemName, streamID: fixture.streamID, @@ -316,15 +289,16 @@ final class ReviewMonitorPreviewAppServerRuntime { ) switch step.mode { case .update, .complete: - upsertStoredItem( - step.itemSnapshot(id: itemID, turnID: snapshotStorageByChatID[fixture.chatID]?.turns.last?.id), - in: fixture.chatID + return await upsertStoredItem( + id: itemID, + kind: step.kind, + content: step.content, + in: fixture ) case .textDelta: - appendStoredText( + return await appendStoredText( step.deltaText ?? "", itemID: itemID, - turnID: snapshotStorageByChatID[fixture.chatID]?.turns.last?.id, kind: step.kind, content: step.content, in: fixture.chatID @@ -332,33 +306,45 @@ final class ReviewMonitorPreviewAppServerRuntime { } } - private func upsertStoredItem(_ item: CodexChatItemSnapshot, in chatID: CodexThreadID) { - guard var snapshot = snapshotStorageByChatID[chatID] else { - return - } - if let index = snapshot.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { - snapshot.items[index] = item - } else { - snapshot.items.append(item) + private func upsertStoredItem( + id: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, + in fixture: ReviewMonitorPreviewChatLogFixture + ) async -> CodexChatItemSnapshot? { + await updateStoredSnapshot(for: fixture) { snapshot in + let item = CodexChatItemSnapshot( + id: id, + turnID: snapshot.turns.last?.id, + kind: kind, + content: content + ) + if let index = snapshot.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { + snapshot.items[index] = item + } else { + snapshot.items.append(item) + } + return item } - snapshotStorageByChatID[chatID] = snapshot } private func appendStoredText( _ delta: String, itemID: String, - turnID: CodexTurnID?, kind: CodexThreadItem.Kind, content: CodexThreadItem.Content, in chatID: CodexThreadID - ) { + ) async -> CodexChatItemSnapshot? { guard delta.isEmpty == false, - var snapshot = snapshotStorageByChatID[chatID] else { - return + let fixture = fixturesByChatID[chatID] else { + return nil } - if let index = snapshot.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { - snapshot.items[index].content.appendPreviewText(delta) - } else { + return await updateStoredSnapshot(for: fixture) { snapshot in + let turnID = snapshot.turns.last?.id + if let index = snapshot.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { + snapshot.items[index].content.appendPreviewText(delta) + return snapshot.items[index] + } var item = CodexChatItemSnapshot( id: itemID, turnID: turnID, @@ -367,8 +353,20 @@ final class ReviewMonitorPreviewAppServerRuntime { ) item.content.appendPreviewText(delta) snapshot.items.append(item) + return item + } + } + + private func updateStoredSnapshot( + for fixture: ReviewMonitorPreviewChatLogFixture, + _ mutation: (inout CodexChatSnapshot) -> CodexChatItemSnapshot? + ) async -> CodexChatItemSnapshot? { + guard var snapshot = await threadStore.snapshot(id: fixture.chatID)?.chatSnapshot, + let item = mutation(&snapshot) else { + return nil } - snapshotStorageByChatID[chatID] = snapshot + await threadStore.upsert(fixture.threadSnapshot(snapshot)) + return item } private func emitItem( @@ -697,6 +695,37 @@ private extension ReviewMonitorPreviewChatLogFixture { } } +private extension CodexThreadSnapshot { + var chatSnapshot: CodexChatSnapshot { + let turnSnapshots = turns ?? [] + let chatTurns = turnSnapshots.map { turn in + CodexChatTurnStateSnapshot( + id: turn.id, + status: turn.status, + errorDescription: turn.errorMessage, + usage: nil + ) + } + let chatItems = turnSnapshots.flatMap { turn in + turn.items.map { item in + CodexChatItemSnapshot( + id: item.id, + turnID: turn.id, + kind: item.kind, + content: item.content, + rawPayload: item.rawPayload + ) + } + } + return CodexChatSnapshot( + chatID: id, + phase: turnSnapshots.compactMap(\.errorMessage).first.map(CodexDataPhase.failed) ?? .loaded, + turns: chatTurns, + items: chatItems + ) + } +} + private extension CodexThreadItem.Content { mutating func appendPreviewText(_ delta: String) { switch self { diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 65131507..41b84dcb 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -41,8 +41,8 @@ public final class ReviewMonitorPreviewContentSource { ) } - public func snapshotForTesting(chatID: CodexThreadID) -> CodexChatSnapshot? { - runtime.snapshotForTesting(chatID: chatID) + public func snapshotForTesting(chatID: CodexThreadID) async -> CodexChatSnapshot? { + await runtime.snapshotForTesting(chatID: chatID) } } diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 3b995d21..7d436a28 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -616,6 +616,7 @@ private final class ReviewChatLogFixtureRetainer { weak var store: CodexReviewStore? let runtime: ReviewMonitorPreviewAppServerRuntime private var chatIDs: Set + private var upsertTask: Task? init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { self.store = store @@ -628,35 +629,39 @@ private final class ReviewChatLogFixtureRetainer { } func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) { - let previousItemsByID = Dictionary( - uniqueKeysWithValues: runtime.snapshotForTesting(chatID: chatID)?.items.map { ($0.id, $0) } ?? [] - ) - for item in items { - guard let previousItem = previousItemsByID[item.id] else { - 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) { - runtime.appendPreviewText( - outputDelta, - to: chatID, - itemID: item.id, - kind: item.kind, - content: previousItem.content - ) - } else if let textDelta = previousItem.textDeltaForTesting(to: item) { - runtime.appendPreviewText( - textDelta, - to: chatID, - itemID: item.id, - kind: item.kind, - content: previousItem.content - ) - } else { - runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + let previousTask = upsertTask + upsertTask = Task { @MainActor [runtime] in + await previousTask?.value + 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) + } } } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index d9b5fe1f..8cfe6dd3 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -116,7 +116,7 @@ extension ReviewUITests { let previewContent = ReviewMonitorPreviewContent.makeContentSource() let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) - let selectedSnapshot = try #require(previewContent.snapshotForTesting(chatID: selectedChatID)) + let selectedSnapshot = try #require(await previewContent.snapshotForTesting(chatID: selectedChatID)) let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewContent: previewContent @@ -846,7 +846,7 @@ extension ReviewUITests { let previewContent = ReviewMonitorPreviewContent.makeContentSource() let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) - let selectedSnapshot = try #require(previewContent.snapshotForTesting(chatID: selectedChatID)) + let selectedSnapshot = try #require(await previewContent.snapshotForTesting(chatID: selectedChatID)) let expectedLogText = try #require(selectedSnapshot.items.compactMap { $0.text }.first) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewContent: previewContent @@ -1634,12 +1634,12 @@ extension ReviewUITests { @Test func previewRunningChatsAppendPseudoStreamWhenTicked() async throws { let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count await source.appendPreviewChatLogStreamTick() - let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + 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) @@ -1654,7 +1654,7 @@ extension ReviewUITests { @Test func previewChatStreamUsesMixedLogKinds() async throws { let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 @@ -1662,7 +1662,7 @@ extension ReviewUITests { tick = await source.appendPreviewChatLogStreamTick(after: tick) } - let updatedSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + 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")) @@ -1688,29 +1688,29 @@ extension ReviewUITests { @Test func previewChatStreamWaitsAfterEachCompletedItemAndDrainsChunks() async throws { let source = ReviewMonitorPreviewContent.makeContentSource() let runningChatID = CodexThreadID(rawValue: "preview-thread-0-0") - let initialSnapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + let initialSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) let initialItemCount = initialSnapshot.items.count var tick = 0 tick = await source.appendPreviewChatLogStreamTick(after: tick) - var snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + 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 = await source.appendPreviewChatLogStreamTick(after: tick) } - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) tick = await source.appendPreviewChatLogStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 2) #expect(snapshot.items.last?.kind.rawValue == "plan") for _ in 0..<180 where snapshot.items.last?.kind.rawValue != "reasoning" { tick = await source.appendPreviewChatLogStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) } let firstReasoning = try #require(snapshot.items.last) #expect(firstReasoning.kind.rawValue == "reasoning") @@ -1720,7 +1720,7 @@ extension ReviewUITests { for _ in 0..<80 { tick = await source.appendPreviewChatLogStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + 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 { @@ -1734,11 +1734,11 @@ extension ReviewUITests { for _ in 0..<37 { tick = await source.appendPreviewChatLogStreamTick(after: tick) } - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning) tick = await source.appendPreviewChatLogStreamTick(after: tick) - snapshot = try #require(source.snapshotForTesting(chatID: runningChatID)) + snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == countAfterReasoning + 1) #expect(snapshot.items.last?.kind.rawValue == "commandExecution") } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index eb0d56b1..94eb16fa 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2337,7 +2337,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - previewRuntime.appendPreviewText( + await previewRuntime.appendPreviewText( " log", to: chatID, itemID: "msg_1", @@ -2382,7 +2382,7 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) _ = try await awaitTransportRender(transport) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting - previewRuntime.upsertPreviewItem( + await previewRuntime.upsertPreviewItem( id: "progress_1", kind: CodexThreadItem.Kind(rawValue: "progress"), content: .diagnostic("stream.tick 001"), @@ -2825,7 +2825,7 @@ struct ReviewUITests { ) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - previewRuntime.upsertPreviewItem( + await previewRuntime.upsertPreviewItem( id: "fixture-log-\(chat.id)", kind: .agentMessage, content: .message(.init(id: "fixture-log-\(chat.id)", role: .assistant, text: "Initial log")), From a4736d65d76782aca2e113844f909dec18284ab0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:54:53 +0900 Subject: [PATCH 297/359] fix(preview): preserve fake app-server thread ordering --- ...ReviewMonitorPreviewAppServerRuntime.swift | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 0905bf88..7c23e28c 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -54,7 +54,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private let fixtures: [ReviewMonitorPreviewChatLogFixture] private let fixturesByChatID: [CodexThreadID: ReviewMonitorPreviewChatLogFixture] - private let threadStore: CodexAppServerTestThreadStore + private var threadStore: CodexAppServerTestThreadStore private var runtime: CodexAppServerTestRuntime? private var container: CodexModelContainer? private var startTask: Task? @@ -365,10 +365,41 @@ final class ReviewMonitorPreviewAppServerRuntime { let item = mutation(&snapshot) else { return nil } - await threadStore.upsert(fixture.threadSnapshot(snapshot)) + await replaceThreadStorePreservingFixtureOrder( + with: fixture.threadSnapshot(snapshot) + ) return item } + 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 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 { + try await runtime?.transport.stubThreads(replacementStore) + } catch { + } + } + private func emitItem( _ item: CodexChatItemSnapshot, for fixture: ReviewMonitorPreviewChatLogFixture From 4274886975188f8a4c81300f0a4dc914cc955b2f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:12:28 +0900 Subject: [PATCH 298/359] fix(preview): serialize preview snapshot mutations --- ...ReviewMonitorPreviewAppServerRuntime.swift | 38 +++-- .../ReviewMonitorChatLogTesting.swift | 85 ++++++------ Tests/ReviewUITests/ReviewUITests.swift | 130 +++++++++--------- 3 files changed, 135 insertions(+), 118 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 7c23e28c..675b4c70 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -4,6 +4,24 @@ 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 + } +} + @MainActor struct ReviewMonitorPreviewChatLogFixture { let chatID: CodexThreadID @@ -60,6 +78,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private var startTask: Task? private var streamTask: Task? private var notificationTask: Task? + private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue() private var tick = 0 init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { @@ -359,16 +378,19 @@ final class ReviewMonitorPreviewAppServerRuntime { private func updateStoredSnapshot( for fixture: ReviewMonitorPreviewChatLogFixture, - _ mutation: (inout CodexChatSnapshot) -> CodexChatItemSnapshot? + _ mutation: @escaping @Sendable (inout CodexChatSnapshot) -> CodexChatItemSnapshot? ) async -> CodexChatItemSnapshot? { - guard var snapshot = await threadStore.snapshot(id: fixture.chatID)?.chatSnapshot, - let item = mutation(&snapshot) else { - return nil + await snapshotMutationQueue.run { @MainActor [weak self] in + guard let self, + var snapshot = await self.threadStore.snapshot(id: fixture.chatID)?.chatSnapshot, + let item = mutation(&snapshot) else { + return nil + } + await self.replaceThreadStorePreservingFixtureOrder( + with: fixture.threadSnapshot(snapshot) + ) + return item } - await replaceThreadStorePreservingFixtureOrder( - with: fixture.threadSnapshot(snapshot) - ) - return item } private func replaceThreadStorePreservingFixtureOrder( diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 7d436a28..76eff534 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -220,8 +220,8 @@ func appendChatLogEntryForTesting( _ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID -) { - ReviewChatLogFixtureStore.append(entry, to: chatID, turnID: turnID) +) async { + await ReviewChatLogFixtureStore.append(entry, to: chatID, turnID: turnID) } @MainActor @@ -230,8 +230,8 @@ func replaceChatLogTextForTesting( for chatID: CodexThreadID, fixtureID: String, turnID: CodexTurnID -) { - ReviewChatLogFixtureStore.replaceEntries( +) async { + await ReviewChatLogFixtureStore.replaceEntries( [.init(kind: .agentMessage, groupID: "fixture-log-\(fixtureID)", text: text.trimmingCharacters(in: .newlines))], for: chatID, turnID: turnID @@ -572,14 +572,14 @@ private enum ReviewChatLogFixtureStore { _ entries: [ReviewChatLogEntryForTesting], for chatID: CodexThreadID, turnID: CodexTurnID - ) { + ) async { setEntries(entries, for: chatID) - updateRetainedPreviewSource(for: chatID, turnID: turnID) + await updateRetainedPreviewSource(for: chatID, turnID: turnID) } - static func append(_ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID) { + static func append(_ entry: ReviewChatLogEntryForTesting, to chatID: CodexThreadID, turnID: CodexTurnID) async { entriesByChatID[chatID, default: []].append(entry) - updateRetainedPreviewSource(for: chatID, turnID: turnID) + await updateRetainedPreviewSource(for: chatID, turnID: turnID) } static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexChatItemSnapshot] { @@ -603,10 +603,10 @@ private enum ReviewChatLogFixtureStore { return ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: displayDocument.text) } - private static func updateRetainedPreviewSource(for chatID: CodexThreadID, turnID: CodexTurnID) { + private static func updateRetainedPreviewSource(for chatID: CodexThreadID, turnID: CodexTurnID) async { prunePreviewSupportRetainersByStore() for support in runStorePreviewSupportRetainers where support.contains(chatID: chatID) { - support.upsert(chatID: chatID, items: items(for: chatID, turnID: turnID)) + await support.upsert(chatID: chatID, items: items(for: chatID, turnID: turnID)) } } } @@ -616,7 +616,6 @@ private final class ReviewChatLogFixtureRetainer { weak var store: CodexReviewStore? let runtime: ReviewMonitorPreviewAppServerRuntime private var chatIDs: Set - private var upsertTask: Task? init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { self.store = store @@ -628,40 +627,36 @@ private final class ReviewChatLogFixtureRetainer { chatIDs.contains(chatID) } - func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) { - let previousTask = upsertTask - upsertTask = Task { @MainActor [runtime] in - await previousTask?.value - 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) - } + func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) 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) } } } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 94eb16fa..47d3d84d 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -811,13 +811,13 @@ struct ReviewUITests { viewController.performTextFinderAction(findItem) #expect(transport.logFindBarVisibleForTesting) - replaceChatLogTextForTesting( + await replaceChatLogTextForTesting( "Old selection log", for: activeChat.chatID, fixtureID: activeChat.id, turnID: activeChat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Current selection log after stale mutation"), to: recentChat.chatID, turnID: recentChat.turnID @@ -933,7 +933,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .agentMessage, groupID: "message-direct", text: "Selected chat detail update"), to: chat.chatID, turnID: chat.turnID @@ -942,7 +942,7 @@ struct ReviewUITests { var snapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(snapshot.log == "Selected chat detail update") - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .command, groupID: "cmd-direct", @@ -952,7 +952,7 @@ struct ReviewUITests { to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd-direct", @@ -1017,7 +1017,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd-failed-direct", @@ -1073,7 +1073,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd-running-direct", @@ -1131,7 +1131,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .fileChange, groupID: "file-change-direct", @@ -1209,7 +1209,7 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("Automatically compacting context")) #expect(transport.logCommandOutputPanelCountForTesting == 0) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .contextCompaction, groupID: "compact_1", @@ -1338,7 +1338,7 @@ struct ReviewUITests { transport.logCommandOutputPanelOutputScrollVerticalOffsetForTesting) #expect(scrolledOutputScrollOffset < initialOutputScrollMaximumOffset) let expandedOutputAppendReloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd_1", @@ -1376,7 +1376,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("output line 9") == false) #expect(transport.logFindStringForTesting.contains("output line 9") == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd_1", @@ -1386,7 +1386,7 @@ struct ReviewUITests { to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .agentMessage, text: "Visible text after command output."), to: chat.chatID, turnID: chat.turnID @@ -1504,7 +1504,7 @@ struct ReviewUITests { #expect(transport.displayedLogForTesting.contains("Running swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .commandOutput, groupID: "cmd_1", @@ -1582,7 +1582,7 @@ struct ReviewUITests { #expect(transport.logFindStringForTesting.contains("$ swift test") == false) #expect(transport.logFindStringForTesting.contains("output line 3") == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .commandOutput, groupID: "cmd_1", text: "\noutput line 6"), to: chat.chatID, turnID: chat.turnID @@ -1790,7 +1790,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest active line"), to: activeChat.chatID, turnID: activeChat.turnID @@ -1980,7 +1980,7 @@ struct ReviewUITests { ) expectLogVisibleFragmentsWithoutForcingLayout(transport) - replaceChatLogTextForTesting( + await replaceChatLogTextForTesting( longLog, for: shortChat.chatID, fixtureID: shortChat.id, @@ -2034,12 +2034,12 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "stale update"), to: activeChat.chatID, turnID: activeChat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "fresh update"), to: recentChat.chatID, turnID: recentChat.turnID @@ -2255,7 +2255,7 @@ struct ReviewUITests { #expect(emptySnapshot.log.isEmpty) #expect(window.title == "") #expect(window.subtitle == "") - replaceChatLogTextForTesting( + await replaceChatLogTextForTesting( "Deselected log", for: chat.chatID, fixtureID: chat.id, @@ -2292,7 +2292,7 @@ struct ReviewUITests { ) #expect(selectedSnapshot.title == nil) #expect(selectedSnapshot.summary == nil) - replaceChatLogTextForTesting( + await replaceChatLogTextForTesting( "Updated log", for: chat.chatID, fixtureID: chat.id, @@ -2452,12 +2452,12 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .agentMessage, groupID: "msg_1", text: " one"), to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .agentMessage, groupID: "msg_1", text: " two"), to: chat.chatID, turnID: chat.turnID @@ -2490,12 +2490,12 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, groupID: "progress_1", text: "stream.tick 001"), to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, groupID: "progress_2", text: "stream.tick 002"), to: chat.chatID, turnID: chat.turnID @@ -2530,12 +2530,12 @@ struct ReviewUITests { ) transport.setLogReduceMotionForTesting(false) let wordGlowCount = transport.logWordGlowCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"), to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .progress, groupID: "progress_1", @@ -2579,7 +2579,7 @@ struct ReviewUITests { #expect( abs(transport.logMaximumVerticalScrollOffsetForTesting - transport.logMinimumVerticalScrollOffsetForTesting) < 0.5) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .progress, text: @@ -2624,7 +2624,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .plan, groupID: "plan_1", replacesGroup: true, text: "- updated"), to: chat.chatID, turnID: chat.turnID @@ -2669,7 +2669,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .command, groupID: "cmd_1", @@ -2686,7 +2686,7 @@ struct ReviewUITests { to: chat.chatID, turnID: chat.turnID ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .rawReasoning, groupID: "reasoning_2", @@ -2730,7 +2730,7 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let replaceCount = transport.logReplaceCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .agentMessage, groupID: "msg_1", text: "ld**"), to: chat.chatID, turnID: chat.turnID @@ -2766,7 +2766,7 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .plan, groupID: "plan_1", @@ -2780,7 +2780,7 @@ struct ReviewUITests { let replaceCount = transport.logReplaceCountForTesting let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .commandOutput, groupID: "cmd_1", text: "hidden output"), to: chat.chatID, turnID: chat.turnID @@ -2861,7 +2861,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) transport.setLogReduceMotionForTesting(false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"), to: chat.chatID, turnID: chat.turnID @@ -2873,7 +2873,7 @@ struct ReviewUITests { transport.completeLogWordGlowAnimationsForTesting() #expect(transport.logWordGlowCountForTesting == 0) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " again"), to: chat.chatID, turnID: chat.turnID @@ -2883,7 +2883,7 @@ struct ReviewUITests { #expect(transport.logWordGlowCountForTesting == 1) transport.setLogReduceMotionForTesting(true) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " without animation"), to: chat.chatID, turnID: chat.turnID @@ -2934,7 +2934,7 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " hidden backlog"), to: firstChat.chatID, turnID: firstChat.turnID @@ -2948,7 +2948,7 @@ struct ReviewUITests { ) #expect(transport.logWordGlowCountForTesting == 0) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " live"), to: firstChat.chatID, turnID: firstChat.turnID @@ -2984,7 +2984,7 @@ struct ReviewUITests { transport.setLogReduceMotionForTesting(false) let invalidationCount = transport.logWordFadeDisplayInvalidationCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " through options"), to: chat.chatID, turnID: chat.turnID @@ -3029,7 +3029,7 @@ struct ReviewUITests { ) transport.setLogReduceMotionForTesting(false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .rawReasoning, groupID: "reasoning_1", text: " ok"), to: chat.chatID, turnID: chat.turnID @@ -3074,7 +3074,7 @@ struct ReviewUITests { transport.scrollLogToTopForTesting() #expect(transport.isLogPinnedToBottomForTesting == false) let unpinnedAutoFollow = transport.logAutoFollowCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Unpinned update"), to: chat.chatID, turnID: chat.turnID @@ -3086,7 +3086,7 @@ struct ReviewUITests { transport.scrollLogToBottomForTesting() #expect(transport.isLogPinnedToBottomForTesting) let pinnedAutoFollow = transport.logAutoFollowCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Pinned update"), to: chat.chatID, turnID: chat.turnID @@ -3125,7 +3125,7 @@ struct ReviewUITests { let wrappedLine = (0..<140) .map { "wrapped-append-segment-\($0)" } .joined(separator: " ") - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: wrappedLine), to: chat.chatID, turnID: chat.turnID @@ -3172,7 +3172,7 @@ struct ReviewUITests { let offsetBeforeAppend = transport.logVerticalScrollOffsetForTesting let autoFollowBeforeAppend = transport.logAutoFollowCountForTesting let programmaticScrollsBeforeAppend = transport.logProgrammaticScrollCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init( kind: .progress, text: "Near-bottom append should not snap inertial or manual scrolling to the document end" @@ -3218,7 +3218,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest line"), to: chat.chatID, turnID: chat.turnID @@ -3259,7 +3259,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.scrollLogToBottomForTesting() let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest line"), to: chat.chatID, turnID: chat.turnID @@ -3297,7 +3297,7 @@ struct ReviewUITests { transport.setLogScrollerStyleForTesting(.overlay) transport.setLogOverlayScrollersShownForTesting(true) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "short update"), to: chat.chatID, turnID: chat.turnID @@ -3395,7 +3395,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingScrollerImpPair) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest line"), to: chat.chatID, turnID: chat.turnID @@ -3435,7 +3435,7 @@ struct ReviewUITests { transport.setLogOverlayScrollersShownForTesting(true) transport.setLogOverlayScrollerBridgeModeForTesting(.missingHideMethods) let hideCountBeforeAppend = transport.logOverlayScrollerHideRequestCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest line"), to: chat.chatID, turnID: chat.turnID @@ -3541,7 +3541,7 @@ struct ReviewUITests { allowIncrementalUpdate: false ) let appendCount = transport.logAppendCountForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "Newest fragment line"), to: chat.chatID, turnID: chat.turnID @@ -3729,7 +3729,7 @@ struct ReviewUITests { } #expect(transport.logFindClientUsesSnapshotForTesting) #expect(transport.logHasActiveFindQueryForTesting) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended"), to: chat.chatID, turnID: chat.turnID @@ -3759,7 +3759,7 @@ struct ReviewUITests { #expect(transport.isLogPinnedToBottomForTesting == false) let offsetBeforeMiddleAppend = transport.logVerticalScrollOffsetForTesting - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended while the log is not following bottom"), to: chat.chatID, turnID: chat.turnID @@ -3870,7 +3870,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended"), to: firstChat.chatID, turnID: firstChat.turnID @@ -3934,7 +3934,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: appendedLine), to: firstChat.chatID, turnID: firstChat.turnID @@ -4045,7 +4045,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended"), to: chat.chatID, turnID: chat.turnID @@ -4090,7 +4090,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended into snapshot"), to: chat.chatID, turnID: chat.turnID @@ -4104,7 +4104,7 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logSelectedTextForTesting == nil) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended after cleared selection"), to: chat.chatID, turnID: chat.turnID @@ -4146,7 +4146,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(firstNeedleRange) viewController.performTextFinderAction(textFinderMenuItemForTesting(.setSearchString)) viewController.performTextFinderAction(textFinderMenuItemForTesting(.showFindInterface)) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended into snapshot"), to: chat.chatID, turnID: chat.turnID @@ -4161,7 +4161,7 @@ struct ReviewUITests { #expect(transport.logFindClientFirstSelectedRangeForTesting.length == 0) #expect(transport.logHasActiveFindQueryForTesting == false) #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended after cleared query"), to: chat.chatID, turnID: chat.turnID @@ -4205,7 +4205,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logVisibleFindBarSearchStringForTesting == "") #expect(transport.logFindClientUsesSnapshotForTesting == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "future-only needle"), to: chat.chatID, turnID: chat.turnID @@ -4250,7 +4250,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("core")) #expect(transport.logVisibleFindBarSearchStringForTesting == "core") #expect(transport.logHasActiveFindQueryForTesting) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "core appended while query is visible"), to: chat.chatID, turnID: chat.turnID @@ -4297,7 +4297,7 @@ struct ReviewUITests { #expect(transport.setLogVisibleFindBarSearchStringForTesting("alpha")) #expect(transport.logFindStringLengthForTesting == initialLength) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "beta appended after active search"), to: chat.chatID, turnID: chat.turnID @@ -4349,7 +4349,7 @@ struct ReviewUITests { transport.setSelectedLogRangeForTesting(normalSelectionRange) #expect(transport.logSelectedTextForTesting == "copyable") #expect(transport.logHasActiveFindQueryForTesting == false) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle appended after normal selection"), to: chat.chatID, turnID: chat.turnID @@ -4396,7 +4396,7 @@ struct ReviewUITests { #expect(transport.logHasActiveFindQueryForTesting) let initialLength = (reviewChatLogText(for: chat) as NSString).length - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "active query appears after no-result search"), to: chat.chatID, turnID: chat.turnID @@ -4461,7 +4461,7 @@ struct ReviewUITests { #expect(transport.logFindBarVisibleForTesting) #expect(transport.setLogVisibleFindBarSearchStringForTesting("")) #expect(transport.logFindStringLengthForTesting == 0) - appendChatLogEntryForTesting( + await appendChatLogEntryForTesting( .init(kind: .progress, text: "needle first content"), to: chat.chatID, turnID: chat.turnID From 07b795e259524630d177ed49c0cceafa4e572463 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:27:00 +0900 Subject: [PATCH 299/359] fix(preview): rebind thread store during startup --- .../ReviewMonitorPreviewAppServerRuntime.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 675b4c70..f789f078 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -264,10 +264,19 @@ final class ReviewMonitorPreviewAppServerRuntime { let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore) let container = CodexModelContainer(appServer: runtime.server) self.runtime = runtime + try await rebindRuntimeToCurrentThreadStore(runtime) 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) + } while reboundStore !== threadStore + } + private func emit( _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, item: CodexChatItemSnapshot, @@ -417,7 +426,9 @@ final class ReviewMonitorPreviewAppServerRuntime { ) threadStore = replacementStore do { - try await runtime?.transport.stubThreads(replacementStore) + if let runtime { + try await rebindRuntimeToCurrentThreadStore(runtime) + } } catch { } } From 6a3313868b53e46cd56201420d443e6ba14897e1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:43:32 +0900 Subject: [PATCH 300/359] fix(review-ui): fall back to active chat cancellation --- .../ReviewMonitorChatContextMenuView.swift | 11 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 103 +++++++++++++++++- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index e9be6b78..82ee76c1 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -21,13 +21,8 @@ struct ReviewMonitorChatContextMenuView: View { private var isRunning: Bool { let chatID = chat.id.rawValue - if store.hasCancellableReview(forChatID: chatID) { - return true - } - if store.hasReviewRun(forChatID: chatID) { - return false - } - return chat.status?.isActive == true + return store.hasCancellableReview(forChatID: chatID) + || chat.status?.isActive == true } private func cancel() { @@ -35,7 +30,7 @@ struct ReviewMonitorChatContextMenuView: View { Task { if store.hasCancellableReview(forChatID: chatID) { _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface()) - } else if store.hasReviewRun(forChatID: chatID) == false { + } else if chat.status?.isActive == true { _ = try? await chat.cancel() } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 8cfe6dd3..5f11a296 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1,4 +1,5 @@ import AppKit +import CodexAppServerKitTesting import CodexKit import Foundation import ObservationBridge @@ -102,14 +103,99 @@ extension ReviewUITests { #expect(store.reviewRun(id: run.id)?.core.lifecycle.cancellation?.source == .userInterface) #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) - var cancelItemDisabledAfterCancellation = false + var cancelItemEnabledAfterCancellation = false sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in guard let cancelItem = menu.items.first(where: { $0.title == "Cancel" }) else { return } - cancelItemDisabledAfterCancellation = cancelItem.isEnabled == false + cancelItemEnabledAfterCancellation = cancelItem.isEnabled } - #expect(cancelItemDisabledAfterCancellation) + #expect(cancelItemEnabledAfterCancellation) + } + + @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.hasReviewRun(forChatID: chatID.rawValue)) + #expect(store.hasCancellableReview(forChatID: chatID.rawValue) == false) + + 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 previewContentViewControllerRendersSelectedChatLogDuringViewLifecycle() async throws { @@ -1751,6 +1837,17 @@ private func previewSelectedChatID(in store: CodexReviewStore) -> CodexThreadID? 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 diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { if case .diagnostic(let message) = item.content { From 5a77ffb6e01f065cdc4ff0e7329f00112456992b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:52:18 +0900 Subject: [PATCH 301/359] fix(review-ui): avoid duplicate pending chat cancellation --- .../ReviewMonitorChatContextMenuView.swift | 12 ++- Tests/ReviewUITests/ReviewUIShellTests.swift | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 82ee76c1..425b2f49 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -30,9 +30,19 @@ struct ReviewMonitorChatContextMenuView: View { Task { if store.hasCancellableReview(forChatID: chatID) { _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface()) - } else if chat.status?.isActive == true { + } else if shouldCancelActiveChatDirectly(chatID: chatID) { _ = try? await chat.cancel() } } } + + private func shouldCancelActiveChatDirectly(chatID: String) -> Bool { + guard chat.status?.isActive == true else { + return false + } + guard let reviewRun = store.reviewRun(forChatID: chatID) else { + return true + } + return reviewRun.isTerminal + } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 5f11a296..f3c1d72b 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -198,6 +198,90 @@ extension ReviewUITests { #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 pendingRun = ReviewRunRecord.makeForTesting( + id: "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: [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.hasReviewRun(forChatID: chatID.rawValue)) + #expect(store.hasCancellableReview(forChatID: chatID.rawValue) == false) + + 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) + } + + #expect(cancelItemWasEnabled) + 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 From 4b5cf543760f03f66ff484486a6589da7c0eeb80 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:03:26 +0900 Subject: [PATCH 302/359] fix(review-ui): check all matching review runs before fallback --- .../Store/CodexReviewStoreOrderQueries.swift | 9 +++++++++ .../ReviewMonitorChatContextMenuView.swift | 5 +---- Tests/ReviewUITests/ReviewUIShellTests.swift | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 51009fab..db7e582c 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -24,6 +24,15 @@ extension CodexReviewStore { 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 reviewRun(forChatID chatID: String) -> ReviewRunRecord? { orderedReviewRuns.first { runRecord in runRecord.matchesChatID(chatID) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 425b2f49..c9fc9517 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -40,9 +40,6 @@ struct ReviewMonitorChatContextMenuView: View { guard chat.status?.isActive == true else { return false } - guard let reviewRun = store.reviewRun(forChatID: chatID) else { - return true - } - return reviewRun.isTerminal + return store.hasNonTerminalReviewRun(forChatID: chatID) == false } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index f3c1d72b..7853f41e 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -230,8 +230,19 @@ extension ReviewUITests { )) 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: "pending-cancellation-run", + id: "z-pending-cancellation-run", cwd: repo.path, targetSummary: "Pending cancellation review run", threadID: chatID.rawValue, @@ -244,7 +255,7 @@ extension ReviewUITests { let store = CodexReviewStore.makePreviewStore() store.loadReviewCancellationStateForTesting( serverState: .running, - reviewRuns: [pendingRun] + reviewRuns: [terminalRun, pendingRun] ) let viewController = ReviewMonitorSplitViewController( store: store, From d12a958b6bdb520a6209dba82511a54b3ac5bc28 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:18:59 +0900 Subject: [PATCH 303/359] refactor(review-ui): remove codex sidebar library proxy --- .../ReviewMonitorCodexSidebarLibrary.swift | 56 ---------------- .../ReviewMonitorSidebarViewController.swift | 60 ++++++++++------- ...eviewMonitorCodexSidebarLibraryTests.swift | 64 +++++++++++-------- 3 files changed, 75 insertions(+), 105 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift index 1d35acfc..9c3f4e2e 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift @@ -561,59 +561,3 @@ final class ReviewMonitorCodexSidebarOutlineNode { children.isEmpty == false } } - -@MainActor -package final class ReviewMonitorCodexSidebarLibrary { - package static var defaultDescriptor: CodexFetchDescriptor { - CodexFetchDescriptor( - predicate: .init(sourceKinds: [.subAgentReview]), - sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] - ) - } - - private let fetchedResults: CodexFetchedResults - - package init( - modelContext: CodexModelContext, - descriptor: CodexFetchDescriptor = - ReviewMonitorCodexSidebarLibrary.defaultDescriptor - ) { - self.fetchedResults = modelContext.fetchedResults( - for: descriptor, - sectionedBy: .workspaceGroup - ) - } - - package var phase: CodexDataPhase { - fetchedResults.phase - } - - package var lastErrorDescription: String? { - fetchedResults.lastErrorDescription - } - - package var nextCursor: String? { - fetchedResults.nextCursor - } - - package var sections: [CodexFetchSection] { - fetchedResults.sections - } - - package func performFetch() async throws { - try await fetchedResults.performFetch() - } - - package func refresh() async throws { - try await fetchedResults.refresh() - } - - package func loadNextPage() async throws { - try await fetchedResults.loadNextPage() - } - - package func chat(id: CodexThreadID) -> CodexChat? { - fetchedResults.items.first { $0.id == id } - } - -} diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 5bbd19d7..88c9b694 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -95,7 +95,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarObservation: PortableObservationTracking.Token? private var codexSidebarSectionsObservation: PortableObservationTracking.Token? private var codexSidebarFetchTask: Task? - private var codexSidebarLibrary: ReviewMonitorCodexSidebarLibrary? + private var codexSidebarFetchedResults: CodexFetchedResults? private var codexSidebarModelContext: CodexModelContext? private var codexSidebarUnfilteredSections: [CodexFetchSection] = [] private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() @@ -145,7 +145,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD configureOutlineView() applyCodexSidebarSections([]) bindObservation() - bindCodexSidebarLibrary() + bindCodexSidebarFetchedResults() } override func viewDidLayout() { @@ -272,22 +272,38 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func bindCodexSidebarLibrary() { + static var defaultCodexSidebarDescriptor: CodexFetchDescriptor { + CodexFetchDescriptor( + predicate: .init(sourceKinds: [.subAgentReview]), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + ) + } + + private static func makeCodexSidebarFetchedResults( + modelContext: CodexModelContext + ) -> CodexFetchedResults { + modelContext.fetchedResults( + for: defaultCodexSidebarDescriptor, + sectionedBy: .workspaceGroup + ) + } + + private func bindCodexSidebarFetchedResults() { guard let codexModelSource else { return } codexSidebarObservation?.cancel() codexSidebarObservation = withPortableContinuousObservation { [weak self, codexModelSource] _ in _ = codexModelSource.generation - self?.installCodexSidebarLibrary(modelContext: codexModelSource.modelContext) + self?.installCodexSidebarFetchedResults(modelContext: codexModelSource.modelContext) } } - private func installCodexSidebarLibrary(modelContext: CodexModelContext?) { + private func installCodexSidebarFetchedResults(modelContext: CodexModelContext?) { if let modelContext, let codexSidebarModelContext, codexSidebarModelContext === modelContext, - codexSidebarLibrary != nil + codexSidebarFetchedResults != nil { return } @@ -297,36 +313,36 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarSectionsObservation = nil guard let modelContext else { codexSidebarModelContext = nil - codexSidebarLibrary = nil + codexSidebarFetchedResults = nil applyCodexSidebarSections([]) return } - let library = ReviewMonitorCodexSidebarLibrary(modelContext: modelContext) + let fetchedResults = Self.makeCodexSidebarFetchedResults(modelContext: modelContext) codexSidebarModelContext = modelContext - codexSidebarLibrary = library - codexSidebarSectionsObservation = withPortableContinuousObservation { [weak self, library] event in + codexSidebarFetchedResults = fetchedResults + codexSidebarSectionsObservation = withPortableContinuousObservation { [weak self, fetchedResults] event in guard let self, - self.codexSidebarLibrary === library + self.codexSidebarFetchedResults === fetchedResults else { return } - let sections = library.sections + let sections = fetchedResults.sections guard event.kind != .initial else { return } self.applyCodexSidebarSections(sections) } - codexSidebarFetchTask = Task { @MainActor [weak self, library] in + codexSidebarFetchTask = Task { @MainActor [weak self, fetchedResults] in do { - try await library.performFetch() + try await fetchedResults.performFetch() } catch is CancellationError { } catch { } - guard self?.codexSidebarLibrary === library else { + guard self?.codexSidebarFetchedResults === fetchedResults else { return } - self?.applyCodexSidebarSections(library.sections) + self?.applyCodexSidebarSections(fetchedResults.sections) self?.codexSidebarFetchTask = nil } } @@ -750,10 +766,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private var codexSidebarContentIsAuthoritative: Bool { - guard let codexSidebarLibrary else { + guard let codexSidebarFetchedResults else { return false } - switch codexSidebarLibrary.phase { + switch codexSidebarFetchedResults.phase { case .loaded, .failed: return true case .idle, .loading: @@ -806,7 +822,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func codexChatTitlePresentation(id: CodexThreadID) -> (title: String, subtitle: String)? { - if let chat = codexSidebarLibrary?.chat(id: id) { + if let chat = codexSidebarFetchedResults?.items.first(where: { $0.id == id }) { return ( title: chat.title, subtitle: chat.workspace?.url.path ?? "" @@ -1425,7 +1441,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var codexSidebarSectionsForTesting: [CodexFetchSection] { - codexSidebarLibrary?.sections ?? [] + codexSidebarFetchedResults?.sections ?? [] } var codexSidebarRootTitlesForTesting: [String] { @@ -1497,8 +1513,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func refreshCodexSidebarForTesting() async throws { - try await codexSidebarLibrary?.refresh() - applyCodexSidebarSections(codexSidebarLibrary?.sections ?? []) + try await codexSidebarFetchedResults?.refresh() + applyCodexSidebarSections(codexSidebarFetchedResults?.sections ?? []) } var sidebarFullReloadCountForTesting: Int { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift index 4a60d0fc..988eb0cd 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift @@ -6,9 +6,9 @@ import Testing @_spi(Testing) @testable import CodexReviewKit @testable import ReviewUI -@Suite("ReviewMonitor Codex sidebar library") +@Suite("ReviewMonitor Codex sidebar results") @MainActor -struct ReviewMonitorCodexSidebarLibraryTests { +struct ReviewMonitorCodexSidebarResultsTests { @Test func buildsSidebarSectionsFromCodexDataKitFetchResults() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -34,25 +34,25 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - let section = try #require(library.sections.first) + let section = try #require(results.sections.first) 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(library.sections.count == 1) + #expect(results.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.chats.map(\.title) == ["App chat", "Tools chat"]) - #expect(appChat === library.chat(id: CodexThreadID(rawValue: "thread-app"))) + #expect(appChat === results.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) #expect(appChat.workspace?.url.path == resolvedAppPath) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: library.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let outlineSection = try #require(tree.roots.first) let outlineAppWorkspace = try #require(tree.node(rowID: .workspace(appWorkspace.id))) let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) @@ -72,7 +72,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( - library.sections.rowIDs.map(\.rawValue) == [ + results.sections.rowIDs.map(\.rawValue) == [ "workspaceGroup:\(section.workspaceGroupID.rawValue)", "workspace:\(resolvedAppPath)", "chat:thread-app", @@ -108,9 +108,9 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() - let sections = library.sections + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() + let sections = results.sections let originalWorkspace = try #require(sections.first?.workspaces.first) let filteredWorkspace = try #require(sections.filtered(by: .running).first?.workspaces.first) @@ -132,14 +132,14 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) } - @Test func defaultDescriptorFetchesReviewThreadsOnly() async throws { + @Test func defaultCodexSidebarDescriptorFetchesReviewThreadsOnly() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext try await runtime.transport.enqueueThreadList(.init(threads: [])) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() let request = try #require(await runtime.transport.recordedRequests(method: "thread/list").first) let params = try request.decodeParams(ThreadListParams.self) @@ -162,10 +162,10 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - let section = try #require(library.sections.first) + let section = try #require(results.sections.first) let chat = try #require(section.uncategorizedChats.first) #expect(section.workspaces.isEmpty) @@ -180,7 +180,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { ]) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: library.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let outlineSection = try #require(tree.roots.first) let outlineChat = try #require(tree.node(rowID: .chat(chat.id))) #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) @@ -206,11 +206,11 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: library.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let root = try #require(tree.roots.first) let chatNode = try #require(tree.node(rowID: .chat(threadID))) @@ -224,7 +224,7 @@ struct ReviewMonitorCodexSidebarLibraryTests { )) try await context.model(for: threadID).refresh(includeTurns: false) - #expect(tree.apply(sections: library.sections).topologyChanged == false) + #expect(tree.apply(sections: results.sections).topologyChanged == false) #expect(tree.roots.first === root) #expect(tree.node(rowID: .chat(threadID)) === chatNode) #expect(chatNode.title == "Updated review") @@ -259,16 +259,16 @@ struct ReviewMonitorCodexSidebarLibraryTests { ] )) - let library = ReviewMonitorCodexSidebarLibrary(modelContext: context) - try await library.performFetch() - let section = try #require(library.sections.filtered(by: .running).first) + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() + let section = try #require(results.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 sidebarViewControllerInstallsCodexSidebarLibraryFromModelContext() async throws { + @Test func sidebarViewControllerInstallsCodexSidebarFetchedResultsFromModelContext() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -668,6 +668,16 @@ private struct ThreadListParams: Decodable { var sourceKinds: [String]? } +@MainActor +private func makeCodexSidebarFetchedResults( + context: CodexModelContext +) -> CodexFetchedResults { + context.fetchedResults( + 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) From 2bed40d0a0578d5f71dff011ef214bf58c3d1bed Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:28:33 +0900 Subject: [PATCH 304/359] refactor(review-ui): rename sidebar outline files --- ...idebarLibrary.swift => ReviewMonitorCodexSidebarOutline.swift} | 0 ...aryTests.swift => ReviewMonitorCodexSidebarResultsTests.swift} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Sources/ReviewUI/Sidebar/CodexChats/{ReviewMonitorCodexSidebarLibrary.swift => ReviewMonitorCodexSidebarOutline.swift} (100%) rename Tests/ReviewUITests/{ReviewMonitorCodexSidebarLibraryTests.swift => ReviewMonitorCodexSidebarResultsTests.swift} (100%) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift similarity index 100% rename from Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarLibrary.swift rename to Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift similarity index 100% rename from Tests/ReviewUITests/ReviewMonitorCodexSidebarLibraryTests.swift rename to Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift From f7bfaf5de91b388b45aeda5b73ea9048680f561f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:53:43 +0900 Subject: [PATCH 305/359] refactor(review-ui): bind sidebar cells to semantic models --- .../ReviewMonitorCodexSidebarOutline.swift | 105 ++---- .../ReviewMonitorSidebarViewController.swift | 325 ++++++++++++------ ...eviewMonitorCodexSidebarResultsTests.swift | 8 +- Tests/ReviewUITests/ReviewUITests.swift | 12 +- 4 files changed, 264 insertions(+), 186 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 9c3f4e2e..f9c005f7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -1,7 +1,6 @@ import CodexKit import CodexReviewKit import Foundation -import Observation package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringConvertible { package var rawValue: String @@ -312,66 +311,54 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } @MainActor -package enum ReviewMonitorCodexSidebarOutlineItem { - case workspaceGroup(CodexWorkspaceGroup) - case fallbackWorkspaceGroup(id: CodexWorkspaceGroupID, title: String) - case workspace(CodexWorkspace) - case chat(CodexChat) +package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { + case workspaceGroup(CodexWorkspaceGroupID) + case workspace(CodexWorkspaceID) + case chat(CodexThreadID) package var rowID: ReviewMonitorCodexSidebarRowID { switch self { - case .workspaceGroup(let workspaceGroup): - .workspaceGroup(workspaceGroup.id) - case .fallbackWorkspaceGroup(let id, _): + case .workspaceGroup(let id): .workspaceGroup(id) - case .workspace(let workspace): - .workspace(workspace.id) - case .chat(let chat): - .chat(chat.id) + case .workspace(let id): + .workspace(id) + case .chat(let id): + .chat(id) } } - package var title: String { + var selectionID: ReviewMonitorSelectionID { switch self { - case .workspaceGroup(let workspaceGroup): - workspaceGroup.name - case .fallbackWorkspaceGroup(_, let title): - title - case .workspace(let workspace): - workspace.name - case .chat(let chat): - chat.title + case .workspaceGroup(let id): + .workspaceGroup(id) + case .workspace(let id): + .workspace(id) + case .chat(let id): + .chat(id) } } - var selectionID: ReviewMonitorSelectionID { + var workspaceGroupID: CodexWorkspaceGroupID? { switch self { - case .workspaceGroup(let workspaceGroup): - .workspaceGroup(workspaceGroup.id) - case .fallbackWorkspaceGroup(let id, _): - .workspaceGroup(id) - case .workspace(let workspace): - .workspace(workspace.id) - case .chat(let chat): - .chat(chat.id) + case .workspaceGroup(let id): + id + case .workspace, .chat: + nil } } - package func hasSameIdentity(as other: Self) -> Bool { - switch (self, other) { - case (.workspaceGroup(let lhs), .workspaceGroup(let rhs)): - lhs === rhs - case (.fallbackWorkspaceGroup(let lhsID, let lhsTitle), .fallbackWorkspaceGroup(let rhsID, let rhsTitle)): - lhsID == rhsID && lhsTitle == rhsTitle - case (.workspace(let lhs), .workspace(let rhs)): - lhs === rhs - case (.chat(let lhs), .chat(let rhs)): - lhs === rhs - default: - false + var chatID: CodexThreadID? { + switch self { + case .chat(let id): + id + case .workspaceGroup, .workspace: + nil } } + var isChat: Bool { + chatID != nil + } } @MainActor @@ -446,13 +433,7 @@ final class ReviewMonitorCodexSidebarOutlineTree { for section: CodexFetchSection, activeRowIDs: inout Set ) -> ReviewMonitorCodexSidebarOutlineNode { - let item: ReviewMonitorCodexSidebarOutlineItem = - if let workspaceGroup = section.workspaceGroup { - .workspaceGroup(workspaceGroup) - } else { - .fallbackWorkspaceGroup(id: section.workspaceGroupID, title: section.displayTitle) - } - let node = node(for: item, activeRowIDs: &activeRowIDs) + let node = node(for: .workspaceGroup(section.workspaceGroupID), activeRowIDs: &activeRowIDs) let children: [ReviewMonitorCodexSidebarOutlineNode] if section.displaysWorkspaceNodes { children = @@ -464,11 +445,11 @@ final class ReviewMonitorCodexSidebarOutlineTree { ) } + section.uncategorizedChats.map { chat in - self.node(for: .chat(chat), activeRowIDs: &activeRowIDs) + self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs) } } else { children = section.items.map { chat in - self.node(for: .chat(chat), activeRowIDs: &activeRowIDs) + self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs) } } updateChildren(of: node, to: children) @@ -480,9 +461,9 @@ final class ReviewMonitorCodexSidebarOutlineTree { chats: [CodexChat], activeRowIDs: inout Set ) -> ReviewMonitorCodexSidebarOutlineNode { - let node = node(for: .workspace(workspace), activeRowIDs: &activeRowIDs) + let node = node(for: .workspace(workspace.id), activeRowIDs: &activeRowIDs) let children = chats.map { child in - self.node(for: .chat(child), activeRowIDs: &activeRowIDs) + self.node(for: .chat(child.id), activeRowIDs: &activeRowIDs) } updateChildren(of: node, to: children) return node @@ -495,7 +476,7 @@ final class ReviewMonitorCodexSidebarOutlineTree { activeRowIDs.insert(item.rowID) let node = nodesByRowID[item.rowID] ?? ReviewMonitorCodexSidebarOutlineNode(item: item) nodesByRowID[item.rowID] = node - if node.item.hasSameIdentity(as: item) == false { + if node.item != item { node.item = item } return node @@ -525,7 +506,6 @@ struct ReviewMonitorCodexSidebarOutlineTopologyChange: Equatable { } @MainActor -@Observable final class ReviewMonitorCodexSidebarOutlineNode { fileprivate(set) var item: ReviewMonitorCodexSidebarOutlineItem fileprivate(set) var children: [ReviewMonitorCodexSidebarOutlineNode] = [] @@ -538,23 +518,12 @@ final class ReviewMonitorCodexSidebarOutlineNode { item.rowID } - var title: String { - item.title - } - var selectionID: ReviewMonitorSelectionID { item.selectionID } var workspaceGroupID: CodexWorkspaceGroupID? { - switch item { - case .workspaceGroup(let workspaceGroup): - workspaceGroup.id - case .fallbackWorkspaceGroup(let id, _): - id - case .workspace, .chat: - nil - } + item.workspaceGroupID } var isExpandable: Bool { diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 88c9b694..13ed3757 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -98,6 +98,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarFetchedResults: CodexFetchedResults? private var codexSidebarModelContext: CodexModelContext? private var codexSidebarUnfilteredSections: [CodexFetchSection] = [] + private var codexSidebarDisplayedSections: [CodexFetchSection] = [] private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var appliedSidebarKind: SidebarKind? @@ -358,6 +359,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarPresentationOrder .applying(to: codexSidebarUnfilteredSections) .filtered(by: uiState.sidebarReviewChatFilter) + codexSidebarDisplayedSections = sections let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline let applyResult = codexSidebarOutlineTree.apply(sections: sections) applySidebarKind(sidebarKind) @@ -367,6 +369,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if applyResult.topologyChanged { applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) } else if isUsingCodexSidebarOutline { + updateVisibleCodexSidebarCells() reconcileOutlineSelection() } codexSidebarContentDidChange?() @@ -390,6 +393,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } if appliedIncrementally { expandCodexSidebarNodes(codexSidebarOutlineTree.roots) + updateVisibleCodexSidebarCells() if isUsingCodexSidebarOutline { reconcileOutlineSelection() } else if outlineView.selectedRow != -1 { @@ -664,14 +668,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let item = outlineView.item(atRow: outlineView.selectedRow) if let node = codexSidebarNode(from: item) { switch node.item { - case .workspaceGroup(let workspaceGroup): - uiState.selection = .workspaceGroup(workspaceGroup.id) - case .fallbackWorkspaceGroup(let id, _): + case .workspaceGroup(let id): uiState.selection = .workspaceGroup(id) - case .workspace(let workspace): - uiState.selection = .workspace(workspace.id) - case .chat(let chat): - uiState.selection = .chat(chat.id) + case .workspace(let id): + uiState.selection = .workspace(id) + case .chat(let id): + uiState.selection = .chat(id) } } else { uiState.selection = nil @@ -682,7 +684,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let row = outlineView.row(at: point) guard row >= 0, let node = codexSidebarNode(from: outlineView.item(atRow: row)), - case .chat(let chat) = node.item + case .chat(let id) = node.item, + let chat = codexChatSelection(id: id) else { return nil } @@ -781,15 +784,51 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarUnfilteredSections.chat(id: id) } + private func displayedCodexSidebarSection(id: CodexWorkspaceGroupID) -> CodexFetchSection? { + codexSidebarDisplayedSections.first { $0.workspaceGroupID == id } + } + + private func displayedCodexChat(id: CodexThreadID) -> CodexChat? { + codexSidebarDisplayedSections.chat(id: id) + ?? currentChatSelection(id: id) + } + + private func displayedCodexWorkspace(id: CodexWorkspaceID) -> CodexWorkspace? { + for section in codexSidebarDisplayedSections { + if let workspace = section.workspaces.first(where: { $0.id == id }) { + return workspace + } + } + for section in codexSidebarUnfilteredSections { + if let workspace = section.workspaces.first(where: { $0.id == id }) { + return workspace + } + } + return nil + } + + private func codexSidebarTitle(for node: ReviewMonitorCodexSidebarOutlineNode) -> String? { + switch node.item { + case .workspaceGroup(let id): + return displayedCodexSidebarSection(id: id)?.displayTitle + ?? codexWorkspaceGroupSection(id: id)?.displayTitle + case .workspace(let id): + return displayedCodexWorkspace(id: id)?.name + case .chat(let id): + return displayedCodexChat(id: id)?.title + } + } + private func codexWorkspaceSelection( id: CodexWorkspaceID ) -> CodexWorkspace? { guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), - case .workspace(let workspace) = node.item + case .workspace(let workspaceID) = node.item, + workspaceID == id else { return nil } - return workspace + return displayedCodexWorkspace(id: id) } private func codexWorkspaceGroupSelection( @@ -799,10 +838,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } switch node.item { - case .workspaceGroup, .fallbackWorkspaceGroup: + case .workspaceGroup(let workspaceGroupID) where workspaceGroupID == id: return node case .workspace, .chat: return nil + case .workspaceGroup: + return nil } } @@ -814,11 +855,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func codexChatSelection(id: CodexThreadID) -> CodexChat? { guard let node = codexSidebarOutlineTree.node(rowID: .chat(id)), - case .chat(let chat) = node.item + case .chat(let chatID) = node.item, + chatID == id else { return nil } - return chat + return displayedCodexChat(id: id) } func codexChatTitlePresentation(id: CodexThreadID) -> (title: String, subtitle: String)? { @@ -843,7 +885,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let node = codexWorkspaceGroupSelection(id: id) else { return nil } - let title = node.title + let title = codexSidebarTitle(for: node) ?? id.rawValue let workspaceCWDs = codexWorkspaceGroupSection(id: id)?.workspaces.map(\.url.path) ?? [] return ( title: title, @@ -865,17 +907,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } switch node.item { - case .workspaceGroup(let workspaceGroup): - return .codexWorkspaceGroup(id: workspaceGroup.id.rawValue) - case .fallbackWorkspaceGroup(let id, _): + case .workspaceGroup(let id): return .codexWorkspaceGroup(id: id.rawValue) case .workspace: return nil - case .chat(let chat): + case .chat(let id): guard let parent = outlineView.parent(forItem: node) as? ReviewMonitorCodexSidebarOutlineNode else { return nil } - return .codexChat(id: chat.id.rawValue, containerRowID: parent.rowID.rawValue) + return .codexChat(id: id.rawValue, containerRowID: parent.rowID.rawValue) } } @@ -1044,13 +1084,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } guard let targetNode = codexSidebarNode(from: proposedItem), - case .chat(let targetChat) = targetNode.item, + 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: targetChat.id), + let targetIndex = codexVisibleChatIDs(in: parentNode.rowID).firstIndex(of: targetChatID), let draggingLocation, - let targetRow = row(forCodexSidebarSelectionID: .chat(targetChat.id)) + let targetRow = row(forCodexSidebarSelectionID: .chat(targetChatID)) else { return nil } @@ -1144,13 +1184,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func isCodexChatContainer(_ node: ReviewMonitorCodexSidebarOutlineNode) -> Bool { switch node.item { - case .workspaceGroup, .fallbackWorkspaceGroup: - return node.children.contains { child in - if case .chat = child.item { - return true - } - return false - } + case .workspaceGroup: + return node.children.contains { $0.item.isChat } case .workspace: return true case .chat: @@ -1162,12 +1197,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let node = codexSidebarOutlineTree.node(rowID: container) else { return [] } - return node.children.compactMap { child in - if case .chat(let chat) = child.item { - return chat.id - } - return nil - } + return node.children.compactMap(\.item.chatID) } private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { @@ -1236,6 +1266,82 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } + private func updateVisibleCodexSidebarCells() { + guard outlineView.numberOfRows > 0 else { + return + } + for row in 0.. 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 .workspaceGroup, .workspace: + let view = + (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) + as? ReviewMonitorWorkspaceCellView) + ?? ReviewMonitorWorkspaceCellView() + view.identifier = Identifier.workspaceCell + 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 .workspaceGroup(let id): + guard let cellView = cellView as? ReviewMonitorWorkspaceCellView 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 + case .workspace(let id): + guard let cellView = cellView as? ReviewMonitorWorkspaceCellView, + let workspace = displayedCodexWorkspace(id: id) + else { + return false + } + cellView.configure(workspace: workspace) + return true + } + } + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { guard let item else { return codexSidebarOutlineTree.roots.count @@ -1269,7 +1375,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { if let node = codexSidebarNode(from: item) { - if case .chat = node.item { + if node.item.isChat { return rowHeights.reviewChat } return rowHeights.workspace @@ -1360,7 +1466,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { if let node = codexSidebarNode(from: item) { - if case .chat = node.item { + if node.item.isChat { return ReviewMonitorReviewChatTableRowView() } return ReviewMonitorWorkspaceRowView() @@ -1373,27 +1479,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD viewFor tableColumn: NSTableColumn?, item: Any ) -> NSView? { - if let node = codexSidebarNode(from: item) { - if case .chat = node.item { - let view = - (outlineView.makeView(withIdentifier: Identifier.reviewChatCell, owner: self) - as? ReviewMonitorReviewChatCellView) - ?? ReviewMonitorReviewChatCellView() - view.identifier = Identifier.reviewChatCell - view.configure(with: node) - return view - } - - let view = - (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) - as? ReviewMonitorWorkspaceCellView) - ?? ReviewMonitorWorkspaceCellView() - view.identifier = Identifier.workspaceCell - view.configure(with: node) - return view + guard let node = codexSidebarNode(from: item) else { + return nil } - - return nil + return makeCodexSidebarCellView(for: node) } } @@ -1445,17 +1534,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var codexSidebarRootTitlesForTesting: [String] { - codexSidebarOutlineTree.roots.map(\.title) + codexSidebarOutlineTree.roots.map { codexSidebarTitle(for: $0) ?? $0.rowID.rawValue } } var displayedCodexSidebarTitlesForTesting: [String] { (0.. String? { - codexSidebarOutlineTree.node(rowID: rowID)?.title + codexSidebarOutlineTree.node(rowID: rowID).flatMap { codexSidebarTitle(for: $0) } } func displayedCodexChatIDsForTesting(container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { @@ -2040,8 +2132,8 @@ private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { @MainActor private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? - private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? - private var nodeObservation: PortableObservationTracking.Token? + private weak var boundChat: CodexChat? + private var chatObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2049,7 +2141,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } isolated deinit { - nodeObservation?.cancel() + chatObservation?.cancel() } @available(*, unavailable) @@ -2057,17 +2149,15 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { nil } - func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - guard boundNode !== node else { + func configure(with chat: CodexChat) { + guard boundChat !== chat else { return } - objectValue = node - boundNode = node - nodeObservation?.cancel() - nodeObservation = withPortableContinuousObservation { [weak self, node] _ in - guard case .chat(let chat) = node.item else { - return - } + objectValue = chat + boundChat = chat + chatObservation?.cancel() + render(chat) + chatObservation = withPortableContinuousObservation { [weak self, chat] _ in self?.render(chat) } } @@ -2117,10 +2207,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { chat: CodexChat ) -> NSTableCellView { let cellView = ReviewMonitorReviewChatCellView() - cellView.configure( - with: ReviewMonitorCodexSidebarOutlineNode( - item: .chat(chat) - )) + cellView.configure(with: chat) return cellView } @@ -2132,10 +2219,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { guard let cellView = cellView as? ReviewMonitorReviewChatCellView else { fatalError("Expected ReviewMonitorReviewChatCellView.") } - cellView.configure( - with: ReviewMonitorCodexSidebarOutlineNode( - item: .chat(chat) - )) + cellView.configure(with: chat) } #endif @@ -2144,8 +2228,14 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { private let iconView = NSImageView() private let titleLabel = NSTextField(labelWithString: "") private let contentStack = NSStackView() - private weak var boundNode: ReviewMonitorCodexSidebarOutlineNode? - private var nodeObservation: PortableObservationTracking.Token? + private enum BoundIdentity: Equatable { + case workspaceGroup(ObjectIdentifier) + case fallbackWorkspaceGroup(String) + case workspace(ObjectIdentifier) + } + + private var boundIdentity: BoundIdentity? + private var modelObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -2153,7 +2243,7 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { } isolated deinit { - nodeObservation?.cancel() + modelObservation?.cancel() } @available(*, unavailable) @@ -2161,39 +2251,64 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { nil } - func configure(with node: ReviewMonitorCodexSidebarOutlineNode) { - guard boundNode !== node else { + func configure(workspaceGroup: CodexWorkspaceGroup) { + let identity = BoundIdentity.workspaceGroup(ObjectIdentifier(workspaceGroup)) + guard boundIdentity != identity else { return } - objectValue = node - boundNode = node - nodeObservation?.cancel() - nodeObservation = withPortableContinuousObservation { [weak self, node] _ in - self?.render(node.item) + 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(workspace: CodexWorkspace) { + let identity = BoundIdentity.workspace(ObjectIdentifier(workspace)) + guard boundIdentity != identity else { + return + } + objectValue = workspace + boundIdentity = identity + modelObservation?.cancel() + render(workspace: workspace) + modelObservation = withPortableContinuousObservation { [weak self, workspace] _ in + self?.render(workspace: workspace) } } 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(_ item: ReviewMonitorCodexSidebarOutlineItem) { - switch item { - case .workspaceGroup(let workspaceGroup): - configure(title: workspaceGroup.name, toolTip: workspaceGroup.name, systemSymbolName: "folder") - case .fallbackWorkspaceGroup(_, let title): - configure(title: title, toolTip: title, systemSymbolName: "folder") - case .workspace(let workspace): - configure(title: workspace.name, toolTip: workspace.url.path, systemSymbolName: "folder") - case .chat(let chat): - configure( - title: chat.title, - toolTip: chat.preview ?? chat.title, - systemSymbolName: "bubble.left" - ) - } + private func render(workspaceGroup: CodexWorkspaceGroup) { + render(title: workspaceGroup.name, toolTip: workspaceGroup.name, systemSymbolName: "folder") + } + + private func render(workspace: CodexWorkspace) { + render(title: workspace.name, toolTip: workspace.url.path, systemSymbolName: "folder") } private func configureHierarchy() { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 988eb0cd..407fc11e 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -58,7 +58,7 @@ struct ReviewMonitorCodexSidebarResultsTests { let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) #expect(outlineSection.rowID == section.rowID) - #expect(outlineSection.title == section.displayTitle) + #expect(outlineSection.item == .workspaceGroup(section.workspaceGroupID)) #expect(outlineSection.selectionID == .workspaceGroup(section.workspaceGroupID)) #expect(outlineSection.isExpandable) #expect( @@ -66,10 +66,11 @@ struct ReviewMonitorCodexSidebarResultsTests { "workspace:\(resolvedAppPath)", "workspace:\(resolvedToolsPath)", ]) - #expect(outlineAppWorkspace.title == "App") + #expect(outlineAppWorkspace.item == .workspace(appWorkspace.id)) #expect(outlineAppWorkspace.selectionID == .workspace(appWorkspace.id)) #expect(outlineAppWorkspace.isExpandable) #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) + #expect(outlineAppChat.item == .chat(appChat.id)) #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( results.sections.rowIDs.map(\.rawValue) == [ @@ -227,7 +228,8 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(tree.apply(sections: results.sections).topologyChanged == false) #expect(tree.roots.first === root) #expect(tree.node(rowID: .chat(threadID)) === chatNode) - #expect(chatNode.title == "Updated review") + #expect(chatNode.item == .chat(threadID)) + #expect(results.sections.chat(id: threadID)?.title == "Updated review") #expect(root.children.map(\.rowID) == [.chat(threadID)]) #expect(root.children.first === chatNode) } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 47d3d84d..d42c6b2e 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -359,22 +359,14 @@ struct ReviewUITests { ) let cellView = makeReviewMonitorReviewChatCellViewForTesting(chat: placeholderChat) - let initialObjectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) - guard case .chat(let initialObjectChat) = initialObjectNode.item else { - Issue.record("Expected cell to bind a Codex chat node.") - return - } + let initialObjectChat = try #require(cellView.objectValue as? CodexChat) #expect(initialObjectChat.id == placeholderChat.id) #expect(cellView.toolTip == (placeholderChat.workspace?.url.path ?? placeholderChat.preview ?? placeholderChat.title)) configureReviewMonitorReviewChatCellViewForTesting(cellView, chat: loadedChat) - let objectNode = try #require(cellView.objectValue as? ReviewMonitorCodexSidebarOutlineNode) - guard case .chat(let objectChat) = objectNode.item else { - Issue.record("Expected cell to bind a Codex chat node.") - return - } + let objectChat = try #require(cellView.objectValue as? CodexChat) #expect(objectChat.id == loadedChat.id) #expect(cellView.toolTip == (loadedChat.workspace?.url.path ?? loadedChat.preview ?? loadedChat.title)) } From f7c977c078b22f0fbcb0eb47fba44d9f37cfeb3f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:00:17 +0900 Subject: [PATCH 306/359] refactor(review-ui): use CodexKit fetch section relationships --- .../ReviewMonitorCodexSidebarOutline.swift | 55 ++----------------- .../ReviewMonitorSidebarViewController.swift | 10 ++-- ...eviewMonitorCodexSidebarResultsTests.swift | 16 +++--- 3 files changed, 19 insertions(+), 62 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index f9c005f7..3c5a456b 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -28,7 +28,7 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC @MainActor extension CodexFetchSection where Model == CodexChat { - package var workspaceGroupID: CodexWorkspaceGroupID { + package var sidebarWorkspaceGroupID: CodexWorkspaceGroupID { switch id { case .workspaceGroup(let id): id @@ -42,35 +42,7 @@ extension CodexFetchSection where Model == CodexChat { } package var rowID: ReviewMonitorCodexSidebarRowID { - .workspaceGroup(workspaceGroupID) - } - - package var workspaceGroup: CodexWorkspaceGroup? { - items.lazy.compactMap { $0.workspace?.workspaceGroup }.first { $0.id == workspaceGroupID } - ?? items.lazy.compactMap { $0.workspace?.workspaceGroup }.first - } - - package var workspaces: [CodexWorkspace] { - var workspaces: [CodexWorkspace] = [] - var workspaceIDs = Set() - for chat in items { - guard let workspace = chat.workspace, - workspaceIDs.contains(workspace.id) == false - else { - continue - } - workspaceIDs.insert(workspace.id) - workspaces.append(workspace) - } - return workspaces - } - - package var chats: [CodexChat] { - items - } - - package var uncategorizedChats: [CodexChat] { - items.filter { $0.workspace == nil } + .workspaceGroup(sidebarWorkspaceGroupID) } package var displaysWorkspaceNodes: Bool { @@ -88,21 +60,6 @@ extension CodexFetchSection where Model == CodexChat { } return [rowID] + items.map { .chat($0.id) } } - - package func chats(in workspaceID: CodexWorkspaceID) -> [CodexChat] { - items.filter { $0.workspace?.id == workspaceID } - } - - package func chat(id: CodexThreadID) -> CodexChat? { - items.first { $0.id == id } - } - - package func hasSameIdentity(as other: Self) -> Bool { - workspaceGroupID == other.workspaceGroupID - && workspaceGroup === other.workspaceGroup - && displayTitle == other.displayTitle - && items.elementsEqual(other.items) { $0 === $1 } - } } @MainActor @@ -128,7 +85,7 @@ extension Array where Element == CodexFetchSection { return map { section in let latestFinishedChatID = filter.contains(.latestFinished) - ? Self.latestFinishedChat(in: section.chats)?.id + ? Self.latestFinishedChat(in: section.items)?.id : nil return CodexFetchSection( id: section.id, @@ -185,7 +142,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { func applying( to sections: [CodexFetchSection] ) -> [CodexFetchSection] { - ordered(sections, by: workspaceGroupIDs, id: \.workspaceGroupID).map { section in + ordered(sections, by: workspaceGroupIDs, id: \.sidebarWorkspaceGroupID).map { section in if section.displaysWorkspaceNodes == false { return CodexFetchSection( id: section.id, @@ -235,7 +192,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } mutating func prune(to sections: [CodexFetchSection]) { - let activeWorkspaceGroupIDs = sections.map(\.workspaceGroupID) + let activeWorkspaceGroupIDs = sections.map(\.sidebarWorkspaceGroupID) workspaceGroupIDs = workspaceGroupIDs.filter { activeWorkspaceGroupIDs.contains($0) } var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:] @@ -433,7 +390,7 @@ final class ReviewMonitorCodexSidebarOutlineTree { for section: CodexFetchSection, activeRowIDs: inout Set ) -> ReviewMonitorCodexSidebarOutlineNode { - let node = node(for: .workspaceGroup(section.workspaceGroupID), activeRowIDs: &activeRowIDs) + let node = node(for: .workspaceGroup(section.sidebarWorkspaceGroupID), activeRowIDs: &activeRowIDs) let children: [ReviewMonitorCodexSidebarOutlineNode] if section.displaysWorkspaceNodes { children = diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 13ed3757..933b1cac 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -717,10 +717,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if section.workspaces.contains(where: { $0.id == workspaceID }) || section.items.contains(where: { $0.workspaceID == workspaceID }) { - return section.workspaceGroupID + return section.sidebarWorkspaceGroupID } - if section.workspaceGroupID.rawValue == cwd || section.workspaceGroupID.rawValue == "cwd:\(cwd)" { - return section.workspaceGroupID + if section.sidebarWorkspaceGroupID.rawValue == cwd || section.sidebarWorkspaceGroupID.rawValue == "cwd:\(cwd)" { + return section.sidebarWorkspaceGroupID } } return nil @@ -785,7 +785,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func displayedCodexSidebarSection(id: CodexWorkspaceGroupID) -> CodexFetchSection? { - codexSidebarDisplayedSections.first { $0.workspaceGroupID == id } + codexSidebarDisplayedSections.first { $0.sidebarWorkspaceGroupID == id } } private func displayedCodexChat(id: CodexThreadID) -> CodexChat? { @@ -850,7 +850,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func codexWorkspaceGroupSection( id: CodexWorkspaceGroupID ) -> CodexFetchSection? { - codexSidebarUnfilteredSections.first { $0.workspaceGroupID == id } + codexSidebarUnfilteredSections.first { $0.sidebarWorkspaceGroupID == id } } private func codexChatSelection(id: CodexThreadID) -> CodexChat? { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 407fc11e..94bba89f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -47,7 +47,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(section.displayTitle == repo.lastPathComponent) #expect(section.workspaces.map(\.url.path) == [resolvedAppPath, resolvedToolsPath]) #expect(section.workspaces.map(\.name) == ["App", "Tools"]) - #expect(section.chats.map(\.title) == ["App chat", "Tools chat"]) + #expect(section.items.map(\.title) == ["App chat", "Tools chat"]) #expect(appChat === results.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) #expect(appChat.workspace?.url.path == resolvedAppPath) @@ -58,8 +58,8 @@ struct ReviewMonitorCodexSidebarResultsTests { let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) #expect(outlineSection.rowID == section.rowID) - #expect(outlineSection.item == .workspaceGroup(section.workspaceGroupID)) - #expect(outlineSection.selectionID == .workspaceGroup(section.workspaceGroupID)) + #expect(outlineSection.item == .workspaceGroup(section.sidebarWorkspaceGroupID)) + #expect(outlineSection.selectionID == .workspaceGroup(section.sidebarWorkspaceGroupID)) #expect(outlineSection.isExpandable) #expect( outlineSection.children.map(\.rowID.rawValue) == [ @@ -74,7 +74,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( results.sections.rowIDs.map(\.rawValue) == [ - "workspaceGroup:\(section.workspaceGroupID.rawValue)", + "workspaceGroup:\(section.sidebarWorkspaceGroupID.rawValue)", "workspace:\(resolvedAppPath)", "chat:thread-app", "workspace:\(resolvedToolsPath)", @@ -547,16 +547,16 @@ struct ReviewMonitorCodexSidebarResultsTests { let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: secondSection.rowID)) - #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondSection.workspaceGroupID, toIndex: 0)) + #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondSection.sidebarWorkspaceGroupID, toIndex: 0)) #expect( sidebar.codexSidebarRootTitlesForTesting == [ secondSection.displayTitle, firstSection.displayTitle, ]) #expect( - sidebar.codexSidebarSectionsForTesting.map(\.workspaceGroupID) == [ - firstSection.workspaceGroupID, - secondSection.workspaceGroupID, + sidebar.codexSidebarSectionsForTesting.map(\.sidebarWorkspaceGroupID) == [ + firstSection.sidebarWorkspaceGroupID, + secondSection.sidebarWorkspaceGroupID, ]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } From 7d2d79e3e5652d02d5dd2979b069a3a2571ac66e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:58:31 +0900 Subject: [PATCH 307/359] refactor(review-ui): derive previews from fake app server --- ...ReviewMonitorPreviewAppServerRuntime.swift | 8 ++ .../ReviewMonitorPreviewContent.swift | 106 ++++-------------- Tests/ReviewUITests/ReviewUIShellTests.swift | 14 ++- 3 files changed, 37 insertions(+), 91 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index f789f078..36fc1dd1 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -104,6 +104,13 @@ final class ReviewMonitorPreviewAppServerRuntime { await threadStore.snapshot(id: chatID)?.chatSnapshot } + func interruptRequestCountForTesting() async -> Int { + guard let runtime else { + return 0 + } + return await runtime.transport.recordedRequests(method: "turn/interrupt").count + } + func upsertPreviewItem( id: String, kind: CodexThreadItem.Kind, @@ -265,6 +272,7 @@ final class ReviewMonitorPreviewAppServerRuntime { let container = CodexModelContainer(appServer: runtime.server) self.runtime = runtime try await rebindRuntimeToCurrentThreadStore(runtime) + await runtime.transport.stubJSON("{}", for: "turn/interrupt") self.container = container modelSource.install(container: container) } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 41b84dcb..5d074cef 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -44,6 +44,10 @@ public final class ReviewMonitorPreviewContentSource { public func snapshotForTesting(chatID: CodexThreadID) async -> CodexChatSnapshot? { await runtime.snapshotForTesting(chatID: chatID) } + + public func interruptRequestCountForTesting() async -> Int { + await runtime.interruptRequestCountForTesting() + } } @MainActor @@ -170,28 +174,7 @@ public enum ReviewMonitorPreviewContent { let cycle: Int } - private struct PreviewSidebarContent { - var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] - var reviewRuns: [ReviewRunRecord] - } - public static func makeStore() -> CodexReviewStore { - makeStore(previewContent: makeSidebarContent()) - } - - public static func makeContentSource() -> ReviewMonitorPreviewContentSource { - let previewContent = makeSidebarContent() - let store = makeStore(previewContent: previewContent) - let previewRuntime = ReviewMonitorPreviewAppServerRuntime( - fixtures: previewContent.chatLogFixtures - ) - return ReviewMonitorPreviewContentSource( - store: store, - runtime: previewRuntime - ) - } - - private static func makeStore(previewContent: PreviewSidebarContent) -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) ) @@ -200,12 +183,23 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp"), - reviewRuns: previewContent.reviewRuns + 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 } @@ -238,13 +232,7 @@ public enum ReviewMonitorPreviewContent { serverState: .running, account: accounts.first, persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp"), - reviewRuns: [ - makeReviewRunRecord( - for: chatFixture, - runIndex: 0 - ) - ] + serverURL: URL(string: "http://localhost:9417/mcp") ) let fixture = makeChatLogFixture( for: chatFixture @@ -632,7 +620,7 @@ public enum ReviewMonitorPreviewContent { ] } - private static func makeSidebarContent() -> PreviewSidebarContent { + private static func makeChatLogFixtures() -> [ReviewMonitorPreviewChatLogFixture] { let now = Date() let workspacePaths = [ "/path/to/workspace-alpha", @@ -641,7 +629,6 @@ public enum ReviewMonitorPreviewContent { ] var chatLogFixtures: [ReviewMonitorPreviewChatLogFixture] = [] - var reviewRuns: [ReviewRunRecord] = [] for (workspaceIndex, cwd) in workspacePaths.enumerated() { let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() { @@ -666,45 +653,9 @@ public enum ReviewMonitorPreviewContent { makeChatLogFixture( for: chatFixture )) - reviewRuns.append( - makeReviewRunRecord( - for: chatFixture, - runIndex: workspaceIndex * 100 + chatIndex - )) } } - return PreviewSidebarContent( - chatLogFixtures: chatLogFixtures, - reviewRuns: reviewRuns - ) - } - - private static func makeReviewRunRecord( - for chatFixture: PreviewChatFixture, - runIndex: Int - ) -> ReviewRunRecord { - ReviewRunRecord( - id: "preview-run-\(runIndex)", - sessionID: "preview-session", - cwd: chatFixture.cwd, - targetSummary: chatFixture.targetSummary, - core: .init( - run: .init( - reviewThreadID: chatFixture.chatID.rawValue, - threadID: chatFixture.chatID.rawValue, - turnID: chatFixture.turnID.rawValue, - model: chatFixture.model - ), - lifecycle: .init( - status: ReviewRunState(chatFixture.lifecycle), - startedAt: chatFixture.startedAt, - endedAt: chatFixture.endedAt, - cancellation: chatFixture.lifecycle == .cancelled ? .system() : nil, - errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil - ), - lifecycleMessage: chatFixture.summary - ) - ) + return chatLogFixtures } private static func makeChatLogFixture( @@ -1129,23 +1080,6 @@ private extension CodexTurnStatus { } } -private extension ReviewRunState { - init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { - switch lifecycle { - case .queued: - self = .queued - case .running: - self = .running - case .succeeded: - self = .succeeded - case .failed: - self = .failed - case .cancelled: - self = .cancelled - } - } -} - private extension CodexDataPhase { init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) { switch lifecycle { diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 7853f41e..eca991ef 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -64,11 +64,10 @@ extension ReviewUITests { #expect(sidebar.sidebarKindForTesting == .chatList) } - @Test func previewChatContextMenuCancelCancelsMatchingReviewRun() async throws { + @Test func previewChatContextMenuCancelInterruptsActiveFakeAppServerChat() async throws { let previewContent = ReviewMonitorPreviewContent.makeContentSource() let store = previewContent.store let selectedChatID = try #require(previewSelectedChatID(in: store)) - let run = try #require(store.cancellableReviewRun(forChatID: selectedChatID.rawValue)) let viewController = makeReviewMonitorPreviewContentViewControllerForPreview( previewContent: previewContent ) @@ -83,6 +82,8 @@ extension ReviewUITests { sidebar.sidebarKindForTesting == .chatList && sidebar.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar") } + #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) + #expect(store.hasNonTerminalReviewRun(forChatID: selectedChatID.rawValue) == false) var presentedCancelItem = false var cancelItemWasEnabled = false @@ -97,10 +98,13 @@ extension ReviewUITests { #expect(presentedCancelItem) #expect(cancelItemWasEnabled) - try await waitForCondition { - store.reviewRun(id: run.id)?.core.lifecycle.status == .cancelled + try await withTestTimeout(.seconds(2)) { + while await previewContent.interruptRequestCountForTesting() == 0 { + try Task.checkCancellation() + await Task.yield() + } } - #expect(store.reviewRun(id: run.id)?.core.lifecycle.cancellation?.source == .userInterface) + #expect(await previewContent.interruptRequestCountForTesting() == 1) #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) var cancelItemEnabledAfterCancellation = false From 37280d13a5c7605c64da30dfb8b0859fdd768e0c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:34:21 +0900 Subject: [PATCH 308/359] feat(review-ui): add codex selection title resolver --- ...ewMonitorCodexSelectionTitleResolver.swift | 98 +++++++++++ ...itorCodexSelectionTitleResolverTests.swift | 164 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift create mode 100644 Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift new file mode 100644 index 00000000..0d3ab8b9 --- /dev/null +++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift @@ -0,0 +1,98 @@ +import CodexKit + +@MainActor +struct ReviewMonitorCodexSelectionTitlePresentation: Equatable, Sendable { + var title: String + var subtitle: String +} + +@MainActor +final class ReviewMonitorCodexSelectionTitleResolver { + private let modelContext: CodexModelContext + private let fetchedChats: CodexFetchedResults + + init(modelContext: CodexModelContext) { + self.modelContext = modelContext + self.fetchedChats = modelContext.fetchedResults( + for: Self.codexReviewChatsDescriptor, + sectionedBy: .workspaceGroup + ) + } + + func refresh() async throws { + try await fetchedChats.performFetch() + } + + 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 .workspace(let id): + guard let workspace = modelContext.model(for: id) else { + return nil + } + return Self.titlePresentation(for: workspace) + + 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? { + fetchedChats.items.first { $0.id == 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 workspace: CodexWorkspace + ) -> ReviewMonitorCodexSelectionTitlePresentation { + ReviewMonitorCodexSelectionTitlePresentation( + title: workspace.name, + subtitle: workspace.url.path + ) + } + + private static func titlePresentation( + for chat: CodexChat + ) -> ReviewMonitorCodexSelectionTitlePresentation { + ReviewMonitorCodexSelectionTitlePresentation( + title: chat.title, + subtitle: chat.workspace?.url.path ?? "" + ) + } + + private static var codexReviewChatsDescriptor: CodexFetchDescriptor { + CodexFetchDescriptor( + predicate: .init(sourceKinds: [.subAgentReview]), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + ) + } +} diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift new file mode 100644 index 00000000..6138a47e --- /dev/null +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -0,0 +1,164 @@ +import CodexAppServerKitTesting +import CodexKit +import Foundation +import Testing +@testable import ReviewUI + +@Suite("ReviewMonitor Codex selection title resolver") +@MainActor +struct ReviewMonitorCodexSelectionTitleResolverTests { + @Test func resolvesWorkspaceGroupWorkspaceAndChatTitlesFromLoadedCodexModels() 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) + ), + ] + )) + + let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) + try await resolver.refresh() + + 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: .workspace(appWorkspace.id)) + == ReviewMonitorCodexSelectionTitlePresentation( + title: "App", + subtitle: appPath + )) + #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) + ) + ] + )) + + let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) + try await resolver.refresh() + + 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) + ) + ] + )) + + let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) + try await resolver.refresh() + + #expect( + resolver.titlePresentation(for: .chat(floatingThreadID)) + == ReviewMonitorCodexSelectionTitlePresentation( + title: "Floating review", + subtitle: "" + )) + #expect( + resolver.titlePresentation(for: .chat(CodexThreadID(rawValue: "thread-missing"))) == nil + ) + } + + @Test func returnsNilForMissingWorkspaceGroupWorkspaceAndEmptySelection() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(threads: [])) + + let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) + try await resolver.refresh() + + #expect(resolver.titlePresentation(for: nil) == nil) + #expect( + resolver.titlePresentation(for: .workspaceGroup(CodexWorkspaceGroupID(rawValue: "missing"))) == nil + ) + #expect( + resolver.titlePresentation(for: .workspace(CodexWorkspaceID(rawValue: "/missing"))) == nil + ) + } +} + +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 +} From a0b5aec9826b5fbf1a51675065732ff580ce782f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:40:45 +0900 Subject: [PATCH 309/359] fix(review-monitor): route preview composition through injected runtime --- .../ReviewMonitorPreviewComposition.swift | 60 +++++++++++----- .../ReviewMonitorPreviewContent.swift | 71 +++++++++++++++++-- .../CodexReviewMonitorApp.swift | 13 +--- 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 9a1d739a..3db25826 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -6,6 +6,7 @@ import ObjectiveC import ReviewUI nonisolated(unsafe) private var previewContentSourceAssociationKey: UInt8 = 0 +nonisolated(unsafe) private var previewContentDependenciesAssociationKey: UInt8 = 0 @MainActor func makeReviewMonitorPreviewContentViewController() -> NSViewController { @@ -69,28 +70,25 @@ public extension ReviewMonitorWindowController { codexModelSource: ReviewMonitorCodexModelSource, showSettings: @escaping @MainActor () -> Void ) { + guard let previewDependencies = store.previewContentDependenciesForPreviewSupport else { + self.init( + store: store, + codexModelSource: codexModelSource, + showSettings: showSettings + ) + return + } + previewDependencies.startStreaming(interval: .milliseconds(40)) + let uiState = ReviewMonitorUIState(previewSupportAuth: store.auth) + uiState.selectChat(id: previewDependencies.initialChatID) self.init( store: store, - codexModelSource: codexModelSource, - showSettings: showSettings - ) - } - - convenience init( - previewContent: ReviewMonitorPreviewContentSource, - showSettings: (@MainActor () -> Void)? = nil - ) { - previewContent.startStreaming(interval: .milliseconds(40)) - let uiState = ReviewMonitorUIState(previewSupportAuth: previewContent.store.auth) - uiState.selectChat(id: previewContent.initialChatID) - self.init( - store: previewContent.store, uiState: uiState, - codexModelSource: previewContent.codexModelSource, + codexModelSource: previewDependencies.codexModelSource, showSettings: showSettings, - dependencyRetainer: previewContent + dependencyRetainer: previewDependencies ) - window?.contentViewController?.installPreviewContentSourceForTesting(previewContent) + window?.contentViewController?.installPreviewContentDependenciesForTesting(previewDependencies) } } @@ -106,10 +104,16 @@ public extension NSViewController { @discardableResult func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? { - guard let previewContent = previewContentSourceForTesting else { + if let previewContent = previewContentSourceForTesting { + return await previewContent.appendPreviewChatLogStreamTick( + after: tick, + emitsNotifications: true + ) + } + guard let previewDependencies = previewContentDependenciesForTesting else { return nil } - return await previewContent.appendPreviewChatLogStreamTick( + return await previewDependencies.appendPreviewChatLogStreamTick( after: tick, emitsNotifications: true ) @@ -132,4 +136,22 @@ private extension NSViewController { .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } + + var previewContentDependenciesForTesting: ReviewMonitorPreviewContentDependencies? { + objc_getAssociatedObject( + self, + &previewContentDependenciesAssociationKey + ) as? ReviewMonitorPreviewContentDependencies + } + + func installPreviewContentDependenciesForTesting( + _ dependencies: ReviewMonitorPreviewContentDependencies? + ) { + objc_setAssociatedObject( + self, + &previewContentDependenciesAssociationKey, + dependencies, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 5d074cef..4a50ae7f 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,21 +1,63 @@ import Foundation import CodexKit +import ObjectiveC @_spi(Testing) import CodexReviewKit import ReviewUI +nonisolated(unsafe) private var previewContentDependenciesAssociationKey: UInt8 = 0 + +@MainActor +final class ReviewMonitorPreviewContentDependencies { + let runtime: ReviewMonitorPreviewAppServerRuntime + let codexModelSource: ReviewMonitorCodexModelSource + + init(runtime: ReviewMonitorPreviewAppServerRuntime) { + self.runtime = runtime + self.codexModelSource = runtime.modelSource + } + + var initialChatID: CodexThreadID? { + runtime.initialChatID + } + + func start() { + runtime.start() + } + + func startStreaming(interval: Duration) { + runtime.startStreaming(interval: interval) + } + + @discardableResult + func appendPreviewChatLogStreamTick( + after tick: Int = 0, + emitsNotifications: Bool = false + ) async -> Int { + await runtime.appendPreviewStreamTick( + after: tick, + emitsNotifications: emitsNotifications + ) + } +} + @MainActor public final class ReviewMonitorPreviewContentSource { public let store: CodexReviewStore - public let codexModelSource: ReviewMonitorCodexModelSource - let runtime: ReviewMonitorPreviewAppServerRuntime + let dependencies: ReviewMonitorPreviewContentDependencies + public var codexModelSource: ReviewMonitorCodexModelSource { + dependencies.codexModelSource + } + var runtime: ReviewMonitorPreviewAppServerRuntime { + dependencies.runtime + } init( store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime ) { self.store = store - self.runtime = runtime - self.codexModelSource = runtime.modelSource + self.dependencies = ReviewMonitorPreviewContentDependencies(runtime: runtime) + store.installPreviewContentDependenciesForPreviewSupport(dependencies) } var initialChatID: CodexThreadID? { @@ -50,6 +92,27 @@ public final class ReviewMonitorPreviewContentSource { } } +@MainActor +extension CodexReviewStore { + func installPreviewContentDependenciesForPreviewSupport( + _ dependencies: ReviewMonitorPreviewContentDependencies? + ) { + objc_setAssociatedObject( + self, + &previewContentDependenciesAssociationKey, + dependencies, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + var previewContentDependenciesForPreviewSupport: ReviewMonitorPreviewContentDependencies? { + objc_getAssociatedObject( + self, + &previewContentDependenciesAssociationKey + ) as? ReviewMonitorPreviewContentDependencies + } +} + @MainActor public enum ReviewMonitorPreviewContent { fileprivate enum PreviewChatLifecycle { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index e8f7d0c1..c9aaab0b 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -270,15 +270,11 @@ struct ReviewMonitorAppComposition { } ) -> ReviewMonitorAppComposition { let codexModelSource = ReviewMonitorCodexModelSource() - var previewContent: ReviewMonitorPreviewContentSource? return ReviewMonitorAppComposition( makeStore: { context, presentationAnchorProvider in if context.requestsPreviewContent { - let content = ReviewMonitorPreviewContent.makeContentSource() - previewContent = content - return content.store + return ReviewMonitorPreviewContent.makeContentSource().store } - previewContent = nil return makeLiveStore( runtimePreferencesStore.load(), .init( @@ -296,13 +292,6 @@ struct ReviewMonitorAppComposition { ) }, makeWindowController: { store, showSettings in - if let content = previewContent, - content.store === store { - return ReviewMonitorWindowController( - previewContent: content, - showSettings: showSettings - ) - } return ReviewMonitorWindowController( previewSupportStore: store, codexModelSource: codexModelSource, From 0a6fd62f49095de2cef10e481c2e10e5fd88c221 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:52:07 +0900 Subject: [PATCH 310/359] refactor(review-ui): resolve window titles from Codex models --- ...ewMonitorCodexSelectionTitleResolver.swift | 18 +----- .../ReviewMonitorSplitViewController.swift | 57 ++++++++++--------- ...itorCodexSelectionTitleResolverTests.swift | 20 +++++-- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift index 0d3ab8b9..0c099fcf 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift @@ -9,18 +9,9 @@ struct ReviewMonitorCodexSelectionTitlePresentation: Equatable, Sendable { @MainActor final class ReviewMonitorCodexSelectionTitleResolver { private let modelContext: CodexModelContext - private let fetchedChats: CodexFetchedResults init(modelContext: CodexModelContext) { self.modelContext = modelContext - self.fetchedChats = modelContext.fetchedResults( - for: Self.codexReviewChatsDescriptor, - sectionedBy: .workspaceGroup - ) - } - - func refresh() async throws { - try await fetchedChats.performFetch() } func titlePresentation( @@ -51,7 +42,7 @@ final class ReviewMonitorCodexSelectionTitleResolver { } private func loadedChat(id: CodexThreadID) -> CodexChat? { - fetchedChats.items.first { $0.id == id } + modelContext.registeredModel(for: id) } private static func titlePresentation( @@ -88,11 +79,4 @@ final class ReviewMonitorCodexSelectionTitleResolver { subtitle: chat.workspace?.url.path ?? "" ) } - - private static var codexReviewChatsDescriptor: CodexFetchDescriptor { - CodexFetchDescriptor( - predicate: .init(sourceKinds: [.subAgentReview]), - sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] - ) - } } diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 1599c3fc..072fdeab 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -32,6 +32,8 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe 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? @@ -81,12 +83,6 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe uiState: uiState, codexModelSource: codexModelSource ) - sidebarViewController.codexSidebarContentDidChange = { [weak self] in - guard let self else { - return - } - self.applyWindowTitle(self.windowTitlePresentation(for: self.uiState.selection)) - } let transportViewController = ReviewMonitorTransportViewController( uiState: uiState, codexModelSource: codexModelSource @@ -209,6 +205,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe guard let self else { return } + _ = self.codexModelSource?.modelContext let presentation = self.windowTitlePresentation(for: uiState.selection) self.applyWindowTitle(presentation) } @@ -244,28 +241,36 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe private func windowTitlePresentation( for selection: ReviewMonitorSelection? ) -> WindowTitlePresentation { - switch selection { - case .workspaceGroup(let id): - guard let presentation = sidebarViewController?.codexWorkspaceGroupTitlePresentation(id: id) else { - return WindowTitlePresentation(title: "", subtitle: "") - } - return WindowTitlePresentation(title: presentation.title, subtitle: presentation.subtitle) - case .workspace(let id): - guard let presentation = sidebarViewController?.codexWorkspaceTitlePresentation(id: id) else { - return WindowTitlePresentation(title: "", subtitle: "") - } - return WindowTitlePresentation(title: presentation.title, subtitle: presentation.subtitle) - case .chat(let id): - guard let presentation = sidebarViewController?.codexChatTitlePresentation(id: id) else { - return WindowTitlePresentation(title: "", subtitle: "") - } - return WindowTitlePresentation( - title: presentation.title, - subtitle: presentation.subtitle - ) - case nil: + 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) { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 6138a47e..7b32998c 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -33,8 +33,8 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { ] )) + try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - try await resolver.refresh() let appWorkspace = try #require(context.model(for: workspaceID(for: app))) let workspaceGroup = try #require(appWorkspace.workspaceGroup) @@ -77,8 +77,8 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { ] )) + try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - try await resolver.refresh() let workspace = try #require(context.model(for: workspaceID(for: repo))) let workspaceGroup = try #require(workspace.workspaceGroup) @@ -109,8 +109,8 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { ] )) + try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - try await resolver.refresh() #expect( resolver.titlePresentation(for: .chat(floatingThreadID)) @@ -129,8 +129,8 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { try await runtime.transport.enqueueThreadList(.init(threads: [])) + try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - try await resolver.refresh() #expect(resolver.titlePresentation(for: nil) == nil) #expect( @@ -142,6 +142,18 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { } } +@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) } From 9394240869024a162c8751cf3ba6c64fb5679934 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:13:22 +0900 Subject: [PATCH 311/359] test(review-ui): reduce transport render snapshot surface --- ...ReviewMonitorTransportViewController.swift | 80 ---------------- .../ReviewMonitorSelectedCodexChatTests.swift | 30 ++++-- Tests/ReviewUITests/ReviewUIShellTests.swift | 44 ++++++--- Tests/ReviewUITests/ReviewUITests.swift | 96 ++++++++----------- 4 files changed, 98 insertions(+), 152 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 1f80f11b..5dd9c467 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -346,8 +346,6 @@ final class ReviewMonitorTransportViewController: NSViewController { @MainActor extension ReviewMonitorTransportViewController { struct RenderSnapshotForTesting: Sendable, Equatable { - let title: String? - let summary: String? let log: String let isShowingEmptyState: Bool } @@ -383,31 +381,10 @@ final class ReviewMonitorTransportViewController: NSViewController { selectedCodexChat.chat?.items.compactMap(\.text) ?? [] } - var observationForExpectedRenderedStateForTesting: PortableObservationTracking.Token? { - let expectedSelection = expectedRenderedStateForTesting.selection - if displayedSelectionForTesting != expectedSelection { - return selectionObservation - } - switch expectedSelection { - case .workspaceGroup, .workspace, .chat: - return selectionObservation - case nil: - return selectionObservation - } - } - - var displayedTitleForTesting: String? { - nil - } - var displayedLogForTesting: String { logScrollView.displayedTextForTesting } - var displayedSummaryForTesting: String? { - nil - } - var isShowingEmptyStateForTesting: Bool { placeholderViewController.view.isHidden == false && placeholderViewController.content == .noSelection } @@ -718,15 +695,11 @@ final class ReviewMonitorTransportViewController: NSViewController { var renderSnapshotForTesting: RenderSnapshotForTesting { if isShowingEmptyStateForTesting { return .init( - title: nil, - summary: nil, log: "", isShowingEmptyState: true ) } return .init( - title: displayedTitleForTesting, - summary: displayedSummaryForTesting, log: displayedLogForTesting, isShowingEmptyState: false ) @@ -739,46 +712,6 @@ final class ReviewMonitorTransportViewController: NSViewController { ) } - var expectedRenderSnapshotForTesting: RenderSnapshotForTesting { - switch uiState.selection { - case .workspaceGroup: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: false - ) - case .workspace: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: false - ) - case .chat: - .init( - title: nil, - summary: nil, - log: displayedLogForTesting, - isShowingEmptyState: false - ) - case nil: - .init( - title: nil, - summary: nil, - log: "", - isShowingEmptyState: true - ) - } - } - - var expectedRenderedStateForTesting: RenderedStateForTesting { - .init( - snapshot: expectedRenderSnapshotForTesting, - selection: expectedDisplayedSelectionForTesting - ) - } - private var displayedSelectionForTesting: DisplayedSelectionForTesting? { switch displayedSelection { case .workspaceGroup(let id): @@ -792,19 +725,6 @@ final class ReviewMonitorTransportViewController: NSViewController { } } - private var expectedDisplayedSelectionForTesting: DisplayedSelectionForTesting? { - switch uiState.selection { - case .workspaceGroup(let id): - .workspaceGroup(id.rawValue) - case .workspace(let id): - .workspace(id.rawValue) - case .chat(let id): - .chat(id.rawValue) - case nil: - nil - } - } - func scrollLogToTopForTesting() { logScrollView.scrollToTopForTesting() } diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index a9146c9f..32eda225 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -166,7 +166,10 @@ struct ReviewMonitorSelectedCodexChatTests { selectChat(id: "review-thread", in: uiState) - let initialSnapshot = try await awaitTransportRender(transport) { snapshot in + let initialSnapshot = try await awaitTransportRender( + transport, + expectedSelection: .chat("review-thread") + ) { snapshot in snapshot.log.contains("Chat snapshot") } #expect(initialSnapshot.log.contains("Legacy fallback") == false) @@ -185,7 +188,10 @@ struct ReviewMonitorSelectedCodexChatTests { ) ) - let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + 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) @@ -230,7 +236,10 @@ struct ReviewMonitorSelectedCodexChatTests { selectChat(id: "review-thread", in: uiState) - _ = try await awaitTransportRender(transport) { snapshot in + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat("review-thread") + ) { snapshot in snapshot.log == "Initial" } transport.setLogReduceMotionForTesting(false) @@ -251,7 +260,10 @@ struct ReviewMonitorSelectedCodexChatTests { ) ) - let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + let updatedSnapshot = try await awaitTransportRender( + transport, + expectedSelection: .chat("review-thread") + ) { snapshot in snapshot.log == "Initial log" } #expect(updatedSnapshot.log == "Initial log") @@ -297,7 +309,10 @@ struct ReviewMonitorSelectedCodexChatTests { uiState.selection = .chat(CodexThreadID(rawValue: "chat-thread")) - _ = try await awaitTransportRender(transport) { snapshot in + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat("chat-thread") + ) { snapshot in snapshot.log.contains("Generic chat snapshot") } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) @@ -317,7 +332,10 @@ struct ReviewMonitorSelectedCodexChatTests { ) ) - let updatedSnapshot = try await awaitTransportRender(transport) { snapshot in + 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) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index eca991ef..e9dd4d6b 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -40,7 +40,10 @@ extension ReviewUITests { #expect(viewController.splitViewControllerForTesting.isTransportViewLoadedForTesting) let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting - let snapshot = try await awaitTransportRender(transport) { snapshot in + let snapshot = try await awaitTransportRender( + transport, + expectedSelection: .chat(selectedChatID.rawValue) + ) { snapshot in snapshot.log.isEmpty == false && snapshot.isShowingEmptyState == false } @@ -311,7 +314,10 @@ extension ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting - let snapshot = try await awaitTransportRender(transport) { snapshot in + let snapshot = try await awaitTransportRender( + transport, + expectedSelection: .chat(selectedChatID.rawValue) + ) { snapshot in snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } @@ -321,6 +327,8 @@ extension ReviewUITests { @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 ) @@ -329,13 +337,19 @@ extension ReviewUITests { viewController.view.layoutSubtreeIfNeeded() let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting - let initialSnapshot = try await awaitTransportRender(transport) { snapshot in + 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) { snapshot in + 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 @@ -1012,14 +1026,9 @@ extension ReviewUITests { window.setContentSize(NSSize(width: 900, height: 600)) viewController.loadViewIfNeeded() window.layoutIfNeeded() - try await waitForCondition { - window.titleVisibility == .visible && window.title.isEmpty == false - } #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) @@ -1043,7 +1052,10 @@ extension ReviewUITests { window.layoutIfNeeded() let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting - let snapshot = try await awaitTransportRender(transport) { snapshot in + let snapshot = try await awaitTransportRender( + transport, + expectedSelection: .chat(selectedChatID.rawValue) + ) { snapshot in snapshot.log.contains(expectedLogText) && snapshot.isShowingEmptyState == false } @@ -1053,6 +1065,8 @@ extension ReviewUITests { @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 ) @@ -1063,13 +1077,19 @@ extension ReviewUITests { window.layoutIfNeeded() let transport = viewController.splitViewControllerForTesting.transportViewControllerForTesting - let initialSnapshot = try await awaitTransportRender(transport) { snapshot in + 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) { snapshot in + 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 diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index d42c6b2e..9ee2fbb8 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -756,7 +756,6 @@ struct ReviewUITests { #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) - #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } @Test func selectingReviewChatUpdatesDetailPane() async throws { @@ -788,12 +787,8 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(selectedSnapshot.title == nil) - #expect(selectedSnapshot.summary == nil) #expect(selectedSnapshot.log == reviewChatLogText(for: recentChat)) #expect(selectedSnapshot.isShowingEmptyState == false) - #expect(window.title == recentChat.chat.title) - #expect(window.subtitle == recentChat.cwd) #expect(transport.logUsesFindBarForTesting) #expect(transport.logIsIncrementalSearchingEnabledForTesting) #expect(transport.logFindBarVisibleForTesting == false) @@ -877,10 +872,6 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(selectedSnapshot.title == nil) - #expect(selectedSnapshot.summary == nil) - #expect(window.title == chat.chat.title) - #expect(window.subtitle == chat.cwd) let displayedLog = transport.displayedLogForTesting #expect(selectedSnapshot.log == displayedLog) @@ -1616,15 +1607,11 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: activeChat.chatID) - let activeSnapshot = try await awaitChatRenderForTesting( + _ = try await awaitChatRenderForTesting( activeChat, in: transport, allowIncrementalUpdate: false ) - #expect(activeSnapshot.title == nil) - #expect(activeSnapshot.summary == nil) - #expect(window.title == activeChat.chat.title) - #expect(window.subtitle == activeChat.cwd) viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: recentChat.chatID) let recentSnapshot = try await awaitChatRenderForTesting( @@ -1635,14 +1622,10 @@ struct ReviewUITests { #expect( recentSnapshot == .init( - title: nil, - summary: nil, log: reviewChatLogText(for: recentChat), isShowingEmptyState: false ) ) - #expect(window.title == recentChat.chat.title) - #expect(window.subtitle == recentChat.cwd) } @Test func firstSelectionFromEmptyStatePinsUnvisitedReviewChatToBottom() async throws { @@ -2112,7 +2095,10 @@ struct ReviewUITests { ) viewController.sidebarViewControllerForTesting.clickWorkspaceHeaderForTesting(cwd: chat.cwd) - _ = try await awaitTransportRender(transport) + _ = try await awaitTransportRender( + transport, + expectedSelection: .workspaceGroup(expectedWorkspaceGroupID.rawValue) + ) #expect( viewController.sidebarViewControllerForTesting.selectedWorkspaceGroupIDForTesting == expectedWorkspaceGroupID) @@ -2138,7 +2124,6 @@ struct ReviewUITests { #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == nil) #expect(viewController.contentPaneViewControllerForTesting.isShowingEmptyStateForTesting) - #expect(viewController.contentPaneViewControllerForTesting.displayedTitleForTesting == nil) } @Test func removingSelectedReviewChatClearsSelectionWithoutAutoSelectingReplacement() async throws { @@ -2200,13 +2185,14 @@ struct ReviewUITests { && sidebar.selectedReviewChatIDForTesting == nil } - let emptySnapshot = try await awaitContentPaneRender(contentPane) { snapshot in + 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 { @@ -2230,23 +2216,19 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) - let selectedSnapshot = try await awaitChatRenderForTesting( + _ = try await awaitChatRenderForTesting( chat, in: transport, allowIncrementalUpdate: false ) - #expect(selectedSnapshot.title == nil) - #expect(window.title == chat.chat.title) - #expect(window.subtitle == chat.cwd) 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 == "") await replaceChatLogTextForTesting( "Deselected log", for: chat.chatID, @@ -2282,8 +2264,7 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(selectedSnapshot.title == nil) - #expect(selectedSnapshot.summary == nil) + #expect(reviewChatRenderedLogMatches(selectedSnapshot.log, reviewChatLogText(for: chat))) await replaceChatLogTextForTesting( "Updated log", for: chat.chatID, @@ -2293,7 +2274,6 @@ struct ReviewUITests { let updatedSnapshot = try await awaitChatRenderForTesting(chat, in: transport) #expect(viewController.sidebarViewControllerForTesting.selectedReviewChatIDForTesting == chat.chatID) - #expect(updatedSnapshot.summary == nil) #expect(reviewChatRenderedLogMatches(updatedSnapshot.log, reviewChatLogText(for: chat))) } @@ -2325,7 +2305,10 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting let chatID = chat.chatID viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) - _ = try await awaitTransportRender(transport) { $0.log == "Initial" } + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat(chatID.rawValue) + ) { $0.log == "Initial" } transport.setLogReduceMotionForTesting(false) let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting @@ -2337,7 +2320,10 @@ struct ReviewUITests { content: .message(.init(id: "msg_1", role: .assistant, text: "")) ) - let snapshot = try await awaitTransportRender(transport) { $0.log == "Initial log" } + 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) @@ -2372,7 +2358,10 @@ struct ReviewUITests { let transport = viewController.transportViewControllerForTesting let chatID = chat.chatID viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chatID) - _ = try await awaitTransportRender(transport) { $0.log == "Initial" } + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat(chatID.rawValue) + ) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting await previewRuntime.upsertPreviewItem( id: "progress_1", @@ -2381,7 +2370,10 @@ struct ReviewUITests { to: chatID ) - let snapshot = try await awaitTransportRender(transport) { $0.log.hasSuffix("stream.tick 001") } + 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) } @@ -3871,7 +3863,10 @@ struct ReviewUITests { #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.selectReviewChatForTesting(id: secondChat.chatID) @@ -4490,7 +4485,6 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(snapshot.summary == nil) #expect(snapshot.log == reviewChatLogText(for: chat)) } @@ -4519,7 +4513,6 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(snapshot.summary == nil) #expect(snapshot.log == reviewChatLogText(for: chat)) } @@ -4852,17 +4845,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() @@ -4882,7 +4870,7 @@ func awaitTransportRender( throw TestFailure( "timed out waiting for rendered transport state: " + "idle=\(transport.logRenderIsIdleForTesting), " - + "actual=\(state), expected=\(expectedState)" + + "actual=\(state), expectedSelection=\(String(describing: expectedSelection))" ) } @@ -4928,13 +4916,13 @@ 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 ) From a714439537f6aa468753514f01d544b69b1f5214 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:27:38 +0900 Subject: [PATCH 312/359] refactor(review-ui): use key-path section descriptors --- .../Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift | 2 +- .../ReviewMonitorCodexSelectionTitleResolverTests.swift | 2 +- Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 933b1cac..f66094d6 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -285,7 +285,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> CodexFetchedResults { modelContext.fetchedResults( for: defaultCodexSidebarDescriptor, - sectionedBy: .workspaceGroup + sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) ) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 7b32998c..39e5a6a3 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -149,7 +149,7 @@ private func loadReviewChats(in context: CodexModelContext) async throws { predicate: .init(sourceKinds: [.subAgentReview]), sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] ), - sectionedBy: .workspaceGroup + sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) ) try await results.performFetch() } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 94bba89f..59383899 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -676,7 +676,7 @@ private func makeCodexSidebarFetchedResults( ) -> CodexFetchedResults { context.fetchedResults( for: ReviewMonitorSidebarViewController.defaultCodexSidebarDescriptor, - sectionedBy: .workspaceGroup + sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) ) } From 686d644fc2766fd944579769e707476c72bf6ca5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:33:20 +0900 Subject: [PATCH 313/359] refactor(review-ui): derive sidebar sections from fetched results --- .../ReviewMonitorSidebarViewController.swift | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index f66094d6..1165bb70 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -97,8 +97,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarFetchTask: Task? private var codexSidebarFetchedResults: CodexFetchedResults? private var codexSidebarModelContext: CodexModelContext? - private var codexSidebarUnfilteredSections: [CodexFetchSection] = [] - private var codexSidebarDisplayedSections: [CodexFetchSection] = [] private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var appliedSidebarKind: SidebarKind? @@ -144,7 +142,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD super.viewDidLoad() configureHierarchy() configureOutlineView() - applyCodexSidebarSections([]) + applyCodexSidebarSourceSections([]) bindObservation() bindCodexSidebarFetchedResults() } @@ -315,7 +313,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let modelContext else { codexSidebarModelContext = nil codexSidebarFetchedResults = nil - applyCodexSidebarSections([]) + applyCodexSidebarSourceSections([]) return } @@ -332,7 +330,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard event.kind != .initial else { return } - self.applyCodexSidebarSections(sections) + self.applyCodexSidebarSourceSections(sections) } codexSidebarFetchTask = Task { @MainActor [weak self, fetchedResults] in do { @@ -343,23 +341,40 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard self?.codexSidebarFetchedResults === fetchedResults else { return } - self?.applyCodexSidebarSections(fetchedResults.sections) + self?.applyCodexSidebarSourceSections(fetchedResults.sections) self?.codexSidebarFetchTask = nil } } - private func applyCodexSidebarSections(_ sections: [CodexFetchSection]) { - codexSidebarUnfilteredSections = sections + private var codexSidebarSourceSections: [CodexFetchSection] { + codexSidebarFetchedResults?.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) - applyFilteredCodexSidebarSections() + applyCodexSidebarVisibleSections(from: sections) } private func applyFilteredCodexSidebarSections() { - let sections = - codexSidebarPresentationOrder - .applying(to: codexSidebarUnfilteredSections) - .filtered(by: uiState.sidebarReviewChatFilter) - codexSidebarDisplayedSections = sections + applyCodexSidebarVisibleSections(from: codexSidebarSourceSections) + } + + private func applyCodexSidebarVisibleSections( + from sourceSections: [CodexFetchSection] + ) { + let sections = codexSidebarVisibleSections(from: sourceSections) let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline let applyResult = codexSidebarOutlineTree.apply(sections: sections) applySidebarKind(sidebarKind) @@ -713,7 +728,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func workspaceGroupID(forWorkspaceCWD cwd: String) -> CodexWorkspaceGroupID? { let workspaceID = CodexWorkspaceID(rawValue: cwd) - for section in codexSidebarUnfilteredSections { + for section in codexSidebarSourceSections { if section.workspaces.contains(where: { $0.id == workspaceID }) || section.items.contains(where: { $0.workspaceID == workspaceID }) { @@ -781,25 +796,25 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func currentChatSelection(id: CodexThreadID) -> CodexChat? { - codexSidebarUnfilteredSections.chat(id: id) + codexSidebarSourceSections.chat(id: id) } private func displayedCodexSidebarSection(id: CodexWorkspaceGroupID) -> CodexFetchSection? { - codexSidebarDisplayedSections.first { $0.sidebarWorkspaceGroupID == id } + currentCodexSidebarVisibleSections.first { $0.sidebarWorkspaceGroupID == id } } private func displayedCodexChat(id: CodexThreadID) -> CodexChat? { - codexSidebarDisplayedSections.chat(id: id) + currentCodexSidebarVisibleSections.chat(id: id) ?? currentChatSelection(id: id) } private func displayedCodexWorkspace(id: CodexWorkspaceID) -> CodexWorkspace? { - for section in codexSidebarDisplayedSections { + for section in currentCodexSidebarVisibleSections { if let workspace = section.workspaces.first(where: { $0.id == id }) { return workspace } } - for section in codexSidebarUnfilteredSections { + for section in codexSidebarSourceSections { if let workspace = section.workspaces.first(where: { $0.id == id }) { return workspace } @@ -850,7 +865,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func codexWorkspaceGroupSection( id: CodexWorkspaceGroupID ) -> CodexFetchSection? { - codexSidebarUnfilteredSections.first { $0.sidebarWorkspaceGroupID == id } + codexSidebarSourceSections.first { $0.sidebarWorkspaceGroupID == id } } private func codexChatSelection(id: CodexThreadID) -> CodexChat? { @@ -1201,7 +1216,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { - for section in codexSidebarUnfilteredSections { + for section in codexSidebarSourceSections { if section.rowID == container { if section.displaysWorkspaceNodes == false { return section.items.map(\.id) @@ -1606,7 +1621,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD func refreshCodexSidebarForTesting() async throws { try await codexSidebarFetchedResults?.refresh() - applyCodexSidebarSections(codexSidebarFetchedResults?.sections ?? []) + applyCodexSidebarSourceSections(codexSidebarFetchedResults?.sections ?? []) } var sidebarFullReloadCountForTesting: Int { From ded29346b08d4d6110a525fed9064316a396b38d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:49:27 +0900 Subject: [PATCH 314/359] refactor(review-ui): observe codex model context directly --- Sources/ReviewUI/ReviewMonitorCodexModelSource.swift | 7 ------- Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift | 2 +- .../CodexChats/ReviewMonitorSidebarViewController.swift | 3 --- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift index 9982bff6..58fc3a67 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -5,7 +5,6 @@ import Observation @Observable public final class ReviewMonitorCodexModelSource { public private(set) var modelContext: CodexModelContext? - public private(set) var generation: UInt64 = 0 @ObservationIgnored private var container: CodexModelContainer? @@ -17,16 +16,10 @@ public final class ReviewMonitorCodexModelSource { public func install(container: CodexModelContainer) { self.container = container modelContext = container.mainContext - bumpGeneration() } public func clear() { container = nil modelContext = nil - bumpGeneration() - } - - private func bumpGeneration() { - generation &+= 1 } } diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift index 428b7fff..50835ee7 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift @@ -83,7 +83,7 @@ final class ReviewMonitorSelectedCodexChat { return } modelSourceObservation = withPortableContinuousObservation { [weak self, modelSource] _ in - _ = modelSource.generation + _ = modelSource.modelContext self?.refreshBinding() } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 1165bb70..512927e1 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -101,7 +101,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() private var appliedSidebarKind: SidebarKind? private var isReconcilingSelection = false - var codexSidebarContentDidChange: (@MainActor () -> Void)? #if DEBUG private var fullReloadCountForTesting = 0 #endif @@ -293,7 +292,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } codexSidebarObservation?.cancel() codexSidebarObservation = withPortableContinuousObservation { [weak self, codexModelSource] _ in - _ = codexModelSource.generation self?.installCodexSidebarFetchedResults(modelContext: codexModelSource.modelContext) } } @@ -387,7 +385,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD updateVisibleCodexSidebarCells() reconcileOutlineSelection() } - codexSidebarContentDidChange?() } private func applyCodexSidebarOutlineTopologyChanges( From d775a065d27d317e29caf08c9c2f8b9a02742df5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:24:03 +0900 Subject: [PATCH 315/359] refactor(review-ui): centralize chat cancellation policy --- .../Store/CodexReviewStoreOrderQueries.swift | 39 +++++++++++++++++++ .../ReviewMonitorChatContextMenuView.swift | 26 ++++++------- Tests/ReviewUITests/ReviewUIShellTests.swift | 31 +++++++++++---- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index db7e582c..be659db3 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -1,3 +1,23 @@ +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) + package static let pendingReviewCancellation = CodexChatCancellationCapability(isEnabled: true, action: nil) + + package init(isEnabled: Bool, action: Action?) { + self.isEnabled = isEnabled + self.action = action + } +} + extension CodexReviewStore { package var orderedReviewRuns: [ReviewRunRecord] { reviewRuns.sorted { @@ -33,6 +53,25 @@ extension CodexReviewStore { } } + 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) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index c9fc9517..6404b8c7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -16,30 +16,28 @@ struct ReviewMonitorChatContextMenuView: View { Button("Cancel") { cancel() } - .disabled(isRunning == false) - } - - private var isRunning: Bool { - let chatID = chat.id.rawValue - return store.hasCancellableReview(forChatID: chatID) - || chat.status?.isActive == true + .disabled(cancellationCapability.isEnabled == false) } private func cancel() { let chatID = chat.id.rawValue + let action = cancellationCapability.action Task { - if store.hasCancellableReview(forChatID: chatID) { + switch action { + case .some(.reviewRun): _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface()) - } else if shouldCancelActiveChatDirectly(chatID: chatID) { + case .some(.directChat): _ = try? await chat.cancel() + case nil: + break } } } - private func shouldCancelActiveChatDirectly(chatID: String) -> Bool { - guard chat.status?.isActive == true else { - return false - } - return store.hasNonTerminalReviewRun(forChatID: chatID) == false + private var cancellationCapability: CodexChatCancellationCapability { + store.chatCancellationCapability( + forChatID: chat.id.rawValue, + isChatActive: chat.status?.isActive == true + ) } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index e9dd4d6b..7222ad99 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -85,8 +85,12 @@ extension ReviewUITests { sidebar.sidebarKindForTesting == .chatList && sidebar.displayedCodexSidebarTitlesForTesting.contains("Branch: feature/workspace-alpha-sidebar") } - #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) - #expect(store.hasNonTerminalReviewRun(forChatID: selectedChatID.rawValue) == false) + #expect( + store.chatCancellationCapability( + forChatID: selectedChatID.rawValue, + isChatActive: true + ) == .directChat + ) var presentedCancelItem = false var cancelItemWasEnabled = false @@ -108,7 +112,12 @@ extension ReviewUITests { } } #expect(await previewContent.interruptRequestCountForTesting() == 1) - #expect(store.hasCancellableReview(forChatID: selectedChatID.rawValue) == false) + #expect( + store.chatCancellationCapability( + forChatID: selectedChatID.rawValue, + isChatActive: true + ).isEnabled + ) var cancelItemEnabledAfterCancellation = false sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in @@ -184,8 +193,12 @@ extension ReviewUITests { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Active chat" } - #expect(store.hasReviewRun(forChatID: chatID.rawValue)) - #expect(store.hasCancellableReview(forChatID: chatID.rawValue) == false) + #expect( + store.chatCancellationCapability( + forChatID: chatID.rawValue, + isChatActive: true + ) == .directChat + ) var presentedCancelItem = false var cancelItemWasEnabled = false @@ -280,8 +293,12 @@ extension ReviewUITests { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Pending cancellation chat" } - #expect(store.hasReviewRun(forChatID: chatID.rawValue)) - #expect(store.hasCancellableReview(forChatID: chatID.rawValue) == false) + #expect( + store.chatCancellationCapability( + forChatID: chatID.rawValue, + isChatActive: true + ) == .pendingReviewCancellation + ) var cancelItemWasEnabled = false sidebar.presentContextMenuForTesting(chatID: chatID) { menu in From 50252c1d11f428b713832b369ce063f237eeb2b4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:32:15 +0900 Subject: [PATCH 316/359] refactor(review-ui): remove codex sidebar mode branch --- .../ReviewMonitorSidebarViewController.swift | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 512927e1..2cc3c9f6 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -373,15 +373,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD from sourceSections: [CodexFetchSection] ) { let sections = codexSidebarVisibleSections(from: sourceSections) - let wasUsingCodexSidebarOutline = isUsingCodexSidebarOutline let applyResult = codexSidebarOutlineTree.apply(sections: sections) applySidebarKind(sidebarKind) - guard wasUsingCodexSidebarOutline || isUsingCodexSidebarOutline else { - return - } if applyResult.topologyChanged { applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) - } else if isUsingCodexSidebarOutline { + } else { updateVisibleCodexSidebarCells() reconcileOutlineSelection() } @@ -406,11 +402,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if appliedIncrementally { expandCodexSidebarNodes(codexSidebarOutlineTree.roots) updateVisibleCodexSidebarCells() - if isUsingCodexSidebarOutline { - reconcileOutlineSelection() - } else if outlineView.selectedRow != -1 { - outlineView.deselectAll(nil) - } + reconcileOutlineSelection() isReconcilingSelection = false } else { isReconcilingSelection = false @@ -523,11 +515,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD isReconcilingSelection = true outlineView.reloadData() expandCodexSidebarNodes(codexSidebarOutlineTree.roots) - if isUsingCodexSidebarOutline { - reconcileOutlineSelection() - } else if outlineView.selectedRow != -1 { - outlineView.deselectAll(nil) - } + reconcileOutlineSelection() isReconcilingSelection = false } @@ -567,10 +555,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarOutlineTree.roots.isEmpty == false } - private var isUsingCodexSidebarOutline: Bool { - true - } - private func applySidebarKind(_ kind: SidebarKind) { guard appliedSidebarKind != kind else { return From 911254fa8038f525d7c766cddb374ec091d06a3d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:39:01 +0900 Subject: [PATCH 317/359] refactor(review-ui): remove preview-specific ui state initializer --- Sources/ReviewUI/ReviewMonitorUIState.swift | 6 +---- .../ReviewUI/SidebarReviewChatFilter.swift | 24 +++++++++---------- .../ReviewMonitorPreviewComposition.swift | 4 ++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index f925e1ae..2aba199d 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -18,7 +18,7 @@ public final class ReviewMonitorUIState { } } - init( + package init( auth: CodexReviewAuthModel, sidebarReviewChatFilter: SidebarReviewChatFilter = .all, persistSidebarReviewChatFilter: @escaping (SidebarReviewChatFilter) -> Void = { _ in } @@ -28,10 +28,6 @@ public final class ReviewMonitorUIState { self.persistSidebarReviewChatFilter = persistSidebarReviewChatFilter } - package convenience init(previewSupportAuth auth: CodexReviewAuthModel) { - self.init(auth: auth) - } - package func selectChat(id: CodexThreadID?) { selection = id.map(ReviewMonitorSelection.chat) } diff --git a/Sources/ReviewUI/SidebarReviewChatFilter.swift b/Sources/ReviewUI/SidebarReviewChatFilter.swift index bf560260..a8f1254f 100644 --- a/Sources/ReviewUI/SidebarReviewChatFilter.swift +++ b/Sources/ReviewUI/SidebarReviewChatFilter.swift @@ -1,18 +1,18 @@ import Foundation -struct SidebarReviewChatFilter: OptionSet, Hashable, Sendable { - let rawValue: Int +package struct SidebarReviewChatFilter: OptionSet, Hashable, Sendable { + package let rawValue: Int - static let all: SidebarReviewChatFilter = [] - static let running = SidebarReviewChatFilter(rawValue: 1 << 0) - static let latestFinished = SidebarReviewChatFilter(rawValue: 1 << 1) - static let menuFilters: [SidebarReviewChatFilter] = [.running, .latestFinished] + 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] - init(rawValue: Int) { + package init(rawValue: Int) { self.rawValue = rawValue } - var localized: LocalizedStringResource { + package var localized: LocalizedStringResource { if self == .all { "All Items" } else if self == .running { @@ -24,15 +24,15 @@ struct SidebarReviewChatFilter: OptionSet, Hashable, Sendable { } } - var isActive: Bool { + package var isActive: Bool { isEmpty == false } - var allowsReviewChatReordering: Bool { + package var allowsReviewChatReordering: Bool { self == .all || contains(.running) } - var persistedValue: String { + package var persistedValue: String { guard isActive else { return "all" } @@ -41,7 +41,7 @@ struct SidebarReviewChatFilter: OptionSet, Hashable, Sendable { }.joined(separator: ",") } - init?(persistedValue: String) { + package init?(persistedValue: String) { if persistedValue == "all" { self = .all return diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 3db25826..039b4833 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -45,7 +45,7 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( store.auth.applyPersistedAccountStates(previewAccounts.map(savedAccountPayload(from:))) store.auth.selectPersistedAccount(resolvedAccount?.id) - let uiState = ReviewMonitorUIState(previewSupportAuth: store.auth) + let uiState = ReviewMonitorUIState(auth: store.auth) uiState.selectChat(id: resolvedPreviewContent?.initialChatID) if let resolvedPreviewContent { if ownsPreviewContent { @@ -79,7 +79,7 @@ public extension ReviewMonitorWindowController { return } previewDependencies.startStreaming(interval: .milliseconds(40)) - let uiState = ReviewMonitorUIState(previewSupportAuth: store.auth) + let uiState = ReviewMonitorUIState(auth: store.auth) uiState.selectChat(id: previewDependencies.initialChatID) self.init( store: store, From e366b8304c0666ab7b93d6304f7a3bc099183518 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:43:09 +0900 Subject: [PATCH 318/359] perf(review-ui): let codex sidebar cells observe models --- .../ReviewMonitorSidebarViewController.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 2cc3c9f6..3feb815a 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -378,7 +378,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if applyResult.topologyChanged { applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) } else { - updateVisibleCodexSidebarCells() reconcileOutlineSelection() } } @@ -401,7 +400,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } if appliedIncrementally { expandCodexSidebarNodes(codexSidebarOutlineTree.roots) - updateVisibleCodexSidebarCells() reconcileOutlineSelection() isReconcilingSelection = false } else { @@ -1262,20 +1260,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } } - private func updateVisibleCodexSidebarCells() { - guard outlineView.numberOfRows > 0 else { - return - } - for row in 0.. NSView? { switch node.item { case .chat: From b6f44a23b791a41f26f781a2e3a3e9a5c3809316 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:59:25 +0900 Subject: [PATCH 319/359] refactor(review-monitor): make preview app-server DI explicit --- .../ReviewMonitorPreviewComposition.swift | 61 +++++--------- .../ReviewMonitorPreviewContent.swift | 69 +--------------- .../CodexReviewMonitorApp.swift | 80 +++++++++++++------ .../CodexReviewMonitorCITests.swift | 37 +++++---- 4 files changed, 103 insertions(+), 144 deletions(-) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 039b4833..ec7a6cca 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -6,7 +6,6 @@ import ObjectiveC import ReviewUI nonisolated(unsafe) private var previewContentSourceAssociationKey: UInt8 = 0 -nonisolated(unsafe) private var previewContentDependenciesAssociationKey: UInt8 = 0 @MainActor func makeReviewMonitorPreviewContentViewController() -> NSViewController { @@ -66,29 +65,33 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( public extension ReviewMonitorWindowController { convenience init( - previewSupportStore store: CodexReviewStore, + appStore store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource, showSettings: @escaping @MainActor () -> Void ) { - guard let previewDependencies = store.previewContentDependenciesForPreviewSupport else { - self.init( - store: store, - codexModelSource: codexModelSource, - showSettings: showSettings - ) - return - } - previewDependencies.startStreaming(interval: .milliseconds(40)) + 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: previewDependencies.initialChatID) + uiState.selectChat(id: previewContent.initialChatID) self.init( store: store, uiState: uiState, - codexModelSource: previewDependencies.codexModelSource, + codexModelSource: previewContent.codexModelSource, showSettings: showSettings, - dependencyRetainer: previewDependencies + dependencyRetainer: previewContent ) - window?.contentViewController?.installPreviewContentDependenciesForTesting(previewDependencies) + window?.contentViewController?.installPreviewContentSourceForTesting(previewContent) } } @@ -104,16 +107,10 @@ public extension NSViewController { @discardableResult func appendPreviewChatLogStreamTickForTesting(after tick: Int = 0) async -> Int? { - if let previewContent = previewContentSourceForTesting { - return await previewContent.appendPreviewChatLogStreamTick( - after: tick, - emitsNotifications: true - ) - } - guard let previewDependencies = previewContentDependenciesForTesting else { + guard let previewContent = previewContentSourceForTesting else { return nil } - return await previewDependencies.appendPreviewChatLogStreamTick( + return await previewContent.appendPreviewChatLogStreamTick( after: tick, emitsNotifications: true ) @@ -136,22 +133,4 @@ private extension NSViewController { .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } - - var previewContentDependenciesForTesting: ReviewMonitorPreviewContentDependencies? { - objc_getAssociatedObject( - self, - &previewContentDependenciesAssociationKey - ) as? ReviewMonitorPreviewContentDependencies - } - - func installPreviewContentDependenciesForTesting( - _ dependencies: ReviewMonitorPreviewContentDependencies? - ) { - objc_setAssociatedObject( - self, - &previewContentDependenciesAssociationKey, - dependencies, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 4a50ae7f..201eee3c 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,54 +1,15 @@ import Foundation import CodexKit -import ObjectiveC @_spi(Testing) import CodexReviewKit import ReviewUI -nonisolated(unsafe) private var previewContentDependenciesAssociationKey: UInt8 = 0 - -@MainActor -final class ReviewMonitorPreviewContentDependencies { - let runtime: ReviewMonitorPreviewAppServerRuntime - let codexModelSource: ReviewMonitorCodexModelSource - - init(runtime: ReviewMonitorPreviewAppServerRuntime) { - self.runtime = runtime - self.codexModelSource = runtime.modelSource - } - - var initialChatID: CodexThreadID? { - runtime.initialChatID - } - - func start() { - runtime.start() - } - - func startStreaming(interval: Duration) { - runtime.startStreaming(interval: interval) - } - - @discardableResult - func appendPreviewChatLogStreamTick( - after tick: Int = 0, - emitsNotifications: Bool = false - ) async -> Int { - await runtime.appendPreviewStreamTick( - after: tick, - emitsNotifications: emitsNotifications - ) - } -} - @MainActor public final class ReviewMonitorPreviewContentSource { public let store: CodexReviewStore - let dependencies: ReviewMonitorPreviewContentDependencies + let runtime: ReviewMonitorPreviewAppServerRuntime + public var codexModelSource: ReviewMonitorCodexModelSource { - dependencies.codexModelSource - } - var runtime: ReviewMonitorPreviewAppServerRuntime { - dependencies.runtime + runtime.modelSource } init( @@ -56,8 +17,7 @@ public final class ReviewMonitorPreviewContentSource { runtime: ReviewMonitorPreviewAppServerRuntime ) { self.store = store - self.dependencies = ReviewMonitorPreviewContentDependencies(runtime: runtime) - store.installPreviewContentDependenciesForPreviewSupport(dependencies) + self.runtime = runtime } var initialChatID: CodexThreadID? { @@ -92,27 +52,6 @@ public final class ReviewMonitorPreviewContentSource { } } -@MainActor -extension CodexReviewStore { - func installPreviewContentDependenciesForPreviewSupport( - _ dependencies: ReviewMonitorPreviewContentDependencies? - ) { - objc_setAssociatedObject( - self, - &previewContentDependenciesAssociationKey, - dependencies, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - - var previewContentDependenciesForPreviewSupport: ReviewMonitorPreviewContentDependencies? { - objc_getAssociatedObject( - self, - &previewContentDependenciesAssociationKey - ) as? ReviewMonitorPreviewContentDependencies - } -} - @MainActor public enum ReviewMonitorPreviewContent { fileprivate enum PreviewChatLifecycle { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index c9aaab0b..83b58afb 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -208,6 +208,23 @@ private final class ReviewMonitorPresentationAnchorSource { weak var window: NSWindow? } +@MainActor +struct ReviewMonitorAppDependencies { + let store: CodexReviewStore + let previewContent: ReviewMonitorPreviewContentSource? + + init( + store: CodexReviewStore, + previewContent: ReviewMonitorPreviewContentSource? = nil + ) { + if let previewContent { + precondition(previewContent.store === store) + } + self.store = store + self.previewContent = previewContent + } +} + private enum ReviewMonitorNativeAuthentication { static let callbackScheme = "lynnpd.CodexReviewMonitor.auth" } @@ -221,19 +238,25 @@ struct ReviewMonitorAppComposition { 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 @@ -244,7 +267,7 @@ struct ReviewMonitorAppComposition { ) }, makeWindowController: @escaping ( - CodexReviewStore, + ReviewMonitorAppDependencies, @escaping @MainActor () -> Void ) -> NSWindowController, makeSettingsWindowController: @escaping () -> NSWindowController = { @@ -253,7 +276,7 @@ struct ReviewMonitorAppComposition { ) } ) { - self.makeStore = makeStore + self.makeDependencies = makeDependencies self.makeLifecycleController = makeLifecycleController self.makeWindowController = makeWindowController self.makeSettingsWindowController = makeSettingsWindowController @@ -271,29 +294,39 @@ struct ReviewMonitorAppComposition { ) -> ReviewMonitorAppComposition { let codexModelSource = ReviewMonitorCodexModelSource() return ReviewMonitorAppComposition( - makeStore: { context, presentationAnchorProvider in + makeDependencies: { context, presentationAnchorProvider in if context.requestsPreviewContent { - return ReviewMonitorPreviewContent.makeContentSource().store + 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 - ), - { appServer in - if let appServer { - codexModelSource.install(container: CodexModelContainer(appServer: appServer)) - } else { - codexModelSource.clear() - } + ) + ) { appServer in + if let appServer { + codexModelSource.install(container: CodexModelContainer(appServer: appServer)) + } else { + codexModelSource.clear() } - ) + } + return ReviewMonitorAppDependencies(store: store) }, - makeWindowController: { store, showSettings in + makeWindowController: { dependencies, showSettings in + if let previewContent = dependencies.previewContent { + return ReviewMonitorWindowController( + previewContent: previewContent, + showSettings: showSettings + ) + } return ReviewMonitorWindowController( - previewSupportStore: store, + appStore: dependencies.store, codexModelSource: codexModelSource, showSettings: showSettings ) @@ -318,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 74879e9f..a6aadbe3 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -88,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() }, @@ -145,8 +146,8 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeStore: { _, _ in - CodexReviewStore.makePreviewStore() + makeDependencies: { _, _ in + ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) }, makeWindowController: { _, _ in CountingWindowController() @@ -192,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() @@ -239,12 +240,14 @@ struct CodexReviewMonitorCITests { ) 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) @@ -268,12 +271,12 @@ struct CodexReviewMonitorCITests { arguments: [], launchMode: .application ) - let store = composition.makeStore(context) { + let dependencies = composition.makeDependencies(context) { Issue.record("Preview store creation should not request a presentation anchor.") return nil } - let windowController = composition.makeWindowController(store) {} + let windowController = composition.makeWindowController(dependencies) {} let rootViewController = try #require( windowController.window?.contentViewController as? ReviewMonitorRootViewController ) @@ -340,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") @@ -374,9 +379,11 @@ struct CodexReviewMonitorCITests { launchMode: .application ) - let store = composition.makeStore(context) { nil } + let dependencies = composition.makeDependencies(context) { nil } + let store = dependencies.store #expect(store === expectedStore) + #expect(dependencies.previewContent == nil) #expect(capturedLifecycleHandler != nil) } @@ -611,8 +618,8 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeStore: { _, _ in - CodexReviewStore.makePreviewStore() + makeDependencies: { _, _ in + ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) }, makeWindowController: { _, _ in CountingWindowController() From fcae319256e2b61d067b22744c04ea3d83662cdd Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:16:44 +0900 Subject: [PATCH 320/359] refactor(review-ui): use Codex section aliases --- .../Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift | 2 +- .../ReviewMonitorCodexSelectionTitleResolverTests.swift | 2 +- Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 3feb815a..1d2e75ae 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -282,7 +282,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> CodexFetchedResults { modelContext.fetchedResults( for: defaultCodexSidebarDescriptor, - sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) + sectionedBy: .workspaceGroup ) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 39e5a6a3..7b32998c 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -149,7 +149,7 @@ private func loadReviewChats(in context: CodexModelContext) async throws { predicate: .init(sourceKinds: [.subAgentReview]), sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] ), - sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) + sectionedBy: .workspaceGroup ) try await results.performFetch() } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 59383899..94bba89f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -676,7 +676,7 @@ private func makeCodexSidebarFetchedResults( ) -> CodexFetchedResults { context.fetchedResults( for: ReviewMonitorSidebarViewController.defaultCodexSidebarDescriptor, - sectionedBy: CodexSectionDescriptor(\CodexChat.workspaceGroupID) + sectionedBy: .workspaceGroup ) } From 17da54a413c9dd49d68fe7bc22b28165a65c5657 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:28:19 +0900 Subject: [PATCH 321/359] refactor(review-ui): remove duplicate sidebar title projections --- .../ReviewMonitorSidebarViewController.swift | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 1d2e75ae..80d16ae7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -857,45 +857,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return displayedCodexChat(id: id) } - func codexChatTitlePresentation(id: CodexThreadID) -> (title: String, subtitle: String)? { - if let chat = codexSidebarFetchedResults?.items.first(where: { $0.id == id }) { - return ( - title: chat.title, - subtitle: chat.workspace?.url.path ?? "" - ) - } - guard let chat = currentChatSelection(id: id) else { - return nil - } - return ( - title: chat.title, - subtitle: chat.workspace?.url.path ?? "" - ) - } - - func codexWorkspaceGroupTitlePresentation( - id: CodexWorkspaceGroupID - ) -> (title: String, subtitle: String)? { - guard let node = codexWorkspaceGroupSelection(id: id) else { - return nil - } - let title = codexSidebarTitle(for: node) ?? id.rawValue - let workspaceCWDs = codexWorkspaceGroupSection(id: id)?.workspaces.map(\.url.path) ?? [] - return ( - title: title, - subtitle: workspaceCWDs.count == 1 ? (workspaceCWDs.first ?? "") : "\(workspaceCWDs.count) workspaces" - ) - } - - func codexWorkspaceTitlePresentation( - id: CodexWorkspaceID - ) -> (title: String, subtitle: String)? { - guard let workspace = codexWorkspaceSelection(id: id) else { - return nil - } - return (title: workspace.name, subtitle: workspace.url.path) - } - private func dragPayload(for item: Any) -> SidebarDragPayload? { guard let node = codexSidebarNode(from: item) else { return nil From 3a9216ff49cb0d467ec0a75bd4af23144124164b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:07:11 +0900 Subject: [PATCH 322/359] perf(review-ui): reduce sidebar chat hosting layout work --- .../Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 80d16ae7..cb6b1244 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -2119,6 +2119,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { let hostingView = NSHostingView( rootView: ReviewMonitorChatRowView(chat: chat) ) + hostingView.sizingOptions = [] hostingView.translatesAutoresizingMaskIntoConstraints = false hostingView.setAccessibilityIdentifier("review-monitor.review-chat-row") addSubview(hostingView) From bc394196bed142f3442f3734dc8d43041f272523 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:37:18 +0900 Subject: [PATCH 323/359] fix(review-ui): keep sidebar section rows domain-safe --- .../ReviewMonitorCodexSidebarOutline.swift | 88 +++++++++++++++---- .../ReviewMonitorSidebarViewController.swift | 53 +++++++---- ...eviewMonitorCodexSidebarResultsTests.swift | 84 ++++++++++++++++-- 3 files changed, 180 insertions(+), 45 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 3c5a456b..10c08ba4 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -13,6 +13,19 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC .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 workspace(_ id: CodexWorkspaceID) -> Self { .init(rawValue: "workspace:\(id.rawValue)") } @@ -28,13 +41,8 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC @MainActor extension CodexFetchSection where Model == CodexChat { - package var sidebarWorkspaceGroupID: CodexWorkspaceGroupID { - switch id { - case .workspaceGroup(let id): - id - default: - CodexWorkspaceGroupID(rawValue: id.description) - } + package var sidebarWorkspaceGroupID: CodexWorkspaceGroupID? { + workspaceGroupID } package var displayTitle: String { @@ -42,7 +50,10 @@ extension CodexFetchSection where Model == CodexChat { } package var rowID: ReviewMonitorCodexSidebarRowID { - .workspaceGroup(sidebarWorkspaceGroupID) + if let sidebarWorkspaceGroupID { + return .workspaceGroup(sidebarWorkspaceGroupID) + } + return .section(id) } package var displaysWorkspaceNodes: Bool { @@ -82,17 +93,21 @@ extension Array where Element == CodexFetchSection { return self } - return map { section in + 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: section.items.filter { - Self.includes($0, filter: filter, latestFinishedChatID: latestFinishedChatID) - } + items: items ) } } @@ -142,7 +157,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { func applying( to sections: [CodexFetchSection] ) -> [CodexFetchSection] { - ordered(sections, by: workspaceGroupIDs, id: \.sidebarWorkspaceGroupID).map { section in + orderedSections(sections).map { section in if section.displaysWorkspaceNodes == false { return CodexFetchSection( id: section.id, @@ -192,7 +207,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } mutating func prune(to sections: [CodexFetchSection]) { - let activeWorkspaceGroupIDs = sections.map(\.sidebarWorkspaceGroupID) + let activeWorkspaceGroupIDs = sections.compactMap(\.sidebarWorkspaceGroupID) workspaceGroupIDs = workspaceGroupIDs.filter { activeWorkspaceGroupIDs.contains($0) } var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:] @@ -219,6 +234,30 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } } + private func orderedSections( + _ sections: [CodexFetchSection] + ) -> [CodexFetchSection] { + guard workspaceGroupIDs.isEmpty == false else { + return sections + } + let currentDomainIDs = sections.compactMap(\.sidebarWorkspaceGroupID) + var orderedDomainIDs = mergedOrder(preferredIDs: workspaceGroupIDs, currentOrder: currentDomainIDs) + let sectionsByDomainID = Dictionary( + uniqueKeysWithValues: sections.compactMap { section in + section.sidebarWorkspaceGroupID.map { ($0, section) } + } + ) + return sections.map { section in + guard section.sidebarWorkspaceGroupID != nil, + orderedDomainIDs.isEmpty == false + else { + return section + } + let nextID = orderedDomainIDs.removeFirst() + return sectionsByDomainID[nextID] ?? section + } + } + private func ordered( _ elements: [Element], by preferredIDs: [ID], @@ -269,12 +308,15 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { @MainActor package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { + case section(CodexFetchSectionID) case workspaceGroup(CodexWorkspaceGroupID) case workspace(CodexWorkspaceID) case chat(CodexThreadID) package var rowID: ReviewMonitorCodexSidebarRowID { switch self { + case .section(let id): + .section(id) case .workspaceGroup(let id): .workspaceGroup(id) case .workspace(let id): @@ -284,8 +326,10 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { } } - var selectionID: ReviewMonitorSelectionID { + var selectionID: ReviewMonitorSelectionID? { switch self { + case .section: + nil case .workspaceGroup(let id): .workspaceGroup(id) case .workspace(let id): @@ -299,7 +343,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { switch self { case .workspaceGroup(let id): id - case .workspace, .chat: + case .section, .workspace, .chat: nil } } @@ -308,7 +352,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { switch self { case .chat(let id): id - case .workspaceGroup, .workspace: + case .section, .workspaceGroup, .workspace: nil } } @@ -390,7 +434,13 @@ final class ReviewMonitorCodexSidebarOutlineTree { for section: CodexFetchSection, activeRowIDs: inout Set ) -> ReviewMonitorCodexSidebarOutlineNode { - let node = node(for: .workspaceGroup(section.sidebarWorkspaceGroupID), activeRowIDs: &activeRowIDs) + let item: ReviewMonitorCodexSidebarOutlineItem = + if let workspaceGroupID = section.sidebarWorkspaceGroupID { + .workspaceGroup(workspaceGroupID) + } else { + .section(section.id) + } + let node = node(for: item, activeRowIDs: &activeRowIDs) let children: [ReviewMonitorCodexSidebarOutlineNode] if section.displaysWorkspaceNodes { children = @@ -475,7 +525,7 @@ final class ReviewMonitorCodexSidebarOutlineNode { item.rowID } - var selectionID: ReviewMonitorSelectionID { + var selectionID: ReviewMonitorSelectionID? { item.selectionID } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index cb6b1244..ab87c492 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -662,6 +662,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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 .workspace(let id): @@ -694,10 +696,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func shouldAllowSelection(of item: Any?) -> Bool { - if codexSidebarNode(from: item) != nil { - return true - } - return false + codexSidebarNode(from: item)?.selectionID != nil } private func row(forWorkspaceCWD cwd: String) -> Int? { @@ -713,8 +712,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD { return section.sidebarWorkspaceGroupID } - if section.sidebarWorkspaceGroupID.rawValue == cwd || section.sidebarWorkspaceGroupID.rawValue == "cwd:\(cwd)" { - return section.sidebarWorkspaceGroupID + if let sidebarWorkspaceGroupID = section.sidebarWorkspaceGroupID, + sidebarWorkspaceGroupID.rawValue == cwd || sidebarWorkspaceGroupID.rawValue == "cwd:\(cwd)" + { + return sidebarWorkspaceGroupID } } return nil @@ -782,6 +783,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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) @@ -803,6 +808,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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 @@ -834,7 +842,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch node.item { case .workspaceGroup(let workspaceGroupID) where workspaceGroupID == id: return node - case .workspace, .chat: + case .section, .workspace, .chat: return nil case .workspaceGroup: return nil @@ -847,6 +855,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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, @@ -862,6 +876,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } switch node.item { + case .section: + return nil case .workspaceGroup(let id): return .codexWorkspaceGroup(id: id.rawValue) case .workspace: @@ -929,6 +945,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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, @@ -949,14 +966,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } - let beforeID: CodexWorkspaceGroupID? - if displayDestinationIndex < remainingRoots.count, - let workspaceGroupID = remainingRoots[displayDestinationIndex].workspaceGroupID - { - beforeID = workspaceGroupID - } else { - beforeID = nil - } + let beforeID = remainingRoots.dropFirst(displayDestinationIndex).compactMap(\.workspaceGroupID).first return SidebarResolvedDrop( operation: .reorderCodexWorkspaceGroup(id: id, beforeID: beforeID), @@ -1113,7 +1123,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func codexRootRect(for root: ReviewMonitorCodexSidebarOutlineNode) -> NSRect? { - guard let rootRow = row(forCodexSidebarSelectionID: root.selectionID) else { + let rootRow = outlineView.row(forItem: root) + guard rootRow != -1 else { return nil } var rect = outlineView.rect(ofRow: rootRow) @@ -1139,7 +1150,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func isCodexChatContainer(_ node: ReviewMonitorCodexSidebarOutlineNode) -> Bool { switch node.item { - case .workspaceGroup: + case .section, .workspaceGroup: return node.children.contains { $0.item.isChat } case .workspace: return true @@ -1233,7 +1244,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } return view - case .workspaceGroup, .workspace: + case .section, .workspaceGroup, .workspace: let view = (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) as? ReviewMonitorWorkspaceCellView) @@ -1260,6 +1271,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } cellView.configure(with: chat) return true + case .section: + guard let cellView = cellView as? ReviewMonitorWorkspaceCellView else { + return false + } + cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? node.rowID.rawValue) + return true case .workspaceGroup(let id): guard let cellView = cellView as? ReviewMonitorWorkspaceCellView else { return false diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 94bba89f..20844e3c 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -38,6 +38,7 @@ struct ReviewMonitorCodexSidebarResultsTests { try await results.performFetch() let section = try #require(results.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 @@ -58,8 +59,8 @@ struct ReviewMonitorCodexSidebarResultsTests { let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) #expect(outlineSection.rowID == section.rowID) - #expect(outlineSection.item == .workspaceGroup(section.sidebarWorkspaceGroupID)) - #expect(outlineSection.selectionID == .workspaceGroup(section.sidebarWorkspaceGroupID)) + #expect(outlineSection.item == .workspaceGroup(sectionWorkspaceGroupID)) + #expect(outlineSection.selectionID == .workspaceGroup(sectionWorkspaceGroupID)) #expect(outlineSection.isExpandable) #expect( outlineSection.children.map(\.rowID.rawValue) == [ @@ -74,7 +75,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( results.sections.rowIDs.map(\.rawValue) == [ - "workspaceGroup:\(section.sidebarWorkspaceGroupID.rawValue)", + "workspaceGroup:\(sectionWorkspaceGroupID.rawValue)", "workspace:\(resolvedAppPath)", "chat:thread-app", "workspace:\(resolvedToolsPath)", @@ -117,6 +118,7 @@ struct ReviewMonitorCodexSidebarResultsTests { 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( @@ -133,6 +135,31 @@ struct ReviewMonitorCodexSidebarResultsTests { ]) } + @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 results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() + + #expect(results.sections.count == 1) + #expect(results.sections.filtered(by: .running).isEmpty) + } + @Test func defaultCodexSidebarDescriptorFetchesReviewThreadsOnly() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -169,6 +196,7 @@ struct ReviewMonitorCodexSidebarResultsTests { let section = try #require(results.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") @@ -176,7 +204,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(chat.workspace == nil) #expect( section.rowIDs.map(\.rawValue) == [ - section.rowID.rawValue, + "section:unknown:unknown", "chat:thread-uncategorized", ]) @@ -184,6 +212,9 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(tree.apply(sections: results.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) @@ -419,6 +450,41 @@ struct ReviewMonitorCodexSidebarResultsTests { } } + @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 @@ -544,19 +610,21 @@ struct ReviewMonitorCodexSidebarResultsTests { 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: secondSection.sidebarWorkspaceGroupID, toIndex: 0)) + #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 0)) #expect( sidebar.codexSidebarRootTitlesForTesting == [ secondSection.displayTitle, firstSection.displayTitle, ]) #expect( - sidebar.codexSidebarSectionsForTesting.map(\.sidebarWorkspaceGroupID) == [ - firstSection.sidebarWorkspaceGroupID, - secondSection.sidebarWorkspaceGroupID, + sidebar.codexSidebarSectionsForTesting.compactMap(\.sidebarWorkspaceGroupID) == [ + firstWorkspaceGroupID, + secondWorkspaceGroupID, ]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } From 905a95b019f8a2d15b98617296c10d7599c72193 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:47:26 +0900 Subject: [PATCH 324/359] fix(review-ui): reject sidebar drops across section rows --- .../ReviewMonitorSidebarViewController.swift | 21 ++++++ ...eviewMonitorCodexSidebarResultsTests.swift | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index ab87c492..d7ab3f4a 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -957,6 +957,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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 @@ -975,6 +979,23 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } + 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.. Date: Tue, 30 Jun 2026 10:54:46 +0900 Subject: [PATCH 325/359] fix(review-ui): constrain sidebar ordering to section segments --- .../ReviewMonitorCodexSidebarOutline.swift | 38 +++++++++++++------ ...eviewMonitorCodexSidebarResultsTests.swift | 20 ++++++++-- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 10c08ba4..0d034907 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -240,22 +240,36 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { guard workspaceGroupIDs.isEmpty == false else { return sections } - let currentDomainIDs = sections.compactMap(\.sidebarWorkspaceGroupID) - var orderedDomainIDs = mergedOrder(preferredIDs: workspaceGroupIDs, currentOrder: currentDomainIDs) + + 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: sections.compactMap { section in + uniqueKeysWithValues: segment.compactMap { section in section.sidebarWorkspaceGroupID.map { ($0, section) } } ) - return sections.map { section in - guard section.sidebarWorkspaceGroupID != nil, - orderedDomainIDs.isEmpty == false - else { - return section - } - let nextID = orderedDomainIDs.removeFirst() - return sectionsByDomainID[nextID] ?? section - } + return orderedDomainIDs.compactMap { sectionsByDomainID[$0] } } private func ordered( diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 55e84af2..e7d45425 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -632,12 +632,19 @@ struct ReviewMonitorCodexSidebarResultsTests { @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", @@ -670,6 +677,7 @@ struct ReviewMonitorCodexSidebarResultsTests { let sidebar = viewController.sidebarViewControllerForTesting try await waitForCondition { sidebar.codexSidebarRootTitlesForTesting == [ + leadingRepo.lastPathComponent, "Unknown", firstRepo.lastPathComponent, secondRepo.lastPathComponent, @@ -679,20 +687,24 @@ struct ReviewMonitorCodexSidebarResultsTests { let sections = sidebar.codexSidebarSectionsForTesting let sectionRow = try #require(sections.first { $0.sidebarWorkspaceGroupID == nil }) let workspaceGroupSections = sections.filter { $0.sidebarWorkspaceGroupID != nil } - let firstSection = try #require(workspaceGroupSections.first) - let secondSection = try #require(workspaceGroupSections.dropFirst().first) + 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: 0) == false) - #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 1)) + #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 sidebarViewControllerReordersCodexChatsLocallyWithinContainer() async throws { From e98d4218a0c244f0a35f7c77724a73efe205bded Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:05:45 +0900 Subject: [PATCH 326/359] fix(review-ui): apply sidebar ordering after filters --- .../ReviewMonitorSidebarViewController.swift | 3 +- ...eviewMonitorCodexSidebarResultsTests.swift | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index d7ab3f4a..b8377dd8 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -352,8 +352,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD from sourceSections: [CodexFetchSection] ) -> [CodexFetchSection] { codexSidebarPresentationOrder - .applying(to: sourceSections) - .filtered(by: uiState.sidebarReviewChatFilter) + .applying(to: sourceSections.filtered(by: uiState.sidebarReviewChatFilter)) } private var currentCodexSidebarVisibleSections: [CodexFetchSection] { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index e7d45425..4ddeb39f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -707,6 +707,80 @@ struct ReviewMonitorCodexSidebarResultsTests { #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 From 7b3ad40596ead8a6f942b7006e743cbdf0b3042c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:01:34 +0900 Subject: [PATCH 327/359] feat(review-ui): add chat archive context menu --- .../ReviewMonitorChatContextMenuView.swift | 56 +++- .../ReviewMonitorSidebarViewController.swift | 13 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 40 +++ .../ReviewMonitorPreviewContent.swift | 4 + Tests/ReviewUITests/ReviewUIShellTests.swift | 244 ++++++++++++++++++ 5 files changed, 355 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 6404b8c7..5292e4ee 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -1,15 +1,47 @@ +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) { + init( + chat: CodexChat, + store: CodexReviewStore, + archiveConfirmation: ReviewMonitorChatArchiveConfirmation = .appKitAlert + ) { self.chat = chat self.store = store + self.archiveConfirmation = archiveConfirmation } var body: some View { @@ -17,6 +49,12 @@ struct ReviewMonitorChatContextMenuView: View { cancel() } .disabled(cancellationCapability.isEnabled == false) + + Divider() + + Button("Archive") { + archive() + } } private func cancel() { @@ -34,6 +72,22 @@ struct ReviewMonitorChatContextMenuView: View { } } + 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, diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index b8377dd8..24081849 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -99,6 +99,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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 @@ -686,7 +687,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } return NSHostingMenu( - rootView: ReviewMonitorChatContextMenuView(chat: chat, store: store) + rootView: ReviewMonitorChatContextMenuView( + chat: chat, + store: store, + archiveConfirmation: chatArchiveConfirmation + ) ) } @@ -1630,6 +1635,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } + 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) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 36fc1dd1..029c50d3 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -22,6 +22,18 @@ private actor ReviewMonitorPreviewSnapshotMutationQueue { } } +private actor ReviewMonitorPreviewArchivedChatIDs { + private var ids: Set = [] + + func insert(_ id: CodexThreadID) { + ids.insert(id) + } + + func contains(_ id: CodexThreadID) -> Bool { + ids.contains(id) + } +} + @MainActor struct ReviewMonitorPreviewChatLogFixture { let chatID: CodexThreadID @@ -79,6 +91,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private var streamTask: Task? private var notificationTask: Task? private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue() + private let archivedChatIDs = ReviewMonitorPreviewArchivedChatIDs() private var tick = 0 init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { @@ -111,6 +124,13 @@ final class ReviewMonitorPreviewAppServerRuntime { 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, @@ -282,6 +302,15 @@ final class ReviewMonitorPreviewAppServerRuntime { 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 } @@ -418,6 +447,9 @@ final class ReviewMonitorPreviewAppServerRuntime { 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 @@ -611,6 +643,14 @@ private struct PreviewThreadItemParams: Encodable, Sendable { } } +private struct PreviewThreadArchiveParams: Decodable, Sendable { + var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + private struct PreviewTurnDeltaParams: Encodable, Sendable { var threadID: String var turnID: String diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 201eee3c..182ca61e 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -50,6 +50,10 @@ public final class ReviewMonitorPreviewContentSource { public func interruptRequestCountForTesting() async -> Int { await runtime.interruptRequestCountForTesting() } + + public func archiveRequestCountForTesting() async -> Int { + await runtime.archiveRequestCountForTesting() + } } @MainActor diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 7222ad99..41eff0ad 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -129,6 +129,231 @@ extension ReviewUITests { #expect(cancelItemEnabledAfterCancellation) } + @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] + } + try await Task.sleep(for: .milliseconds(100)) + #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 @@ -1984,6 +2209,25 @@ private func makeShellTestGitRepository() throws -> URL { 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: CodexChatItemSnapshot) -> String { if case .diagnostic(let message) = item.content { From c9aa9b1b6ae387e3540bf1a9c1317e67e182c133 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:14:21 +0900 Subject: [PATCH 328/359] test(review-ui): remove archive rejection sleep --- Tests/ReviewUITests/ReviewUIShellTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 41eff0ad..2b2d874d 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -243,7 +243,6 @@ extension ReviewUITests { try await waitForCondition { confirmedChatIDs == [chatID] } - try await Task.sleep(for: .milliseconds(100)) #expect(await runtime.transport.recordedRequests(method: "thread/archive").isEmpty) #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Rejected active archive chat") } From 96ead5a3f1f146d46ada2f0db3ebca9d9b99b0e8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:30:35 +0900 Subject: [PATCH 329/359] refactor(review-ui): flatten Codex sidebar results --- ...ReviewMonitorTransportViewController.swift | 19 +- ...ewMonitorCodexSelectionTitleResolver.swift | 15 -- Sources/ReviewUI/ReviewMonitorSelection.swift | 4 - .../ReviewMonitorCodexSidebarOutline.swift | 100 +-------- .../ReviewMonitorSidebarViewController.swift | 208 ++++++------------ ...itorCodexSelectionTitleResolverTests.swift | 13 +- ...eviewMonitorCodexSidebarResultsTests.swift | 82 +++---- 7 files changed, 117 insertions(+), 324 deletions(-) diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 5dd9c467..e7e61d5e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -120,8 +120,6 @@ final class ReviewMonitorTransportViewController: NSViewController { switch selection { case .workspaceGroup: return displayedSelection != selection?.id - case .workspace: - return displayedSelection != selection?.id case .chat(let selectedChatID): return boundChatID != selectedChatID || displayedSelection != selection?.id @@ -138,12 +136,6 @@ final class ReviewMonitorTransportViewController: NSViewController { logScrollView.isHidden = true displayedSelection = selection?.id - case .workspace: - clearDisplayedLogSelection() - displayPlaceholder(.noFindings) - logScrollView.isHidden = true - displayedSelection = selection?.id - case .chat: if case .chat(let selectedChatID) = selection { displayChat(selectedChatID) @@ -326,7 +318,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch displayedSelection { case .chat: return logScrollView.performDisplayedTextFinderAction(sender) - case .workspaceGroup, .workspace, nil: + case .workspaceGroup, nil: return false } } @@ -335,7 +327,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch displayedSelection { case .chat: return logScrollView.validateDisplayedTextFinderAction(item) - case .workspaceGroup, .workspace, nil: + case .workspaceGroup, nil: return false } } @@ -352,7 +344,6 @@ final class ReviewMonitorTransportViewController: NSViewController { enum DisplayedSelectionForTesting: Sendable, Equatable { case workspaceGroup(String) - case workspace(String) case chat(String) } @@ -716,8 +707,6 @@ final class ReviewMonitorTransportViewController: NSViewController { switch displayedSelection { case .workspaceGroup(let id): .workspaceGroup(id.rawValue) - case .workspace(let id): - .workspace(id.rawValue) case .chat(let id): .chat(id.rawValue) case nil: @@ -861,7 +850,7 @@ final class ReviewMonitorTransportViewController: NSViewController { switch target ?? displayedSelectionForTesting { case .chat(let id): resolvedTarget = .chat(CodexThreadID(rawValue: id)) - case .workspaceGroup, .workspace, nil: + case .workspaceGroup, nil: return false } let resolvedRestorationTarget = @@ -901,7 +890,7 @@ final class ReviewMonitorTransportViewController: NSViewController { hidePlaceholder() logScrollView.isHidden = false displayedSelection = .chat(chatID) - case .workspaceGroup, .workspace: + case .workspaceGroup: break } } diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift index 0c099fcf..3a975601 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift @@ -24,12 +24,6 @@ final class ReviewMonitorCodexSelectionTitleResolver { } return Self.titlePresentation(for: workspaceGroup) - case .workspace(let id): - guard let workspace = modelContext.model(for: id) else { - return nil - } - return Self.titlePresentation(for: workspace) - case .chat(let id): guard let chat = loadedChat(id: id) else { return nil @@ -62,15 +56,6 @@ final class ReviewMonitorCodexSelectionTitleResolver { ) } - private static func titlePresentation( - for workspace: CodexWorkspace - ) -> ReviewMonitorCodexSelectionTitlePresentation { - ReviewMonitorCodexSelectionTitlePresentation( - title: workspace.name, - subtitle: workspace.url.path - ) - } - private static func titlePresentation( for chat: CodexChat ) -> ReviewMonitorCodexSelectionTitlePresentation { diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index b8967a14..27093faf 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -3,22 +3,18 @@ import CodexReviewKit enum ReviewMonitorSelectionID: Hashable, Sendable { case workspaceGroup(CodexWorkspaceGroupID) - case workspace(CodexWorkspaceID) case chat(CodexThreadID) } @MainActor enum ReviewMonitorSelection: Hashable, Sendable { case workspaceGroup(CodexWorkspaceGroupID) - case workspace(CodexWorkspaceID) case chat(CodexThreadID) var id: ReviewMonitorSelectionID { switch self { case .workspaceGroup(let id): return .workspaceGroup(id) - case .workspace(let id): - return .workspace(id) case .chat(let id): return .chat(id) } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 0d034907..15241b30 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -26,10 +26,6 @@ package struct ReviewMonitorCodexSidebarRowID: Hashable, Sendable, CustomStringC } } - package static func workspace(_ id: CodexWorkspaceID) -> Self { - .init(rawValue: "workspace:\(id.rawValue)") - } - package static func chat(_ id: CodexThreadID) -> Self { .init(rawValue: "chat:\(id.rawValue)") } @@ -56,19 +52,7 @@ extension CodexFetchSection where Model == CodexChat { return .section(id) } - package var displaysWorkspaceNodes: Bool { - workspaces.count > 1 || (workspaces.isEmpty == false && uncategorizedChats.isEmpty == false) - } - package var rowIDs: [ReviewMonitorCodexSidebarRowID] { - if displaysWorkspaceNodes { - return [rowID] - + workspaces.flatMap { workspace in - [ReviewMonitorCodexSidebarRowID.workspace(workspace.id)] - + chats(in: workspace.id).map { .chat($0.id) } - } - + uncategorizedChats.map { .chat($0.id) } - } return [rowID] + items.map { .chat($0.id) } } } @@ -158,35 +142,15 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { to sections: [CodexFetchSection] ) -> [CodexFetchSection] { orderedSections(sections).map { section in - if section.displaysWorkspaceNodes == false { - return CodexFetchSection( - id: section.id, - title: section.title, - items: ordered( - section.items, - by: chatIDsByContainer[section.rowID] ?? [], - id: \.id - ) - ) - } - - var items: [CodexChat] = [] - for workspace in section.workspaces { - let container = ReviewMonitorCodexSidebarRowID.workspace(workspace.id) - items.append( - contentsOf: ordered( - section.chats(in: workspace.id), - by: chatIDsByContainer[container] ?? [], - id: \.id - )) - } - items.append( - contentsOf: ordered( - section.uncategorizedChats, + CodexFetchSection( + id: section.id, + title: section.title, + items: ordered( + section.items, by: chatIDsByContainer[section.rowID] ?? [], id: \.id - )) - return CodexFetchSection(id: section.id, title: section.title, items: items) + ) + ) } } @@ -212,14 +176,7 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { var activeChatIDsByContainer: [ReviewMonitorCodexSidebarRowID: Set] = [:] for section in sections { - if section.displaysWorkspaceNodes { - activeChatIDsByContainer[section.rowID] = Set(section.uncategorizedChats.map(\.id)) - for workspace in section.workspaces { - activeChatIDsByContainer[.workspace(workspace.id)] = Set(section.chats(in: workspace.id).map(\.id)) - } - } else { - activeChatIDsByContainer[section.rowID] = Set(section.items.map(\.id)) - } + activeChatIDsByContainer[section.rowID] = Set(section.items.map(\.id)) } let currentChatIDsByContainer = chatIDsByContainer chatIDsByContainer = currentChatIDsByContainer.reduce(into: [:]) { result, entry in @@ -324,7 +281,6 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { case section(CodexFetchSectionID) case workspaceGroup(CodexWorkspaceGroupID) - case workspace(CodexWorkspaceID) case chat(CodexThreadID) package var rowID: ReviewMonitorCodexSidebarRowID { @@ -333,8 +289,6 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { .section(id) case .workspaceGroup(let id): .workspaceGroup(id) - case .workspace(let id): - .workspace(id) case .chat(let id): .chat(id) } @@ -346,8 +300,6 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { nil case .workspaceGroup(let id): .workspaceGroup(id) - case .workspace(let id): - .workspace(id) case .chat(let id): .chat(id) } @@ -357,7 +309,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { switch self { case .workspaceGroup(let id): id - case .section, .workspace, .chat: + case .section, .chat: nil } } @@ -366,7 +318,7 @@ package enum ReviewMonitorCodexSidebarOutlineItem: Equatable { switch self { case .chat(let id): id - case .section, .workspaceGroup, .workspace: + case .section, .workspaceGroup: nil } } @@ -455,36 +407,8 @@ final class ReviewMonitorCodexSidebarOutlineTree { .section(section.id) } let node = node(for: item, activeRowIDs: &activeRowIDs) - let children: [ReviewMonitorCodexSidebarOutlineNode] - if section.displaysWorkspaceNodes { - children = - section.workspaces.map { workspace in - workspaceNode( - for: workspace, - chats: section.chats(in: workspace.id), - activeRowIDs: &activeRowIDs - ) - } - + section.uncategorizedChats.map { chat in - self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs) - } - } else { - children = section.items.map { chat in - self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs) - } - } - updateChildren(of: node, to: children) - return node - } - - private func workspaceNode( - for workspace: CodexWorkspace, - chats: [CodexChat], - activeRowIDs: inout Set - ) -> ReviewMonitorCodexSidebarOutlineNode { - let node = node(for: .workspace(workspace.id), activeRowIDs: &activeRowIDs) - let children = chats.map { child in - self.node(for: .chat(child.id), activeRowIDs: &activeRowIDs) + let children = section.items.map { chat in + self.node(for: .chat(chat.id), activeRowIDs: &activeRowIDs) } updateChildren(of: node, to: children) return node diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 24081849..a2f71742 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -20,7 +20,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private enum Identifier { static let tableColumn = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.Column") static let reviewChatCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.ReviewChatCell") - static let workspaceCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.WorkspaceCell") + static let workspaceGroupCell = NSUserInterfaceItemIdentifier("ReviewMonitorReviewChats.WorkspaceGroupCell") } private enum DragType { @@ -28,20 +28,20 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private struct SidebarRowHeights { - let workspace: CGFloat + let workspaceGroup: CGFloat let reviewChat: CGFloat @MainActor static func measure() -> SidebarRowHeights { SidebarRowHeights( - workspace: measuredWorkspaceRowHeight(), + workspaceGroup: measuredWorkspaceGroupRowHeight(), reviewChat: measuredReviewMonitorChatRowHeight() ) } @MainActor - private static func measuredWorkspaceRowHeight() -> CGFloat { - let cellView = ReviewMonitorWorkspaceCellView() + private static func measuredWorkspaceGroupRowHeight() -> CGFloat { + let cellView = ReviewMonitorWorkspaceGroupCellView() cellView.configure(title: "workspace-alpha", toolTip: "/tmp/workspace-alpha") return ceil(cellView.fittingSize.height) } @@ -93,9 +93,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var sidebarFilterObservation: PortableObservationTracking.Token? private var sidebarSelectionObservation: PortableObservationTracking.Token? private var codexSidebarObservation: PortableObservationTracking.Token? - private var codexSidebarSectionsObservation: PortableObservationTracking.Token? + private var codexSidebarTransactionTask: Task? private var codexSidebarFetchTask: Task? - private var codexSidebarFetchedResults: CodexFetchedResults? + private var codexSidebarResultsController: CodexFetchedResultsController? private var codexSidebarModelContext: CodexModelContext? private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() @@ -130,7 +130,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD sidebarFilterObservation?.cancel() sidebarSelectionObservation?.cancel() codexSidebarObservation?.cancel() - codexSidebarSectionsObservation?.cancel() + codexSidebarTransactionTask?.cancel() codexSidebarFetchTask?.cancel() } @@ -278,10 +278,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } - private static func makeCodexSidebarFetchedResults( + private static func makeCodexSidebarFetchedResultsController( modelContext: CodexModelContext - ) -> CodexFetchedResults { - modelContext.fetchedResults( + ) -> CodexFetchedResultsController { + modelContext.fetchedResultsController( for: defaultCodexSidebarDescriptor, sectionedBy: .workspaceGroup ) @@ -301,52 +301,58 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let modelContext, let codexSidebarModelContext, codexSidebarModelContext === modelContext, - codexSidebarFetchedResults != nil + codexSidebarResultsController != nil { return } codexSidebarFetchTask?.cancel() codexSidebarFetchTask = nil - codexSidebarSectionsObservation?.cancel() - codexSidebarSectionsObservation = nil + codexSidebarTransactionTask?.cancel() + codexSidebarTransactionTask = nil guard let modelContext else { codexSidebarModelContext = nil - codexSidebarFetchedResults = nil + codexSidebarResultsController = nil applyCodexSidebarSourceSections([]) return } - let fetchedResults = Self.makeCodexSidebarFetchedResults(modelContext: modelContext) + let resultsController = Self.makeCodexSidebarFetchedResultsController(modelContext: modelContext) codexSidebarModelContext = modelContext - codexSidebarFetchedResults = fetchedResults - codexSidebarSectionsObservation = withPortableContinuousObservation { [weak self, fetchedResults] event in - guard let self, - self.codexSidebarFetchedResults === fetchedResults - else { - return - } - let sections = fetchedResults.sections - guard event.kind != .initial else { - return + 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) } - self.applyCodexSidebarSourceSections(sections) } - codexSidebarFetchTask = Task { @MainActor [weak self, fetchedResults] in + codexSidebarFetchTask = Task { @MainActor [weak self, resultsController] in do { - try await fetchedResults.performFetch() + try await resultsController.performFetch() } catch is CancellationError { } catch { } - guard self?.codexSidebarFetchedResults === fetchedResults else { + guard self?.codexSidebarResultsController === resultsController + else { return } - self?.applyCodexSidebarSourceSections(fetchedResults.sections) + self?.applyCodexSidebarSourceSections(resultsController.sections) self?.codexSidebarFetchTask = nil } } + private func applyCodexSidebarTransaction( + _: CodexFetchedResultsTransaction, + controller: CodexFetchedResultsController + ) { + applyCodexSidebarSourceSections(controller.sections) + } + private var codexSidebarSourceSections: [CodexFetchSection] { - codexSidebarFetchedResults?.sections ?? [] + codexSidebarResultsController?.sections ?? [] } private func codexSidebarVisibleSections( @@ -608,23 +614,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - case .workspace(let selectedWorkspaceID): - guard codexWorkspaceSelection(id: selectedWorkspaceID) != nil, - let row = row(forCodexSidebarSelectionID: .workspace(selectedWorkspaceID)) - else { - guard codexSidebarContentIsAuthoritative else { - return - } - uiState.selection = nil - outlineView.deselectAll(nil) - return - } - - guard outlineView.selectedRow != row else { - return - } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - case .chat(let selectedChatID): guard currentChatSelection(id: selectedChatID) != nil else { guard codexSidebarContentIsAuthoritative else { @@ -666,8 +655,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = nil case .workspaceGroup(let id): uiState.selection = .workspaceGroup(id) - case .workspace(let id): - uiState.selection = .workspace(id) case .chat(let id): uiState.selection = .chat(id) } @@ -704,8 +691,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func row(forWorkspaceCWD cwd: String) -> Int? { - row(forCodexSidebarSelectionID: .workspace(CodexWorkspaceID(rawValue: cwd))) - ?? row(forCodexSidebarSelectionID: .workspaceGroup(CodexWorkspaceGroupID(rawValue: cwd))) + let workspaceGroupID = workspaceGroupID(forWorkspaceCWD: cwd) + ?? CodexWorkspaceGroupID(rawValue: cwd) + return row(forCodexSidebarSelectionID: .workspaceGroup(workspaceGroupID)) } private func workspaceGroupID(forWorkspaceCWD cwd: String) -> CodexWorkspaceGroupID? { @@ -736,8 +724,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func row(forCodexSidebarSelectionID selectionID: ReviewMonitorSelectionID) -> Int? { let rowID: ReviewMonitorCodexSidebarRowID switch selectionID { - case .workspace(let id): - rowID = .workspace(id) case .chat(let id): rowID = .chat(id) case .workspaceGroup(let id): @@ -760,18 +746,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch selection { case .workspaceGroup(let id): return codexWorkspaceGroupSelection(id: id) != nil - case .workspace(let id): - return codexWorkspaceSelection(id: id) != nil case .chat(let id): return currentChatSelection(id: id) != nil } } private var codexSidebarContentIsAuthoritative: Bool { - guard let codexSidebarFetchedResults else { + guard let codexSidebarResultsController else { return false } - switch codexSidebarFetchedResults.phase { + switch codexSidebarResultsController.phase { case .loaded, .failed: return true case .idle, .loading: @@ -796,20 +780,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ?? currentChatSelection(id: id) } - private func displayedCodexWorkspace(id: CodexWorkspaceID) -> CodexWorkspace? { - for section in currentCodexSidebarVisibleSections { - if let workspace = section.workspaces.first(where: { $0.id == id }) { - return workspace - } - } - for section in codexSidebarSourceSections { - if let workspace = section.workspaces.first(where: { $0.id == id }) { - return workspace - } - } - return nil - } - private func codexSidebarTitle(for node: ReviewMonitorCodexSidebarOutlineNode) -> String? { switch node.item { case .section(let id): @@ -818,25 +788,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case .workspaceGroup(let id): return displayedCodexSidebarSection(id: id)?.displayTitle ?? codexWorkspaceGroupSection(id: id)?.displayTitle - case .workspace(let id): - return displayedCodexWorkspace(id: id)?.name case .chat(let id): return displayedCodexChat(id: id)?.title } } - private func codexWorkspaceSelection( - id: CodexWorkspaceID - ) -> CodexWorkspace? { - guard let node = codexSidebarOutlineTree.node(rowID: .workspace(id)), - case .workspace(let workspaceID) = node.item, - workspaceID == id - else { - return nil - } - return displayedCodexWorkspace(id: id) - } - private func codexWorkspaceGroupSelection( id: CodexWorkspaceGroupID ) -> ReviewMonitorCodexSidebarOutlineNode? { @@ -846,7 +802,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch node.item { case .workspaceGroup(let workspaceGroupID) where workspaceGroupID == id: return node - case .section, .workspace, .chat: + case .section, .chat: return nil case .workspaceGroup: return nil @@ -884,8 +840,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil case .workspaceGroup(let id): return .codexWorkspaceGroup(id: id.rawValue) - case .workspace: - return nil case .chat(let id): guard let parent = outlineView.parent(forItem: node) as? ReviewMonitorCodexSidebarOutlineNode else { return nil @@ -1177,8 +1131,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch node.item { case .section, .workspaceGroup: return node.children.contains { $0.item.isChat } - case .workspace: - return true case .chat: return false } @@ -1194,13 +1146,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func codexUnfilteredChatIDs(in container: ReviewMonitorCodexSidebarRowID) -> [CodexThreadID] { for section in codexSidebarSourceSections { if section.rowID == container { - if section.displaysWorkspaceNodes == false { - return section.items.map(\.id) - } - return section.uncategorizedChats.map(\.id) - } - for workspace in section.workspaces where ReviewMonitorCodexSidebarRowID.workspace(workspace.id) == container { - return section.chats(in: workspace.id).map(\.id) + return section.items.map(\.id) } } return [] @@ -1269,12 +1215,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD return nil } return view - case .section, .workspaceGroup, .workspace: + case .section, .workspaceGroup: let view = - (outlineView.makeView(withIdentifier: Identifier.workspaceCell, owner: self) - as? ReviewMonitorWorkspaceCellView) - ?? ReviewMonitorWorkspaceCellView() - view.identifier = Identifier.workspaceCell + (outlineView.makeView(withIdentifier: Identifier.workspaceGroupCell, owner: self) + as? ReviewMonitorWorkspaceGroupCellView) + ?? ReviewMonitorWorkspaceGroupCellView() + view.identifier = Identifier.workspaceGroupCell guard configureCodexSidebarCell(view, for: node) else { return nil } @@ -1297,13 +1243,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD cellView.configure(with: chat) return true case .section: - guard let cellView = cellView as? ReviewMonitorWorkspaceCellView else { + 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? ReviewMonitorWorkspaceCellView else { + guard let cellView = cellView as? ReviewMonitorWorkspaceGroupCellView else { return false } if let workspaceGroup = displayedCodexSidebarSection(id: id)?.workspaceGroup @@ -1314,14 +1260,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? id.rawValue) } return true - case .workspace(let id): - guard let cellView = cellView as? ReviewMonitorWorkspaceCellView, - let workspace = displayedCodexWorkspace(id: id) - else { - return false - } - cellView.configure(workspace: workspace) - return true } } @@ -1361,7 +1299,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if node.item.isChat { return rowHeights.reviewChat } - return rowHeights.workspace + return rowHeights.workspaceGroup } return rowHeights.reviewChat } @@ -1452,7 +1390,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if node.item.isChat { return ReviewMonitorReviewChatTableRowView() } - return ReviewMonitorWorkspaceRowView() + return ReviewMonitorWorkspaceGroupRowView() } return nil } @@ -1513,7 +1451,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var codexSidebarSectionsForTesting: [CodexFetchSection] { - codexSidebarFetchedResults?.sections ?? [] + codexSidebarResultsController?.sections ?? [] } var codexSidebarRootTitlesForTesting: [String] { @@ -1588,8 +1526,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func refreshCodexSidebarForTesting() async throws { - try await codexSidebarFetchedResults?.refresh() - applyCodexSidebarSourceSections(codexSidebarFetchedResults?.sections ?? []) + try await codexSidebarResultsController?.refresh() + applyCodexSidebarSourceSections(codexSidebarResultsController?.sections ?? []) } var sidebarFullReloadCountForTesting: Int { @@ -1675,7 +1613,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var expectedWorkspaceRowRectHeightForTesting: CGFloat { - rowHeights.workspace + outlineView.intercellSpacing.height + rowHeights.workspaceGroup + outlineView.intercellSpacing.height } var expectedReviewChatRowRectHeightForTesting: CGFloat { @@ -1708,7 +1646,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD atColumn: 0, row: row, makeIfNecessary: true - ) as? ReviewMonitorWorkspaceCellView + ) as? ReviewMonitorWorkspaceGroupCellView else { return nil } @@ -1954,9 +1892,8 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { withLevel level: Int, isSourceListGroupRow: Bool ) -> CGFloat { - // Workspace rows keep AppKit's native disclosure gutter. Review chat rows do - // not add outline-level indentation; the SwiftUI Label icon slot is - // the visual indent. + // 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 } @@ -2103,7 +2040,7 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { } @MainActor -private final class ReviewMonitorWorkspaceRowView: NSTableRowView { +private final class ReviewMonitorWorkspaceGroupRowView: NSTableRowView { override var isEmphasized: Bool { get { false } set {} @@ -2214,14 +2151,13 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { #endif @MainActor -private final class ReviewMonitorWorkspaceCellView: NSTableCellView { +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) - case workspace(ObjectIdentifier) } private var boundIdentity: BoundIdentity? @@ -2266,20 +2202,6 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { render(title: title, toolTip: title, systemSymbolName: "folder") } - func configure(workspace: CodexWorkspace) { - let identity = BoundIdentity.workspace(ObjectIdentifier(workspace)) - guard boundIdentity != identity else { - return - } - objectValue = workspace - boundIdentity = identity - modelObservation?.cancel() - render(workspace: workspace) - modelObservation = withPortableContinuousObservation { [weak self, workspace] _ in - self?.render(workspace: workspace) - } - } - func configure(title: String, toolTip: String, systemSymbolName: String = "folder") { objectValue = title boundIdentity = .fallbackWorkspaceGroup(title) @@ -2297,10 +2219,6 @@ private final class ReviewMonitorWorkspaceCellView: NSTableCellView { render(title: workspaceGroup.name, toolTip: workspaceGroup.name, systemSymbolName: "folder") } - private func render(workspace: CodexWorkspace) { - render(title: workspace.name, toolTip: workspace.url.path, systemSymbolName: "folder") - } - private func configureHierarchy() { translatesAutoresizingMaskIntoConstraints = false diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 7b32998c..a8dcc416 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -7,7 +7,7 @@ import Testing @Suite("ReviewMonitor Codex selection title resolver") @MainActor struct ReviewMonitorCodexSelectionTitleResolverTests { - @Test func resolvesWorkspaceGroupWorkspaceAndChatTitlesFromLoadedCodexModels() async throws { + @Test func resolvesWorkspaceGroupAndChatTitlesFromLoadedCodexModels() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeTitleResolverGitRepository() @@ -46,12 +46,6 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { title: repo.lastPathComponent, subtitle: "2 workspaces" )) - #expect( - resolver.titlePresentation(for: .workspace(appWorkspace.id)) - == ReviewMonitorCodexSelectionTitlePresentation( - title: "App", - subtitle: appPath - )) #expect( resolver.titlePresentation(for: .chat(appThreadID)) == ReviewMonitorCodexSelectionTitlePresentation( @@ -123,7 +117,7 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { ) } - @Test func returnsNilForMissingWorkspaceGroupWorkspaceAndEmptySelection() async throws { + @Test func returnsNilForMissingWorkspaceGroupAndEmptySelection() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -136,9 +130,6 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { #expect( resolver.titlePresentation(for: .workspaceGroup(CodexWorkspaceGroupID(rawValue: "missing"))) == nil ) - #expect( - resolver.titlePresentation(for: .workspace(CodexWorkspaceID(rawValue: "/missing"))) == nil - ) } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 4ddeb39f..18732581 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -9,7 +9,7 @@ import Testing @Suite("ReviewMonitor Codex sidebar results") @MainActor struct ReviewMonitorCodexSidebarResultsTests { - @Test func buildsSidebarSectionsFromCodexDataKitFetchResults() async throws { + @Test func buildsFlatSidebarSectionsFromCodexFetchedResultsController() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -34,28 +34,27 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() + let controller = makeCodexSidebarFetchedResultsController(context: context) + try await controller.performFetch() - let section = try #require(results.sections.first) + 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(results.sections.count == 1) + #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 === results.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) + #expect(appChat === controller.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) #expect(appChat.workspace?.url.path == resolvedAppPath) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: results.sections).topologyChanged) + #expect(tree.apply(sections: controller.sections).topologyChanged) let outlineSection = try #require(tree.roots.first) - let outlineAppWorkspace = try #require(tree.node(rowID: .workspace(appWorkspace.id))) let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) #expect(outlineSection.rowID == section.rowID) @@ -64,21 +63,15 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(outlineSection.isExpandable) #expect( outlineSection.children.map(\.rowID.rawValue) == [ - "workspace:\(resolvedAppPath)", - "workspace:\(resolvedToolsPath)", + "chat:thread-app", + "chat:thread-tools", ]) - #expect(outlineAppWorkspace.item == .workspace(appWorkspace.id)) - #expect(outlineAppWorkspace.selectionID == .workspace(appWorkspace.id)) - #expect(outlineAppWorkspace.isExpandable) - #expect(outlineAppWorkspace.children.map(\.rowID.rawValue) == ["chat:thread-app"]) #expect(outlineAppChat.item == .chat(appChat.id)) #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( - results.sections.rowIDs.map(\.rawValue) == [ + controller.sections.rowIDs.map(\.rawValue) == [ "workspaceGroup:\(sectionWorkspaceGroupID.rawValue)", - "workspace:\(resolvedAppPath)", "chat:thread-app", - "workspace:\(resolvedToolsPath)", "chat:thread-tools", ]) } @@ -110,9 +103,9 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() - let sections = results.sections + 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) @@ -153,11 +146,11 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() + let controller = makeCodexSidebarFetchedResultsController(context: context) + try await controller.performFetch() - #expect(results.sections.count == 1) - #expect(results.sections.filtered(by: .running).isEmpty) + #expect(controller.sections.count == 1) + #expect(controller.sections.filtered(by: .running).isEmpty) } @Test func defaultCodexSidebarDescriptorFetchesReviewThreadsOnly() async throws { @@ -166,8 +159,8 @@ struct ReviewMonitorCodexSidebarResultsTests { try await runtime.transport.enqueueThreadList(.init(threads: [])) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() + 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) @@ -190,10 +183,10 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() + let controller = makeCodexSidebarFetchedResultsController(context: context) + try await controller.performFetch() - let section = try #require(results.sections.first) + let section = try #require(controller.sections.first) let chat = try #require(section.uncategorizedChats.first) #expect(section.sidebarWorkspaceGroupID == nil) @@ -209,7 +202,7 @@ struct ReviewMonitorCodexSidebarResultsTests { ]) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: results.sections).topologyChanged) + #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"))) @@ -238,11 +231,11 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() + let controller = makeCodexSidebarFetchedResultsController(context: context) + try await controller.performFetch() let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: results.sections).topologyChanged) + #expect(tree.apply(sections: controller.sections).topologyChanged) let root = try #require(tree.roots.first) let chatNode = try #require(tree.node(rowID: .chat(threadID))) @@ -256,11 +249,11 @@ struct ReviewMonitorCodexSidebarResultsTests { )) try await context.model(for: threadID).refresh(includeTurns: false) - #expect(tree.apply(sections: results.sections).topologyChanged == 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(results.sections.chat(id: threadID)?.title == "Updated review") + #expect(controller.sections.chat(id: threadID)?.title == "Updated review") #expect(root.children.map(\.rowID) == [.chat(threadID)]) #expect(root.children.first === chatNode) } @@ -292,16 +285,16 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let results = makeCodexSidebarFetchedResults(context: context) - try await results.performFetch() - let section = try #require(results.sections.filtered(by: .running).first) + 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 sidebarViewControllerInstallsCodexSidebarFetchedResultsFromModelContext() async throws { + @Test func sidebarViewControllerInstallsCodexSidebarFetchedResultsControllerFromModelContext() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -342,8 +335,6 @@ struct ReviewMonitorCodexSidebarResultsTests { repo.lastPathComponent, "App review", ]) - let workspace = try #require(sidebar.codexSidebarSectionsForTesting.first?.workspaces.first) - #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .workspace(workspace.id)) == nil) #expect(sidebar.reviewChatRowHeightForTesting(threadID) == sidebar.expectedReviewChatRowRectHeightForTesting) sidebar.selectCodexSidebarRowForTesting(rowID: .chat(threadID)) @@ -820,7 +811,6 @@ struct ReviewMonitorCodexSidebarResultsTests { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(secondThreadID)) == "Second review" } let section = try #require(sidebar.codexSidebarSectionsForTesting.first) - let workspace = try #require(section.workspaces.first) let container = section.rowID let fullReloadCountBeforeReorder = sidebar.sidebarFullReloadCountForTesting @@ -828,7 +818,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: .chat(secondThreadID))) #expect(sidebar.performCodexChatDropForTesting(id: secondThreadID, container: container, childIndex: 0)) #expect(sidebar.displayedCodexChatIDsForTesting(container: container) == [secondThreadID, firstThreadID]) - #expect(section.chats(in: workspace.id).map(\.id) == [firstThreadID, secondThreadID]) + #expect(section.items.map(\.id) == [firstThreadID, secondThreadID]) #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } @@ -891,10 +881,10 @@ private struct ThreadListParams: Decodable { } @MainActor -private func makeCodexSidebarFetchedResults( +private func makeCodexSidebarFetchedResultsController( context: CodexModelContext -) -> CodexFetchedResults { - context.fetchedResults( +) -> CodexFetchedResultsController { + context.fetchedResultsController( for: ReviewMonitorSidebarViewController.defaultCodexSidebarDescriptor, sectionedBy: .workspaceGroup ) From 06a932e4ee774e5288eb7cf2224f2e317445cd5c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:44:52 +0900 Subject: [PATCH 330/359] refactor(review-ui): drive reviews through CodexDataKit --- Package.swift | 1 + .../AppServerCodexReviewBackend.swift | 28 +++- .../LiveCodexReviewStoreBackend.swift | 21 ++- .../ReviewMonitorCodexChatLogProjection.swift | 12 +- ...onitorSelectedCodexChatLogProjection.swift | 12 +- .../ReviewMonitorSidebarViewController.swift | 3 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 157 +++++++++++++++++- ...eviewMonitorCodexSidebarResultsTests.swift | 89 +++++++++- .../ReviewMonitorSelectedCodexChatTests.swift | 93 +++++++++++ Tests/ReviewUITests/ReviewUIShellTests.swift | 21 ++- .../CodexReviewMonitorApp.swift | 6 +- 11 files changed, 408 insertions(+), 35 deletions(-) diff --git a/Package.swift b/Package.swift index 4dd00454..c7bedd66 100644 --- a/Package.swift +++ b/Package.swift @@ -50,6 +50,7 @@ let package = Package( .target( name: "CodexReviewAppServer", dependencies: [ + .product(name: "CodexKit", package: "CodexKit"), .product(name: "CodexAppServerKit", package: "CodexKit"), "CodexReviewKit", ], diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 2bdd39b4..1ca7f8f0 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1,4 +1,5 @@ import Foundation +import CodexKit import CodexAppServerKit import CodexReviewKit import OSLog @@ -16,6 +17,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" private let appServer: CodexAppServer + private let modelContext: CodexModelContext? private var reviewEventSessionsByAttemptID: [String: AppServerReviewEventSession] = [:] private var activeReviewAttemptIDByThreadID: [String: String] = [:] private var activeThreadIDsByAttemptID: [String: Set] = [:] @@ -24,8 +26,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] - package init(appServer: CodexAppServer) { + package init(appServer: CodexAppServer, modelContext: CodexModelContext? = nil) { self.appServer = appServer + self.modelContext = modelContext } package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { @@ -90,11 +93,22 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) - let review = try await appServer.startReview( - in: workspace, - target: request.request.target.appServerReviewTarget, - options: reviewThreadOptions(request) - ) + let review: CodexReviewSession + if let modelContext { + review = try await modelContext.startReview( + in: workspace, + input: CodexReviewInput( + target: request.request.target.appServerReviewTarget, + options: reviewThreadOptions(request) + ) + ).session + } else { + review = try await appServer.startReview( + in: workspace, + target: request.request.target.appServerReviewTarget, + options: reviewThreadOptions(request) + ) + } let attemptID = makeAppServerReviewAttemptID() let run = CodexReviewBackendModel.Review.Run( attemptID: attemptID, @@ -113,7 +127,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private func reviewThreadOptions( _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { - .init( + return .init( model: request.model, approvalMode: .denyAll, permissions: .profile(id: Self.reviewPermissionProfileID), diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index f41584f1..38e5d701 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -9,7 +9,7 @@ import CodexReviewMCPServer private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void -public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexAppServer?) -> Void +public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexModelContainer?) -> Void private let defaultExternalURLOpener: ExternalURLOpener = { url in _ = NSWorkspace.shared.open(url) @@ -184,9 +184,14 @@ public extension CodexReviewStore { appServerLifecycleHandler: appServerLifecycleHandler, appServerRuntimeFactory: { codexHomeURL in let appServer = try await appServerFactory(codexHomeURL) + let modelContainer = CodexModelContainer(appServer: appServer) return .init( appServer: appServer, - backend: AppServerCodexReviewBackend(appServer: appServer) + modelContainer: modelContainer, + backend: AppServerCodexReviewBackend( + appServer: appServer, + modelContext: modelContainer.mainContext + ) ) } ), @@ -416,9 +421,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) )) }.value + let modelContainer = CodexModelContainer(appServer: appServer) return .init( appServer: appServer, - backend: AppServerCodexReviewBackend(appServer: appServer) + modelContainer: modelContainer, + backend: AppServerCodexReviewBackend( + appServer: appServer, + modelContext: modelContainer.mainContext + ) ) } } @@ -447,12 +457,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let runtime = try await appServerRuntimeFactory(codexHomeURL) let appServer = runtime.appServer let backend = runtime.backend - let modelContainer = CodexModelContainer(appServer: appServer) + let modelContainer = runtime.modelContainer startedAppServer = appServer self.appServer = appServer self.appServerModelContainer = modelContainer self.appServerBackend = backend - appServerLifecycleHandler?(appServer) + appServerLifecycleHandler?(modelContainer) observeAuthNotifications(appServer: appServer, backend: backend, store: store) if let mcpHTTPServerFactory { let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider( @@ -1647,6 +1657,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { @MainActor private struct AppServerRuntime: Sendable { var appServer: CodexAppServer + var modelContainer: CodexModelContainer var backend: AppServerCodexReviewBackend } diff --git a/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift index 1deb582d..21c5bc5e 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift @@ -21,12 +21,16 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { documentProjection.reset() return nil } + let suppressUserMessages = snapshot.items.contains { item in + item.kind == .enteredReviewMode || item.kind == .exitedReviewMode + } let blocks = snapshot.items.flatMap { projectedBlocks( from: $0, turnSnapshot: snapshot.turn, chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + chatUpdatedAt: chatUpdatedAt, + suppressUserMessages: suppressUserMessages ) } guard blocks.isEmpty == false else { @@ -40,10 +44,14 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { from item: CodexChatItemSnapshot, turnSnapshot: CodexChatTurnStateSnapshot, chatCreatedAt: Date?, - chatUpdatedAt: Date? + chatUpdatedAt: Date?, + suppressUserMessages: Bool ) -> [ReviewMonitorLogProjectedBlock] { switch item.content { case .message(let message): + guard suppressUserMessages == false || message.role != .user else { + return [] + } return [ projectedBlock( item, diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift index 1bc4aa23..f93bddec 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift @@ -54,15 +54,21 @@ struct ReviewMonitorSelectedCodexChatLogProjection { } switch update.kind { - case .snapshot, - .turnUpdated, - .phaseChanged: + case .snapshot: return renderSnapshot( from: snapshot, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt, allowIncrementalUpdate: false ) + case .turnUpdated, + .phaseChanged: + return renderSnapshot( + from: snapshot, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: hasLogDocument + ) case .itemUpserted(let item): return renderSnapshot( from: snapshot, diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index a2f71742..9c154f6a 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -273,8 +273,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD static var defaultCodexSidebarDescriptor: CodexFetchDescriptor { CodexFetchDescriptor( - predicate: .init(sourceKinds: [.subAgentReview]), - sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] ) } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 029c50d3..dcd13164 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -34,6 +34,18 @@ private actor ReviewMonitorPreviewArchivedChatIDs { } } +private actor ReviewMonitorPreviewCancelledChatIDs { + private var ids: Set = [] + + func insert(_ id: CodexThreadID) { + ids.insert(id) + } + + func contains(_ id: CodexThreadID) -> Bool { + ids.contains(id) + } +} + @MainActor struct ReviewMonitorPreviewChatLogFixture { let chatID: CodexThreadID @@ -92,6 +104,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private var notificationTask: Task? private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue() private let archivedChatIDs = ReviewMonitorPreviewArchivedChatIDs() + private let cancelledChatIDs = ReviewMonitorPreviewCancelledChatIDs() private var tick = 0 init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { @@ -239,7 +252,12 @@ final class ReviewMonitorPreviewAppServerRuntime { after currentTick: Int = 0, emitsNotifications: Bool = false ) async -> Int { - let runningFixtures = fixtures.filter(\.isRunning) + 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 } @@ -292,7 +310,12 @@ final class ReviewMonitorPreviewAppServerRuntime { let container = CodexModelContainer(appServer: runtime.server) self.runtime = runtime try await rebindRuntimeToCurrentThreadStore(runtime) - await runtime.transport.stubJSON("{}", for: "turn/interrupt") + 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) } @@ -314,6 +337,28 @@ final class ReviewMonitorPreviewAppServerRuntime { } 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.phase = .loaded + 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, item: CodexChatItemSnapshot, @@ -439,6 +484,23 @@ final class ReviewMonitorPreviewAppServerRuntime { } } + private func updateStoredSnapshot( + for fixture: ReviewMonitorPreviewChatLogFixture, + mutation: @escaping @Sendable (inout CodexChatSnapshot) -> Void + ) async -> CodexChatSnapshot? { + await snapshotMutationQueue.run { @MainActor [weak self] in + guard let self, + var snapshot = await self.threadStore.snapshot(id: fixture.chatID)?.chatSnapshot else { + return nil + } + mutation(&snapshot) + await self.replaceThreadStorePreservingFixtureOrder( + with: fixture.threadSnapshot(snapshot, status: .idle) + ) + return snapshot + } + } + private func replaceThreadStorePreservingFixtureOrder( with updatedSnapshot: CodexThreadSnapshot ) async { @@ -616,6 +678,49 @@ final class ReviewMonitorPreviewAppServerRuntime { ) ) } + + private func emitCancelledState( + _ snapshot: CodexChatSnapshot, + 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.initialSnapshot.turns.last?.id + ?? CodexTurnID(rawValue: "preview-turn") + 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 { @@ -651,6 +756,47 @@ private struct PreviewThreadArchiveParams: Decodable, Sendable { } } +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 @@ -783,7 +929,10 @@ private extension ReviewMonitorPreviewChatLogFixture { threadSnapshot(initialSnapshot) } - func threadSnapshot(_ snapshot: CodexChatSnapshot) -> CodexThreadSnapshot { + func threadSnapshot( + _ snapshot: CodexChatSnapshot, + status: CodexThreadStatus? = nil + ) -> CodexThreadSnapshot { CodexThreadSnapshot( id: chatID, workspace: workspaceCWD.map { URL(fileURLWithPath: $0, isDirectory: true) }, @@ -792,7 +941,7 @@ private extension ReviewMonitorPreviewChatLogFixture { modelProvider: model, updatedAt: updatedAt, recencyAt: recencyAt, - status: status, + status: status ?? self.status, turns: snapshot.turns.map { turn in CodexTurnSnapshot( id: turn.id, diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 18732581..1b3ffe2c 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -153,7 +153,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(controller.sections.filtered(by: .running).isEmpty) } - @Test func defaultCodexSidebarDescriptorFetchesReviewThreadsOnly() async throws { + @Test func defaultCodexSidebarDescriptorUsesDedicatedHomeWithoutSourceFiltering() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext @@ -164,7 +164,7 @@ struct ReviewMonitorCodexSidebarResultsTests { let request = try #require(await runtime.transport.recordedRequests(method: "thread/list").first) let params = try request.decodeParams(ThreadListParams.self) - #expect(params.sourceKinds == ["subAgentReview"]) + #expect(params.sourceKinds == nil) } @Test func sidebarIncludesUncategorizedChatsWithStableRowIDs() async throws { @@ -874,6 +874,91 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(sidebar.sidebarFullReloadCountForTesting == reloadCountAfterInitialFetch) } + + @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 { diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift index 32eda225..7731c00a 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift @@ -271,6 +271,95 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(transport.logReloadCountForTesting == reloadCount) } + @Test func codexChatLogProjectionSkipsUserPromptWhenReviewModeLogExists() { + var projection = ReviewMonitorCodexChatLogProjection() + let document = projection.render( + from: .init( + turn: .init(id: CodexTurnID(rawValue: "turn-review"), status: .running), + items: [ + .init( + id: "user-message", + turnID: CodexTurnID(rawValue: "turn-review"), + kind: .userMessage, + content: .message(.init( + id: "user-message", + role: .user, + text: "Review the current code changes." + )) + ), + .init( + id: "entered-review", + turnID: CodexTurnID(rawValue: "turn-review"), + kind: .enteredReviewMode, + content: .log("Review the current code changes.") + ), + ] + ), + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.text == "Review the current code changes.") + } + + @Test func codexChatLogProjectionRendersUserMessageWithoutReviewModeLog() { + var projection = ReviewMonitorCodexChatLogProjection() + let document = projection.render( + from: .init( + turn: .init(id: CodexTurnID(rawValue: "turn-chat"), status: .running), + items: [ + .init( + id: "user-message", + turnID: CodexTurnID(rawValue: "turn-chat"), + kind: .userMessage, + content: .message(.init( + id: "user-message", + role: .user, + text: "Explain the current diff." + )) + ), + ] + ), + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.text == "Explain the current diff.") + } + + @Test func selectedCodexChatStatusOnlyChangesKeepIncrementalLogUpdates() { + var projection = ReviewMonitorSelectedCodexChatLogProjection() + let turnID = CodexTurnID(rawValue: "turn-review") + let snapshot = CodexChatSnapshot( + chatID: CodexThreadID(rawValue: "review-thread"), + phase: .loading, + turns: [.init(id: turnID, status: .running)], + items: [ + .init( + id: "message-review", + turnID: turnID, + kind: .agentMessage, + content: .message(.init( + id: "message-review", + role: .assistant, + text: "Running review" + )) + ), + ] + ) + + let initialChange = projection.apply(snapshotChange(snapshot), chatCreatedAt: nil, chatUpdatedAt: nil) + let statusChange = projection.apply( + .turnUpdated(.init(id: turnID, status: .running)), + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(initialChange?.allowsIncrementalRender == false) + #expect(statusChange?.allowsIncrementalRender == true) + #expect(statusChange?.sourceDocument?.text == "Running review") + } + @Test func selectedCodexChatRendersThreadAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext @@ -349,6 +438,10 @@ struct ReviewMonitorSelectedCodexChatTests { let chatID = CodexThreadID(rawValue: id) uiState.selection = .chat(chatID) } + + private func snapshotChange(_ snapshot: CodexChatSnapshot) -> CodexChatChange { + .snapshot(snapshot) + } } private struct ThreadItemParams: Encodable, Sendable { diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 2b2d874d..66b7f9ba 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -112,12 +112,19 @@ extension ReviewUITests { } } #expect(await previewContent.interruptRequestCountForTesting() == 1) - #expect( - store.chatCancellationCapability( - forChatID: selectedChatID.rawValue, - isChatActive: true - ).isEnabled - ) + 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 + } var cancelItemEnabledAfterCancellation = false sidebar.presentContextMenuForTesting(chatID: selectedChatID) { menu in @@ -126,7 +133,7 @@ extension ReviewUITests { } cancelItemEnabledAfterCancellation = cancelItem.isEnabled } - #expect(cancelItemEnabledAfterCancellation) + #expect(cancelItemEnabledAfterCancellation == false) } @Test func inactiveChatContextMenuArchiveSkipsConfirmationAndRemovesSidebarChat() async throws { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 83b58afb..a9aaf363 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -309,9 +309,9 @@ struct ReviewMonitorAppComposition { browserSessionPolicy: .ephemeral, presentationAnchorProvider: presentationAnchorProvider ) - ) { appServer in - if let appServer { - codexModelSource.install(container: CodexModelContainer(appServer: appServer)) + ) { modelContainer in + if let modelContainer { + codexModelSource.install(container: modelContainer) } else { codexModelSource.clear() } From 2cbcd4add64d55b5e4350b5f0f7645aca4b86e0d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:32:31 +0900 Subject: [PATCH 331/359] fix(review-ui): simplify chat row rendering --- .../CodexChats/ReviewMonitorChatRowView.swift | 127 ++++++++---------- .../ReviewMonitorSidebarViewController.swift | 51 ++++++- 2 files changed, 107 insertions(+), 71 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index f9969408..f7060d03 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -3,51 +3,35 @@ import SwiftUI import CodexKit @MainActor -private struct ReviewMonitorChatRowContent: View { +struct ReviewMonitorChatRowView: View { var chat: CodexChat var body: some View { - ReviewMonitorChatRowLayout( - id: chat.id.rawValue, - title: chat.title, - model: chat.modelProvider, - subtitle: chat.preview?.trimmedNonEmpty, - startedAt: chat.status?.isActive == true ? chat.activityDate : nil, - endedAt: nil, - isRunning: chat.status?.isActive == true - ) - } -} + let isRunning = chat.status?.isActive == true + let startedAt = isRunning ? chat.activityDate : nil -@MainActor -private struct ReviewMonitorChatRowLayout: View { - var id: String - var title: String - var model: String? - var subtitle: String? - var startedAt: Date? - var endedAt: Date? - var isRunning: Bool - - var body: some View { Label { VStack { HStack { - Text(title) + Text(chat.title) .truncationMode(.tail) Spacer(minLength: 0) - ReviewMonitorChatRowTimerLabel(startedAt: startedAt, endedAt: endedAt) + if let startedAt { + Text( + timerInterval: startedAt...(.distantFuture), + pauseTime: nil, + countsDown: false, + showsHours: true + ) + .monospacedDigit() .foregroundStyle(.secondary) .layoutPriority(1) + } } .lineLimit(1) HStack { - if let model { - Text(model) - } - if let subtitle { - Text(subtitle) - } + Text(chat.modelProvider?.trimmedNonEmpty ?? "") + Text(chat.preview?.trimmedNonEmpty ?? "") Spacer(minLength: 0) } .textScale(.secondary) @@ -66,39 +50,12 @@ private struct ReviewMonitorChatRowLayout: View { .animation(.default, value: isRunning) .padding(.leading, SidebarLayout.disclosureGutterWidth) } - .transaction(value: id) { transaction in + .transaction(value: chat.id.rawValue) { transaction in transaction.disablesAnimations = true } } } -@MainActor -struct ReviewMonitorChatRowView: View { - var chat: CodexChat - - var body: some View { - ReviewMonitorChatRowContent(chat: chat) - } -} - -@MainActor -struct ReviewMonitorChatRowTimerLabel: View { - var startedAt: Date? - var endedAt: Date? - - var body: some View { - if let startedAt { - Text( - timerInterval: startedAt...(endedAt ?? .distantFuture), - pauseTime: endedAt, - countsDown: false, - showsHours: true - ) - .monospacedDigit() - } - } -} - private extension String { var trimmedNonEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) @@ -114,22 +71,52 @@ private extension CodexChat { @MainActor package func measuredReviewMonitorChatRowHeight() -> CGFloat { - ReviewMonitorChatRowLayout.measureMeasuredHeight() + ReviewMonitorChatRowView.measureMeasuredHeight() } @MainActor -extension ReviewMonitorChatRowLayout { +extension ReviewMonitorChatRowView { static func measureMeasuredHeight() -> CGFloat { let hostingView = NSHostingView( - rootView: ReviewMonitorChatRowLayout( - id: "row-height-measurement", - title: "Uncommitted changes", - model: "gpt-5.5", - subtitle: "Review output preview", - startedAt: Date(timeIntervalSince1970: 0), - endedAt: nil, - isRunning: true - ) + 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/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 9c154f6a..21f86cb0 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -344,9 +344,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarTransaction( - _: CodexFetchedResultsTransaction, + _ transaction: CodexFetchedResultsTransaction, controller: CodexFetchedResultsController ) { + print( + "[ReviewMonitorSidebarDebug] transaction reason=\(transaction.reason) " + + "sections=\(transaction.sectionChanges) items=\(transaction.itemChanges) " + + "snapshot=\(debugDescribeSections(controller.sections))" + ) applyCodexSidebarSourceSections(controller.sections) } @@ -366,11 +371,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarSourceSections(_ sections: [CodexFetchSection]) { + print("[ReviewMonitorSidebarDebug] source sections \(debugDescribeSections(sections))") codexSidebarPresentationOrder.prune(to: sections) applyCodexSidebarVisibleSections(from: sections) } private func applyFilteredCodexSidebarSections() { + print("[ReviewMonitorSidebarDebug] filter changed source=\(debugDescribeSections(codexSidebarSourceSections))") applyCodexSidebarVisibleSections(from: codexSidebarSourceSections) } @@ -378,7 +385,12 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD from sourceSections: [CodexFetchSection] ) { let sections = codexSidebarVisibleSections(from: sourceSections) + print("[ReviewMonitorSidebarDebug] visible sections \(debugDescribeSections(sections))") let applyResult = codexSidebarOutlineTree.apply(sections: sections) + print( + "[ReviewMonitorSidebarDebug] outline apply topologyChanged=\(applyResult.topologyChanged) " + + "changes=\(applyResult.topologyChanges.map(debugDescribeTopologyChange))" + ) applySidebarKind(sidebarKind) if applyResult.topologyChanged { applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) @@ -398,6 +410,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD isReconcilingSelection = true var appliedIncrementally = true for change in changes { + print("[ReviewMonitorSidebarDebug] apply topology change \(debugDescribeTopologyChange(change))") guard applyCodexSidebarOutlineTopologyChange(change) else { appliedIncrementally = false break @@ -441,6 +454,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> Bool { let oldChildRowIDs = change.oldChildRowIDs let newChildRowIDs = change.newChildRowIDs + print( + "[ReviewMonitorSidebarDebug] child delta parent=\(change.parentRowID?.rawValue ?? "root") " + + "old=\(oldChildRowIDs.map(\.rawValue)) new=\(newChildRowIDs.map(\.rawValue))" + ) guard oldChildRowIDs != newChildRowIDs else { return true } @@ -465,6 +482,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD newChildRowIDSet.contains(rowID) ? nil : offset } if removedIndexes.isEmpty == false { + print("[ReviewMonitorSidebarDebug] remove indexes=\(Array(removedIndexes)) parent=\(change.parentRowID?.rawValue ?? "root")") outlineView.removeItems( at: IndexSet(removedIndexes), inParent: parentItem, @@ -476,6 +494,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD oldChildRowIDSet.contains(rowID) ? nil : offset } if insertedIndexes.isEmpty == false { + print("[ReviewMonitorSidebarDebug] insert indexes=\(Array(insertedIndexes)) parent=\(change.parentRowID?.rawValue ?? "root")") outlineView.insertItems( at: IndexSet(insertedIndexes), inParent: parentItem, @@ -499,6 +518,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard currentIndex != targetIndex else { continue } + print( + "[ReviewMonitorSidebarDebug] move row=\(targetRowID.rawValue) " + + "from=\(currentIndex) to=\(targetIndex) parent=\((parentItem as? ReviewMonitorCodexSidebarOutlineNode)?.rowID.rawValue ?? "root")" + ) outlineView.moveItem( at: currentIndex, inParent: parentItem, @@ -512,6 +535,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func reloadCodexSidebarOutline() { + print("[ReviewMonitorSidebarDebug] reloadData roots=\(codexSidebarOutlineTree.roots.map { $0.rowID.rawValue })") #if DEBUG fullReloadCountForTesting += 1 #endif @@ -1239,6 +1263,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return false } + print( + "[ReviewMonitorSidebarDebug] configure chat row id=\(chat.id.rawValue) " + + "title=\(chat.title) active=\(chat.status?.isActive == true) " + + "recency=\(String(describing: chat.recencyAt))" + ) cellView.configure(with: chat) return true case .section: @@ -1254,8 +1283,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let workspaceGroup = displayedCodexSidebarSection(id: id)?.workspaceGroup ?? codexWorkspaceGroupSection(id: id)?.workspaceGroup { + print("[ReviewMonitorSidebarDebug] configure workspace group id=\(id.rawValue) title=\(workspaceGroup.name)") cellView.configure(workspaceGroup: workspaceGroup) } else { + print("[ReviewMonitorSidebarDebug] configure fallback workspace group id=\(id.rawValue)") cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? id.rawValue) } return true @@ -1402,9 +1433,27 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let node = codexSidebarNode(from: item) else { return nil } + print("[ReviewMonitorSidebarDebug] viewFor row=\(node.rowID.rawValue)") return makeCodexSidebarCellView(for: node) } + private func debugDescribeSections(_ sections: [CodexFetchSection]) -> String { + sections.map { section in + let chats = section.items.map(\.id.rawValue).joined(separator: ",") + return "\(section.rowID.rawValue)[\(chats)]" + } + .joined(separator: " | ") + } + + private func debugDescribeTopologyChange( + _ change: ReviewMonitorCodexSidebarOutlineTopologyChange + ) -> String { + let parent = change.parentRowID?.rawValue ?? "root" + let old = change.oldChildRowIDs.map(\.rawValue).joined(separator: ",") + let new = change.newChildRowIDs.map(\.rawValue).joined(separator: ",") + return "\(parent):[\(old)]->[\(new)]" + } + } #if DEBUG From 47db8de41277c542a6d7e9981724485a1a2acc28 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:21:07 +0900 Subject: [PATCH 332/359] refactor(review-ui): split chat log renderer target --- Package.swift | 13 +- ...wMonitorCommandOutputDisplayDocument.swift | 6 + ...viewMonitorLogCommandOutputPanelView.swift | 0 .../ReviewMonitorLogDocumentProjection.swift | 0 .../Detail/ReviewMonitorLogDocumentView.swift | 0 .../Detail/ReviewMonitorLogLineCounter.swift | 0 .../Detail/ReviewMonitorLogProjection.swift | 4 +- .../Detail/ReviewMonitorLogRenderer.swift | 0 .../ReviewMonitorLogRenderingViews.swift | 0 .../Detail/ReviewMonitorLogScrollView.swift | 0 .../Detail/ReviewMonitorLogTextFinder.swift | 0 .../ReviewMonitorCodexChatLogProjection.swift | 0 ...MonitorCodexChatLogSourceProjection.swift} | 2 +- .../ReviewMonitorCodexChatLogTarget.swift | 805 ++++++++++++++++++ ...ReviewMonitorTransportViewController.swift | 483 ++++------- .../ReviewMonitorSelectedCodexChat.swift | 203 ----- .../ReviewMonitorSidebarViewController.swift | 54 +- .../ReviewMonitorChatLogTesting.swift | 3 +- ...> ReviewMonitorCodexChatDetailTests.swift} | 53 +- Tests/ReviewUITests/ReviewUITests.swift | 1 + 20 files changed, 1020 insertions(+), 607 deletions(-) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorCommandOutputDisplayDocument.swift (99%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogCommandOutputPanelView.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogDocumentProjection.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogDocumentView.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogLineCounter.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogProjection.swift (99%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogRenderer.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogRenderingViews.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogScrollView.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/Detail/ReviewMonitorLogTextFinder.swift (100%) rename Sources/{ReviewUI => ReviewChatLogUI}/ReviewMonitorCodexChatLogProjection.swift (100%) rename Sources/{ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift => ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift} (98%) create mode 100644 Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift delete mode 100644 Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift rename Tests/ReviewUITests/{ReviewMonitorSelectedCodexChatTests.swift => ReviewMonitorCodexChatDetailTests.swift} (91%) diff --git a/Package.swift b/Package.swift index c7bedd66..cd5fd97c 100644 --- a/Package.swift +++ b/Package.swift @@ -100,7 +100,7 @@ let package = Package( name: "ReviewUI", dependencies: [ "CodexReviewKit", - "TextTransitions", + "ReviewChatLogUI", .product(name: "CodexKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], @@ -108,6 +108,16 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .target( + name: "ReviewChatLogUI", + dependencies: [ + "TextTransitions", + .product(name: "CodexKit", package: "CodexKit"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .target( name: "ReviewUIPreviewSupport", dependencies: [ @@ -178,6 +188,7 @@ let package = Package( .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", + "ReviewChatLogUI", "ReviewUI", "ReviewUIPreviewSupport", ], diff --git a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift similarity index 99% rename from Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 13146a00..747e46d1 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -970,3 +970,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 100% rename from Sources/ReviewUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogCommandOutputPanelView.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentProjection.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentProjection.swift similarity index 100% rename from Sources/ReviewUI/Detail/ReviewMonitorLogDocumentProjection.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentProjection.swift diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift similarity index 100% rename from Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift 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/ReviewUI/Detail/ReviewMonitorLogProjection.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift similarity index 99% rename from Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift index 7f783591..980e927e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorLogProjection.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift @@ -1,8 +1,8 @@ import Foundation -enum ReviewMonitorLog {} +package enum ReviewMonitorLog {} -extension ReviewMonitorLog { +package extension ReviewMonitorLog { struct BlockID: Codable, Hashable, Sendable { var rawValue: String diff --git a/Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderer.swift similarity index 100% rename from Sources/ReviewUI/Detail/ReviewMonitorLogRenderer.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogRenderer.swift 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 100% rename from Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift rename to Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift 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/ReviewUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift similarity index 100% rename from Sources/ReviewUI/ReviewMonitorCodexChatLogProjection.swift rename to Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift similarity index 98% rename from Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift rename to Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index f93bddec..c1de445e 100644 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -29,7 +29,7 @@ enum ReviewMonitorLogSourceChange: Equatable { } @MainActor -struct ReviewMonitorSelectedCodexChatLogProjection { +struct ReviewMonitorCodexChatLogSourceProjection { private var turnProjection = CodexChatTurnProjection() private var logProjection = ReviewMonitorCodexChatLogProjection() private var hasLogDocument = false diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift new file mode 100644 index 00000000..40d78404 --- /dev/null +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -0,0 +1,805 @@ +import AppKit +import CodexKit + +@MainActor +package final class ReviewMonitorCodexChatLogTarget { + private static let incrementalLogUpdateCoalescingDelay: Duration = .milliseconds(10) + + private enum LogRenderTarget: Equatable { + case chat(CodexThreadID) + } + + 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() + } + cancelSelectedChatObservation() + cancelPendingLogSourceChange() + selectedChatLogTask?.cancel() + selectedChatLogTask = nil + 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() + selectedChatLogTask?.cancel() + selectedChatLogTask = nil + 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 { + 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 + ) { + selectedChatLogTask?.cancel() + selectedChatLogTask = Task { @MainActor [weak self, weak chat, weak modelContext] in + guard 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 + for await change in observation.changes { + guard Task.isCancelled == false, + self.boundChat === chat, + self.isCurrentLogRenderTarget(target) + else { + return + } + self.publishSelectedCodexChatLogChange( + self.logProjection.apply( + change, + chatCreatedAt: chat.createdAt, + chatUpdatedAt: chat.updatedAt + ), + target: target, + initialRestorationTarget: initialRestorationTarget + ) + } + } catch is CancellationError { + } catch { + } + } + } + + 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 { + 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/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index e7e61d5e..1513d603 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,29 +1,19 @@ import AppKit import CodexKit import ObservationBridge +import ReviewChatLogUI @MainActor final class ReviewMonitorTransportViewController: NSViewController { - private enum LogRenderTarget: Equatable { - case chat(CodexThreadID) - } - private let codexModelSource: ReviewMonitorCodexModelSource? private let uiState: ReviewMonitorUIState - private let selectedCodexChat: ReviewMonitorSelectedCodexChat - private let logScrollView = ReviewMonitorLogScrollView() - private var logRenderer = ReviewMonitorLogRenderer() + private let chatLogTarget = ReviewMonitorCodexChatLogTarget() private let placeholderViewController = PlaceholderViewController() private var displayedContentConstraints: [NSLayoutConstraint] = [] private var selectionObservation: PortableObservationTracking.Token? - private var selectedChatLogTask: Task? + private var boundModelContext: CodexModelContext? private var boundChatID: CodexThreadID? private var displayedSelection: ReviewMonitorSelectionID? - private var logScrollTargetsByChatID: [CodexThreadID: ReviewMonitorLogScrollView.ScrollRestorationTarget] = [:] - private var logRenderTask: Task? - private var logRenderGeneration: UInt64 = 0 - private var appliedLogRenderGeneration: UInt64 = 0 - private var hasAppliedBoundLog = false convenience init( uiState: ReviewMonitorUIState, @@ -41,9 +31,6 @@ final class ReviewMonitorTransportViewController: NSViewController { ) { self.codexModelSource = codexModelSource self.uiState = uiState - self.selectedCodexChat = ReviewMonitorSelectedCodexChat( - modelSource: codexModelSource - ) super.init(nibName: nil, bundle: nil) } @@ -54,8 +41,6 @@ final class ReviewMonitorTransportViewController: NSViewController { isolated deinit { selectionObservation?.cancel() - selectedChatLogTask?.cancel() - logRenderTask?.cancel() } override func loadView() { @@ -78,17 +63,18 @@ 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(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( @@ -106,79 +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 .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 .workspaceGroup: clearDisplayedLogSelection() displayPlaceholder(.noFindings) - logScrollView.isHidden = true + chatLogTarget.view.isHidden = true displayedSelection = selection?.id case .chat: if case .chat(let selectedChatID) = selection { - displayChat(selectedChatID) + displayChat(selectedChatID, modelContext: modelContext) } hidePlaceholder() - logScrollView.isHidden = false + chatLogTarget.view.isHidden = false displayedSelection = selection?.id case nil: clearDisplayedLogSelection() displayPlaceholder(.noSelection) - logScrollView.isHidden = true + chatLogTarget.view.isHidden = true displayedSelection = nil } } - private func displayChat(_ selectedChatID: CodexThreadID) { - let isSwitchingRenderedChat = boundChatID != nil && boundChatID != selectedChatID - cacheBoundLogScrollTarget() - if isSwitchingRenderedChat { - logScrollView.resetFindStateForContentReuse() + private func displayChat(_ selectedChatID: CodexThreadID, modelContext: CodexModelContext?) { + guard boundChatID != selectedChatID || boundModelContext !== modelContext else { + return } - selectedChatLogTask?.cancel() - selectedChatLogTask = nil - resetLogRenderer() boundChatID = selectedChatID - let restorationTarget = restorationTarget(chatID: selectedChatID) - selectedCodexChat.bind(toChatID: selectedChatID) - startSelectedCodexChatLogStream( - target: .chat(selectedChatID), - initialRestorationTarget: restorationTarget - ) + boundModelContext = modelContext + guard let modelContext else { + chatLogTarget.clear() + return + } + + let chat = modelContext.model(for: selectedChatID) + chatLogTarget.bind(chat: chat, modelContext: modelContext) } private func clearDisplayedLogSelection() { - cacheBoundLogScrollTarget() - selectedChatLogTask?.cancel() - selectedChatLogTask = nil boundChatID = nil - selectedCodexChat.unbind() - resetLogRenderer() - logScrollView.resetFindStateForContentReuse() - logScrollView.clear() + boundModelContext = nil + chatLogTarget.clear() } @discardableResult @@ -196,128 +181,11 @@ final class ReviewMonitorTransportViewController: NSViewController { return hiddenChanged } - @discardableResult - private func renderBoundLog( - sourceDocument: ReviewMonitorLog.Document, - target: LogRenderTarget, - restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget, - allowIncrementalUpdate: Bool - ) -> Bool { - 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 startSelectedCodexChatLogStream( - target: LogRenderTarget, - initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget - ) { - startLogSourceChangeStream( - selectedCodexChat.logSourceChangeStream(), - target: target, - initialRestorationTarget: initialRestorationTarget - ) - } - - private func startLogSourceChangeStream( - _ stream: AsyncStream, - target: LogRenderTarget, - initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget - ) { - selectedChatLogTask?.cancel() - selectedChatLogTask = Task { @MainActor [weak self] in - for await change in stream { - guard let self, - self.isCurrentLogRenderTarget(target) - else { - return - } - let hasAppliedInitialDocument = self.hasAppliedBoundLog - self.applySelectedCodexChatLogChange( - change, - target: target, - restorationTarget: hasAppliedInitialDocument - ? self.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 { - 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 restorationTarget( - chatID: CodexThreadID - ) -> ReviewMonitorLogScrollView.ScrollRestorationTarget { - logScrollTargetsByChatID[chatID] ?? .bottom - } - @discardableResult func performDisplayedTextFinderAction(_ sender: Any?) -> Bool { switch displayedSelection { case .chat: - return logScrollView.performDisplayedTextFinderAction(sender) + return chatLogTarget.performDisplayedTextFinderAction(sender) case .workspaceGroup, nil: return false } @@ -326,7 +194,7 @@ final class ReviewMonitorTransportViewController: NSViewController { func validateDisplayedTextFinderAction(_ item: NSValidatedUserInterfaceItem) -> Bool { switch displayedSelection { case .chat: - return logScrollView.validateDisplayedTextFinderAction(item) + return chatLogTarget.validateDisplayedTextFinderAction(item) case .workspaceGroup, nil: return false } @@ -357,23 +225,11 @@ final class ReviewMonitorTransportViewController: NSViewController { } var selectedChatLogTaskForTesting: Task? { - selectedChatLogTask - } - - var selectedCodexChatIDForTesting: String? { - selectedCodexChat.chat?.id.rawValue - } - - var selectedCodexChatPhaseForTesting: CodexDataPhase { - selectedCodexChat.phase - } - - var selectedCodexChatItemTextsForTesting: [String] { - selectedCodexChat.chat?.items.compactMap(\.text) ?? [] + chatLogTarget.selectedChatLogTaskForTesting } var displayedLogForTesting: String { - logScrollView.displayedTextForTesting + chatLogTarget.displayedTextForTesting } var isShowingEmptyStateForTesting: Bool { @@ -389,278 +245,278 @@ final class ReviewMonitorTransportViewController: NSViewController { } var logAppendCountForTesting: Int { - logScrollView.appendCount + chatLogTarget.appendCount } var logReplaceCountForTesting: Int { - logScrollView.replaceCount + chatLogTarget.replaceCount } var logReloadCountForTesting: Int { - logScrollView.reloadCount + chatLogTarget.reloadCount } var logAutoFollowCountForTesting: Int { - logScrollView.autoFollowCount + chatLogTarget.autoFollowCount } var logWordGlowCountForTesting: Int { - logScrollView.wordGlowCountForTesting + chatLogTarget.wordGlowCountForTesting } var logWordFadeRenderingAttributeRangeCountForTesting: Int { - logScrollView.wordFadeRenderingAttributeRangeCountForTesting + chatLogTarget.wordFadeRenderingAttributeRangeCountForTesting } var logWordFadeStorageUsesOpaqueTextColorForTesting: Bool { - logScrollView.wordFadeStorageUsesOpaqueTextColorForTesting + chatLogTarget.wordFadeStorageUsesOpaqueTextColorForTesting } var logWordFadeDisplayInvalidationCountForTesting: Int { - logScrollView.wordFadeDisplayInvalidationCountForTesting + chatLogTarget.wordFadeDisplayInvalidationCountForTesting } var logCommandOutputPanelCountForTesting: Int { - logScrollView.commandOutputPanelCountForTesting + chatLogTarget.commandOutputPanelCountForTesting } var logTerminalDecorationRectCountForTesting: Int { - logScrollView.terminalDecorationRectCountForTesting + chatLogTarget.terminalDecorationRectCountForTesting } var logExpandedCommandOutputPanelCountForTesting: Int { - logScrollView.expandedCommandOutputPanelCountForTesting + chatLogTarget.expandedCommandOutputPanelCountForTesting } var logCommandOutputPanelUsesTextKit2ForTesting: Bool { - logScrollView.commandOutputPanelUsesTextKit2ForTesting + chatLogTarget.commandOutputPanelUsesTextKit2ForTesting } var logCommandOutputPanelUsesInlineAttachmentForTesting: Bool { - logScrollView.commandOutputPanelUsesInlineAttachmentForTesting + chatLogTarget.commandOutputPanelUsesInlineAttachmentForTesting } var logCommandOutputPanelUsesButtonAttachmentForTesting: Bool { - logScrollView.commandOutputPanelUsesButtonAttachmentForTesting + chatLogTarget.commandOutputPanelUsesButtonAttachmentForTesting } var logCollapsedCommandOutputPanelAttachmentLineHeightForTesting: CGFloat? { - logScrollView.collapsedCommandOutputPanelAttachmentLineHeightForTesting + chatLogTarget.collapsedCommandOutputPanelAttachmentLineHeightForTesting } var logCollapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting: Bool { - logScrollView.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting + chatLogTarget.collapsedCommandOutputPanelAttachmentPayloadIsEmptyForTesting } var logCommandOutputPanelUsesSystemMaterialBackgroundForTesting: Bool { - logScrollView.commandOutputPanelUsesSystemMaterialBackgroundForTesting + chatLogTarget.commandOutputPanelUsesSystemMaterialBackgroundForTesting } var logCommandOutputPanelVisibleLineCapacityForTesting: Int { - logScrollView.commandOutputPanelVisibleLineCapacityForTesting + chatLogTarget.commandOutputPanelVisibleLineCapacityForTesting } var logCommandOutputPanelResultTextForTesting: String? { - logScrollView.commandOutputPanelResultTextForTesting + chatLogTarget.commandOutputPanelResultTextForTesting } var logCommandOutputPanelTerminalTextForTesting: String? { - logScrollView.commandOutputPanelTerminalTextForTesting + chatLogTarget.commandOutputPanelTerminalTextForTesting } func logCommandOutputPanelTerminalTextForTesting(blockID: ReviewMonitorLog.BlockID) -> String? { - logScrollView.commandOutputPanelTerminalTextForTesting(blockID: blockID) + chatLogTarget.commandOutputPanelTerminalTextForTesting(blockID: blockID) } var logCommandOutputPanelCommandLineTextForTesting: String? { - logScrollView.commandOutputPanelCommandLineTextForTesting + chatLogTarget.commandOutputPanelCommandLineTextForTesting } var logCommandOutputPanelOutputScrollTextForTesting: String? { - logScrollView.commandOutputPanelOutputScrollTextForTesting + chatLogTarget.commandOutputPanelOutputScrollTextForTesting } var logCommandOutputPanelOutputScrollIsScrollableForTesting: Bool { - logScrollView.commandOutputPanelOutputScrollIsScrollableForTesting + chatLogTarget.commandOutputPanelOutputScrollIsScrollableForTesting } var logCommandOutputPanelOutputScrollUsesHorizontalScrollingForTesting: Bool { - logScrollView.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting + chatLogTarget.commandOutputPanelOutputScrollUsesHorizontalScrollingForTesting } var logCommandOutputPanelOutputScrollVerticalOffsetForTesting: CGFloat? { - logScrollView.commandOutputPanelOutputScrollVerticalOffsetForTesting + chatLogTarget.commandOutputPanelOutputScrollVerticalOffsetForTesting } var logCommandOutputPanelOutputScrollMaximumVerticalOffsetForTesting: CGFloat? { - logScrollView.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting + chatLogTarget.commandOutputPanelOutputScrollMaximumVerticalOffsetForTesting } func scrollCommandOutputPanelOutputForTesting(deltaY: CGFloat) -> Bool { - logScrollView.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY) + chatLogTarget.scrollCommandOutputPanelOutputForTesting(deltaY: deltaY) } var logCommandOutputPanelOutputHitTestTargetsTextViewForTesting: Bool { - logScrollView.commandOutputPanelOutputHitTestTargetsTextViewForTesting + chatLogTarget.commandOutputPanelOutputHitTestTargetsTextViewForTesting } func logFinderRectsForTesting(_ range: NSRange) -> [NSRect] { - logScrollView.finderRectsForTesting(range) + chatLogTarget.finderRectsForTesting(range) } var logFirstCommandOutputPanelRectForTesting: NSRect? { - logScrollView.firstCommandOutputPanelRectForTesting + chatLogTarget.firstCommandOutputPanelRectForTesting } var logCommandOutputPanelToggleSymbolNameForTesting: String? { - logScrollView.commandOutputPanelToggleSymbolNameForTesting + chatLogTarget.commandOutputPanelToggleSymbolNameForTesting } var logCommandOutputPanelLeadingAlignmentDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelLeadingAlignmentDeltaForTesting + chatLogTarget.commandOutputPanelLeadingAlignmentDeltaForTesting } var logCommandOutputPanelChevronSizeDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelChevronSizeDeltaForTesting + chatLogTarget.commandOutputPanelChevronSizeDeltaForTesting } var logCommandOutputPanelChevronVerticalAlignmentDeltaForTesting: CGFloat? { - logScrollView.commandOutputPanelChevronVerticalAlignmentDeltaForTesting + chatLogTarget.commandOutputPanelChevronVerticalAlignmentDeltaForTesting } func logHitTestTargetsDocumentViewForFirstOccurrenceForTesting(_ text: String) -> Bool { - logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) + chatLogTarget.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) } func toggleFirstLogCommandOutputPanelForTesting() { - logScrollView.toggleFirstCommandOutputPanelForTesting() + chatLogTarget.toggleFirstCommandOutputPanelForTesting() } @discardableResult func clickFirstLogCommandOutputPanelHeaderForTesting() -> Bool { - logScrollView.clickFirstCommandOutputPanelHeaderForTesting() + chatLogTarget.clickFirstCommandOutputPanelHeaderForTesting() } @discardableResult func clickLogCommandOutputPanelHeaderForTesting(blockID: ReviewMonitorLog.BlockID) -> Bool { - logScrollView.clickCommandOutputPanelHeaderForTesting(blockID: blockID) + chatLogTarget.clickCommandOutputPanelHeaderForTesting(blockID: blockID) } func completeLogWordGlowAnimationsForTesting() { - logScrollView.completeWordGlowAnimationsForTesting() + chatLogTarget.completeWordGlowAnimationsForTesting() } func advanceLogWordGlowAnimationsAfterInitialDelayForTesting(_ delay: TimeInterval) { - logScrollView.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay) + chatLogTarget.advanceWordGlowAnimationsAfterInitialDelayForTesting(delay) } func setLogReduceMotionForTesting(_ reduceMotion: Bool?) { - logScrollView.setReduceMotionForTesting(reduceMotion) + chatLogTarget.setReduceMotionForTesting(reduceMotion) } var logUsesCustomTextKit2SurfaceForTesting: Bool { - logScrollView.usesCustomTextKit2SurfaceForTesting + chatLogTarget.usesCustomTextKit2SurfaceForTesting } var logUsesTextViewForTesting: Bool { - logScrollView.usesTextViewForTesting + chatLogTarget.usesTextViewForTesting } var logUsesLogLayoutManagerForTesting: Bool { - logScrollView.usesLogLayoutManagerForTesting + chatLogTarget.usesLogLayoutManagerForTesting } var logIsEditableForTesting: Bool { - logScrollView.isEditableForTesting + chatLogTarget.isEditableForTesting } var logIsSelectableForTesting: Bool { - logScrollView.isSelectableForTesting + chatLogTarget.isSelectableForTesting } var logUsesFindBarForTesting: Bool { - logScrollView.usesFindBarForTesting + chatLogTarget.usesFindBarForTesting } var logIsIncrementalSearchingEnabledForTesting: Bool { - logScrollView.isIncrementalSearchingEnabledForTesting + chatLogTarget.isIncrementalSearchingEnabledForTesting } var logFindBarVisibleForTesting: Bool { - logScrollView.isFindBarVisibleForTesting + chatLogTarget.isFindBarVisibleForTesting } var logTextFinderIdentifierForTesting: ObjectIdentifier { - logScrollView.textFinderIdentifierForTesting + chatLogTarget.textFinderIdentifierForTesting } var logFindVisibleCharacterRangesForTesting: [NSRange] { - logScrollView.findVisibleCharacterRangesForTesting + chatLogTarget.findVisibleCharacterRangesForTesting } var logFindStringLengthForTesting: Int { - logScrollView.findStringLengthForTesting + chatLogTarget.findStringLengthForTesting } var logFindClientUsesSnapshotForTesting: Bool { - logScrollView.findClientUsesSnapshotForTesting + chatLogTarget.findClientUsesSnapshotForTesting } var logFindClientSnapshotMapsToDocumentForTesting: Bool { - logScrollView.findClientSnapshotMapsToDocumentForTesting + chatLogTarget.findClientSnapshotMapsToDocumentForTesting } var logFindClientFirstSelectedRangeForTesting: NSRange { - logScrollView.findClientFirstSelectedRangeForTesting + chatLogTarget.findClientFirstSelectedRangeForTesting } var logHasActiveFindQueryForTesting: Bool { - logScrollView.hasActiveFindQueryForTesting + chatLogTarget.hasActiveFindQueryForTesting } var logVisibleFindBarSearchStringForTesting: String? { - logScrollView.visibleFindBarSearchStringForTesting + chatLogTarget.visibleFindBarSearchStringForTesting } @discardableResult func setLogVisibleFindBarSearchStringForTesting(_ string: String) -> Bool { - logScrollView.setVisibleFindBarSearchStringForTesting(string) + chatLogTarget.setVisibleFindBarSearchStringForTesting(string) } var logFindIndicatorInvalidationCountForTesting: Int { - logScrollView.findIndicatorInvalidationCountForTesting + chatLogTarget.findIndicatorInvalidationCountForTesting } var logFindIncrementalMatchRangeCountForTesting: Int { - logScrollView.findIncrementalMatchRangeCountForTesting + chatLogTarget.findIncrementalMatchRangeCountForTesting } var logFindBarContainerContentViewIsTextContentViewForTesting: Bool { - logScrollView.findBarContainerContentViewIsTextContentViewForTesting + chatLogTarget.findBarContainerContentViewIsTextContentViewForTesting } var logFindIncrementalSearchUsesSystemHighlightingForTesting: Bool { - logScrollView.findIncrementalSearchUsesSystemHighlightingForTesting + chatLogTarget.findIncrementalSearchUsesSystemHighlightingForTesting } var logHitTestTargetsDocumentViewForTesting: Bool { - logScrollView.hitTestTargetsDocumentViewForTesting + chatLogTarget.hitTestTargetsDocumentViewForTesting } var logWritingToolsDisabledForTesting: Bool { - logScrollView.writingToolsDisabledForTesting + chatLogTarget.writingToolsDisabledForTesting } var logOverlayScrollerHideRequestCountForTesting: Int { - logScrollView.overlayScrollerHideRequestCountForTesting + chatLogTarget.overlayScrollerHideRequestCountForTesting } var logRenderIsIdleForTesting: Bool { - appliedLogRenderGeneration == logRenderGeneration + chatLogTarget.logRenderIsIdleForTesting } var logFrameForTesting: NSRect { - logScrollView.frame + chatLogTarget.frame } var viewFrameForTesting: NSRect { @@ -676,7 +532,7 @@ final class ReviewMonitorTransportViewController: NSViewController { } var displayedViewFrameForTesting: NSRect { - logScrollView.frame + chatLogTarget.frame } var activeDisplayedViewConstraintCountForTesting: Int { @@ -715,159 +571,146 @@ final class ReviewMonitorTransportViewController: NSViewController { } func scrollLogToTopForTesting() { - logScrollView.scrollToTopForTesting() + chatLogTarget.scrollToTopForTesting() } func scrollLogToOffsetForTesting(_ y: CGFloat) { - logScrollView.scrollToOffsetForTesting(y) + chatLogTarget.scrollToOffsetForTesting(y) } var logVerticalScrollOffsetForTesting: CGFloat { - logScrollView.verticalScrollOffsetForTesting + chatLogTarget.verticalScrollOffsetForTesting } var logViewportHeightForTesting: CGFloat { - logScrollView.viewportHeightForTesting + chatLogTarget.viewportHeightForTesting } var logMinimumVerticalScrollOffsetForTesting: CGFloat { - logScrollView.minimumVerticalScrollOffsetForTesting + chatLogTarget.minimumVerticalScrollOffsetForTesting } var logMaximumVerticalScrollOffsetForTesting: CGFloat { - logScrollView.maximumVerticalScrollOffsetForTesting + chatLogTarget.maximumVerticalScrollOffsetForTesting } var logTextContentFrameForTesting: NSRect { - logScrollView.textContentFrameForTesting + chatLogTarget.textContentFrameForTesting } var logDocumentViewFrameForTesting: NSRect { - logScrollView.documentViewFrameForTesting + chatLogTarget.documentViewFrameForTesting } var logContentInsetsForTesting: NSEdgeInsets { - logScrollView.contentInsetsForTesting + chatLogTarget.contentInsetsForTesting } var logAutomaticallyAdjustsContentInsetsForTesting: Bool { - logScrollView.automaticallyAdjustsContentInsetsForTesting + chatLogTarget.automaticallyAdjustsContentInsetsForTesting } var logTextContainerSizeForTesting: NSSize { - logScrollView.textContainerSizeForTesting + chatLogTarget.textContainerSizeForTesting } var logTextContainerInsetForTesting: NSSize { - logScrollView.textContainerInsetForTesting + chatLogTarget.textContainerInsetForTesting } var logVisibleFragmentViewCountForTesting: Int { - logScrollView.visibleFragmentViewCountForTesting + chatLogTarget.visibleFragmentViewCountForTesting } var logVisibleFragmentViewCountWithoutForcingLayoutForTesting: Int { - logScrollView.visibleFragmentViewCountWithoutForcingLayoutForTesting + chatLogTarget.visibleFragmentViewCountWithoutForcingLayoutForTesting } var logVisibleFragmentBoundsForTesting: NSRect { - logScrollView.visibleFragmentBoundsForTesting + chatLogTarget.visibleFragmentBoundsForTesting } var logVisibleFragmentBoundsWithoutForcingLayoutForTesting: NSRect { - logScrollView.visibleFragmentBoundsWithoutForcingLayoutForTesting + chatLogTarget.visibleFragmentBoundsWithoutForcingLayoutForTesting } var logStaleFragmentViewCountForTesting: Int { - logScrollView.staleFragmentViewCountForTesting + chatLogTarget.staleFragmentViewCountForTesting } var logProgrammaticScrollCountForTesting: Int { - logScrollView.programmaticScrollCountForTesting + chatLogTarget.programmaticScrollCountForTesting } var logAccessibilityValueForTesting: String? { - logScrollView.accessibilityValueForTesting + chatLogTarget.accessibilityValueForTesting } var logSelectedTextForTesting: String? { - logScrollView.selectedTextForTesting + chatLogTarget.selectedTextForTesting } var logSelectedRangeForTesting: NSRange { - logScrollView.selectedRangeForTesting + chatLogTarget.selectedRangeForTesting } var logFindStringForTesting: String { - logScrollView.findStringForTesting + chatLogTarget.findStringForTesting } func selectAllLogForTesting() { - logScrollView.selectAllForTesting() + chatLogTarget.selectAllForTesting() } func setSelectedLogRangeForTesting(_ range: NSRange) { - logScrollView.setSelectedLogRangeForTesting(range) + chatLogTarget.setSelectedLogRangeForTesting(range) } var logDocumentViewExportsUserInterfaceValidationForTesting: Bool { - logScrollView.documentViewExportsUserInterfaceValidationForTesting + chatLogTarget.documentViewExportsUserInterfaceValidationForTesting } func validateLogDocumentUserInterfaceItemForTesting(_ item: NSValidatedUserInterfaceItem) -> Bool { - logScrollView.validateDocumentUserInterfaceItemForTesting(item) + chatLogTarget.validateDocumentUserInterfaceItemForTesting(item) } func clearLogFinderSelectedRangesForTesting() { - logScrollView.clearFinderSelectedRangesForTesting() + chatLogTarget.clearFinderSelectedRangesForTesting() } func setLogFinderSelectedRangeForTesting(_ range: NSRange) { - logScrollView.setFinderSelectedRangeForTesting(range) + chatLogTarget.setFinderSelectedRangeForTesting(range) } func simulateLogFinderEmptySelectedRangesForTesting() { - logScrollView.simulateFinderEmptySelectedRangesForTesting() + chatLogTarget.simulateFinderEmptySelectedRangesForTesting() } func performLogKeyboardCommandForTesting(_ selector: Selector) { - logScrollView.performKeyboardCommandForTesting(selector) + chatLogTarget.performKeyboardCommandForTesting(selector) } @discardableResult func renderLogForTesting(text: String, allowIncrementalUpdate: Bool) -> Bool { - logScrollView.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate) + chatLogTarget.renderForTesting(text: text, allowIncrementalUpdate: allowIncrementalUpdate) } @discardableResult func renderLogDocumentForTesting( _ sourceDocument: ReviewMonitorLog.Document, target: DisplayedSelectionForTesting? = nil, - restoring restorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget? = nil, allowIncrementalUpdate: Bool ) -> Bool { - let resolvedTarget: LogRenderTarget + let chatID: CodexThreadID switch target ?? displayedSelectionForTesting { case .chat(let id): - resolvedTarget = .chat(CodexThreadID(rawValue: id)) + chatID = CodexThreadID(rawValue: id) case .workspaceGroup, nil: return false } - let resolvedRestorationTarget = - restorationTarget - ?? { - guard hasAppliedBoundLog == false else { - return logScrollView.currentScrollRestorationTarget - } - switch resolvedTarget { - case .chat(let id): - return logScrollTargetsByChatID[id] ?? .bottom - } - }() - return renderBoundLog( - sourceDocument: sourceDocument, - target: resolvedTarget, - restorationTarget: resolvedRestorationTarget, + return chatLogTarget.renderLogDocumentForTesting( + sourceDocument, + chatID: chatID, allowIncrementalUpdate: allowIncrementalUpdate ) } @@ -876,19 +719,11 @@ final class ReviewMonitorTransportViewController: NSViewController { switch target { case .chat(let id): let chatID = CodexThreadID(rawValue: id) - if boundChatID != chatID { - let isSwitchingRenderedChat = boundChatID != nil - cacheBoundLogScrollTarget() - if isSwitchingRenderedChat { - logScrollView.resetFindStateForContentReuse() - } - selectedChatLogTask?.cancel() - selectedChatLogTask = nil - resetLogRenderer() - boundChatID = chatID - } + chatLogTarget.bindLogRenderTargetForTesting(chatID) + boundChatID = chatID + boundModelContext = nil hidePlaceholder() - logScrollView.isHidden = false + chatLogTarget.view.isHidden = false displayedSelection = .chat(chatID) case .workspaceGroup: break @@ -896,37 +731,37 @@ final class ReviewMonitorTransportViewController: NSViewController { } func copyLogSelectionForTesting() { - logScrollView.copySelectionForTesting() + chatLogTarget.copySelectionForTesting() } func beginLogLiveResizeForTesting() { - logScrollView.beginLiveResizeForTesting() + chatLogTarget.beginLiveResizeForTesting() } func endLogLiveResizeForTesting() { - logScrollView.endLiveResizeForTesting() + chatLogTarget.endLiveResizeForTesting() } func scrollLogToBottomForTesting() { - logScrollView.scrollToBottomForTesting() + chatLogTarget.scrollToBottomForTesting() } var isLogPinnedToBottomForTesting: Bool { - logScrollView.isPinnedToBottomForTesting + chatLogTarget.isPinnedToBottomForTesting } func setLogScrollerStyleForTesting(_ style: NSScroller.Style) { - logScrollView.setScrollerStyleForTesting(style) + chatLogTarget.setScrollerStyleForTesting(style) } func setLogOverlayScrollersShownForTesting(_ isShown: Bool?) { - logScrollView.setOverlayScrollersShownForTesting(isShown) + chatLogTarget.setOverlayScrollersShownForTesting(isShown) } func setLogOverlayScrollerBridgeModeForTesting( - _ mode: ReviewMonitorLogScrollView.OverlayScrollerBridgeModeForTesting + _ mode: ReviewMonitorCodexChatLogTarget.OverlayScrollerBridgeModeForTesting ) { - logScrollView.setOverlayScrollerBridgeModeForTesting(mode) + chatLogTarget.setOverlayScrollerBridgeModeForTesting(mode) } } #endif diff --git a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift b/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift deleted file mode 100644 index 50835ee7..00000000 --- a/Sources/ReviewUI/ReviewMonitorSelectedCodexChat.swift +++ /dev/null @@ -1,203 +0,0 @@ -import CodexKit -import Foundation -import Observation -import ObservationBridge - -@MainActor -@Observable -final class ReviewMonitorSelectedCodexChat { - private static let incrementalLogUpdateCoalescingDelay: Duration = .milliseconds(10) - - private(set) var chatID: CodexThreadID? - private(set) var chat: CodexChat? - var phase: CodexDataPhase { - chat?.phase ?? .idle - } - var lastErrorDescription: String? { - chat?.lastErrorDescription - } - - @ObservationIgnored - private let modelSource: ReviewMonitorCodexModelSource? - @ObservationIgnored - private weak var boundModelContext: CodexModelContext? - @ObservationIgnored - private var boundChatID: CodexThreadID? - @ObservationIgnored - private var targetChatID: CodexThreadID? - @ObservationIgnored - private var observation: CodexChatObservation? - @ObservationIgnored - private var observationTask: Task? - @ObservationIgnored - private var pendingLogSourceChange: ReviewMonitorLogSourceChange? - @ObservationIgnored - private var pendingLogSourceChangeTask: Task? - @ObservationIgnored - private var logProjection = ReviewMonitorSelectedCodexChatLogProjection() - @ObservationIgnored - private var modelSourceObservation: PortableObservationTracking.Token? - @ObservationIgnored - private var currentLogSourceDocument: ReviewMonitorLog.Document? - @ObservationIgnored - private var logSourceChangeContinuations: [UUID: AsyncStream.Continuation] = [:] - - init(modelSource: ReviewMonitorCodexModelSource?) { - self.modelSource = modelSource - bindModelSource() - } - - isolated deinit { - modelSourceObservation?.cancel() - pendingLogSourceChangeTask?.cancel() - cancelObservation() - } - - func bind(toChatID chatID: CodexThreadID?) { - boundChatID = chatID - refreshBinding() - } - - func unbind() { - boundChatID = nil - refreshBinding() - } - - func logSourceChangeStream() -> AsyncStream { - let id = UUID() - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - logSourceChangeContinuations[id] = pair.continuation - if let currentLogSourceDocument { - pair.continuation.yield(.replaceAll(currentLogSourceDocument)) - } - pair.continuation.onTermination = { [weak self] _ in - Task { @MainActor [weak self] in - self?.logSourceChangeContinuations.removeValue(forKey: id) - } - } - return pair.stream - } - - private func bindModelSource() { - guard let modelSource else { - return - } - modelSourceObservation = withPortableContinuousObservation { [weak self, modelSource] _ in - _ = modelSource.modelContext - self?.refreshBinding() - } - } - - private func refreshBinding() { - let nextChatID = boundChatID - let nextModelContext = modelSource?.modelContext - guard nextChatID != targetChatID || nextModelContext !== boundModelContext else { - return - } - cancelObservation() - targetChatID = nextChatID - chatID = nextChatID - chat = nil - cancelPendingLogSourceChange() - publishLogSourceChangeNow(.clear) - logProjection.reset() - boundModelContext = nextModelContext - - guard let nextChatID else { - return - } - - guard let modelContext = nextModelContext else { - return - } - - let nextChat = modelContext.model(for: nextChatID) - chat = nextChat - - observationTask = Task { @MainActor [weak self, nextChat, nextChatID, modelContext] in - do { - let observation = try await Self.observe(chat: nextChat, modelContext: modelContext) - guard Task.isCancelled == false, - let self, - self.targetChatID == nextChatID, - self.chat === nextChat - else { - observation.cancel() - return - } - self.observation = observation - for await change in observation.changes { - guard Task.isCancelled == false, - self.targetChatID == nextChatID, - self.chat === nextChat - else { - break - } - self.publishLogSourceChange( - self.logProjection.apply( - change, - chatCreatedAt: nextChat.createdAt, - chatUpdatedAt: nextChat.updatedAt - )) - } - } catch is CancellationError { - } catch { - } - } - } - - private func cancelObservation() { - observationTask?.cancel() - observationTask = nil - observation?.cancel() - observation = nil - } - - private func publishLogSourceChange(_ change: ReviewMonitorLogSourceChange?) { - guard let change else { - return - } - guard change.allowsIncrementalRender else { - cancelPendingLogSourceChange() - publishLogSourceChangeNow(change) - return - } - pendingLogSourceChange = change - 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 change = self.pendingLogSourceChange - self.pendingLogSourceChange = nil - self.pendingLogSourceChangeTask = nil - self.publishLogSourceChangeNow(change) - } - } - - private func cancelPendingLogSourceChange() { - pendingLogSourceChangeTask?.cancel() - pendingLogSourceChangeTask = nil - pendingLogSourceChange = nil - } - - private func publishLogSourceChangeNow(_ change: ReviewMonitorLogSourceChange?) { - guard let change else { - return - } - currentLogSourceDocument = change.sourceDocument - for continuation in logSourceChangeContinuations.values { - continuation.yield(change) - } - } - - private static func observe( - chat: CodexChat, - modelContext: CodexModelContext - ) async throws -> CodexChatObservation { - try await modelContext.observe(chat) - } -} diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 21f86cb0..6b3e4554 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -319,13 +319,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarModelContext = modelContext codexSidebarResultsController = resultsController codexSidebarTransactionTask = Task { @MainActor [weak self, resultsController] in - for await transaction in resultsController.transactions { + for await _ in resultsController.transactions { guard let self, self.codexSidebarResultsController === resultsController else { return } - self.applyCodexSidebarTransaction(transaction, controller: resultsController) + self.applyCodexSidebarTransaction(controller: resultsController) } } codexSidebarFetchTask = Task { @MainActor [weak self, resultsController] in @@ -344,14 +344,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarTransaction( - _ transaction: CodexFetchedResultsTransaction, controller: CodexFetchedResultsController ) { - print( - "[ReviewMonitorSidebarDebug] transaction reason=\(transaction.reason) " - + "sections=\(transaction.sectionChanges) items=\(transaction.itemChanges) " - + "snapshot=\(debugDescribeSections(controller.sections))" - ) applyCodexSidebarSourceSections(controller.sections) } @@ -371,13 +365,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func applyCodexSidebarSourceSections(_ sections: [CodexFetchSection]) { - print("[ReviewMonitorSidebarDebug] source sections \(debugDescribeSections(sections))") codexSidebarPresentationOrder.prune(to: sections) applyCodexSidebarVisibleSections(from: sections) } private func applyFilteredCodexSidebarSections() { - print("[ReviewMonitorSidebarDebug] filter changed source=\(debugDescribeSections(codexSidebarSourceSections))") applyCodexSidebarVisibleSections(from: codexSidebarSourceSections) } @@ -385,12 +377,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD from sourceSections: [CodexFetchSection] ) { let sections = codexSidebarVisibleSections(from: sourceSections) - print("[ReviewMonitorSidebarDebug] visible sections \(debugDescribeSections(sections))") let applyResult = codexSidebarOutlineTree.apply(sections: sections) - print( - "[ReviewMonitorSidebarDebug] outline apply topologyChanged=\(applyResult.topologyChanged) " - + "changes=\(applyResult.topologyChanges.map(debugDescribeTopologyChange))" - ) applySidebarKind(sidebarKind) if applyResult.topologyChanged { applyCodexSidebarOutlineTopologyChanges(applyResult.topologyChanges) @@ -410,7 +397,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD isReconcilingSelection = true var appliedIncrementally = true for change in changes { - print("[ReviewMonitorSidebarDebug] apply topology change \(debugDescribeTopologyChange(change))") guard applyCodexSidebarOutlineTopologyChange(change) else { appliedIncrementally = false break @@ -454,10 +440,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) -> Bool { let oldChildRowIDs = change.oldChildRowIDs let newChildRowIDs = change.newChildRowIDs - print( - "[ReviewMonitorSidebarDebug] child delta parent=\(change.parentRowID?.rawValue ?? "root") " - + "old=\(oldChildRowIDs.map(\.rawValue)) new=\(newChildRowIDs.map(\.rawValue))" - ) guard oldChildRowIDs != newChildRowIDs else { return true } @@ -482,7 +464,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD newChildRowIDSet.contains(rowID) ? nil : offset } if removedIndexes.isEmpty == false { - print("[ReviewMonitorSidebarDebug] remove indexes=\(Array(removedIndexes)) parent=\(change.parentRowID?.rawValue ?? "root")") outlineView.removeItems( at: IndexSet(removedIndexes), inParent: parentItem, @@ -494,7 +475,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD oldChildRowIDSet.contains(rowID) ? nil : offset } if insertedIndexes.isEmpty == false { - print("[ReviewMonitorSidebarDebug] insert indexes=\(Array(insertedIndexes)) parent=\(change.parentRowID?.rawValue ?? "root")") outlineView.insertItems( at: IndexSet(insertedIndexes), inParent: parentItem, @@ -518,10 +498,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard currentIndex != targetIndex else { continue } - print( - "[ReviewMonitorSidebarDebug] move row=\(targetRowID.rawValue) " - + "from=\(currentIndex) to=\(targetIndex) parent=\((parentItem as? ReviewMonitorCodexSidebarOutlineNode)?.rowID.rawValue ?? "root")" - ) outlineView.moveItem( at: currentIndex, inParent: parentItem, @@ -535,7 +511,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private func reloadCodexSidebarOutline() { - print("[ReviewMonitorSidebarDebug] reloadData roots=\(codexSidebarOutlineTree.roots.map { $0.rowID.rawValue })") #if DEBUG fullReloadCountForTesting += 1 #endif @@ -1263,11 +1238,6 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return false } - print( - "[ReviewMonitorSidebarDebug] configure chat row id=\(chat.id.rawValue) " - + "title=\(chat.title) active=\(chat.status?.isActive == true) " - + "recency=\(String(describing: chat.recencyAt))" - ) cellView.configure(with: chat) return true case .section: @@ -1283,10 +1253,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let workspaceGroup = displayedCodexSidebarSection(id: id)?.workspaceGroup ?? codexWorkspaceGroupSection(id: id)?.workspaceGroup { - print("[ReviewMonitorSidebarDebug] configure workspace group id=\(id.rawValue) title=\(workspaceGroup.name)") cellView.configure(workspaceGroup: workspaceGroup) } else { - print("[ReviewMonitorSidebarDebug] configure fallback workspace group id=\(id.rawValue)") cellView.configureFallbackWorkspaceGroup(title: codexSidebarTitle(for: node) ?? id.rawValue) } return true @@ -1433,27 +1401,9 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let node = codexSidebarNode(from: item) else { return nil } - print("[ReviewMonitorSidebarDebug] viewFor row=\(node.rowID.rawValue)") return makeCodexSidebarCellView(for: node) } - private func debugDescribeSections(_ sections: [CodexFetchSection]) -> String { - sections.map { section in - let chats = section.items.map(\.id.rawValue).joined(separator: ",") - return "\(section.rowID.rawValue)[\(chats)]" - } - .joined(separator: " | ") - } - - private func debugDescribeTopologyChange( - _ change: ReviewMonitorCodexSidebarOutlineTopologyChange - ) -> String { - let parent = change.parentRowID?.rawValue ?? "root" - let old = change.oldChildRowIDs.map(\.rawValue).joined(separator: ",") - let new = change.newChildRowIDs.map(\.rawValue).joined(separator: ",") - return "\(parent):[\(old)]->[\(new)]" - } - } #if DEBUG diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 76eff534..adf24831 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -2,6 +2,7 @@ import Foundation import Testing import CodexKit @_spi(Testing) @testable import CodexReviewKit +@testable import ReviewChatLogUI @testable import ReviewUI @testable import ReviewUIPreviewSupport @@ -587,7 +588,7 @@ private enum ReviewChatLogFixtureStore { } static func logText(for snapshot: CodexChatSnapshot, chatUpdatedAt: Date?) -> String { - var projection = ReviewMonitorSelectedCodexChatLogProjection() + var projection = ReviewMonitorCodexChatLogSourceProjection() var document: ReviewMonitorLog.Document? for change in CodexChatChange.previewChangesForTesting(from: nil, to: snapshot) { document = projection.apply( diff --git a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift similarity index 91% rename from Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift rename to Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 7731c00a..495118ea 100644 --- a/Tests/ReviewUITests/ReviewMonitorSelectedCodexChatTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -2,12 +2,13 @@ import CodexKit import CodexAppServerKitTesting import Testing @_spi(Testing) @testable import CodexReviewKit +@testable import ReviewChatLogUI @testable import ReviewUI -@Suite("ReviewMonitor selected Codex chat", .serialized) +@Suite("ReviewMonitor selected Codex chat detail", .serialized) @MainActor -struct ReviewMonitorSelectedCodexChatTests { - @Test func selectedReviewChatObservesChatID() async throws { +struct ReviewMonitorCodexChatDetailTests { + @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")) @@ -45,15 +46,16 @@ struct ReviewMonitorSelectedCodexChatTests { uiState.selection = .chat(CodexThreadID(rawValue: "review-thread")) - try await waitForCondition { - transport.selectedCodexChatIDForTesting == "review-thread" - && transport.selectedCodexChatPhaseForTesting == .loading - && transport.selectedCodexChatItemTextsForTesting == ["Review snapshot"] + _ = 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 clearingSelectionDetachesSelectedCodexChat() async throws { + @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")) @@ -68,15 +70,17 @@ struct ReviewMonitorSelectedCodexChatTests { transport.loadViewIfNeeded() selectChat(id: "review-thread", in: uiState) - try await waitForCondition { - transport.selectedCodexChatIDForTesting == "review-thread" - && transport.selectedCodexChatPhaseForTesting == .loaded + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat("review-thread") + ) { snapshot in + snapshot.log == "" } uiState.selection = nil try await waitForCondition { - transport.selectedCodexChatIDForTesting == nil - && transport.selectedCodexChatPhaseForTesting == .idle + transport.renderedStateForTesting.selection == nil + && transport.renderedStateForTesting.snapshot.isShowingEmptyState } } @@ -117,14 +121,18 @@ struct ReviewMonitorSelectedCodexChatTests { transport.loadViewIfNeeded() selectChat(id: "review-thread", in: uiState) - #expect(transport.selectedCodexChatIDForTesting == nil) + try await waitForCondition { + transport.renderedStateForTesting.selection == .chat("review-thread") + } + #expect(transport.displayedLogForTesting == "") modelSource.install(container: CodexModelContainer(appServer: runtime.server)) - try await waitForCondition { - transport.selectedCodexChatIDForTesting == "review-thread" - && transport.selectedCodexChatPhaseForTesting == .loading - && transport.selectedCodexChatItemTextsForTesting == ["Late source"] + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat("review-thread") + ) { snapshot in + snapshot.log == "Late source" } } @@ -198,7 +206,7 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(updatedSnapshot.log.contains("Log fallback") == false) } - @Test func selectedCodexChatTextAppendUsesAppendPath() async throws { + @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")) @@ -327,8 +335,8 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(document?.text == "Explain the current diff.") } - @Test func selectedCodexChatStatusOnlyChangesKeepIncrementalLogUpdates() { - var projection = ReviewMonitorSelectedCodexChatLogProjection() + @Test func codexChatStatusOnlyChangesKeepIncrementalLogUpdates() { + var projection = ReviewMonitorCodexChatLogSourceProjection() let turnID = CodexTurnID(rawValue: "turn-review") let snapshot = CodexChatSnapshot( chatID: CodexThreadID(rawValue: "review-thread"), @@ -360,7 +368,7 @@ struct ReviewMonitorSelectedCodexChatTests { #expect(statusChange?.sourceDocument?.text == "Running review") } - @Test func selectedCodexChatRendersThreadAndLiveUpdates() async throws { + @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")) @@ -405,7 +413,6 @@ struct ReviewMonitorSelectedCodexChatTests { snapshot.log.contains("Generic chat snapshot") } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) - #expect(transport.selectedCodexChatIDForTesting == "chat-thread") try await runtime.transport.emitServerNotification( method: "item/updated", diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 9ee2fbb8..be0cdac0 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -7,6 +7,7 @@ import CodexReviewKit import SwiftUI import Testing @_spi(Testing) @testable import CodexReviewKit +@testable import ReviewChatLogUI @testable import ReviewUI @testable import ReviewUIPreviewSupport import CodexReviewTesting From eebf86d12471d1ede4dc57f0960c27e737617119 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:45:38 +0900 Subject: [PATCH 333/359] fix(review-ui): stabilize review monitor chat rendering --- Package.resolved | 11 +- .../AppServerCodexReviewBackend.swift | 64 ++- .../LiveCodexReviewStoreBackend.swift | 4 +- .../CodexReviewMCPHTTPServer.swift | 17 +- .../CodexReviewMCPServer.swift | 18 +- .../ReviewMCPLogProjection.swift | 9 - Sources/CodexReviewTesting/TestSupport.swift | 36 +- ...wMonitorCommandOutputDisplayDocument.swift | 23 +- .../Detail/ReviewMonitorLogProjection.swift | 4 +- .../Detail/ReviewMonitorLogScrollView.swift | 2 +- .../ReviewMonitorCodexChatLogProjection.swift | 277 +++++++++---- ...wMonitorCodexChatLogSourceProjection.swift | 90 ++--- .../ReviewMonitorCodexChatLogTarget.swift | 38 +- .../CodexChats/ReviewMonitorChatRowView.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 193 ++++++--- ...ReviewMonitorPreviewAppServerRuntime.swift | 173 ++++---- .../ReviewMonitorPreviewContent.swift | 56 ++- .../ReviewMonitorChatLogTesting.swift | 169 +++----- .../ReviewMonitorCodexChatDetailTests.swift | 380 +++++++++++++++--- ...eviewMonitorCodexSidebarResultsTests.swift | 154 ++++++- Tests/ReviewUITests/ReviewUIShellTests.swift | 37 +- Tests/ReviewUITests/ReviewUITests.swift | 4 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- 23 files changed, 1223 insertions(+), 549 deletions(-) diff --git a/Package.resolved b/Package.resolved index a2370001..63f1bac0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "def0fe429586f0032af3e78b1884133aa9747c47dc4c22080e5d9232c010398d", + "originHash" : "90f38c0ebccdddb6f0afb83c3cd39a6c863d7fb9e9f06e78cbab558a95092898", "pins" : [ { "identity" : "eventsource", @@ -19,6 +19,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/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 1ca7f8f0..ff3ae2c2 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -17,7 +17,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private static let reviewPermissionProfileID = ":danger-full-access" private let appServer: CodexAppServer - private let modelContext: CodexModelContext? + private let dataKitModelContext: CodexModelContext? private var reviewEventSessionsByAttemptID: [String: AppServerReviewEventSession] = [:] private var activeReviewAttemptIDByThreadID: [String: String] = [:] private var activeThreadIDsByAttemptID: [String: Set] = [:] @@ -26,9 +26,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] - package init(appServer: CodexAppServer, modelContext: CodexModelContext? = nil) { + package init(appServer: CodexAppServer, dataKitModelContext: CodexModelContext? = nil) { self.appServer = appServer - self.modelContext = modelContext + self.dataKitModelContext = dataKitModelContext } package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { @@ -92,23 +92,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) - let review: CodexReviewSession - if let modelContext { - review = try await modelContext.startReview( - in: workspace, - input: CodexReviewInput( - target: request.request.target.appServerReviewTarget, - options: reviewThreadOptions(request) - ) - ).session - } else { - review = try await appServer.startReview( - in: workspace, - target: request.request.target.appServerReviewTarget, - options: reviewThreadOptions(request) - ) - } + let review = try await startReviewSession(for: request) let attemptID = makeAppServerReviewAttemptID() let run = CodexReviewBackendModel.Review.Run( attemptID: attemptID, @@ -124,6 +108,46 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { return await session.attempt() } + private func startReviewSession(for request: CodexReviewBackendModel.Review.Start) async throws + -> CodexReviewSession + { + let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) + if let dataKitModelContext { + return try await startDataKitBackedReviewSession( + request, + in: workspace, + modelContext: dataKitModelContext + ) + } + return try await startRawAppServerReviewSession(request, in: workspace) + } + + private func startDataKitBackedReviewSession( + _ request: CodexReviewBackendModel.Review.Start, + in workspace: URL, + modelContext: CodexModelContext + ) async throws -> CodexReviewSession { + try await modelContext.startReview( + in: workspace, + input: CodexReviewInput( + target: request.request.target.appServerReviewTarget, + options: reviewThreadOptions(request) + ) + ) + .session + } + + private func startRawAppServerReviewSession( + _ request: CodexReviewBackendModel.Review.Start, + in workspace: URL + ) async throws -> CodexReviewSession { + try await appServer.startReview( + in: workspace, + target: request.request.target.appServerReviewTarget, + options: reviewThreadOptions(request) + ) + } + private func reviewThreadOptions( _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 38e5d701..a8d5ece8 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -190,7 +190,7 @@ public extension CodexReviewStore { modelContainer: modelContainer, backend: AppServerCodexReviewBackend( appServer: appServer, - modelContext: modelContainer.mainContext + dataKitModelContext: modelContainer.mainContext ) ) } @@ -427,7 +427,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { modelContainer: modelContainer, backend: AppServerCodexReviewBackend( appServer: appServer, - modelContext: modelContainer.mainContext + dataKitModelContext: modelContainer.mainContext ) ) } 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/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index fcd4310b..1d10bad8 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -177,18 +177,30 @@ package extension CodexReviewMCPServer { let chat = modelContext.model(for: CodexThreadID(rawValue: threadID)) do { - try await chat.refresh(includeTurns: true) + try await modelContext.refresh(chat, includeTurns: true) } catch { return nil } - guard let snapshot = chat.turnSnapshot(for: CodexTurnID(rawValue: turnID)) else { + let codexTurnID = CodexTurnID(rawValue: turnID) + guard chat.turn(id: codexTurnID) != nil else { return nil } - return ReviewMCPLogProjection(result: result, turnSnapshot: snapshot) + return ReviewMCPLogProjection( + result: result, + turnID: codexTurnID, + threadItems: chat.items(in: codexTurnID).map(\.threadItemForReviewMCP) + ) } } } +@MainActor +private extension CodexChat.Item { + var threadItemForReviewMCP: CodexThreadItem { + CodexThreadItem(id: id, kind: kind, content: content, rawPayload: rawPayload) + } +} + private extension CodexReviewAPI.Run.Selector { func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Run.Selector { guard runID == nil, statuses == nil else { diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index bf7ad8c8..5afbae1f 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -50,15 +50,6 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { self.finalResult = nil } - @MainActor - init(result: CodexReviewAPI.Read.Result, turnSnapshot: CodexChatTurnSnapshot) { - self.init( - result: result, - turnID: turnSnapshot.turnID, - threadItems: turnSnapshot.threadItems - ) - } - init( result: CodexReviewAPI.Read.Result, turnID: CodexTurnID, diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index 685738e1..a1e913d6 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -2,6 +2,7 @@ import Foundation import CodexAppServerKit import CodexAppServerKitTesting import CodexReviewKit +import Synchronization package typealias AsyncGate = CodexAppServerTestGate package typealias OneShotGate = AsyncGate @@ -23,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) } @@ -49,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) + } } } diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 747e46d1..12ff4054 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -582,11 +582,10 @@ enum ReviewMonitorCommandOutputDisplayDocument { 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 @@ -599,6 +598,12 @@ 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: ReviewMonitorLog.Metadata?, isActive: Bool, @@ -658,9 +663,7 @@ enum ReviewMonitorCommandOutputDisplayDocument { } 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" } @@ -670,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 } diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift index 980e927e..31846963 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogProjection.swift @@ -541,7 +541,7 @@ enum ReviewMonitorLogStyler { switch normalized { case "completed", "complete", "succeeded", "success": return true - case "failed", "failure", "errored", "error", "cancelled", "canceled": + case "failed", "failure", "errored", "error", "cancelled", "canceled", "interrupted": return false default: return metadata?.completedAt != nil @@ -562,7 +562,7 @@ enum ReviewMonitorLogStyler { return .running case "completed", "complete", "succeeded", "success", "passed", "applied": return .success - case "failed", "failure", "errored", "error", "cancelled", "canceled": + case "failed", "failure", "errored", "error", "cancelled", "canceled", "interrupted": return .failure case "warning", "warn", "updated": return .warning diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift index f49c8d56..9d4014a0 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift @@ -1052,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 diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index 21c5bc5e..5a2dbb15 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -1,7 +1,8 @@ import CodexKit import Foundation -struct ReviewMonitorCodexChatLogProjection: Sendable { +@MainActor +struct ReviewMonitorCodexChatLogProjection { private var documentProjection = ReviewMonitorLogDocumentProjection() var currentDocument: ReviewMonitorLog.Document { @@ -13,21 +14,87 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { } mutating func render( - from snapshot: CodexChatProjectedTurnSnapshot, + from chat: CodexChat, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewMonitorLog.Document? { - guard snapshot.items.isEmpty == false else { + 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 = snapshot.items.contains { item in + let suppressUserMessages = items.contains { item in item.kind == .enteredReviewMode || item.kind == .exitedReviewMode } - let blocks = snapshot.items.flatMap { + let blocks = items.flatMap { projectedBlocks( from: $0, - turnSnapshot: snapshot.turn, + turnStatus: turnStatus, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt, suppressUserMessages: suppressUserMessages @@ -40,9 +107,9 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return documentProjection.render(projectedBlocks: blocks) } - private func projectedBlocks( - from item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot, + private func projectedBlocks( + from item: Item, + turnStatus: CodexTurnStatus?, chatCreatedAt: Date?, chatUpdatedAt: Date?, suppressUserMessages: Bool @@ -79,9 +146,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return commandBlocks( item: item, command: command, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + turnStatus: turnStatus ) case .fileChange(let fileChange): let title = fileChange.path ?? "File change" @@ -97,7 +162,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { status: fileChangeStatus( item: item, fileChange: fileChange, - turnSnapshot: turnSnapshot + turnStatus: turnStatus ), path: fileChange.path ) @@ -118,7 +183,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { metadata: .init( sourceType: "contextCompaction", title: title, - status: turnSnapshot.status?.rawValue + status: turnStatus?.rawValue ) ) ] @@ -132,7 +197,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { for: item, sourceType: item.kind.rawValue, title: message, - status: itemStatus(for: item, turnSnapshot: turnSnapshot).rawValue + status: itemStatus(for: item, turnStatus: turnStatus).rawValue ) ) ] @@ -146,7 +211,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { for: item, sourceType: "log", title: message, - status: itemStatus(for: item, turnSnapshot: turnSnapshot).rawValue + status: itemStatus(for: item, turnStatus: turnStatus).rawValue ) ) ] @@ -176,20 +241,16 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return "\(title)\n\(detail)" } - private func commandBlocks( - item: CodexChatItemSnapshot, + private func commandBlocks( + item: Item, command: CodexCommand, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? + turnStatus: CodexTurnStatus? ) -> [ReviewMonitorLogProjectedBlock] { let groupID = sourceBlockID(for: item) let metadata = commandMetadata( item: item, command: command, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + turnStatus: turnStatus ) let commandLine = command.command.nilIfEmpty let output = command.output ?? "" @@ -203,9 +264,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { metadata: outputOnlyCommandMetadata( item: item, command: command, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + turnStatus: turnStatus ) ) ] @@ -224,7 +283,7 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { ] if output.isEmpty == false { blocks.append( - .init( + ReviewMonitorLogProjectedBlock( id: derivedBlockID(prefix: "commandOutput", item: item), kind: .commandOutput, groupID: groupID, @@ -235,8 +294,8 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return blocks } - private func webSearchBlocks( - item: CodexChatItemSnapshot, + private func webSearchBlocks( + item: Item, toolCall: CodexToolCall ) -> [ReviewMonitorLogProjectedBlock] { let query = toolCall.arguments ?? toolCall.name ?? "Web search" @@ -258,8 +317,8 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { ] } - private func toolCallBlocks( - item: CodexChatItemSnapshot, + private func toolCallBlocks( + item: Item, toolCall: CodexToolCall ) -> [ReviewMonitorLogProjectedBlock] { let label = [toolCall.namespace, toolCall.server, toolCall.name] @@ -289,8 +348,8 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { ] } - private func projectedBlock( - _ item: CodexChatItemSnapshot, + private func projectedBlock( + _ item: Item, kind: ReviewMonitorLog.Kind, groupID: String? = nil, text: String, @@ -305,27 +364,27 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { ) } - private func derivedBlockID( + private func derivedBlockID( prefix: String, - item: CodexChatItemSnapshot + item: Item ) -> ReviewMonitorLog.BlockID { ReviewMonitorLog.BlockID("\(prefix):\(sourceBlockID(for: item))") } - private func sourceBlockID(for item: CodexChatItemSnapshot) -> String { - let rawTurnID = item.turnID?.rawValue ?? "unknown-turn" + private func sourceBlockID(for item: Item) -> String { + let rawTurnID = item.projectionTurnID?.rawValue ?? "unknown-turn" return "\(rawTurnID):\(item.id)" } - private func logKind( - for item: CodexChatItemSnapshot, + private func logKind( + for item: Item, fallback: ReviewMonitorLog.Kind ) -> ReviewMonitorLog.Kind { ReviewMonitorLog.Kind(rawValue: item.kind.rawValue) ?? fallback } - private func metadata( - for item: CodexChatItemSnapshot, + private func metadata( + for item: Item, sourceType: String, title: String? = nil, status: String? = nil, @@ -352,14 +411,12 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { ) } - private func commandMetadata( - item: CodexChatItemSnapshot, + private func commandMetadata( + item: Item, command: CodexCommand, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? + turnStatus: CodexTurnStatus? ) -> ReviewMonitorLog.Metadata { - let status = commandStatus(item: item, command: command, turnSnapshot: turnSnapshot) + let status = commandStatus(item: item, command: command, turnStatus: turnStatus) return .init( sourceType: "commandExecution", status: status, @@ -367,24 +424,24 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { command: command.command, cwd: command.cwd, exitCode: command.exitCode, - startedAt: chatCreatedAt, - completedAt: terminalStatus(itemStatus(for: item, turnSnapshot: turnSnapshot)) ? chatUpdatedAt : nil, + startedAt: command.startedAt, + completedAt: command.completedAt, + durationMs: command.durationMilliseconds, commandStatus: status ) } - private func outputOnlyCommandMetadata( - item: CodexChatItemSnapshot, + private func outputOnlyCommandMetadata( + item: Item, command: CodexCommand, - turnSnapshot: CodexChatTurnStateSnapshot, - chatCreatedAt: Date?, - chatUpdatedAt: Date? + turnStatus: CodexTurnStatus? ) -> ReviewMonitorLog.Metadata { guard - command.cwd != nil || command.exitCode != nil || command.status != nil || chatCreatedAt != nil - || chatUpdatedAt != nil + command.cwd != nil || command.exitCode != nil || command.status != nil + || command.startedAt != nil || command.completedAt != nil + || command.durationMilliseconds != nil else { - let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + let status = itemStatus(for: item, turnStatus: turnStatus) return .init( sourceType: "command", title: "Command output", @@ -394,18 +451,16 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return commandMetadata( item: item, command: command, - turnSnapshot: turnSnapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt + turnStatus: turnStatus ) } - private func commandStatus( - item: CodexChatItemSnapshot, + private func commandStatus( + item: Item, command: CodexCommand, - turnSnapshot: CodexChatTurnStateSnapshot + turnStatus: CodexTurnStatus? ) -> String { - let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + let status = itemStatus(for: item, turnStatus: turnStatus) if terminalStatus(status) { return status.rawValue } @@ -420,23 +475,23 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { return command.status?.rawValue ?? status.rawValue } - private func fileChangeStatus( - item: CodexChatItemSnapshot, + private func fileChangeStatus( + item: Item, fileChange: CodexFileChange, - turnSnapshot: CodexChatTurnStateSnapshot + turnStatus: CodexTurnStatus? ) -> String { - let status = itemStatus(for: item, turnSnapshot: turnSnapshot) + let status = itemStatus(for: item, turnStatus: turnStatus) if terminalStatus(status) { return status.rawValue } return fileChange.status?.rawValue ?? status.rawValue } - private func itemStatus( - for item: CodexChatItemSnapshot, - turnSnapshot: CodexChatTurnStateSnapshot + private func itemStatus( + for item: Item, + turnStatus: CodexTurnStatus? ) -> CodexTurnStatus { - item.itemStatus ?? turnSnapshot.status ?? .running + item.itemStatus ?? turnStatus ?? .running } private func terminalStatus(_ status: CodexTurnStatus) -> Bool { @@ -449,9 +504,81 @@ struct ReviewMonitorCodexChatLogProjection: Sendable { } } -private extension CodexChatItemSnapshot { +@MainActor +private protocol CodexChatLogProjectionItem { + var id: String { get } + var projectionTurnID: CodexTurnID? { get } + var kind: CodexThreadItem.Kind { get } + var content: CodexThreadItem.Content { get } + var itemStatus: CodexTurnStatus? { get } +} + +extension CodexChat.Item: CodexChatLogProjectionItem { + fileprivate var projectionTurnID: CodexTurnID? { + turnID + } + + var itemStatus: CodexTurnStatus? { + content.reviewMonitorLogItemStatus + } +} + +@MainActor +private struct CodexChatModelLogItem: CodexChatLogProjectionItem { + var item: CodexChat.Item + var turnStatus: CodexTurnStatus? + + var id: String { + item.id + } + + var projectionTurnID: CodexTurnID? { + item.turnID + } + + var kind: CodexThreadItem.Kind { + item.kind + } + + var content: CodexThreadItem.Content { + item.content + } + + var itemStatus: CodexTurnStatus? { + item.content.reviewMonitorLogItemStatus ?? turnStatus + } +} + +@MainActor +private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem { + var item: CodexThreadItem + var turnID: CodexTurnID + var turnStatus: CodexTurnStatus? + + var id: String { + item.id + } + + var projectionTurnID: CodexTurnID? { + turnID + } + + var kind: CodexThreadItem.Kind { + item.kind + } + + var content: CodexThreadItem.Content { + item.content + } + var itemStatus: CodexTurnStatus? { - switch content { + item.content.reviewMonitorLogItemStatus ?? turnStatus + } +} + +private extension CodexThreadItem.Content { + var reviewMonitorLogItemStatus: CodexTurnStatus? { + switch self { case .command(let command): return command.status case .fileChange(let fileChange): diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index c1de445e..0f25164d 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -30,80 +30,64 @@ enum ReviewMonitorLogSourceChange: Equatable { @MainActor struct ReviewMonitorCodexChatLogSourceProjection { - private var turnProjection = CodexChatTurnProjection() private var logProjection = ReviewMonitorCodexChatLogProjection() private var hasLogDocument = false mutating func reset() { - turnProjection = CodexChatTurnProjection() logProjection.reset() hasLogDocument = false } - mutating func apply( - _ change: CodexChatChange, + mutating func applyBaseline( + from chat: CodexChat, chatCreatedAt: Date?, chatUpdatedAt: Date? ) -> ReviewMonitorLogSourceChange? { - let update = turnProjection.apply(change) - guard update.affectsSelectedTurn else { - return nil - } - guard let snapshot = update.snapshot else { - return clearIfNeeded() - } + renderChat( + from: chat, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + allowIncrementalUpdate: false + ) ?? .clear + } - switch update.kind { - case .snapshot: - return renderSnapshot( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: false - ) - case .turnUpdated, - .phaseChanged: - return renderSnapshot( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: hasLogDocument - ) - case .itemUpserted(let item): - return renderSnapshot( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: hasLogDocument && item.turnID == snapshot.turn.id - ) - case .itemTextAppended(_, _, _, let item): - return renderSnapshot( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: hasLogDocument && item.turnID == snapshot.turn.id - ) - case .itemRemoved: - return renderSnapshot( - from: snapshot, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: hasLogDocument - ) - case .ignored: - return nil + 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 renderSnapshot( - from snapshot: CodexChatProjectedTurnSnapshot, + private mutating func renderChat( + from chat: CodexChat, chatCreatedAt: Date?, chatUpdatedAt: Date?, allowIncrementalUpdate: Bool ) -> ReviewMonitorLogSourceChange? { guard let document = logProjection.render( - from: snapshot, + from: chat, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt ) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index 40d78404..ad7d76b5 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -1,5 +1,8 @@ import AppKit import CodexKit +import OSLog + +private let logger = Logger(subsystem: "CodexReviewKit", category: "chat-log-target") @MainActor package final class ReviewMonitorCodexChatLogTarget { @@ -7,6 +10,13 @@ package final class ReviewMonitorCodexChatLogTarget { private enum LogRenderTarget: Equatable { case chat(CodexThreadID) + + var debugDescription: String { + switch self { + case .chat(let id): + "chat(\(id.rawValue))" + } + } } package var view: NSView { @@ -57,6 +67,9 @@ package final class ReviewMonitorCodexChatLogTarget { if isSwitchingRenderedChat { logScrollView.resetFindStateForContentReuse() } + logger.debug( + "Binding Codex chat log chatID=\(selectedChatID.rawValue, privacy: .public) switchingChat=\(isSwitchingRenderedChat, privacy: .public)" + ) cancelSelectedChatObservation() cancelPendingLogSourceChange() selectedChatLogTask?.cancel() @@ -106,6 +119,11 @@ package final class ReviewMonitorCodexChatLogTarget { 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 @@ -153,7 +171,16 @@ package final class ReviewMonitorCodexChatLogTarget { return } self.selectedChatObservation = observation - for await change in observation.changes { + 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) @@ -162,7 +189,8 @@ package final class ReviewMonitorCodexChatLogTarget { } self.publishSelectedCodexChatLogChange( self.logProjection.apply( - change, + update, + in: observation.chat, chatCreatedAt: chat.createdAt, chatUpdatedAt: chat.updatedAt ), @@ -243,6 +271,12 @@ package final class ReviewMonitorCodexChatLogTarget { 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 diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index f7060d03..d39ae145 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -7,7 +7,7 @@ struct ReviewMonitorChatRowView: View { var chat: CodexChat var body: some View { - let isRunning = chat.status?.isActive == true + let isRunning = chat.phase == .loading || chat.status?.isActive == true let startedAt = isRunning ? chat.activityDate : nil Label { diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 6b3e4554..fab59cee 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -260,13 +260,15 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } sidebarSelectionObservation = withPortableContinuousObservation { [weak self, uiState] event in - _ = uiState.selection?.id + 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() } } @@ -319,13 +321,13 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarModelContext = modelContext codexSidebarResultsController = resultsController codexSidebarTransactionTask = Task { @MainActor [weak self, resultsController] in - for await _ in resultsController.transactions { + for await transaction in resultsController.transactions { guard let self, self.codexSidebarResultsController === resultsController else { return } - self.applyCodexSidebarTransaction(controller: resultsController) + self.applyCodexSidebarTransaction(transaction, controller: resultsController) } } codexSidebarFetchTask = Task { @MainActor [weak self, resultsController] in @@ -344,11 +346,30 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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 ?? [] } @@ -589,7 +610,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private func reconcileOutlineSelection() { guard let selection = uiState.selection else { if outlineView.selectedRow != -1 { - outlineView.deselectAll(nil) + deselectOutlineSelectionForReconciliation() } return } @@ -599,46 +620,69 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard codexWorkspaceGroupSelection(id: selectedWorkspaceGroupID) != nil, let row = row(forCodexSidebarSelectionID: .workspaceGroup(selectedWorkspaceGroupID)) else { - guard codexSidebarContentIsAuthoritative else { - return + if shouldClearMissingSemanticSelection(selection) { + uiState.selection = nil } - uiState.selection = nil - outlineView.deselectAll(nil) + deselectOutlineSelectionForReconciliation() return } guard outlineView.selectedRow != row else { return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + selectOutlineRowForReconciliation(row) case .chat(let selectedChatID): guard currentChatSelection(id: selectedChatID) != nil else { - guard codexSidebarContentIsAuthoritative else { - return + if shouldClearMissingSemanticSelection(selection) { + uiState.selection = nil } - uiState.selection = nil - outlineView.deselectAll(nil) + deselectOutlineSelectionForReconciliation() return } guard let row = row(forCurrentChatSelectionID: selectedChatID) else { + deselectOutlineSelectionForReconciliation() return } guard outlineView.selectedRow != row else { return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + selectOutlineRowForReconciliation(row) } } - private func updateSelectionFromOutlineView() { + 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 @@ -738,26 +782,24 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD guard let selection else { return true } - guard codexSidebarContentIsAuthoritative else { - return true - } - switch selection { - case .workspaceGroup(let id): - return codexWorkspaceGroupSelection(id: id) != nil - case .chat(let id): - return currentChatSelection(id: id) != nil - } + return shouldClearMissingSemanticSelection(selection) == false } - private var codexSidebarContentIsAuthoritative: Bool { - guard let codexSidebarResultsController else { + private func shouldClearMissingSemanticSelection(_ selection: ReviewMonitorSelection) -> Bool { + guard let codexSidebarModelContext else { return false } - switch codexSidebarResultsController.phase { - case .loaded, .failed: - return true - case .idle, .loading: - 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 } } @@ -1437,6 +1479,16 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD 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 @@ -1513,6 +1565,17 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } 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 } @@ -1541,7 +1604,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = .chat(chat.id) return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + outlineView.selectUserInitiatedRowForTesting(row) } func selectReviewChatForTesting(id chatID: CodexThreadID) { @@ -1553,7 +1616,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = .chat(chatID) return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + outlineView.selectUserInitiatedRowForTesting(row) } func presentContextMenuForTesting( @@ -1582,7 +1645,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd) return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + outlineView.selectUserInitiatedRowForTesting(row) } func clearSelectionForTesting() { @@ -1702,8 +1765,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD uiState.selection = fallbackWorkspaceGroupSelection(cwd: cwd) return } - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - updateSelectionFromOutlineView() + outlineView.selectUserInitiatedRowForTesting(row) + updateSelectionFromOutlineView(acceptingProgrammaticSelection: true) } func focusSidebarForTesting() { @@ -1877,6 +1940,8 @@ 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? @@ -1898,7 +1963,15 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) handlePrimaryInteraction(at: point) { - super.mouseDown(with: event) + performUserSelectionInteraction { + super.mouseDown(with: event) + } + } + } + + override func keyDown(with event: NSEvent) { + performUserSelectionInteraction { + super.keyDown(with: event) } } @@ -1948,6 +2021,37 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { 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 @@ -2034,6 +2138,12 @@ private final class ReviewMonitorSidebarOutlineView: NSOutlineView { acceptsFirstResponder } + func selectUserInitiatedRowForTesting(_ row: Int) { + performUserSelectionInteraction { + selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + } + #endif } @@ -2057,17 +2167,12 @@ private final class ReviewMonitorReviewChatTableRowView: NSTableRowView { private final class ReviewMonitorReviewChatCellView: NSTableCellView { private var hostingView: NSHostingView? private weak var boundChat: CodexChat? - private var chatObservation: PortableObservationTracking.Token? override init(frame frameRect: NSRect) { super.init(frame: frameRect) configureHierarchy() } - isolated deinit { - chatObservation?.cancel() - } - @available(*, unavailable) required init?(coder: NSCoder) { nil @@ -2079,11 +2184,7 @@ private final class ReviewMonitorReviewChatCellView: NSTableCellView { } objectValue = chat boundChat = chat - chatObservation?.cancel() render(chat) - chatObservation = withPortableContinuousObservation { [weak self, chat] _ in - self?.render(chat) - } } private func configureHierarchy() { diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index dcd13164..51572de7 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -46,6 +46,11 @@ private actor ReviewMonitorPreviewCancelledChatIDs { } } +private struct ReviewMonitorPreviewStoredThreadItem: Sendable { + var item: CodexThreadItem + var turnID: CodexTurnID +} + @MainActor struct ReviewMonitorPreviewChatLogFixture { let chatID: CodexThreadID @@ -59,7 +64,7 @@ struct ReviewMonitorPreviewChatLogFixture { let cwd: String let streamID: String let isRunning: Bool - let initialSnapshot: CodexChatSnapshot + let initialThreadSnapshot: CodexThreadSnapshot init( chatID: CodexThreadID, @@ -73,7 +78,7 @@ struct ReviewMonitorPreviewChatLogFixture { cwd: String, streamID: String, isRunning: Bool, - initialSnapshot: CodexChatSnapshot + initialThreadSnapshot: CodexThreadSnapshot ) { self.chatID = chatID self.title = title @@ -86,7 +91,7 @@ struct ReviewMonitorPreviewChatLogFixture { self.cwd = cwd self.streamID = streamID self.isRunning = isRunning - self.initialSnapshot = initialSnapshot + self.initialThreadSnapshot = initialThreadSnapshot } } @@ -126,8 +131,8 @@ final class ReviewMonitorPreviewAppServerRuntime { return (title: fixture.title, subtitle: fixture.cwd) } - func snapshotForTesting(chatID: CodexThreadID) async -> CodexChatSnapshot? { - await threadStore.snapshot(id: chatID)?.chatSnapshot + func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { + await threadStore.snapshot(id: chatID) } func interruptRequestCountForTesting() async -> Int { @@ -197,10 +202,10 @@ final class ReviewMonitorPreviewAppServerRuntime { try await self?.emitTextDelta( delta, itemID: itemID, - turnID: item.turnID ?? CodexTurnID(rawValue: "preview-turn"), + turnID: item.turnID, chatID: chatID, kind: kind, - content: item.content, + content: item.item.content, runtime: runtime ) } catch { @@ -278,15 +283,15 @@ final class ReviewMonitorPreviewAppServerRuntime { ) else { continue } - guard let item = await apply(frame.step, cycle: frame.cycle, for: fixture) else { - continue - } - if emitsNotifications { - enqueueNotification { [weak self] in - await self?.emit(frame.step, item: item, for: fixture) - } + 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 } @@ -343,8 +348,7 @@ final class ReviewMonitorPreviewAppServerRuntime { } await cancelledChatIDs.insert(chatID) guard let cancelledSnapshot = await updateStoredSnapshot(for: fixture, mutation: { snapshot in - snapshot.phase = .loaded - snapshot.turns = snapshot.turns.map { turn in + snapshot.turns = snapshot.turns?.map { turn in var turn = turn if turn.status.isTerminalForPreview == false { turn.status = .cancelled @@ -361,27 +365,26 @@ final class ReviewMonitorPreviewAppServerRuntime { private func emit( _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, - item: CodexChatItemSnapshot, + storedItem: ReviewMonitorPreviewStoredThreadItem, for fixture: ReviewMonitorPreviewChatLogFixture ) async { guard let runtime else { return } - let turnID = item.turnID ?? fixture.initialSnapshot.turns.last?.id ?? CodexTurnID(rawValue: "preview-turn") do { switch step.mode { case .textDelta: try await emitTextDelta( step.deltaText ?? "", - itemID: item.id, - turnID: turnID, + itemID: storedItem.item.id, + turnID: storedItem.turnID, chatID: fixture.chatID, - kind: item.kind, - content: item.content, + kind: storedItem.item.kind, + content: storedItem.item.content, runtime: runtime ) case .update, .complete: - await emitItem(item, for: fixture) + await emitItem(storedItem, for: fixture) } } catch { } @@ -391,7 +394,7 @@ final class ReviewMonitorPreviewAppServerRuntime { _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, cycle: Int, for fixture: ReviewMonitorPreviewChatLogFixture - ) async -> CodexChatItemSnapshot? { + ) async -> ReviewMonitorPreviewStoredThreadItem? { let itemID = ReviewMonitorPreviewContent.previewChatLogItemID( itemName: step.itemName, streamID: fixture.streamID, @@ -421,20 +424,24 @@ final class ReviewMonitorPreviewAppServerRuntime { kind: CodexThreadItem.Kind, content: CodexThreadItem.Content, in fixture: ReviewMonitorPreviewChatLogFixture - ) async -> CodexChatItemSnapshot? { - await updateStoredSnapshot(for: fixture) { snapshot in - let item = CodexChatItemSnapshot( + ) async -> ReviewMonitorPreviewStoredThreadItem? { + let fallbackTurnID = fixture.previewFallbackTurnID + return await updateStoredSnapshot(for: fixture) { snapshot in + let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID) + let item = CodexThreadItem( id: id, - turnID: snapshot.turns.last?.id, kind: kind, content: content ) - if let index = snapshot.items.firstIndex(where: { $0.id == item.id && $0.turnID == item.turnID }) { - snapshot.items[index] = item + 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.items.append(item) + snapshot.turns?[turnIndex].items.append(item) } - return item + return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) } } @@ -444,36 +451,42 @@ final class ReviewMonitorPreviewAppServerRuntime { kind: CodexThreadItem.Kind, content: CodexThreadItem.Content, in chatID: CodexThreadID - ) async -> CodexChatItemSnapshot? { + ) 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.turns.last?.id - if let index = snapshot.items.firstIndex(where: { $0.id == itemID && $0.turnID == turnID }) { - snapshot.items[index].content.appendPreviewText(delta) - return snapshot.items[index] + let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID) + guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else { + return nil } - var item = CodexChatItemSnapshot( + 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, - turnID: turnID, kind: kind, content: content ) item.content.appendPreviewText(delta) - snapshot.items.append(item) - return item + snapshot.turns?[turnIndex].items.append(item) + return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) } } private func updateStoredSnapshot( for fixture: ReviewMonitorPreviewChatLogFixture, - _ mutation: @escaping @Sendable (inout CodexChatSnapshot) -> CodexChatItemSnapshot? - ) async -> CodexChatItemSnapshot? { + _ 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)?.chatSnapshot, + var snapshot = await self.threadStore.snapshot(id: fixture.chatID), let item = mutation(&snapshot) else { return nil } @@ -486,11 +499,11 @@ final class ReviewMonitorPreviewAppServerRuntime { private func updateStoredSnapshot( for fixture: ReviewMonitorPreviewChatLogFixture, - mutation: @escaping @Sendable (inout CodexChatSnapshot) -> Void - ) async -> CodexChatSnapshot? { + 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)?.chatSnapshot else { + var snapshot = await self.threadStore.snapshot(id: fixture.chatID) else { return nil } mutation(&snapshot) @@ -536,7 +549,7 @@ final class ReviewMonitorPreviewAppServerRuntime { } private func emitItem( - _ item: CodexChatItemSnapshot, + _ storedItem: ReviewMonitorPreviewStoredThreadItem, for fixture: ReviewMonitorPreviewChatLogFixture ) async { do { @@ -550,8 +563,12 @@ final class ReviewMonitorPreviewAppServerRuntime { method: "item/updated", params: PreviewThreadItemParams( threadID: fixture.chatID.rawValue, - turnID: item.turnID?.rawValue ?? fixture.initialSnapshot.turns.last?.id.rawValue ?? "preview-turn", - item: makePreviewNotificationItem(id: item.id, kind: item.kind, content: item.content) + turnID: storedItem.turnID.rawValue, + item: makePreviewNotificationItem( + id: storedItem.item.id, + kind: storedItem.item.kind, + content: storedItem.item.content + ) ) ) } catch { @@ -680,7 +697,7 @@ final class ReviewMonitorPreviewAppServerRuntime { } private func emitCancelledState( - _ snapshot: CodexChatSnapshot, + _ snapshot: CodexThreadSnapshot, for fixture: ReviewMonitorPreviewChatLogFixture ) async { do { @@ -689,9 +706,7 @@ final class ReviewMonitorPreviewAppServerRuntime { return } await runtime.transport.waitForNotificationStreamCount(1) - let turnID = snapshot.turns.last?.id - ?? fixture.initialSnapshot.turns.last?.id - ?? CodexTurnID(rawValue: "preview-turn") + let turnID = snapshot.turns?.last?.id ?? fixture.previewFallbackTurnID try await runtime.transport.emitServerNotification( method: "thread/status/changed", params: PreviewThreadStatusParams( @@ -926,11 +941,15 @@ private func previewNotificationPath(_ content: CodexThreadItem.Content) -> Stri private extension ReviewMonitorPreviewChatLogFixture { var threadSnapshot: CodexThreadSnapshot { - threadSnapshot(initialSnapshot) + threadSnapshot(initialThreadSnapshot) + } + + var previewFallbackTurnID: CodexTurnID { + initialThreadSnapshot.turns?.last?.id ?? CodexTurnID(rawValue: "preview-turn") } func threadSnapshot( - _ snapshot: CodexChatSnapshot, + _ snapshot: CodexThreadSnapshot, status: CodexThreadStatus? = nil ) -> CodexThreadSnapshot { CodexThreadSnapshot( @@ -942,48 +961,18 @@ private extension ReviewMonitorPreviewChatLogFixture { updatedAt: updatedAt, recencyAt: recencyAt, status: status ?? self.status, - turns: snapshot.turns.map { turn in - CodexTurnSnapshot( - id: turn.id, - status: turn.status, - errorMessage: turn.errorDescription, - items: snapshot.items - .filter { $0.turnID == turn.id } - .map(\.threadItem) - ) - } + turns: snapshot.turns ) } } private extension CodexThreadSnapshot { - var chatSnapshot: CodexChatSnapshot { - let turnSnapshots = turns ?? [] - let chatTurns = turnSnapshots.map { turn in - CodexChatTurnStateSnapshot( - id: turn.id, - status: turn.status, - errorDescription: turn.errorMessage, - usage: nil - ) - } - let chatItems = turnSnapshots.flatMap { turn in - turn.items.map { item in - CodexChatItemSnapshot( - id: item.id, - turnID: turn.id, - kind: item.kind, - content: item.content, - rawPayload: item.rawPayload - ) - } + mutating func ensurePreviewTurn(fallback turnID: CodexTurnID) -> CodexTurnID { + if let existingTurnID = turns?.last?.id { + return existingTurnID } - return CodexChatSnapshot( - chatID: id, - phase: turnSnapshots.compactMap(\.errorMessage).first.map(CodexDataPhase.failed) ?? .loaded, - turns: chatTurns, - items: chatItems - ) + turns = [CodexTurnSnapshot(id: turnID, status: .running)] + return turnID } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 182ca61e..b8e3af03 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -43,7 +43,7 @@ public final class ReviewMonitorPreviewContentSource { ) } - public func snapshotForTesting(chatID: CodexThreadID) async -> CodexChatSnapshot? { + public func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { await runtime.snapshotForTesting(chatID: chatID) } @@ -148,10 +148,9 @@ public enum ReviewMonitorPreviewContent { self.content = content } - func itemSnapshot(id: String, turnID: CodexTurnID) -> CodexChatItemSnapshot { - CodexChatItemSnapshot( + func threadItem(id: String) -> CodexThreadItem { + CodexThreadItem( id: id, - turnID: turnID, kind: kind, content: content ) @@ -165,10 +164,9 @@ public enum ReviewMonitorPreviewContent { let mode: PreviewStreamMode let deltaText: String? - func itemSnapshot(id: String, turnID: CodexTurnID?) -> CodexChatItemSnapshot { - CodexChatItemSnapshot( + func threadItem(id: String) -> CodexThreadItem { + CodexThreadItem( id: id, - turnID: turnID, kind: kind, content: content ) @@ -667,25 +665,19 @@ public enum ReviewMonitorPreviewContent { private static func makeChatLogFixture( for chatFixture: PreviewChatFixture ) -> ReviewMonitorPreviewChatLogFixture { - let turn = CodexChatTurnStateSnapshot( + let turn = CodexTurnSnapshot( id: chatFixture.turnID, status: CodexTurnStatus(chatFixture.lifecycle), - errorDescription: chatFixture.lifecycle == .failed ? chatFixture.summary : nil, - usage: nil - ) - let initialSnapshot = CodexChatSnapshot( - chatID: chatFixture.chatID, - phase: CodexDataPhase( - chatFixture.lifecycle, - errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil - ), - turns: [turn], + errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil, items: makeInitialChatItems( streamID: chatFixture.id, - chatItems: chatFixture.chatItems, - turnID: turn.id + chatItems: chatFixture.chatItems ) ) + let initialThreadSnapshot = CodexThreadSnapshot( + id: chatFixture.chatID, + turns: [turn] + ) return ReviewMonitorPreviewChatLogFixture( chatID: chatFixture.chatID, title: chatFixture.targetSummary, @@ -698,34 +690,32 @@ public enum ReviewMonitorPreviewContent { cwd: chatFixture.cwd, streamID: chatFixture.id, isRunning: chatFixture.lifecycle == .running, - initialSnapshot: initialSnapshot + initialThreadSnapshot: initialThreadSnapshot ) } private static func makeInitialChatItems( streamID: String, - chatItems: [PreviewChatLogItemTemplate], - turnID: CodexTurnID - ) -> [CodexChatItemSnapshot] { - var snapshots: [CodexChatItemSnapshot] = [] + chatItems: [PreviewChatLogItemTemplate] + ) -> [CodexThreadItem] { + var items: [CodexThreadItem] = [] for item in chatItems { - let snapshot = item.itemSnapshot( + let threadItem = item.threadItem( id: previewChatLogItemID( itemName: item.itemName, streamID: streamID, cycle: 0 - ), - turnID: turnID + ) ) - if let index = snapshots.firstIndex(where: { - $0.id == snapshot.id && $0.turnID == snapshot.turnID + if let index = items.firstIndex(where: { + $0.id == threadItem.id }) { - snapshots[index] = snapshot + items[index] = threadItem } else { - snapshots.append(snapshot) + items.append(threadItem) } } - return snapshots + return items } private static func diagnosticItem( diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index adf24831..012d18a1 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -128,7 +128,7 @@ struct ReviewChatFixtureForTesting { var turnID: CodexTurnID var streamID: String var isRunning: Bool - var initialSnapshot: CodexChatSnapshot + var initialSnapshot: CodexThreadSnapshot var chatID: CodexThreadID { chat.id @@ -197,10 +197,9 @@ func makeReviewChatFixtureForTesting( status: CodexThreadStatus(chatFixtureStatusForTesting: status) ) ReviewChatLogFixtureStore.setEntries(chatEntries, for: resolvedChatID) - let initialSnapshot = makeCodexChatSnapshotForTesting( + let initialSnapshot = makeCodexThreadSnapshotForTesting( chatID: resolvedChatID, turnID: resolvedTurnID, - phase: CodexDataPhase(chatFixtureStatusForTesting: status, errorMessage: errorMessage), turnStatus: CodexTurnStatus(chatFixtureStatusForTesting: status), turnErrorDescription: errorMessage, items: ReviewChatLogFixtureStore.items(for: resolvedChatID, turnID: resolvedTurnID) @@ -311,14 +310,14 @@ func makeReviewMonitorSplitViewControllerForTesting( @MainActor func reviewChatLogText(for fixture: ReviewChatFixtureForTesting) -> String { reviewChatLogText( - for: codexChatSnapshotForTesting(fixture), + for: codexThreadSnapshotForTesting(fixture), chatUpdatedAt: fixture.chat.updatedAt ) } @MainActor func reviewChatLogText( - for snapshot: CodexChatSnapshot, + for snapshot: CodexThreadSnapshot, chatUpdatedAt: Date? = nil ) -> String { ReviewChatLogFixtureStore.logText(for: snapshot, chatUpdatedAt: chatUpdatedAt) @@ -344,14 +343,14 @@ func awaitChatRenderForTesting( @MainActor func awaitChatRenderForTesting( - snapshot: CodexChatSnapshot, + 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.chatID, + chatID: snapshot.id, expectedLog: reviewChatLogText(for: snapshot), in: transport, allowIncrementalUpdate: allowIncrementalUpdate, @@ -422,7 +421,7 @@ func makePreviewAppServerRuntimeForTesting( cwd: String, streamID: String, isRunning: Bool, - initialSnapshot: CodexChatSnapshot + initialSnapshot: CodexThreadSnapshot ) -> ReviewMonitorPreviewAppServerRuntime { ReviewMonitorPreviewAppServerRuntime( fixtures: [ @@ -442,10 +441,9 @@ func makePreviewMessageItemForTesting( id: String, text: String, turnID: CodexTurnID -) -> CodexChatItemSnapshot { - CodexChatItemSnapshot( +) -> CodexThreadItem { + CodexThreadItem( id: id, - turnID: turnID, kind: .agentMessage, content: .message(.init(id: id, role: .assistant, text: text)) ) @@ -471,12 +469,11 @@ private func makePreviewChatLogFixtureForTesting( @MainActor private func makePreviewChatLogFixtureForTesting( fixture: ReviewChatFixtureForTesting, - items makeItems: (CodexTurnID) -> [CodexChatItemSnapshot] + items makeItems: (CodexTurnID) -> [CodexThreadItem] ) -> ReviewMonitorPreviewChatLogFixture { - let initialSnapshot = makeCodexChatSnapshotForTesting( + let initialSnapshot = makeCodexThreadSnapshotForTesting( chatID: fixture.chatID, - phase: fixture.initialSnapshot.phase, - turns: fixture.initialSnapshot.turns, + turns: fixture.initialSnapshot.turns ?? [], items: makeItems(fixture.turnID) ) return makePreviewChatLogFixtureForTesting( @@ -489,51 +486,51 @@ private func makePreviewChatLogFixtureForTesting( } @MainActor -func codexChatSnapshotForTesting(_ fixture: ReviewChatFixtureForTesting) -> CodexChatSnapshot { - makeCodexChatSnapshotForTesting( +func codexThreadSnapshotForTesting(_ fixture: ReviewChatFixtureForTesting) -> CodexThreadSnapshot { + makeCodexThreadSnapshotForTesting( chatID: fixture.chatID, - phase: fixture.initialSnapshot.phase, - turns: fixture.initialSnapshot.turns, + turns: fixture.initialSnapshot.turns ?? [], items: ReviewChatLogFixtureStore.items(for: fixture.chatID, turnID: fixture.turnID) ) } @MainActor -func makeCodexChatSnapshotForTesting( +func makeCodexThreadSnapshotForTesting( chatID: CodexThreadID, turnID: CodexTurnID, - phase: CodexDataPhase = .loaded, turnStatus: CodexTurnStatus = .completed, turnErrorDescription: String? = nil, - items: [CodexChatItemSnapshot] = [] -) -> CodexChatSnapshot { - makeCodexChatSnapshotForTesting( + items: [CodexThreadItem] = [] +) -> CodexThreadSnapshot { + makeCodexThreadSnapshotForTesting( chatID: chatID, - phase: phase, turns: [ .init( id: turnID, status: turnStatus, - errorDescription: turnErrorDescription, - usage: nil + errorMessage: turnErrorDescription, + items: items ) - ], - items: items + ] ) } @MainActor -func makeCodexChatSnapshotForTesting( +func makeCodexThreadSnapshotForTesting( chatID: CodexThreadID, - phase: CodexDataPhase = .loaded, - turns: [CodexChatTurnStateSnapshot], - items: [CodexChatItemSnapshot] = [] -) -> CodexChatSnapshot { - CodexChatSnapshot( - chatID: chatID, - phase: phase, - turns: turns, - items: items + 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 ) } @@ -543,7 +540,7 @@ func makePreviewChatLogFixtureForTesting( cwd: String, streamID: String, isRunning: Bool, - initialSnapshot: CodexChatSnapshot + initialSnapshot: CodexThreadSnapshot ) -> ReviewMonitorPreviewChatLogFixture { ReviewMonitorPreviewChatLogFixture( chatID: chat.id, @@ -557,7 +554,7 @@ func makePreviewChatLogFixtureForTesting( cwd: cwd, streamID: streamID, isRunning: isRunning, - initialSnapshot: initialSnapshot + initialThreadSnapshot: initialSnapshot ) } @@ -583,20 +580,17 @@ private enum ReviewChatLogFixtureStore { await updateRetainedPreviewSource(for: chatID, turnID: turnID) } - static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexChatItemSnapshot] { + static func items(for chatID: CodexThreadID, turnID: CodexTurnID) -> [CodexThreadItem] { makeChatItems(from: entriesByChatID[chatID, default: []], turnID: turnID) } - static func logText(for snapshot: CodexChatSnapshot, chatUpdatedAt: Date?) -> String { - var projection = ReviewMonitorCodexChatLogSourceProjection() - var document: ReviewMonitorLog.Document? - for change in CodexChatChange.previewChangesForTesting(from: nil, to: snapshot) { - document = projection.apply( - change, - chatCreatedAt: nil, - chatUpdatedAt: chatUpdatedAt - )?.sourceDocument ?? document - } + 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 "" } @@ -628,7 +622,7 @@ private final class ReviewChatLogFixtureRetainer { chatIDs.contains(chatID) } - func upsert(chatID: CodexThreadID, items: [CodexChatItemSnapshot]) async { + func upsert(chatID: CodexThreadID, items: [CodexThreadItem]) async { let previousItemsByID = Dictionary( uniqueKeysWithValues: await runtime.snapshotForTesting(chatID: chatID)?.items.map { ($0.id, $0) } ?? [] ) @@ -672,7 +666,7 @@ private var previewSupportRetainersByStore: [ObjectIdentifier: ReviewChatLogFixt private func makeChatItems( from entries: [ReviewChatLogEntryForTesting], turnID: CodexTurnID -) -> [CodexChatItemSnapshot] { +) -> [CodexThreadItem] { var accumulated: [String: ReviewChatLogAccumulatedItem] = [:] var orderedIDs: [String] = [] for entry in entries { @@ -688,7 +682,7 @@ private func makeChatItems( } private struct ReviewChatLogAccumulatedItem { - var snapshot: CodexChatItemSnapshot + var snapshot: CodexThreadItem init( entry: ReviewChatLogEntryForTesting, @@ -701,7 +695,6 @@ private struct ReviewChatLogAccumulatedItem { case .command: snapshot = .init( id: itemID, - turnID: turnID, kind: .commandExecution, content: .command( .init( @@ -715,7 +708,6 @@ private struct ReviewChatLogAccumulatedItem { case .commandOutput: snapshot = .init( id: itemID, - turnID: turnID, kind: .commandExecution, content: .command( .init( @@ -729,7 +721,6 @@ private struct ReviewChatLogAccumulatedItem { case .fileChange: snapshot = .init( id: itemID, - turnID: turnID, kind: .fileChange, content: .fileChange( .init( @@ -741,7 +732,6 @@ private struct ReviewChatLogAccumulatedItem { case .agentMessage: snapshot = .init( id: itemID, - turnID: turnID, kind: .agentMessage, content: .message( .init( @@ -753,42 +743,36 @@ private struct ReviewChatLogAccumulatedItem { case .plan, .todoList: snapshot = .init( id: itemID, - turnID: turnID, kind: entry.kind == .todoList ? .init(rawValue: "todoList") : .plan, content: .plan((existing?.plainText ?? "") + entry.text) ) case .reasoning, .rawReasoning: snapshot = .init( id: itemID, - turnID: turnID, kind: entry.kind == .rawReasoning ? .init(rawValue: "rawReasoning") : .reasoning, content: .reasoning(.init(content: (existing?.reasoningText ?? "") + entry.text)) ) case .reasoningSummary: snapshot = .init( id: itemID, - turnID: turnID, kind: .init(rawValue: "reasoningSummary"), content: .reasoning(.init(summary: (existing?.reasoningText ?? "") + entry.text)) ) case .contextCompaction: snapshot = .init( id: itemID, - turnID: turnID, kind: .contextCompaction, content: .contextCompaction(entry.text) ) case .toolCall: snapshot = .init( id: itemID, - turnID: turnID, kind: .mcpToolCall, content: .toolCall(.init(result: (existing?.toolResult ?? "") + entry.text, status: status)) ) case .diagnostic, .error, .progress, .event: snapshot = .init( id: itemID, - turnID: turnID, kind: .init(rawValue: entry.kind.rawValue), content: .diagnostic((existing?.plainText ?? "") + entry.text) ) @@ -902,49 +886,15 @@ private func codexTurnStatus(for entry: ReviewChatLogEntryForTesting) -> CodexTu return CodexTurnStatus(rawValue: rawValue) } -private extension CodexChatChange { - static func previewChangesForTesting(from previous: CodexChatSnapshot?, to current: CodexChatSnapshot) -> [Self] { - guard let previous else { - return [.snapshot(current)] - } - - var changes: [Self] = [] - let previousTurnsByID = Dictionary(uniqueKeysWithValues: previous.turns.map { ($0.id, $0) }) - for turn in current.turns where previousTurnsByID[turn.id] != turn { - changes.append(.turnUpdated(turn)) - } - - let previousItemsByID = Dictionary(uniqueKeysWithValues: previous.items.map { ($0.id, $0) }) - let currentItemIDs = Set(current.items.map(\.id)) - for removedItem in previous.items where currentItemIDs.contains(removedItem.id) == false { - changes.append(.itemRemoved(id: removedItem.id, turnID: removedItem.turnID)) - } - for item in current.items { - guard let previousItem = previousItemsByID[item.id] else { - changes.append(.itemInserted(item)) - continue - } - guard previousItem != item else { - continue - } - if let delta = previousItem.textDeltaForTesting(to: item) { - changes.append(.itemTextAppended(id: item.id, turnID: item.turnID, delta: delta, item: item)) - } else { - changes.append(.itemUpdated(item)) - } - } - - if previous.phase != current.phase { - changes.append(.phaseChanged(current.phase)) - } - return changes +extension CodexThreadSnapshot { + var items: [CodexThreadItem] { + (turns ?? []).flatMap(\.items) } } -private extension CodexChatItemSnapshot { - func outputDeltaForTesting(to item: CodexChatItemSnapshot) -> String? { - guard turnID == item.turnID, - kind == item.kind, +private extension CodexThreadItem { + func outputDeltaForTesting(to item: CodexThreadItem) -> String? { + guard kind == item.kind, preservesOutputDeltaMetadata(for: item), let previousOutput = commandOutputForTesting, let nextOutput = item.commandOutputForTesting, @@ -956,9 +906,8 @@ private extension CodexChatItemSnapshot { return String(nextOutput.dropFirst(previousOutput.count)) } - func textDeltaForTesting(to item: CodexChatItemSnapshot) -> String? { + func textDeltaForTesting(to item: CodexThreadItem) -> String? { guard - turnID == item.turnID, kind == item.kind, sameContentShapeForTesting(as: item), preservesTextDeltaMetadata(for: item), @@ -972,7 +921,7 @@ private extension CodexChatItemSnapshot { return String(nextText.dropFirst(previousText.count)) } - private func preservesTextDeltaMetadata(for item: CodexChatItemSnapshot) -> Bool { + private func preservesTextDeltaMetadata(for item: CodexThreadItem) -> Bool { switch (content, item.content) { case (.command, .command), (.fileChange, .fileChange), @@ -983,7 +932,7 @@ private extension CodexChatItemSnapshot { } } - private func preservesOutputDeltaMetadata(for item: CodexChatItemSnapshot) -> Bool { + private func preservesOutputDeltaMetadata(for item: CodexThreadItem) -> Bool { switch (content, item.content) { case (.command(let previous), .command(let next)): previous.command == next.command @@ -1018,7 +967,7 @@ private extension CodexChatItemSnapshot { } } - private func sameContentShapeForTesting(as item: CodexChatItemSnapshot) -> Bool { + private func sameContentShapeForTesting(as item: CodexThreadItem) -> Bool { switch (content, item.content) { case (.message, .message), (.plan, .plan), diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 495118ea..3a46037e 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -8,6 +8,48 @@ import Testing @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 @@ -55,6 +97,139 @@ struct ReviewMonitorCodexChatDetailTests { #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 @@ -279,30 +454,31 @@ struct ReviewMonitorCodexChatDetailTests { #expect(transport.logReloadCountForTesting == reloadCount) } - @Test func codexChatLogProjectionSkipsUserPromptWhenReviewModeLogExists() { + @Test func codexChatLogProjectionSkipsUserPromptWhenReviewModeLogExists() async throws { var projection = ReviewMonitorCodexChatLogProjection() - let document = projection.render( - from: .init( - turn: .init(id: CodexTurnID(rawValue: "turn-review"), status: .running), - items: [ - .init( + 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", - turnID: CodexTurnID(rawValue: "turn-review"), - kind: .userMessage, - content: .message(.init( - id: "user-message", - role: .user, - text: "Review the current code changes." - )) - ), - .init( - id: "entered-review", - turnID: CodexTurnID(rawValue: "turn-review"), - kind: .enteredReviewMode, - content: .log("Review the current code changes.") - ), - ] - ), + 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 ) @@ -310,24 +486,25 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.text == "Review the current code changes.") } - @Test func codexChatLogProjectionRendersUserMessageWithoutReviewModeLog() { + @Test func codexChatLogProjectionRendersUserMessageWithoutReviewModeLog() async throws { var projection = ReviewMonitorCodexChatLogProjection() - let document = projection.render( - from: .init( - turn: .init(id: CodexTurnID(rawValue: "turn-chat"), status: .running), - items: [ - .init( + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-chat"), + status: .running, + items: [ + .init( + id: "user-message", + kind: .userMessage, + content: .message(.init( id: "user-message", - turnID: CodexTurnID(rawValue: "turn-chat"), - kind: .userMessage, - content: .message(.init( - id: "user-message", - role: .user, - text: "Explain the current diff." - )) - ), - ] - ), + role: .user, + text: "Explain the current diff." + )) + ), + ] + ) + let document = projection.render( + from: turn, chatCreatedAt: nil, chatUpdatedAt: nil ) @@ -335,30 +512,33 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.text == "Explain the current diff.") } - @Test func codexChatStatusOnlyChangesKeepIncrementalLogUpdates() { + @Test func codexChatStatusOnlyChangesKeepIncrementalLogUpdates() async throws { var projection = ReviewMonitorCodexChatLogSourceProjection() let turnID = CodexTurnID(rawValue: "turn-review") - let snapshot = CodexChatSnapshot( - chatID: CodexThreadID(rawValue: "review-thread"), - phase: .loading, - turns: [.init(id: turnID, status: .running)], - items: [ + let chat = try await makeProjectionChat( + turns: [ .init( - id: "message-review", - turnID: turnID, - kind: .agentMessage, - content: .message(.init( - id: "message-review", - role: .assistant, - text: "Running review" - )) + 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.apply(snapshotChange(snapshot), chatCreatedAt: nil, chatUpdatedAt: nil) + let initialChange = projection.applyBaseline(from: chat, chatCreatedAt: nil, chatUpdatedAt: nil) let statusChange = projection.apply( - .turnUpdated(.init(id: turnID, status: .running)), + .turnUpdated(id: turnID), + in: chat, chatCreatedAt: nil, chatUpdatedAt: nil ) @@ -368,6 +548,81 @@ struct ReviewMonitorCodexChatDetailTests { #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 @@ -446,8 +701,21 @@ struct ReviewMonitorCodexChatDetailTests { uiState.selection = .chat(chatID) } - private func snapshotChange(_ snapshot: CodexChatSnapshot) -> CodexChatChange { - .snapshot(snapshot) + 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 } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 1b3ffe2c..c227e5df 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -247,7 +247,7 @@ struct ReviewMonitorCodexSidebarResultsTests { name: "Updated review", updatedAt: Date(timeIntervalSince1970: 2_000) )) - try await context.model(for: threadID).refresh(includeTurns: false) + try await context.refresh(context.model(for: threadID), includeTurns: false) #expect(tree.apply(sections: controller.sections).topologyChanged == false) #expect(tree.roots.first === root) @@ -397,8 +397,10 @@ struct ReviewMonitorCodexSidebarResultsTests { uiState.selection = .chat(hiddenRunThreadID) try await waitForCondition { - uiState.selection == nil && sidebar.selectedReviewChatIDForTesting == nil + sidebar.nativeSelectedReviewChatIDForTesting == nil } + #expect(uiState.selection == .chat(hiddenRunThreadID)) + #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(hiddenRunThreadID)) == nil) } @Test func sidebarViewControllerTracksCodexSidebarFetchResultChanges() async throws { @@ -441,6 +443,90 @@ struct ReviewMonitorCodexSidebarResultsTests { } } + @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 @@ -540,7 +626,7 @@ struct ReviewMonitorCodexSidebarResultsTests { name: "App review renamed", updatedAt: Date(timeIntervalSince1970: 6_000) )) - try await chat.refresh(includeTurns: false) + try await context.refresh(chat, includeTurns: false) try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(threadID)) == "App review renamed" @@ -875,6 +961,68 @@ struct ReviewMonitorCodexSidebarResultsTests { #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 diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 66b7f9ba..ed3b8964 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -80,11 +80,22 @@ extension ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() - let sidebar = viewController.splitViewControllerForTesting.sidebarViewControllerForTesting + 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, @@ -115,7 +126,7 @@ extension ReviewUITests { try await withTestTimeout(.seconds(2)) { while true { let snapshot = await previewContent.snapshotForTesting(chatID: selectedChatID) - if snapshot?.turns.last?.status == .cancelled { + if snapshot?.turns?.last?.status == .cancelled { break } try Task.checkCancellation() @@ -124,15 +135,31 @@ extension ReviewUITests { } 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) } @@ -2235,7 +2262,7 @@ private func performChatContextMenuItemForTesting( } @MainActor -private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { +private func diagnosticMessage(_ item: CodexThreadItem) -> String { if case .diagnostic(let message) = item.content { return message } @@ -2243,7 +2270,7 @@ private func diagnosticMessage(_ item: CodexChatItemSnapshot) -> String { } @MainActor -private func reasoningText(_ item: CodexChatItemSnapshot) -> String { +private func reasoningText(_ item: CodexThreadItem) -> String { if case .reasoning(let reasoning) = item.content { return reasoning.text } @@ -2251,7 +2278,7 @@ private func reasoningText(_ item: CodexChatItemSnapshot) -> String { } @MainActor -private func contextCompactionTitle(_ item: CodexChatItemSnapshot) -> String { +private func contextCompactionTitle(_ item: CodexThreadItem) -> String { if case .contextCompaction(let title) = item.content { return title ?? "" } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index be0cdac0..c1caf793 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2771,11 +2771,11 @@ struct ReviewUITests { turnID: chat.turnID ) _ = try await awaitChatRenderForTesting(chat, in: transport) { snapshot in - snapshot.log.contains("Running Command") + snapshot.log.contains("Command output") } #expect(transport.displayedLogForTesting.contains("- updated with longer replacement text")) - #expect(transport.displayedLogForTesting.contains("Running Command")) + #expect(transport.displayedLogForTesting.contains("Command output")) #expect(transport.displayedLogForTesting.contains("Command output - 1 line") == false) #expect(transport.displayedLogForTesting.contains("hidden output") == false) #expect(transport.logAppendCountForTesting == appendCount + 1) 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 9f05de89..e9b8ccf7 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "208a11147004ff156a8f88d60010ca83a080f5ac9a772429fe0fcd768b73dbd9", + "originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76", "pins" : [ { "identity" : "eventsource", @@ -19,6 +19,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", From 830cecf6d5f41d20d14b13e242d2d4c2a33a0d63 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:23:51 +0900 Subject: [PATCH 334/359] fix(review-ui): resolve terminal command output status --- .../CodexReviewMCPServer.swift | 4 +- .../ReviewMonitorCodexChatLogProjection.swift | 81 +++++++++++++++---- .../ReviewMonitorCodexChatDetailTests.swift | 79 ++++++++++++++++++ 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 1d10bad8..3b4b725a 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -195,9 +195,9 @@ package extension CodexReviewMCPServer { } @MainActor -private extension CodexChat.Item { +private extension CodexItem { var threadItemForReviewMCP: CodexThreadItem { - CodexThreadItem(id: id, kind: kind, content: content, rawPayload: rawPayload) + CodexThreadItem(id: itemID, kind: kind, content: content, rawPayload: rawPayload) } } diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index 5a2dbb15..22cdf98d 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -372,8 +372,7 @@ struct ReviewMonitorCodexChatLogProjection { } private func sourceBlockID(for item: Item) -> String { - let rawTurnID = item.projectionTurnID?.rawValue ?? "unknown-turn" - return "\(rawTurnID):\(item.id)" + item.sourceID } private func logKind( @@ -461,6 +460,15 @@ struct ReviewMonitorCodexChatLogProjection { 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 } @@ -469,9 +477,6 @@ struct ReviewMonitorCodexChatLogProjection { { return commandStatus.rawValue } - if let exitCode = command.exitCode { - return exitCode == 0 ? CodexTurnStatus.completed.rawValue : CodexTurnStatus.failed.rawValue - } return command.status?.rawValue ?? status.rawValue } @@ -491,7 +496,15 @@ struct ReviewMonitorCodexChatLogProjection { for item: Item, turnStatus: CodexTurnStatus? ) -> CodexTurnStatus { - item.itemStatus ?? turnStatus ?? .running + 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 { @@ -506,18 +519,28 @@ struct ReviewMonitorCodexChatLogProjection { @MainActor private protocol CodexChatLogProjectionItem { - var id: String { get } + 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 CodexChat.Item: CodexChatLogProjectionItem { +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 } @@ -525,17 +548,25 @@ extension CodexChat.Item: CodexChatLogProjectionItem { @MainActor private struct CodexChatModelLogItem: CodexChatLogProjectionItem { - var item: CodexChat.Item + var item: CodexItem var turnStatus: CodexTurnStatus? - var id: String { - item.id + 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 } @@ -545,7 +576,7 @@ private struct CodexChatModelLogItem: CodexChatLogProjectionItem { } var itemStatus: CodexTurnStatus? { - item.content.reviewMonitorLogItemStatus ?? turnStatus + item.content.reviewMonitorLogItemStatus } } @@ -555,14 +586,25 @@ private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem { var turnID: CodexTurnID var turnStatus: CodexTurnStatus? - var id: String { + 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 } @@ -572,7 +614,18 @@ private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem { } var itemStatus: CodexTurnStatus? { - item.content.reviewMonitorLogItemStatus ?? turnStatus + item.content.reviewMonitorLogItemStatus + } + + private var semanticItemID: String { + switch item.kind { + case .enteredReviewMode: + "review-marker:enteredReviewMode" + case .exitedReviewMode: + "review-marker:exitedReviewMode" + default: + item.id + } } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 3a46037e..c0adff1e 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -1,5 +1,6 @@ import CodexKit import CodexAppServerKitTesting +import Foundation import Testing @_spi(Testing) @testable import CodexReviewKit @testable import ReviewChatLogUI @@ -512,6 +513,84 @@ struct ReviewMonitorCodexChatDetailTests { #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") From c0e8133ebcbd971dfd0949a8c9b40f59e4022120 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:54:38 +0900 Subject: [PATCH 335/359] test(review-ui): cover terminalized command transcripts --- Tests/ReviewUITests/ReviewUITests.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index c1caf793..24f0d099 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2681,9 +2681,19 @@ struct ReviewUITests { turnID: chat.turnID ) - let snapshot = try await awaitChatRenderForTesting(chat, in: transport) + let snapshot = try await awaitChatRenderForTesting( + chatID: chat.chatID, + expectedLog: """ + Need to inspect files. + + Ran git diff + + 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) From ca7cf4ac2a25da9d738d1568e3632a41f7e3d2ee Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:34:21 +0900 Subject: [PATCH 336/359] fix(ui): use thread status for running chat rows --- .../ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index d39ae145..f7060d03 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -7,7 +7,7 @@ struct ReviewMonitorChatRowView: View { var chat: CodexChat var body: some View { - let isRunning = chat.phase == .loading || chat.status?.isActive == true + let isRunning = chat.status?.isActive == true let startedAt = isRunning ? chat.activityDate : nil Label { From f3576ede1718a432357bdabaab3969973b828533 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:00:19 +0900 Subject: [PATCH 337/359] refactor(app-server): own DataKit context through model actor --- .../AppServerCodexReviewBackend.swift | 34 +++++-------------- .../LiveCodexReviewStoreBackend.swift | 4 +-- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index ff3ae2c2..88be8956 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -13,11 +13,12 @@ private func makeAppServerReviewAttemptID() -> String { UUID().uuidString } -package actor AppServerCodexReviewBackend: CodexReviewBackend { +package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private static let reviewPermissionProfileID = ":danger-full-access" private let appServer: CodexAppServer - private let dataKitModelContext: CodexModelContext? + 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] = [:] @@ -26,9 +27,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:] private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] - package init(appServer: CodexAppServer, dataKitModelContext: CodexModelContext? = nil) { + package init(appServer: CodexAppServer, modelContainer: CodexModelContainer? = nil) { + let modelContainer = modelContainer ?? CodexModelContainer(appServer: appServer) self.appServer = appServer - self.dataKitModelContext = dataKitModelContext + self.modelContainer = modelContainer + self.modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: modelContainer) } package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { @@ -112,20 +115,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { -> CodexReviewSession { let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) - if let dataKitModelContext { - return try await startDataKitBackedReviewSession( - request, - in: workspace, - modelContext: dataKitModelContext - ) - } - return try await startRawAppServerReviewSession(request, in: workspace) + return try await startDataKitBackedReviewSession(request, in: workspace) } private func startDataKitBackedReviewSession( _ request: CodexReviewBackendModel.Review.Start, - in workspace: URL, - modelContext: CodexModelContext + in workspace: URL ) async throws -> CodexReviewSession { try await modelContext.startReview( in: workspace, @@ -137,17 +132,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend { .session } - private func startRawAppServerReviewSession( - _ request: CodexReviewBackendModel.Review.Start, - in workspace: URL - ) async throws -> CodexReviewSession { - try await appServer.startReview( - in: workspace, - target: request.request.target.appServerReviewTarget, - options: reviewThreadOptions(request) - ) - } - private func reviewThreadOptions( _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index a8d5ece8..2e77db16 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -190,7 +190,7 @@ public extension CodexReviewStore { modelContainer: modelContainer, backend: AppServerCodexReviewBackend( appServer: appServer, - dataKitModelContext: modelContainer.mainContext + modelContainer: modelContainer ) ) } @@ -427,7 +427,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { modelContainer: modelContainer, backend: AppServerCodexReviewBackend( appServer: appServer, - dataKitModelContext: modelContainer.mainContext + modelContainer: modelContainer ) ) } From 6e34054fa7ee4fa4c3320e47f606dcb6bdaa721c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:39:23 +0900 Subject: [PATCH 338/359] fix(mcp): require final review for completed runs --- Docs/mcp.md | 4 +- .../AppServerCodexReviewBackend.swift | 12 ++-- Sources/CodexReviewKit/CodexReviewTypes.swift | 18 +++++- .../CodexReviewKit/Model/ReviewRunCore.swift | 5 +- .../Store/CodexReviewStoreCancellation.swift | 1 + .../Store/CodexReviewStoreReviews.swift | 17 ++++-- .../CodexReviewMCPToolResults.swift | 10 ++-- .../AppServerClientTests.swift | 60 ++++++++++++++++--- .../CodexReviewHostTests.swift | 2 +- .../ReviewBackendEventSessionTests.swift | 4 +- .../CodexReviewStoreCommandTests.swift | 48 +++++++-------- .../CodexReviewMCPHTTPServerTests.swift | 26 +++++--- .../CodexReviewMCPServerTests.swift | 2 +- 13 files changed, 145 insertions(+), 64 deletions(-) diff --git a/Docs/mcp.md b/Docs/mcp.md index f686c4b8..cc54a2ad 100644 --- a/Docs/mcp.md +++ b/Docs/mcp.md @@ -49,7 +49,7 @@ Returns: - `errorMessage` - `review` - `hasFinalReview` - - `finalReview` from the Codex chat turn 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` @@ -64,7 +64,7 @@ Returns: Notes: - `lifecycle.message` is review-run lifecycle text. It is not final review content. -- `review.finalReview` comes from the Codex chat projection. If no chat projection is available, it is `null` even when the run has reached a terminal lifecycle state. +- `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` diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 88be8956..02a98ae2 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -550,8 +550,13 @@ private enum AppServerTypedReviewEventAdapter { 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 + .completed(finalReview: finalReview) ] } @@ -581,11 +586,6 @@ private enum AppServerTypedReviewEventAdapter { guard item.kind.rawValue != "enteredReviewMode" else { return [] } - if phase == .completed, item.kind.rawValue == "exitedReviewMode" { - return [ - .completed - ] - } return [] } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 87c5926f..82a60298 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -275,15 +275,31 @@ package extension CodexReviewBackendModel.Review { } } +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 + 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 diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 412deef1..3897a280 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -51,15 +51,18 @@ package struct ReviewRunCore: Codable, Sendable, Hashable { 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 + lifecycleMessage: String, + finalReview: String? = nil ) { self.run = run self.lifecycle = lifecycle self.lifecycleMessage = lifecycleMessage + self.finalReview = finalReview?.nilIfEmpty } package var isTerminal: Bool { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 8b6835df..a9c80b04 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -54,6 +54,7 @@ extension CodexReviewStore { cancellation.message.nilIfEmpty ?? runRecord.core.lifecycle.errorMessage runRecord.core.lifecycle.endedAt = endedAt + runRecord.core.finalReview = nil noteReviewRunMutation() } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 6ec4daaf..d848b780 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -536,6 +536,7 @@ extension CodexReviewStore { runRecord.core.lifecycle.status = .running runRecord.core.lifecycle.startedAt = startedAt runRecord.core.lifecycleMessage = "Review started." + runRecord.core.finalReview = nil writeDiagnosticsIfNeeded() } @@ -548,6 +549,7 @@ extension CodexReviewStore { runRecord.core.lifecycle.endedAt = endedAt runRecord.core.lifecycle.errorMessage = message runRecord.core.lifecycleMessage = message + runRecord.core.finalReview = nil writeDiagnosticsIfNeeded() } @@ -674,7 +676,7 @@ extension CodexReviewStore { if completePendingCancellationIfNeeded(for: runRecord) { return recoveryState.currentRun } - completeReview(runRecord) + markReviewFailed(runRecord, message: "Review completed without a final response.") } return recoveryState.currentRun } @@ -695,7 +697,7 @@ extension CodexReviewStore { if completePendingCancellationIfNeeded(for: runRecord) { return true } - completeReview(runRecord) + markReviewFailed(runRecord, message: "Review completed without a final response.") } return true } @@ -813,8 +815,8 @@ extension CodexReviewStore { updatedRun.model = model ?? updatedRun.model runtimeState.setActiveRun(updatedRun, for: runRecord.id) runRecord.core.lifecycleMessage = "Review started." - case .completed: - completeReview(runRecord) + case .completed(let completion): + completeReview(runRecord, finalReview: completion.finalReview) case .failed(let message): markReviewFailed(runRecord, message: message) case .cancelled(let message): @@ -842,14 +844,19 @@ extension CodexReviewStore { return true } - private func completeReview(_ runRecord: ReviewRunRecord) { + 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() runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt runRecord.core.lifecycleMessage = "Review completed." + runRecord.core.finalReview = finalReview writeDiagnosticsIfNeeded() } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 4c1cbeb9..ebd65e3c 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -89,7 +89,7 @@ private extension CodexReviewAPI.Read.Result { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "review": core.structuredReviewContent(finalReview: log.finalResult), + "review": core.structuredReviewContent(), ] object["log"] = includeDetails @@ -324,7 +324,7 @@ private extension CodexReviewAPI.Run.ListItem { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "review": core.structuredReviewContent(finalReview: nil), + "review": core.structuredReviewContent(), ]) } } @@ -355,7 +355,7 @@ private extension CodexReviewAPI.Cancel.Outcome { elapsedSeconds: nil, cancellable: false ), - "review": core.structuredReviewContent(finalReview: nil), + "review": core.structuredReviewContent(), ]) } } @@ -407,8 +407,8 @@ private extension ReviewRunCore { ) } - func structuredReviewContent(finalReview: String?) -> Value { - let finalReview = finalReview?.nilIfEmpty + func structuredReviewContent() -> Value { + let finalReview = self.finalReview?.nilIfEmpty return .object([ "hasFinalReview": .bool(finalReview != nil), "finalReview": finalReview.map(Value.string) ?? .null, diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 40f73757..e944047c 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -93,13 +93,19 @@ struct AppServerClientTests { method: "turn/completed", params: TestTurnNotification( threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") + turn: .init( + id: "turn-1", + status: "completed", + items: [ + .finalAnswer(id: "msg-1", text: "Looks good.") + ] + ) ) ) #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed) + #expect(try await iterator.next() == .completed(finalReview: "Looks good.")) } @Test func backendIgnoresAgentMessageDeltasInLifecycleStream() async throws { @@ -136,14 +142,26 @@ struct AppServerClientTests { method: "turn/completed", params: TestTurnNotification( threadID: "review-thread-1", - turn: .init(id: "turn-1", status: "completed") + turn: .init( + id: "turn-1", + status: "completed", + items: [ + .finalAnswer(id: "msg-1", text: "first") + ] + ) ) ) try await runtime.transport.emitServerNotification( method: "turn/completed", params: TestTurnNotification( threadID: "review-thread-2", - turn: .init(id: "turn-2", status: "completed") + turn: .init( + id: "turn-2", + status: "completed", + items: [ + .finalAnswer(id: "msg-1", text: "second") + ] + ) ) ) @@ -151,13 +169,13 @@ struct AppServerClientTests { #expect( try await firstIterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) - #expect(try await firstIterator.next() == .completed) + #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) + #expect(try await secondIterator.next() == .completed(finalReview: "second")) } @Test func backendKeepsCommandOutputDeltasInCodexChat() async throws { @@ -198,11 +216,17 @@ struct AppServerClientTests { method: "turn/completed", params: TestTurnNotification( threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") + turn: .init( + id: "turn-1", + status: "completed", + items: [ + .finalAnswer(id: "msg-1", text: "No issues found.") + ] + ) ) ) - #expect(try await nextEvent(from: attempt.events) == .completed) + #expect(try await nextEvent(from: attempt.events) == .completed(finalReview: "No issues found.")) } @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { @@ -542,6 +566,13 @@ private struct TestTurnNotification: Encodable, Sendable { private struct TestTurn: Encodable, Sendable { var id: String var status: String + var items: [TestItem]? + + init(id: String, status: String, items: [TestItem]? = nil) { + self.id = id + self.status = status + self.items = items + } } private struct TestDeltaNotification: Encodable, Sendable { @@ -574,6 +605,7 @@ 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? @@ -585,6 +617,7 @@ private struct TestItem: Encodable, Sendable { type: String, id: String, text: String? = nil, + phase: String? = nil, review: String? = nil, command: String? = nil, cwd: String? = nil, @@ -595,6 +628,7 @@ private struct TestItem: Encodable, Sendable { self.type = type self.id = id self.text = text + self.phase = phase self.review = review self.command = command self.cwd = cwd @@ -602,4 +636,14 @@ private struct TestItem: Encodable, Sendable { self.exitCode = exitCode self.status = status } + + static func finalAnswer(id: String, text: String) -> TestItem { + .init( + type: "agentMessage", + id: id, + text: text, + phase: "final_answer", + status: "completed" + ) + } } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 6122ea23..c9fe95f4 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -135,7 +135,7 @@ struct CodexReviewHostTests { }.first) #expect(startReview.model == "gpt-5.5") - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) await backend.finishEvents() _ = try await reviewTask.value } diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index e12126ef..6a46c045 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -22,7 +22,7 @@ struct ReviewBackendEventSessionTests { await session.receive( [ .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), - .completed, + .completed(finalReview: "No issues found."), ], controlThreadID: "review-thread") #expect( @@ -34,7 +34,7 @@ struct ReviewBackendEventSessionTests { )) #expect( try await nextEvent(from: attempt.events) - == .completed) + == .completed(finalReview: "No issues found.")) #expect(await recorder.startedTurnIDs() == ["turn-1"]) #expect(await recorder.finishedRun() == makeRun()) diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 69678cca..6390d2c3 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -18,7 +18,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result #expect(read.runID == "run-1") @@ -54,7 +54,7 @@ struct CodexReviewStoreCommandTests { #expect(running.runID == "run-1") #expect(running.core.lifecycle.status == .running) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let final = try await store.awaitReview( sessionID: "session-1", runID: "run-1", @@ -84,7 +84,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", timeout: .seconds(1) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let final = try await awaited #expect(final.core.lifecycle.status == .succeeded) @@ -202,7 +202,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await result let commands = await backend.recordedCommands() @@ -231,7 +231,7 @@ struct CodexReviewStoreCommandTests { let runningSnapshot = try #require(await probe.waitUntilRunStatus(.running, runID: "run-1")) #expect(runningSnapshot.run("run-1")?.summary == "Review started.") - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result #expect(read.core.lifecycleMessage == "Review completed.") } @@ -247,7 +247,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/old-project", target: .baseBranch("main")) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await first await backend.finishEvents() @@ -255,7 +255,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await second #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"]) @@ -272,7 +272,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await first await backend.finishEvents() @@ -280,7 +280,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await second #expect( @@ -309,7 +309,7 @@ struct CodexReviewStoreCommandTests { #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await result } } @@ -337,7 +337,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await result #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") @@ -460,7 +460,7 @@ struct CodexReviewStoreCommandTests { } } == false) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result #expect(read.core.lifecycle.status == .succeeded) } @@ -541,7 +541,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: recoveredRun) + await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -612,7 +612,7 @@ struct CodexReviewStoreCommandTests { #expect(recoveredFromRuns.last?.turnID == "turn-actual") try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: recoveredRun) + await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -655,8 +655,8 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: initialRun) - await backend.yield(.completed, for: 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) @@ -703,7 +703,7 @@ struct CodexReviewStoreCommandTests { await recoverGate.open() try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: recoveredRun) + await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -747,7 +747,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: recoveredRun) + await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1262,7 +1262,7 @@ struct CodexReviewStoreCommandTests { } #expect(try store.readReview(runID: "run-1").cancellable) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await result let commands = await backend.recordedCommands() @@ -1373,7 +1373,7 @@ struct CodexReviewStoreCommandTests { try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) - await backend.yield(.completed, for: recoveredRun) + await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result #expect(read.core.lifecycle.status == .succeeded) @@ -1458,7 +1458,7 @@ struct CodexReviewStoreCommandTests { #expect(readAfterFailure.core.lifecycle.cancellation == nil) #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed") - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) _ = try await result } } @@ -1479,7 +1479,7 @@ struct CodexReviewStoreCommandTests { runID: "run-1", cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result #expect(read.core.lifecycle.status == .cancelled) @@ -1502,7 +1502,7 @@ struct CodexReviewStoreCommandTests { ) async let cancel = store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) try await backend.waitForInterruptReview(timeout: .seconds(2)) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) await interruptGate.open() _ = try await cancel let read = try await result @@ -1597,7 +1597,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result #expect(read.runID == "run-1") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index da2c3e33..65897509 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -160,7 +160,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed) + 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) @@ -173,8 +173,13 @@ struct CodexReviewMCPHTTPServerTests { resolved.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Review completed.") #expect( - resolved.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) - #expect(resolved.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) + 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( @@ -230,7 +235,7 @@ struct CodexReviewMCPHTTPServerTests { #expect( running.value(for: ["result", "structuredContent", "nextAction", "tool"]) as? String == "review_await") - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let awaited = try await postJSONRPC( endpoint: endpoint, sessionID: sessionID, @@ -254,8 +259,13 @@ struct CodexReviewMCPHTTPServerTests { awaited.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String == "Review completed.") #expect( - awaited.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool == false) - #expect(awaited.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) + 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) @@ -294,7 +304,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") @@ -853,7 +863,7 @@ struct CodexReviewMCPHTTPServerTests { await backend.waitForStartReview() await server.runSessionCleanupForTesting(now: .distantFuture) await gate.open() - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 44a0e048..6b51462a 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -35,7 +35,7 @@ struct CodexReviewMCPServerTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges), waitTimeout: nil )) - await backend.yield(.completed) + await backend.yield(.completed(finalReview: "No issues found.")) let resolved = try await response guard case .reviewStart(let snapshot) = resolved else { From 610b87470ad4398a03bad86e58a61c0a6b7086af Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:33:45 +0900 Subject: [PATCH 339/359] chore(appserver): remove unused review group helpers --- .../AppServerCodexReviewBackend.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 02a98ae2..3b1fcb5a 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -814,12 +814,3 @@ private extension CodexLoginHandle { } } } - - -private func reasoningSummaryGroupID(itemID: String, summaryIndex: Int) -> String { - "\(itemID):summary:\(summaryIndex)" -} - -private func rawReasoningGroupID(itemID: String, contentIndex: Int) -> String { - "\(itemID):\(contentIndex)" -} From fe121a33564a5659a80d2a5af22a88aa2b2be673 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:59:10 +0900 Subject: [PATCH 340/359] fix(ui): serialize chat log re-observation and surface observe errors Chain each new chat log observation task on the previous task's completion so an in-flight observation registration is released before observing again; rapid selection changes could otherwise hit chatObservationAlreadyActive, which the empty catch swallowed and left the log pane stale with no recovery. Log non-cancellation observation errors instead of discarding them, and keep the cancelled task referenced through clear() so the next bind can await its teardown. Co-Authored-By: Claude Fable 5 --- .../ReviewMonitorCodexChatLogTarget.swift | 17 ++++++++++++----- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index ad7d76b5..ead102dc 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -72,8 +72,6 @@ package final class ReviewMonitorCodexChatLogTarget { ) cancelSelectedChatObservation() cancelPendingLogSourceChange() - selectedChatLogTask?.cancel() - selectedChatLogTask = nil resetLogRenderer() logProjection.reset() boundChatID = selectedChatID @@ -90,8 +88,9 @@ package final class ReviewMonitorCodexChatLogTarget { @discardableResult package func clear() -> Bool { cacheBoundLogScrollTarget() + // Keep the cancelled task referenced so the next bind can await its + // teardown before re-observing. selectedChatLogTask?.cancel() - selectedChatLogTask = nil cancelSelectedChatObservation() cancelPendingLogSourceChange() boundChatID = nil @@ -155,9 +154,14 @@ package final class ReviewMonitorCodexChatLogTarget { target: LogRenderTarget, initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget ) { - selectedChatLogTask?.cancel() + let previousTask = selectedChatLogTask + previousTask?.cancel() selectedChatLogTask = Task { @MainActor [weak self, weak chat, weak modelContext] in - guard let chat, let modelContext else { + // 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 { @@ -200,6 +204,9 @@ package final class ReviewMonitorCodexChatLogTarget { } } catch is CancellationError { } catch { + logger.error( + "Codex chat log observation failed chatID=\(chat.id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)" + ) } } } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 24f0d099..cfb0e22a 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2237,7 +2237,7 @@ struct ReviewUITests { turnID: chat.turnID ) - #expect(contentPane.selectedChatLogTaskForTesting == nil) + #expect(contentPane.selectedChatLogTaskForTesting?.isCancelled != false) #expect(contentPane.renderSnapshotForTesting == emptySnapshot) } From aec30a7cf4098631c34d097bdeba24285f872796 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:02:46 +0900 Subject: [PATCH 341/359] build: pin CodexKit to the reviewed main revision CodexKit integration merged upstream (lynnswap/CodexKit#16); replace the temporary local path dependency with the pinned merge revision as the in-flight comment required. CodexKit has no tagged releases yet, so pin by revision. Co-Authored-By: Claude Fable 5 --- Package.resolved | 10 +++++++++- Package.swift | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index 63f1bac0..02368d2a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "90f38c0ebccdddb6f0afb83c3cd39a6c863d7fb9e9f06e78cbab558a95092898", + "originHash" : "643ee9f1e86f0f8cc956f0b9542148281e53145a190653855e03c6e8f2d29c5b", "pins" : [ + { + "identity" : "codexkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/CodexKit.git", + "state" : { + "revision" : "6de9e019d35095390252102bd30b069e1090c874" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index cd5fd97c..1d276405 100644 --- a/Package.swift +++ b/Package.swift @@ -33,9 +33,9 @@ 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")), - // Temporary local reference while CodexKit integration work is in flight. - // Replace this with a pinned remote CodexKit revision before merging. - .package(path: "../CodexKit"), + // 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( From bbc7d3aa4b5ebe6b59b663d773c7a20069eb3231 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:02:46 +0900 Subject: [PATCH 342/359] test(review-ui): include item kind in command panel block ids Command output panel block ids reuse CodexItem.id.rawValue, whose format is "::"; the test helper omitted the kind segment, so every click-by-block-id expectation failed. Take the item kind (default commandExecution) and pass fileChange where the fixture is a file change. Co-Authored-By: Claude Fable 5 --- Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift | 7 +++++-- Tests/ReviewUITests/ReviewUITests.swift | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 012d18a1..4118e7e3 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -452,9 +452,12 @@ func makePreviewMessageItemForTesting( @MainActor func chatCommandOutputBlockIDForTesting( turnID: CodexTurnID, - itemID: String + itemID: String, + kind: CodexThreadItem.Kind = .commandExecution ) -> ReviewMonitorLog.BlockID { - ReviewMonitorLog.BlockID("commandOutput:\(turnID.rawValue):\(itemID)") + // Command output panel block ids reuse CodexItem.id.rawValue, whose + // format is "::". + ReviewMonitorLog.BlockID("commandOutput:\(turnID.rawValue):\(kind.rawValue):\(itemID)") } @MainActor diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index cfb0e22a..1f02d39e 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1137,7 +1137,7 @@ struct ReviewUITests { #expect(snapshot.log.contains("Sources/App.swift | 12") == false) #expect(transport.logCommandOutputPanelCountForTesting == 1) - let panelBlockID = chatCommandOutputBlockIDForTesting(turnID: chat.turnID, itemID: "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") From e89817a70af03ff62bcea33cdc0d876d16aa3815 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:03:39 +0900 Subject: [PATCH 343/359] build: update ReviewMonitor resolved pins for remote CodexKit Co-Authored-By: Claude Fable 5 --- .../xcshareddata/swiftpm/Package.resolved | 8 ++++++++ 1 file changed, 8 insertions(+) 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 e9b8ccf7..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", From d61a345a2ed804973708ed5d50467e3e31f83c44 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:55:20 +0900 Subject: [PATCH 344/359] fix(mcp,host): address branch-wide review findings - Fall back to the stored finalReview in MCP tool text content when no log projection is available, so clients reading text still get the review result; pin the new behavior in the streamable HTTP session test. - Require runId/runID in the review_read input schema to match its handler, mirroring review_await. - Route every app-server container teardown through clearAppServerModelContainer so the lifecycle handler always releases the UI model source; the auth-stream failure path previously left the window attached to a closed container. Co-Authored-By: Claude Fable 5 --- .../LiveCodexReviewStoreBackend.swift | 16 +++++++++++----- .../CodexReviewMCPToolResults.swift | 2 +- .../CodexReviewMCPToolSchemas.swift | 4 ++++ .../CodexReviewMCPHTTPServerTests.swift | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 2e77db16..a849a8a0 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -488,10 +488,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await startedHTTPServer?.stop() await startedAppServer?.close() self.appServer = nil - self.appServerModelContainer = nil + clearAppServerModelContainer() self.appServerBackend = nil self.mcpHTTPServer = nil - appServerLifecycleHandler?(nil) authNotificationTask?.cancel() authNotificationTask = nil store.transitionToFailed(failureMessage) @@ -563,9 +562,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } self.appServer = nil - self.appServerModelContainer = nil + clearAppServerModelContainer() self.mcpHTTPServer = nil - appServerLifecycleHandler?(nil) authNotificationTask?.cancel() authNotificationTask = nil await mcpHTTPServer?.stop() @@ -1178,6 +1176,14 @@ 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 @@ -1199,7 +1205,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let failedAppServer = appServer let failedMCPHTTPServer = mcpHTTPServer appServer = nil - appServerModelContainer = nil + clearAppServerModelContainer() appServerBackend = nil mcpHTTPServer = nil authNotificationTask = nil diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index ebd65e3c..21bfdfc9 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -34,7 +34,7 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul private extension CodexReviewAPI.Read.Result { func textContent(log: ReviewMCPLogProjection) -> String { - log.finalResult?.nilIfEmpty ?? core.displayLifecycleMessage + log.finalResult?.nilIfEmpty ?? core.finalReview ?? core.displayLifecycleMessage } func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift index 2f7a3992..05306d91 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolSchemas.swift @@ -44,6 +44,10 @@ func schema(for tool: CodexReviewMCP.Tool.Name) -> Value { "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([ diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 65897509..35034a78 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -870,7 +870,7 @@ struct CodexReviewMCPHTTPServerTests { #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 completed.") + #expect(startText == "No issues found.") #expect(startText?.contains("rawLogText") == false) let tools = try await postJSONRPC( endpoint: endpoint, From 8d47d91a412df31cfad28d410700f7a875e843ea Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:55:45 +0900 Subject: [PATCH 345/359] fix(mcp): bound detailed review_read log responses to a tail page Detailed reads serialized the entire chat transcript with page metadata claiming the full count as the limit, so long reviews produced unbounded MCP responses. Return the last 100 items with honest page metadata (hasMoreBefore/previousOffset) pointing at the omitted head, restoring the bounded behavior the projection refactor dropped. Co-Authored-By: Claude Fable 5 --- .../CodexReviewMCPToolResults.swift | 24 +++++++++++++------ .../CodexReviewMCPHTTPServerTests.swift | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 21bfdfc9..836c1206 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -151,21 +151,31 @@ private extension ReviewMCPLogProjection { truncatedFields: &truncatedFields ), ] + // 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. + let limit = Self.detailedItemsLimit + let total = items.count + let pageItems = items.suffix(limit) + let returned = pageItems.count + let offset = total - returned let page = LogEntryPage( - total: items.count, - offset: 0, - limit: items.count, - returned: items.count, - hasMoreBefore: false, + total: total, + offset: offset, + limit: limit, + returned: returned, + hasMoreBefore: offset > 0, hasMoreAfter: false, - previousOffset: nil, + previousOffset: offset > 0 ? max(0, offset - limit) : nil, nextOffset: nil ) - object["items"] = .array(items.map { $0.structuredContent() }) + 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 { diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 35034a78..d99e36a7 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -547,7 +547,7 @@ struct CodexReviewMCPHTTPServerTests { #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 == 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) From 5c87cb06705a51b3d5a483c4b9f35fae650bb601 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:11:55 +0900 Subject: [PATCH 346/359] fix(app-server): keep review profile on restarts and defer thread cleanup Restart and cancel-resume paths rebuilt CodexThread.Options with only the model, dropping the review approval policy, permission profile, and source fields the initial start sets; route every review thread handle through one reviewThreadOptions builder. Terminal reviews also deleted their review/source threads immediately, removing the CodexChat records the sidebar and MCP log projection still read. cleanupReview now keeps session bookkeeping immediate but defers the destructive thread deletion until cleanupActiveReviewsForShutdown flushes it at runtime teardown, so completed review chats stay readable for the rest of the session. Co-Authored-By: Claude Fable 5 --- .../AppServerCodexReviewBackend.swift | 41 ++++++++++++++++--- .../AppServerClientTests.swift | 15 ++++++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3b1fcb5a..e9d1043b 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -27,6 +27,16 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { 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 @@ -135,8 +145,14 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private func reviewThreadOptions( _ request: CodexReviewBackendModel.Review.Start ) -> CodexThread.Options { - return .init( - model: request.model, + reviewThreadOptions(model: request.model) + } + + // 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( + model: model, approvalMode: .denyAll, permissions: .profile(id: Self.reviewPermissionProfileID), ephemeral: false, @@ -205,7 +221,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { let review = try await appServer.restartPreparedReview( appServerToken, target: request.request.target.appServerReviewTarget, - threadOptions: .init(model: interruptedRun.model ?? request.model) + threadOptions: reviewThreadOptions(model: interruptedRun.model ?? request.model) ) let attemptID = makeAppServerReviewAttemptID() let recoveredRun = CodexReviewBackendModel.Review.Run( @@ -237,13 +253,27 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics } } - await cleanupAppServerReview(run, additionalCleanupThreadIDs: additionalCleanupThreadIDs) + deferredThreadCleanupsByAttemptID[run.attemptID] = .init( + run: run, + additionalCleanupThreadIDs: additionalCleanupThreadIDs + ) for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) } } + 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 = [] @@ -267,6 +297,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } await cleanupReview(run) } + await flushDeferredThreadCleanups() } package func reviewEventSessionMetricsForTesting( @@ -416,7 +447,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } let review = try await appServer.resumeReview( identity, - threadOptions: .init(model: run.model) + threadOptions: reviewThreadOptions(model: run.model) ) return try await review.cancel() } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index e944047c..6a310848 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -229,7 +229,7 @@ struct AppServerClientTests { #expect(try await nextEvent(from: attempt.events) == .completed(finalReview: "No issues found.")) } - @Test func cleanupDeletesReviewThreadsThroughCodexThreadHandles() async throws { + @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") @@ -240,6 +240,13 @@ struct AppServerClientTests { let attempt = try await backend.startReview(makeReviewStart()) await backend.cleanupReview(attempt.run) + // Completed review chats stay readable until runtime teardown. + #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) + + await backend.cleanupActiveReviewsForShutdown( + .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: []) + ) + 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 } @@ -406,6 +413,12 @@ struct AppServerClientTests { 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 } From df5b4d2d45006dad6e2d899709a80666473479b1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:40:35 +0900 Subject: [PATCH 347/359] test(host): wait for the rate-limit refresh before asserting requests liveStoreCompletesBrowserLoginFromAccountNotifications synchronized only on the account update, so on slow CI runners the trailing account/rateLimits/read request could be missing from the asserted sequence. Wait for the full request count first, matching the sibling add-account test that already synchronizes through the refresh. Co-Authored-By: Claude Fable 5 --- Tests/CodexReviewHostTests/CodexReviewHostTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index c9fe95f4..be7639c5 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -565,6 +565,9 @@ struct CodexReviewHostTests { 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" }) From e1b69b44d7b1aee2865ac1cd7907a7469410362e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:30 +0900 Subject: [PATCH 348/359] fix(review-ui): seed workspace-group reorder from the current order The first workspace-group drag operated on an empty preferred-order array, so the reorder could not place the dragged group relative to the target; carry the currently displayed root order through the resolved drop and merge it before reordering, mirroring the chat reorder path. Co-Authored-By: Claude Fable 5 --- .../ReviewMonitorCodexSidebarOutline.swift | 11 +++++++++-- .../ReviewMonitorSidebarViewController.swift | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 15241b30..5fe31898 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -154,8 +154,15 @@ struct ReviewMonitorCodexSidebarPresentationOrder: Equatable { } } - mutating func reorderWorkspaceGroup(id: CodexWorkspaceGroupID, before targetID: CodexWorkspaceGroupID?) -> Bool { - Self.reorder(id: id, before: targetID, in: &workspaceGroupIDs) + 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( diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index fab59cee..9d8ae510 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -57,6 +57,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD case none case reorderCodexWorkspaceGroup( id: CodexWorkspaceGroupID, + currentOrder: [CodexWorkspaceGroupID], beforeID: CodexWorkspaceGroupID? ) case reorderCodexChat( @@ -971,7 +972,11 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD let beforeID = remainingRoots.dropFirst(displayDestinationIndex).compactMap(\.workspaceGroupID).first return SidebarResolvedDrop( - operation: .reorderCodexWorkspaceGroup(id: id, beforeID: beforeID), + operation: .reorderCodexWorkspaceGroup( + id: id, + currentOrder: codexSidebarOutlineTree.roots.compactMap(\.workspaceGroupID), + beforeID: beforeID + ), dropItem: nil, dropChildIndex: clampedDestinationIndex ) @@ -1221,8 +1226,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD switch resolvedDrop.operation { case .none: return false - case .reorderCodexWorkspaceGroup(let id, let beforeID): - guard codexSidebarPresentationOrder.reorderWorkspaceGroup(id: id, before: beforeID) else { + case .reorderCodexWorkspaceGroup(let id, let currentOrder, let beforeID): + guard + codexSidebarPresentationOrder.reorderWorkspaceGroup( + id: id, + currentOrder: currentOrder, + before: beforeID + ) + else { return false } applyFilteredCodexSidebarSections() From 29ab0d64c56ef27d673f631038fe02dee1b92f44 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:30 +0900 Subject: [PATCH 349/359] fix(store): cancel bounded review-start workers on caller cancellation The bounded startReview overload lacked the cancellation handler the unbounded path has, so MCP clients disconnecting mid-wait orphaned the active worker. Timeouts still return with the worker running. Co-Authored-By: Claude Fable 5 --- .../Store/CodexReviewStoreReviews.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index d848b780..3763bc68 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -34,7 +34,16 @@ extension CodexReviewStore { waitTimeout: Duration ) async throws -> CodexReviewAPI.Read.Result { let runID = try beginReview(sessionID: sessionID, request: request) - return try await awaitReview(sessionID: sessionID, runID: runID, timeout: waitTimeout) + // 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( From 9460586798fbe173ce6b906d3d76573f0c872a9f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:30 +0900 Subject: [PATCH 350/359] fix(store): disable the cancel command while cancellation is pending pendingReviewCancellation advertised an enabled capability with no action, leaving a clickable no-op Cancel menu item; the pending state is now visible but disabled, and the context-menu test pins the disabled state. Co-Authored-By: Claude Fable 5 --- .../CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift | 4 +++- Tests/ReviewUITests/ReviewUIShellTests.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index be659db3..548372c1 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -10,7 +10,9 @@ package struct CodexChatCancellationCapability: Equatable, Sendable { 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) - package static let pendingReviewCancellation = CodexChatCancellationCapability(isEnabled: true, action: nil) + // 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 diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index ed3b8964..8d8854bc 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -567,7 +567,9 @@ extension ReviewUITests { menu.performActionForItem(at: cancelIndex) } - #expect(cancelItemWasEnabled) + // 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 From 9be9a6539d37b1cbafd2c3c8e56c10697f766709 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:30 +0900 Subject: [PATCH 351/359] docs(app-server): document the external-browser login fallback CodexAppServerKit's loginChatGPT cannot yet forward the native web-authentication callback scheme to account/login/start, so the challenge must not be marked native; record why until the CodexKit API exists. Co-Authored-By: Claude Fable 5 --- .../CodexReviewAppServer/AppServerCodexReviewBackend.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index e9d1043b..ac700635 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -89,6 +89,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { 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 From 26b33056b03edaface4033a52cee4181779a0bf3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:05:50 +0900 Subject: [PATCH 352/359] build: update the shared SwiftPM scheme for renamed test targets The CodexReview scheme still referenced CodexReviewTests (renamed to CodexReviewKitTests) and the deleted ArchitectureFenceTests, so Xcode and xcodebuild -scheme CodexReview test hit unresolved testables. Co-Authored-By: Claude Fable 5 --- .../xcschemes/CodexReview.xcscheme | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) 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:"> - - - - Date: Fri, 3 Jul 2026 23:05:50 +0900 Subject: [PATCH 353/359] fix(review-ui): count nil-status chats as finished in the latest filter Listed chats frequently omit status; the latest-finished scan skipped them, hiding completed chats unless the backend supplied an explicit idle status. Classify finished as not-actively-running, matching the rest of the sidebar. Co-Authored-By: Claude Fable 5 --- .../CodexChats/ReviewMonitorCodexSidebarOutline.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index 5fe31898..b2d52f30 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -114,7 +114,10 @@ extension Array where Element == CodexFetchSection { var latestChat: CodexChat? var latestDate = Date.distantPast for chat in chats { - guard chat.status.map({ $0.isActive == false }) ?? false else { + // 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 From 27c2285ee1af833113dff56361a9ea35dcb51dd5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:05:50 +0900 Subject: [PATCH 354/359] fix(mcp): bound detailed log entry id arrays to the returned page orderedEntryIds/activeEntryIds still serialized every projected entry, so detailed review_read responses kept scaling with the full transcript despite the 100-item tail page; filter both arrays to the returned window. Co-Authored-By: Claude Fable 5 --- .../CodexReviewMCPToolResults.swift | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 836c1206..aed05baf 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -133,32 +133,17 @@ private extension ReviewMCPLogProjection { } func structuredContentWithItems() -> 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 - ), - ] // 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. + // 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 = items.suffix(limit) + 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, @@ -169,6 +154,28 @@ private extension ReviewMCPLogProjection { 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)) From 7620f78b0cc529fdef2e4855b4f2c0acb8b57c83 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:22 +0900 Subject: [PATCH 355/359] fix(host): restore CODEX_HOME and legacy account-kind compatibility The runtime-home fallback ignored CODEX_HOME, starting the app-server against HOME/.codex_review for users and CI that isolate homes with the environment variable; honor it first, matching the pre-migration AppServerCodexHome.url order. Account registries written before the kind field also failed to decode wholesale, dropping persisted accounts; restore the decodeIfPresent + legacyDefault behavior. Co-Authored-By: Claude Fable 5 --- .../LiveCodexReviewStoreBackend.swift | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index a849a8a0..5b8142e8 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -321,6 +321,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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) @@ -1750,7 +1754,10 @@ 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) - self.kind = try container.decode(Kind.self, forKey: .kind) + // 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) self.lastActivatedAt = try container.decodeIfPresent(Date.self, forKey: .lastActivatedAt) self.lastRateLimitFetchAt = try container.decodeIfPresent(Date.self, forKey: .lastRateLimitFetchAt) @@ -1767,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: From dd05d11c93e3ceadeaccb2a47ec5a6dc1395a278 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:32:14 +0900 Subject: [PATCH 356/359] fix(mcp): resolve structured final review from the log projection structuredContent still built finalReview/hasFinalReview/reviewResult from core.finalReview alone, so clients consuming structured results saw a terminal review with finalReview: null whenever only the CodexChat log projection held the final review; pass the resolved value from the read path, matching the text content. Co-Authored-By: Claude Fable 5 --- .../CodexReviewMCPToolResults.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index aed05baf..6b5d4d02 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -89,7 +89,9 @@ private extension CodexReviewAPI.Read.Result { elapsedSeconds: elapsedSeconds, cancellable: cancellable ), - "review": core.structuredReviewContent(), + "review": core.structuredReviewContent( + resolvedFinalReview: log.finalResult?.nilIfEmpty ?? core.finalReview + ), ] object["log"] = includeDetails @@ -424,8 +426,11 @@ private extension ReviewRunCore { ) } - func structuredReviewContent() -> Value { - let finalReview = self.finalReview?.nilIfEmpty + // 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, From 4cee5f307eda007dbd34835d59bb04340d5dda28 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:30:53 +0900 Subject: [PATCH 357/359] fix(ui): clear the chat log pane when an observation fails Switching chats intentionally keeps the previous transcript on screen until the next baseline renders (anti-flash, pinned by test), but when the new observation throws no baseline ever arrives and another chat's log stayed visible indefinitely; clear the pane in the failure path while the failed chat is still bound. Co-Authored-By: Claude Fable 5 --- .../ReviewMonitorCodexChatLogTarget.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index ead102dc..e8a33730 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -207,6 +207,17 @@ package final class ReviewMonitorCodexChatLogTarget { 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() } } } From b652a921b0a64406e8d574ae135eaa7624bf9b23 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:58:28 +0900 Subject: [PATCH 358/359] fix(review-ui): reorder workspace groups against the unfiltered order Dragging a group while a chat filter was active passed only the visible roots as the current order, so hidden groups were dropped from the remembered order and moved when the filter cleared. Use the unfiltered source-section group order and map end-of-visible-list drops to the next unfiltered group, mirroring the chat reorder path. Co-Authored-By: Claude Fable 5 --- .../ReviewMonitorSidebarViewController.swift | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 9d8ae510..68b2c1e3 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -969,12 +969,21 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } let remainingRoots = codexSidebarOutlineTree.roots.filter { $0 !== sourceNode } - let beforeID = remainingRoots.dropFirst(displayDestinationIndex).compactMap(\.workspaceGroupID).first + 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: codexSidebarOutlineTree.roots.compactMap(\.workspaceGroupID), + currentOrder: currentOrder, beforeID: beforeID ), dropItem: nil, @@ -982,6 +991,32 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } + 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, From 51b27e10651e377b4961243bd1e16c000735c735 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:40:33 +0900 Subject: [PATCH 359/359] fix(mcp): harden log projection final result and revisions The final MCP result took the last message item regardless of role, so a trailing user prompt could replace the review findings; only agent messages qualify now, with the existing core.finalReview fallback covering transcripts without one. Item revisions also digested only the text length, hiding same-length edits from clients that compare log.revision; digest the content instead. Co-Authored-By: Claude Fable 5 --- .../ReviewMCPLogProjection.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 5afbae1f..acf10b01 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -70,7 +70,9 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } let itemRevision = threadItems .map { item in - "\(item.id):\(item.kind.rawValue):\(item.text?.count ?? 0)" + // 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 = [ @@ -94,8 +96,26 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } 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().compactMap { $0.content.messageText }.first + 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) } }