diff --git a/README.md b/README.md index b0b7676..46b511f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Listen to the SwiftASB Codex apps promo clip: ### Status -SwiftASB is actively maintained and supported by Gale. Our current API is v1, and `v1.7.4` is the current and latest release. +SwiftASB is actively maintained and supported by Gale. Our current API is v1, and `v1.7.5` is the current and latest release. ### What This Project Is @@ -38,7 +38,7 @@ I built SwiftASB because I saw so many others building and forking existing Apps Add SwiftASB to your `Package.swift` dependencies: ```swift -.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.7.4"), +.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.7.5"), ``` Then add the library product to your target dependencies: @@ -60,7 +60,7 @@ Check your Codex version: ```bash codex --version ``` -*Note: SwiftASB currently supports the latest reviewed Codex CLI minor release, `0.139.x`. This narrow reviewed window will be revised once the app-server schema stabilizes or Codex CLI reaches a v1.x.x release.* +*Note: SwiftASB currently supports the reviewed current Codex CLI minor release, `0.140.x`, and the latest prior minor, `0.139.x`, when that prior runtime remains compatible. This narrow reviewed window will be revised once the app-server schema stabilizes or Codex CLI reaches a v1.x.x release.* Add the Socket Marketplace to Codex and enable the SwiftASB Skills Plugin: diff --git a/ROADMAP.md b/ROADMAP.md index 4edfc2b..d16a393 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ | --- | --- | --- | | Bundled schema-driven wire generation | `Shipped internally` | `scripts/generate-wire-types.sh` derives from the bundled v2 schema, patches dynamic JSON to `CodexWireJSONValue`, and validates the staged Swift output. | | Promoted generated v2 wire snapshot | `Shipped internally` | `Sources/SwiftASB/Generated/CodexWire/Latest/` now contains a wider lifecycle batch covering bootstrap, stored and loaded thread reads, filesystem reads and watches, config reads, extension inventory, remote-control status plus pairing/client-management wire families, thread goals, and many thread, turn, item, reasoning, and tool-progress notifications, alongside the hand-owned `CodexWireInitializeResponse` shim. | -| Codex CLI schema review | `Shipped / ongoing` | The current reviewed compatibility window is `codex-cli 0.139.x`; the staged `v0.139.0` refresh added an optional thread target to `mcpServer/startupStatus/updated` and wording-only turn-environment comment drift, and SwiftASB kept the new MCP status target internal-only for now while preserving the repeatable dump and generation path through `scripts/dump-codex-schemas.sh` and `scripts/generate-wire-types.sh`. | +| Codex CLI schema review | `Shipped / ongoing` | The current reviewed compatibility window is `codex-cli 0.140.x plus 0.139.x when feasible`; the staged `v0.140.0` refresh promoted additive config, plugin/app metadata, selected capability roots, and sub-agent activity wire changes internally, widened the public turn-item kind for `subAgentActivity`, and removed old public compatibility shims for fields no longer present in the reviewed app-server shape while preserving the repeatable dump and generation path through `scripts/dump-codex-schemas.sh` and `scripts/generate-wire-types.sh`. | | Stdio subprocess transport | `Shipped internally` | The transport launches `codex app-server --listen stdio://`, frames newline-delimited JSON, correlates request IDs, and captures stderr for diagnostics. | | Raw server-event fanout | `Shipped internally` | Transport can stream raw JSON-RPC notifications and server requests to higher layers. | | Typed protocol request encoding | `Shipped internally` | `initialize`, `initialized`, core thread and turn methods, archive-state actions, filesystem reads and watches, config reads, app/skill/plugin/collaboration-mode inventory, model/MCP/hook reads, MCP resource reads, and thread-goal methods are encoded through the protocol layer. | @@ -50,7 +50,7 @@ | Typed protocol notification decoding | `Partially shipped` | The protocol layer now maps a broader batch of app, thread, turn, item, reasoning, hook, MCP-status, config-warning, deprecation, remote-control, and reroute notifications, plus the item lifecycle needed to drive the current observable tool, MCP, file-edit, hook, and compaction summaries. | | Public owning client actor | `Shipped` | `CodexAppServer` owns transport plus protocol and exposes startup, shutdown, initialize, thread start, and turn start. | | Public value-typed request and result models | `Shipped` | Public API uses hand-owned Swift value types rather than exposing `CodexWire...` directly. | -| App-wide capability surfaces | `Partially shipped` | `CodexExtensions.Inventory` now provides observable model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes with SwiftASB-owned refresh from app-server inventory notifications. Direct methods such as `listModels(...)`, `readModelCapabilities()`, `listHooks(...)`, `extensions.mcp.statusSnapshot()`, and `extensions.mcp.readResource(...)` remain available for one-off reads and inspector-style detail. Deprecated app-server inventory and MCP conveniences remain compatibility-only while consumers move to the top-level extension owner. Broader app-wide settings and actions still need deliberate public models before promotion. | +| App-wide capability surfaces | `Partially shipped` | `CodexExtensions.Inventory` now provides observable model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes with SwiftASB-owned refresh from app-server inventory notifications. Direct methods such as `listModels(...)`, `readModelCapabilities()`, `listHooks(...)`, `extensions.mcp.statusSnapshot()`, and `extensions.mcp.readResource(...)` remain available for one-off reads and inspector-style detail. Broader app-wide settings and actions still need deliberate public models before promotion. | | Initialize handshake | `Shipped` | `initialize(...)` automatically sends the follow-up `initialized` notification. | | Thread start flow | `Shipped` | `startThread(...)` returns `CodexThread`, which carries thread metadata plus a back-reference to the shared app-server owner. | | Stored thread list flow | `Shipped` | `listThreads(...)` wraps `thread/list`, returns typed stored-thread pages, and now reconciles local thread metadata plus explicit archived or unarchived list results back into the internal history store. | @@ -88,7 +88,7 @@ | Internal thread history persistence | `Partially shipped` | The package now has a Core Data-backed `ThreadHistoryStore` that persists live-built thread and turn history, hydrates stored turns from `thread/read`, `thread/resume`, `thread/fork`, and `thread/turns/list`, seeds previously unknown local threads from paged history, widens persisted turn identity to stay thread-scoped across forks, and records explicit fork lineage while preserving conservative reconciliation that keeps richer local detail when upstream stored history is thinner. Public history paging/search helpers and archive-retention policy are still open. | | Direct local Codex thread storage | `Planned / prototyped` | SwiftASB now has a maintainer plan for an opt-in, read-only local Codex storage reader that can inventory threads through Codex's SQLite metadata and lazily hydrate JSONL evidence without forcing every caller through the app-server JSONL pipe. AgentSB prototypes the inspection shape for reports only; the package API, version gates, privacy defaults, and fallback behavior remain separate SwiftASB design work. See [`docs/maintainers/codex-direct-thread-storage-plan.md`](docs/maintainers/codex-direct-thread-storage-plan.md). | | Convenience run API | `Not started` | No `run(...)` or one-shot text convenience layer yet. | -| Binary discovery and compatibility policy | `Partially shipped` | Explicit binary override exists, the docs now define a current-reviewed Codex CLI support window of `0.139.x`, transport startup checks PATH, common Homebrew paths, and the npm global prefix on macOS, and `cliExecutableDiagnostics()` now exposes the resolved binary, version string, and documented support-window assessment. Any further diagnostics work is now expansion rather than a missing baseline surface. | +| Binary discovery and compatibility policy | `Partially shipped` | Explicit binary override exists, the docs now define a current-reviewed Codex CLI support window of `0.140.x plus 0.139.x when feasible`, transport startup checks PATH, common Homebrew paths, and the npm global prefix on macOS, and `cliExecutableDiagnostics()` now exposes the resolved binary, version string, and documented support-window assessment. Any further diagnostics work is now expansion rather than a missing baseline surface. | | README-level consumer docs | `Shipped / ongoing` | The README covers installation, runtime assumptions, first-use examples, the supported lifecycle, SwiftUI companion surfaces, and the current Codex CLI compatibility window. Future README work should track new public API additions rather than prerelease readiness. | | AgentSB maintainer automation | `Report-first maintainer app` | `Tools/AgentSB/` is a repo-local Python maintainer app that inspects SwiftASB deterministically, writes tracked reports under `docs/agents/reports/`, evaluates safety-boundary cases, diffs schema dumps, writes reviewable maintenance drafts, and prototypes local Codex thread-index inspection for future SwiftASB planning. The v1 boundary stays report-first: safe auto-apply is classifier-gated and limited to AgentSB-owned report artifacts, and it must not mutate Swift source, generated wire snapshots, public API, releases, or behavior-changing docs. | | Agent workflow guidance | `Shipped / ongoing` | SwiftASB-specific Codex guidance now ships through `socket`'s [`swiftasb-skills`](https://github.com/gaelic-ghost/socket/tree/main/plugins/swiftasb-skills) plugin, with skills for explaining SwiftASB, choosing an integration shape, building SwiftUI-facing app state, and diagnosing integration failures. This repo now points package users and maintainers at that plugin while keeping SwiftASB source, DocC, tests, generated-wire review, and release notes here as the package source of truth. | @@ -112,17 +112,17 @@ lifecycle, SPI visibility, basic history hydration, first-pass reconciliation, or command-approval completion. Those slices now exist and shipped in the `v1.7.1` baseline. -The next meaningful work after the `v1.7.4` patch release is to probe and deliberately shape the newly promoted -Codex CLI `0.139.x` wire families before widening public API. The `v0.139.0` -promotion itself stayed intentionally small: SwiftASB refreshed the promoted -wire snapshot, kept the new optional `threadId` on -`mcpServer/startupStatus/updated` internal-only for now, and left the public -MCP diagnostics surface app-wide and summary-oriented. Remote-control +The next meaningful work after the `v1.7.5` patch release is to probe and deliberately shape the newly promoted +Codex CLI `0.140.x` wire families before widening public API further. The +`v0.140.0` promotion refreshes the internal wire snapshot, exposes +`subAgentActivity` as a public turn-item kind, and removes older public +compatibility shims for prior app-server shapes rather than carrying fallback +fields forward. Capability roots, plugin sharing metadata, remote-control pairing/client management and pairing status, account token usage, turn -moderation metadata, plugin app templates, skills extra roots, richer -turn-start context, runtime workspace roots, environments, structured output -schemas, and rollout-path forking still need live behavior evidence before -SwiftASB turns them into stable Swift surfaces. Descriptors should compile +moderation metadata, plugin app templates, richer turn-start context, runtime +workspace roots, environments, structured output schemas, and rollout-path +forking still need live behavior evidence before SwiftASB turns them into +stable Swift surfaces. Descriptors should compile against Codex-owned workspace, Git, file, and thread facts wherever possible, rather than making SwiftASB or a sandboxed client infer repository identity by walking the local filesystem. @@ -782,10 +782,10 @@ workflow earns them in a later feature release. public API until live behavior and consumer use cases are clearer. - [x] Decide whether v1 should support only the latest documented rolling window or whether a shorter first-v1 compatibility promise is more honest. - Decision: use a narrow latest-reviewed-minor support window, currently - `0.139.x`, and widen deliberately after generated-wire and public API review - catches up - with later Codex CLI releases. + Decision: use a narrow current-plus-latest-prior support window where + feasible, currently `0.140.x plus 0.139.x when feasible`, and widen + deliberately after generated-wire and public API review catches up with later + Codex CLI releases. ### History And Observable Companions @@ -893,7 +893,8 @@ workflow earns them in a later feature release. #### Compatibility Window - The compatibility promise is intentionally narrow while app-server schema is - moving quickly: reviewed support for Codex CLI `0.139.x`. + moving quickly: reviewed support for current Codex CLI `0.140.x` plus the + latest prior minor `0.139.x` when feasible. - SwiftASB discovers `codex` from an explicit executable URL, `PATH`, common Homebrew locations, or the npm global prefix, and exposes startup diagnostics through `cliExecutableDiagnostics()`. @@ -903,7 +904,7 @@ workflow earns them in a later feature release. #### Migration Notes - Existing `v0.9.x` consumers should update the SwiftPM dependency to - `from: "1.7.4"` once the tag is published. + `from: "1.7.5"` once the tag is published. - The v1 API surface has removed stale pre-v1 compatibility shims and phantom fields that no longer exist in the reviewed `v0.128.0` schema. - Same-thread overlapping turns are rejected client-side with @@ -928,7 +929,7 @@ workflow earns them in a later feature release. - Keep an eye on future Swift Package Index builds after compatibility-window or DocC changes; the `v1.1.1` listing and documentation link are live, and - `v1.7.4` should be rechecked after the patch tag is indexed. + `v1.7.5` should be rechecked after the patch tag is indexed. - Add broader live server-request coverage for permissions and MCP elicitation if those become stronger public runtime guarantees. - Continue tuning recent companion cache calibration, richer file previews, @@ -1181,13 +1182,13 @@ runtime can be driven with a mock Responses provider. completion. - [x] MCP server elicitation. Decision: deterministic fake-transport coverage proves public routing and - response behavior, and the opt-in live server-request runner now drives an - app-connector MCP fixture through the real app-server. The probe asserts MCP - tool-call delivery, `mcpServer/elicitation/request` delivery, SwiftASB's - JSON-RPC response, `serverRequest/resolved`, and terminal turn completion. - The regular stdio MCP fixture remains available as an explicitly opted-in - observational probe, while app-connector MCP is the deterministic live - elicitation coverage source. + response behavior. The opt-in live server-request runner keeps an + app-connector MCP fixture in the release gate, but current Codex CLI behavior + may complete the turn without routing that fixture to the MCP event stream. + When the app-server emits the MCP tool call, the probe still asserts + `mcpServer/elicitation/request` delivery, SwiftASB's JSON-RPC response, + `serverRequest/resolved`, and terminal turn completion. The regular stdio MCP + fixture remains available as an explicitly opted-in observational probe. - [ ] Guardian denied-action approval after SwiftASB owns a stable public model. - [x] Model capability snapshot through `CodexAppServer.readModelCapabilities()`. diff --git a/Sources/ASBAppKit/ASBThreadSidebarView.swift b/Sources/ASBAppKit/ASBThreadSidebarView.swift index 62014c9..154c0d8 100644 --- a/Sources/ASBAppKit/ASBThreadSidebarView.swift +++ b/Sources/ASBAppKit/ASBThreadSidebarView.swift @@ -20,8 +20,8 @@ public final class ASBThreadSidebarView: NSView { ) { self.snapshot = snapshot self.onIntent = onIntent - self.scrollView = NSScrollView() - self.outlineView = NSOutlineView() + scrollView = NSScrollView() + outlineView = NSOutlineView() super.init(frame: .zero) configureViewHierarchy() apply(snapshot) @@ -102,6 +102,7 @@ public final class ASBThreadSidebarView: NSView { let rowIndex = outlineView.row(forItem: row) guard rowIndex >= 0 else { return } + outlineView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) } @@ -111,6 +112,7 @@ public final class ASBThreadSidebarView: NSView { let row = outlineView.item(atRow: selectedRow) as? ASBThreadSidebarRow, let item = row.threadItem else { return } + onIntent?(.openThread(id: item.id)) } } @@ -144,6 +146,7 @@ extension ASBThreadSidebarView: NSOutlineViewDataSource { extension ASBThreadSidebarView: NSOutlineViewDelegate { public func outlineViewSelectionDidChange(_ notification: Notification) { guard !suppressSelectionIntent else { return } + let selectedRow = outlineView.selectedRow guard selectedRow >= 0, let row = outlineView.item(atRow: selectedRow) as? ASBThreadSidebarRow, @@ -152,6 +155,7 @@ extension ASBThreadSidebarView: NSOutlineViewDelegate { onIntent?(.selectThread(id: nil)) return } + onIntent?(.selectThread(id: item.id)) } @@ -161,6 +165,7 @@ extension ASBThreadSidebarView: NSOutlineViewDelegate { item: Any ) -> NSView? { guard let row = item as? ASBThreadSidebarRow else { return nil } + let cell = outlineView.makeView( withIdentifier: ASBThreadSidebarRowView.identifier, owner: self @@ -181,11 +186,12 @@ extension ASBThreadSidebarView: NSOutlineViewDelegate { heightOfRowByItem item: Any ) -> CGFloat { guard let row = item as? ASBThreadSidebarRow else { return 28 } + switch row.kind { - case .section: - return 24 - case .thread: - return 44 + case .section: + return 24 + case .thread: + return 44 } } } @@ -197,6 +203,10 @@ final class ASBThreadSidebarAdapter { private(set) var sections: [ASBThreadSidebarRow] = [] + var flattenedRows: [ASBThreadSidebarRow] { + sections.flatMap { [$0] + $0.children } + } + init(snapshot: ThreadSidebarSnapshot = .init()) { apply(snapshot) } @@ -205,10 +215,6 @@ final class ASBThreadSidebarAdapter { sections = snapshot.sections.map(ASBThreadSidebarRow.init(section:)) } - var flattenedRows: [ASBThreadSidebarRow] { - sections.flatMap { [$0] + $0.children } - } - func row(forThreadID threadID: String) -> ASBThreadSidebarRow? { sections.lazy .flatMap(\.children) @@ -219,6 +225,7 @@ final class ASBThreadSidebarAdapter { guard let row = item as? ASBThreadSidebarRow else { return sections.count } + return row.children.count } @@ -226,11 +233,13 @@ final class ASBThreadSidebarAdapter { guard let row = item as? ASBThreadSidebarRow else { return sections[index] } + return row.children[index] } func isExpandable(_ item: Any) -> Bool { guard let row = item as? ASBThreadSidebarRow else { return false } + return !row.children.isEmpty } } @@ -238,7 +247,7 @@ final class ASBThreadSidebarAdapter { /// One AppKit outline row derived from a presentation snapshot. @MainActor final class ASBThreadSidebarRow: NSObject, Identifiable { - enum Kind: Sendable, Equatable { + enum Kind: Equatable { case section case thread } @@ -251,22 +260,22 @@ final class ASBThreadSidebarRow: NSObject, Identifiable { let children: [ASBThreadSidebarRow] init(section: ThreadSidebarSection) { - self.id = "section:\(section.id)" - self.kind = .section - self.title = section.title - self.subtitle = nil - self.threadItem = nil - self.children = section.items.map(ASBThreadSidebarRow.init(item:)) + id = "section:\(section.id)" + kind = .section + title = section.title + subtitle = nil + threadItem = nil + children = section.items.map(ASBThreadSidebarRow.init(item:)) super.init() } init(item: ThreadSidebarItem) { - self.id = "thread:\(item.id)" - self.kind = .thread - self.title = item.title - self.subtitle = Self.subtitle(for: item) - self.threadItem = item - self.children = [] + id = "thread:\(item.id)" + kind = .thread + title = item.title + subtitle = Self.subtitle(for: item) + threadItem = item + children = [] super.init() } diff --git a/Sources/ASBPresentation/AgendaDashboardPresentation.swift b/Sources/ASBPresentation/AgendaDashboardPresentation.swift index d59cc20..d5cf698 100644 --- a/Sources/ASBPresentation/AgendaDashboardPresentation.swift +++ b/Sources/ASBPresentation/AgendaDashboardPresentation.swift @@ -10,6 +10,14 @@ public struct AgendaSnapshot: Sendable, Equatable { public var proposedPlan: AgendaProposedPlan? public var updatedAt: Int? + public var hasGoal: Bool { + !goalTitle.isEmpty + } + + public var hasPlan: Bool { + currentPlan != nil || proposedPlan != nil + } + public init( threadID: String? = nil, goalTitle: String = "", @@ -28,14 +36,6 @@ public struct AgendaSnapshot: Sendable, Equatable { self.updatedAt = updatedAt } - public var hasGoal: Bool { - !goalTitle.isEmpty - } - - public var hasPlan: Bool { - currentPlan != nil || proposedPlan != nil - } - @MainActor public init(agenda: CodexThread.Agenda) { self.init( @@ -142,18 +142,18 @@ public enum AgendaGoalStatus: String, Sendable, Equatable { public init(_ status: CodexThread.Goal.Status) { switch status { - case .active: - self = .active - case .blocked: - self = .blocked - case .budgetLimited: - self = .budgetLimited - case .complete: - self = .complete - case .paused: - self = .paused - case .usageLimited: - self = .usageLimited + case .active: + self = .active + case .blocked: + self = .blocked + case .budgetLimited: + self = .budgetLimited + case .complete: + self = .complete + case .paused: + self = .paused + case .usageLimited: + self = .usageLimited } } } @@ -165,12 +165,12 @@ public enum AgendaStepStatus: String, Sendable, Equatable { public init(_ status: CodexThread.Agenda.Plan.Step.Status) { switch status { - case .completed: - self = .completed - case .inProgress: - self = .inProgress - case .pending: - self = .pending + case .completed: + self = .completed + case .inProgress: + self = .inProgress + case .pending: + self = .pending } } } @@ -261,22 +261,22 @@ public struct DashboardSnapshot: Sendable, Equatable { private static func describe(_ diagnostic: CodexDiagnosticEvent) -> String { switch diagnostic { - case let .warning(warning): - warning.message - case let .guardianWarning(warning): - warning.message - case let .modelRerouted(reroute): - "Model rerouted from \(reroute.fromModel) to \(reroute.toModel)." - case .modelVerification: - "Model verification updated." - case let .configWarning(warning): - warning.summary - case let .deprecationNotice(notice): - notice.summary - case let .mcpServerStatusChanged(status): - "MCP server \(status.name) status changed to \(status.status.rawValue)." - case let .remoteControlStatusChanged(status): - "Remote control \(status.serverName) status changed to \(status.status.rawValue)." + case let .warning(warning): + warning.message + case let .guardianWarning(warning): + warning.message + case let .modelRerouted(reroute): + "Model rerouted from \(reroute.fromModel) to \(reroute.toModel)." + case .modelVerification: + "Model verification updated." + case let .configWarning(warning): + warning.summary + case let .deprecationNotice(notice): + notice.summary + case let .mcpServerStatusChanged(status): + "MCP server \(status.name) status changed to \(status.status.rawValue)." + case let .remoteControlStatusChanged(status): + "Remote control \(status.serverName) status changed to \(status.status.rawValue)." } } } @@ -288,12 +288,12 @@ public enum DashboardActivityStatus: String, Sendable, Equatable { public init(_ status: CodexThread.Dashboard.ActivityStatus) { switch status { - case .errored: - self = .errored - case .idle: - self = .idle - case .inProgress: - self = .inProgress + case .errored: + self = .errored + case .idle: + self = .idle + case .inProgress: + self = .inProgress } } } @@ -308,18 +308,18 @@ public enum DashboardAutoReviewStatus: String, Sendable, Equatable { public init(_ status: CodexThread.Dashboard.AutoReviewStatus) { switch status { - case .aborted: - self = .aborted - case .approved: - self = .approved - case .denied: - self = .denied - case .idle: - self = .idle - case .inProgress: - self = .inProgress - case .timedOut: - self = .timedOut + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .idle: + self = .idle + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut } } } diff --git a/Sources/ASBPresentation/RecentActivityPresentation.swift b/Sources/ASBPresentation/RecentActivityPresentation.swift index fa5bea4..e209433 100644 --- a/Sources/ASBPresentation/RecentActivityPresentation.swift +++ b/Sources/ASBPresentation/RecentActivityPresentation.swift @@ -9,6 +9,10 @@ public struct RecentActivitySnapshot: Sendable, Equatable { public var canLoadOlderItems: Bool public var errorDescription: String? + public var isEmpty: Bool { + items.isEmpty + } + public init( items: [RecentActivityItem] = [], selectedItemID: String? = nil, @@ -25,10 +29,6 @@ public struct RecentActivitySnapshot: Sendable, Equatable { self.errorDescription = errorDescription } - public var isEmpty: Bool { - items.isEmpty - } - @MainActor public init( recentFiles: CodexThread.RecentFiles? = nil, @@ -55,36 +55,36 @@ public struct RecentActivitySnapshot: Sendable, Equatable { private static func sort(_ lhs: RecentActivityItem, _ rhs: RecentActivityItem) -> Bool { switch (lhs.turnStartedAt, rhs.turnStartedAt) { - case let (left?, right?) where left != right: - return left > right - case (nil, _?): - return false - case (_?, nil): - return true - default: - break + case let (left?, right?) where left != right: + return left > right + case (nil, _?): + return false + case (_?, nil): + return true + default: + break } switch (lhs.turnOrderIndex, rhs.turnOrderIndex) { - case let (left?, right?) where left != right: - return left > right - case (nil, _?): - return false - case (_?, nil): - return true - default: - break + case let (left?, right?) where left != right: + return left > right + case (nil, _?): + return false + case (_?, nil): + return true + default: + break } switch (lhs.itemOrderIndex, rhs.itemOrderIndex) { - case let (left?, right?) where left != right: - return left < right - case (nil, _?): - return false - case (_?, nil): - return true - default: - return lhs.id < rhs.id + case let (left?, right?) where left != right: + return left < right + case (nil, _?): + return false + case (_?, nil): + return true + default: + return lhs.id < rhs.id } } } @@ -192,23 +192,23 @@ public enum RecentActivityStatus: String, Sendable, Equatable { public init(_ status: CodexThread.RecentFiles.FileSnapshot.Status) { switch status { - case .completed: - self = .completed - case .errored: - self = .errored - case .inProgress: - self = .inProgress + case .completed: + self = .completed + case .errored: + self = .errored + case .inProgress: + self = .inProgress } } public init(_ status: CodexThread.RecentCommands.CommandSnapshot.Status) { switch status { - case .completed: - self = .completed - case .errored: - self = .errored - case .inProgress: - self = .inProgress + case .completed: + self = .completed + case .errored: + self = .errored + case .inProgress: + self = .inProgress } } } diff --git a/Sources/ASBPresentation/ThreadSidebarPresentation.swift b/Sources/ASBPresentation/ThreadSidebarPresentation.swift index 1f0f8ed..5d59731 100644 --- a/Sources/ASBPresentation/ThreadSidebarPresentation.swift +++ b/Sources/ASBPresentation/ThreadSidebarPresentation.swift @@ -12,6 +12,16 @@ public struct ThreadSidebarSnapshot: Sendable, Equatable { public var isLoading: Bool public var errorDescription: String? + /// All visible items in renderer order. + public var items: [ThreadSidebarItem] { + sections.flatMap(\.items) + } + + /// True when there is no visible thread row. + public var isEmpty: Bool { + items.isEmpty + } + public init( sections: [ThreadSidebarSection] = [], selection: ThreadSelectionState = .init(), @@ -24,16 +34,6 @@ public struct ThreadSidebarSnapshot: Sendable, Equatable { self.errorDescription = errorDescription } - /// All visible items in renderer order. - public var items: [ThreadSidebarItem] { - sections.flatMap(\.items) - } - - /// True when there is no visible thread row. - public var isEmpty: Bool { - items.isEmpty - } - /// Projects the current app-wide library companion into a sidebar snapshot. @MainActor public init( @@ -73,6 +73,7 @@ public struct ThreadSidebarSnapshot: Sendable, Equatable { from threads: [CodexAppServer.Library.ThreadSnapshot] ) -> [ThreadSidebarSection] { guard !threads.isEmpty else { return [] } + return [ ThreadSidebarSection( id: ThreadSidebarSection.defaultSectionID, @@ -237,20 +238,20 @@ public enum ThreadSidebarSourceBadge: Sendable, Equatable { public init(source: CodexAppServer.ThreadSource) { switch source { - case .appServer: - self = .appServer - case .cli: - self = .cli - case .exec: - self = .exec - case .vscode: - self = .vscode - case let .custom(label): - self = .custom(label) - case let .subAgent(source): - self = .subAgent(kind: source.kind.rawValue) - case .unknown: - self = .unknown + case .appServer: + self = .appServer + case .cli: + self = .cli + case .exec: + self = .exec + case .vscode: + self = .vscode + case let .custom(label): + self = .custom(label) + case let .subAgent(source): + self = .subAgent(kind: source.kind.rawValue) + case .unknown: + self = .unknown } } } @@ -277,14 +278,14 @@ public enum ThreadSidebarActivityStatus: Sendable, Equatable { self = .waitingOnUserInput } else { switch thread.status.type { - case .active: - self = .active - case .idle: - self = .idle - case .notLoaded: - self = .notLoaded - case .systemError: - self = .systemError + case .active: + self = .active + case .idle: + self = .idle + case .notLoaded: + self = .notLoaded + case .systemError: + self = .systemError } } } diff --git a/Sources/ASBPresentation/TurnTimelinePresentation.swift b/Sources/ASBPresentation/TurnTimelinePresentation.swift index 4765c99..d4a83af 100644 --- a/Sources/ASBPresentation/TurnTimelinePresentation.swift +++ b/Sources/ASBPresentation/TurnTimelinePresentation.swift @@ -11,6 +11,16 @@ public struct TurnTimelineSnapshot: Sendable, Equatable { public var canLoadNewerTurns: Bool public var errorDescription: String? + /// All timeline items in renderer order. + public var items: [TurnTimelineItem] { + sections.flatMap(\.items) + } + + /// True when there is no visible turn or active call row. + public var isEmpty: Bool { + items.isEmpty + } + public init( sections: [TurnTimelineSection] = [], viewport: TurnTimelineViewportState = .init(), @@ -31,16 +41,6 @@ public struct TurnTimelineSnapshot: Sendable, Equatable { self.errorDescription = errorDescription } - /// All timeline items in renderer order. - public var items: [TurnTimelineItem] { - sections.flatMap(\.items) - } - - /// True when there is no visible turn or active call row. - public var isEmpty: Bool { - items.isEmpty - } - @MainActor public init( recentTurns: CodexThread.RecentTurns, @@ -264,37 +264,37 @@ public enum TurnTimelineItemKind: String, Sendable, Equatable { public init(rawKind: String) { switch rawKind { - case "agentMessage": - self = .agentMessage - case "collabAgentToolCall": - self = .collabTool - case "commandExecution": - self = .command - case "dynamicToolCall": - self = .dynamicTool - case "fileChange": - self = .fileEdit - case "mcpToolCall": - self = .mcp - case "reasoning": - self = .reasoning - default: - self = .unknown + case "agentMessage": + self = .agentMessage + case "collabAgentToolCall": + self = .collabTool + case "commandExecution": + self = .command + case "dynamicToolCall": + self = .dynamicTool + case "fileChange": + self = .fileEdit + case "mcpToolCall": + self = .mcp + case "reasoning": + self = .reasoning + default: + self = .unknown } } public init(callKind: CodexTurnHandle.Minimap.CallSnapshot.Kind) { switch callKind { - case .collabTool: - self = .collabTool - case .command: - self = .command - case .dynamicTool: - self = .dynamicTool - case .fileEdit: - self = .fileEdit - case .mcp: - self = .mcp + case .collabTool: + self = .collabTool + case .command: + self = .command + case .dynamicTool: + self = .dynamicTool + case .fileEdit: + self = .fileEdit + case .mcp: + self = .mcp } } } @@ -337,19 +337,19 @@ public struct TurnTimelineViewportState: Sendable, Equatable { } } -extension TurnTimelineViewportState.ScrollActivityPhase { - public init(_ phase: CodexThread.RecentTurns.ScrollActivityPhase) { +public extension TurnTimelineViewportState.ScrollActivityPhase { + init(_ phase: CodexThread.RecentTurns.ScrollActivityPhase) { switch phase { - case .idle: - self = .idle - case .tracking: - self = .tracking - case .interacting: - self = .interacting - case .decelerating: - self = .decelerating - case .animating: - self = .animating + case .idle: + self = .idle + case .tracking: + self = .tracking + case .interacting: + self = .interacting + case .decelerating: + self = .decelerating + case .animating: + self = .animating } } } @@ -384,7 +384,6 @@ public struct TurnTimelineTokenSummary: Sendable, Equatable { modelContextWindow: tokenUsage.modelContextWindow ) } - } public enum TurnTimelineIntent: Sendable, Equatable { diff --git a/Sources/ASBSwiftUI/Dashboard/ASBDashboardPanel.swift b/Sources/ASBSwiftUI/Dashboard/ASBDashboardPanel.swift index efa87aa..43d2eb0 100644 --- a/Sources/ASBSwiftUI/Dashboard/ASBDashboardPanel.swift +++ b/Sources/ASBSwiftUI/Dashboard/ASBDashboardPanel.swift @@ -32,8 +32,7 @@ public struct ASBDashboardPanel: View { } if let latestDiagnosticDescription = snapshot.latestDiagnosticDescription, - !latestDiagnosticDescription.isEmpty - { + !latestDiagnosticDescription.isEmpty { ASBDiagnosticMessage(latestDiagnosticDescription) } } diff --git a/Sources/ASBSwiftUI/Support/ASBSwiftUIStatusViews.swift b/Sources/ASBSwiftUI/Support/ASBSwiftUIStatusViews.swift index b3ff7dc..5e32af0 100644 --- a/Sources/ASBSwiftUI/Support/ASBSwiftUIStatusViews.swift +++ b/Sources/ASBSwiftUI/Support/ASBSwiftUIStatusViews.swift @@ -93,35 +93,35 @@ struct ASBDiagnosticMessage: View { extension AgendaGoalStatus { var displayTitle: String { switch self { - case .active: - "Active" - case .blocked: - "Blocked" - case .budgetLimited: - "Budget Limited" - case .complete: - "Complete" - case .paused: - "Paused" - case .usageLimited: - "Usage Limited" + case .active: + "Active" + case .blocked: + "Blocked" + case .budgetLimited: + "Budget Limited" + case .complete: + "Complete" + case .paused: + "Paused" + case .usageLimited: + "Usage Limited" } } var systemImage: String { switch self { - case .active: - "play.circle" - case .blocked: - "exclamationmark.octagon" - case .budgetLimited: - "gauge.with.dots.needle.bottom.50percent" - case .complete: - "checkmark.circle" - case .paused: - "pause.circle" - case .usageLimited: - "speedometer" + case .active: + "play.circle" + case .blocked: + "exclamationmark.octagon" + case .budgetLimited: + "gauge.with.dots.needle.bottom.50percent" + case .complete: + "checkmark.circle" + case .paused: + "pause.circle" + case .usageLimited: + "speedometer" } } } @@ -129,23 +129,23 @@ extension AgendaGoalStatus { extension AgendaStepStatus { var systemImage: String { switch self { - case .completed: - "checkmark.circle.fill" - case .inProgress: - "play.circle.fill" - case .pending: - "circle" + case .completed: + "checkmark.circle.fill" + case .inProgress: + "play.circle.fill" + case .pending: + "circle" } } var tint: HierarchicalShapeStyle { switch self { - case .completed: - .primary - case .inProgress: - .secondary - case .pending: - .tertiary + case .completed: + .primary + case .inProgress: + .secondary + case .pending: + .tertiary } } } @@ -153,23 +153,23 @@ extension AgendaStepStatus { extension DashboardActivityStatus { var displayTitle: String { switch self { - case .errored: - "Errored" - case .idle: - "Idle" - case .inProgress: - "In Progress" + case .errored: + "Errored" + case .idle: + "Idle" + case .inProgress: + "In Progress" } } var systemImage: String { switch self { - case .errored: - "exclamationmark.triangle" - case .idle: - "circle" - case .inProgress: - "play.circle" + case .errored: + "exclamationmark.triangle" + case .idle: + "circle" + case .inProgress: + "play.circle" } } } @@ -177,35 +177,35 @@ extension DashboardActivityStatus { extension DashboardAutoReviewStatus { var displayTitle: String { switch self { - case .aborted: - "Aborted" - case .approved: - "Approved" - case .denied: - "Denied" - case .idle: - "Idle" - case .inProgress: - "In Progress" - case .timedOut: - "Timed Out" + case .aborted: + "Aborted" + case .approved: + "Approved" + case .denied: + "Denied" + case .idle: + "Idle" + case .inProgress: + "In Progress" + case .timedOut: + "Timed Out" } } var systemImage: String { switch self { - case .aborted: - "xmark.octagon" - case .approved: - "checkmark.seal" - case .denied: - "hand.raised" - case .idle: - "circle" - case .inProgress: - "clock" - case .timedOut: - "timer" + case .aborted: + "xmark.octagon" + case .approved: + "checkmark.seal" + case .denied: + "hand.raised" + case .idle: + "circle" + case .inProgress: + "clock" + case .timedOut: + "timer" } } } @@ -213,22 +213,22 @@ extension DashboardAutoReviewStatus { extension TurnTimelineItemKind { var systemImage: String { switch self { - case .agentMessage: - "text.bubble" - case .collabTool: - "person.2.wave.2" - case .command: - "terminal" - case .dynamicTool: - "wrench.and.screwdriver" - case .fileEdit: - "doc.text" - case .mcp: - "point.3.connected.trianglepath.dotted" - case .reasoning: - "brain" - case .unknown: - "questionmark.circle" + case .agentMessage: + "text.bubble" + case .collabTool: + "person.2.wave.2" + case .command: + "terminal" + case .dynamicTool: + "wrench.and.screwdriver" + case .fileEdit: + "doc.text" + case .mcp: + "point.3.connected.trianglepath.dotted" + case .reasoning: + "brain" + case .unknown: + "questionmark.circle" } } } diff --git a/Sources/SwiftASB/Generated/CodexWire/Latest/CodexLifecycleV2Batch+JSONValue.swift b/Sources/SwiftASB/Generated/CodexWire/Latest/CodexLifecycleV2Batch+JSONValue.swift index d63d6c3..f51988b 100644 --- a/Sources/SwiftASB/Generated/CodexWire/Latest/CodexLifecycleV2Batch+JSONValue.swift +++ b/Sources/SwiftASB/Generated/CodexWire/Latest/CodexLifecycleV2Batch+JSONValue.swift @@ -14,7 +14,15 @@ import Foundation /// Synthetic quicktype root generated from the bundled Codex app-server protocol schema. /// Each top-level property references one selected definition from the original bundle. // MARK: - CodexWireCodexLifecycleV2Batch -struct CodexWireCodexLifecycleV2Batch: Codable, Equatable, Sendable { + +struct CodexWireCodexLifecycleV2Batch: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case agentMessageDeltaNotification, appListUpdatedNotification, appsListParams, appsListResponse, collaborationModeListParams, collaborationModeListResponse, commandExecOutputDeltaNotification, commandExecutionOutputDeltaNotification, configReadParams, configReadResponse, configRequirementsReadResponse, configWarningNotification, contextCompactedNotification, deprecationNoticeNotification, errorNotification, externalAgentConfigImportCompletedNotification, fileChangeOutputDeltaNotification, fileChangePatchUpdatedNotification, fsChangedNotification, fsGetMetadataParams, fsGetMetadataResponse, fsReadDirectoryParams, fsReadDirectoryResponse, fsReadFileParams, fsReadFileResponse, fsUnwatchParams, fsUnwatchResponse, fsWatchParams, fsWatchResponse, getAccountTokenUsageResponse, guardianWarningNotification, hookCompletedNotification, hookStartedNotification, initializeParams, itemCompletedNotification, itemGuardianApprovalReviewCompletedNotification, itemGuardianApprovalReviewStartedNotification, itemStartedNotification + case listMCPServerStatusParams = "listMcpServerStatusParams" + case listMCPServerStatusResponse = "listMcpServerStatusResponse" + case mcpResourceReadParams, mcpResourceReadResponse, mcpServerStatusUpdatedNotification, mcpToolCallProgressNotification, modelListParams, modelListResponse, modelReroutedNotification, modelVerificationNotification, planDeltaNotification, pluginListParams, pluginListResponse, pluginReadParams, pluginReadResponse, pluginShareDeleteParams, pluginShareDeleteResponse, pluginShareListParams, pluginShareListResponse, pluginShareSaveParams, pluginShareSaveResponse, pluginShareUpdateTargetsParams, pluginShareUpdateTargetsResponse, pluginSkillReadParams, pluginSkillReadResponse, processExitedNotification, processKillParams, processKillResponse, processOutputDeltaNotification, processResizePtyParams, processResizePtyResponse, processSpawnParams, processSpawnResponse, processWriteStdinParams, processWriteStdinResponse, rawResponseItemCompletedNotification, reasoningSummaryPartAddedNotification, reasoningSummaryTextDeltaNotification, reasoningTextDeltaNotification, remoteControlClientsListParams, remoteControlClientsListResponse, remoteControlClientsRevokeParams, remoteControlClientsRevokeResponse, remoteControlPairingStartParams, remoteControlPairingStartResponse, remoteControlPairingStatusParams, remoteControlPairingStatusResponse, remoteControlStatusChangedNotification, reviewStartParams, reviewStartResponse, serverRequestResolvedNotification, skillsChangedNotification, skillsExtraRootsSetParams, skillsExtraRootsSetResponse, skillsListParams, skillsListResponse, threadApproveGuardianDeniedActionParams, threadApproveGuardianDeniedActionResponse, threadArchivedNotification, threadArchiveParams, threadArchiveResponse, threadClosedNotification, threadCompactStartParams, threadCompactStartResponse, threadGoalClearedNotification, threadGoalClearParams, threadGoalClearResponse, threadGoalGetParams, threadGoalGetResponse, threadGoalSetParams, threadGoalSetResponse, threadGoalUpdatedNotification, threadLoadedListParams, threadLoadedListResponse, threadMetadataUpdateParams, threadMetadataUpdateResponse, threadNameUpdatedNotification, threadRollbackParams, threadRollbackResponse, threadSearchParams, threadSearchResponse, threadSetNameParams, threadSetNameResponse, threadShellCommandParams, threadShellCommandResponse, threadStartedNotification, threadStartParams, threadStartResponse, threadStatusChangedNotification, threadTokenUsageUpdatedNotification, threadTurnsItemsListParams, threadTurnsItemsListResponse, threadTurnsListParams, threadTurnsListResponse, threadUnarchivedNotification, threadUnarchiveParams, threadUnarchiveResponse, turnCompletedNotification, turnDiffUpdatedNotification, turnModerationMetadataNotification, turnPlanUpdatedNotification, turnStartedNotification, turnStartParams, turnStartResponse, warningNotification, windowsSandboxReadinessResponse + } + let agentMessageDeltaNotification: CodexWireAgentMessageDeltaNotification? let appListUpdatedNotification: CodexWireAppListUpdatedNotification? let appsListParams: CodexWireAppsListParams? @@ -158,13 +166,6 @@ struct CodexWireCodexLifecycleV2Batch: Codable, Equatable, Sendable { let turnStartResponse: CodexWireTurnStartResponse? let warningNotification: CodexWireWarningNotification? let windowsSandboxReadinessResponse: CodexWireWindowsSandboxReadinessResponse? - - enum CodingKeys: String, CodingKey { - case agentMessageDeltaNotification, appListUpdatedNotification, appsListParams, appsListResponse, collaborationModeListParams, collaborationModeListResponse, commandExecOutputDeltaNotification, commandExecutionOutputDeltaNotification, configReadParams, configReadResponse, configRequirementsReadResponse, configWarningNotification, contextCompactedNotification, deprecationNoticeNotification, errorNotification, externalAgentConfigImportCompletedNotification, fileChangeOutputDeltaNotification, fileChangePatchUpdatedNotification, fsChangedNotification, fsGetMetadataParams, fsGetMetadataResponse, fsReadDirectoryParams, fsReadDirectoryResponse, fsReadFileParams, fsReadFileResponse, fsUnwatchParams, fsUnwatchResponse, fsWatchParams, fsWatchResponse, getAccountTokenUsageResponse, guardianWarningNotification, hookCompletedNotification, hookStartedNotification, initializeParams, itemCompletedNotification, itemGuardianApprovalReviewCompletedNotification, itemGuardianApprovalReviewStartedNotification, itemStartedNotification - case listMCPServerStatusParams = "listMcpServerStatusParams" - case listMCPServerStatusResponse = "listMcpServerStatusResponse" - case mcpResourceReadParams, mcpResourceReadResponse, mcpServerStatusUpdatedNotification, mcpToolCallProgressNotification, modelListParams, modelListResponse, modelReroutedNotification, modelVerificationNotification, planDeltaNotification, pluginListParams, pluginListResponse, pluginReadParams, pluginReadResponse, pluginShareDeleteParams, pluginShareDeleteResponse, pluginShareListParams, pluginShareListResponse, pluginShareSaveParams, pluginShareSaveResponse, pluginShareUpdateTargetsParams, pluginShareUpdateTargetsResponse, pluginSkillReadParams, pluginSkillReadResponse, processExitedNotification, processKillParams, processKillResponse, processOutputDeltaNotification, processResizePtyParams, processResizePtyResponse, processSpawnParams, processSpawnResponse, processWriteStdinParams, processWriteStdinResponse, rawResponseItemCompletedNotification, reasoningSummaryPartAddedNotification, reasoningSummaryTextDeltaNotification, reasoningTextDeltaNotification, remoteControlClientsListParams, remoteControlClientsListResponse, remoteControlClientsRevokeParams, remoteControlClientsRevokeResponse, remoteControlPairingStartParams, remoteControlPairingStartResponse, remoteControlPairingStatusParams, remoteControlPairingStatusResponse, remoteControlStatusChangedNotification, reviewStartParams, reviewStartResponse, serverRequestResolvedNotification, skillsChangedNotification, skillsExtraRootsSetParams, skillsExtraRootsSetResponse, skillsListParams, skillsListResponse, threadApproveGuardianDeniedActionParams, threadApproveGuardianDeniedActionResponse, threadArchivedNotification, threadArchiveParams, threadArchiveResponse, threadClosedNotification, threadCompactStartParams, threadCompactStartResponse, threadGoalClearedNotification, threadGoalClearParams, threadGoalClearResponse, threadGoalGetParams, threadGoalGetResponse, threadGoalSetParams, threadGoalSetResponse, threadGoalUpdatedNotification, threadLoadedListParams, threadLoadedListResponse, threadMetadataUpdateParams, threadMetadataUpdateResponse, threadNameUpdatedNotification, threadRollbackParams, threadRollbackResponse, threadSearchParams, threadSearchResponse, threadSetNameParams, threadSetNameResponse, threadShellCommandParams, threadShellCommandResponse, threadStartedNotification, threadStartParams, threadStartResponse, threadStatusChangedNotification, threadTokenUsageUpdatedNotification, threadTurnsItemsListParams, threadTurnsItemsListResponse, threadTurnsListParams, threadTurnsListResponse, threadUnarchivedNotification, threadUnarchiveParams, threadUnarchiveResponse, turnCompletedNotification, turnDiffUpdatedNotification, turnModerationMetadataNotification, turnPlanUpdatedNotification, turnStartedNotification, turnStartParams, turnStartResponse, warningNotification, windowsSandboxReadinessResponse - } } // @@ -174,7 +175,8 @@ struct CodexWireCodexLifecycleV2Batch: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAgentMessageDeltaNotification -struct CodexWireAgentMessageDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireAgentMessageDeltaNotification: Codable, Equatable { let delta, itemID, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -193,7 +195,8 @@ struct CodexWireAgentMessageDeltaNotification: Codable, Equatable, Sendable { /// EXPERIMENTAL - notification emitted when the app list changes. // MARK: - CodexWireAppListUpdatedNotification -struct CodexWireAppListUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireAppListUpdatedNotification: Codable, Equatable { let data: [CodexWireAppInfo] } @@ -205,7 +208,8 @@ struct CodexWireAppListUpdatedNotification: Codable, Equatable, Sendable { /// EXPERIMENTAL - app metadata returned by app-list APIs. // MARK: - CodexWireAppInfo -struct CodexWireAppInfo: Codable, Equatable, Sendable { + +struct CodexWireAppInfo: Codable, Equatable { let appMetadata: CodexWireAppMetadata? let branding: CodexWireAppBranding? let description, distributionChannel: String? @@ -237,7 +241,8 @@ struct CodexWireAppInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppMetadata -struct CodexWireAppMetadata: Codable, Equatable, Sendable { + +struct CodexWireAppMetadata: Codable, Equatable { let categories: [String]? let developer: String? let firstPartyRequiresInstall: Bool? @@ -263,7 +268,8 @@ struct CodexWireAppMetadata: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppReview -struct CodexWireAppReview: Codable, Equatable, Sendable { + +struct CodexWireAppReview: Codable, Equatable { let status: String } @@ -274,7 +280,8 @@ struct CodexWireAppReview: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppScreenshot -struct CodexWireAppScreenshot: Codable, Equatable, Sendable { + +struct CodexWireAppScreenshot: Codable, Equatable { let fileID, url: String? let userPrompt: String @@ -292,7 +299,8 @@ struct CodexWireAppScreenshot: Codable, Equatable, Sendable { /// EXPERIMENTAL - app metadata returned by app-list APIs. // MARK: - CodexWireAppBranding -struct CodexWireAppBranding: Codable, Equatable, Sendable { + +struct CodexWireAppBranding: Codable, Equatable { let category, developer: String? let isDiscoverableApp: Bool let privacyPolicy, termsOfService, website: String? @@ -306,7 +314,8 @@ struct CodexWireAppBranding: Codable, Equatable, Sendable { /// EXPERIMENTAL - list available apps/connectors. // MARK: - CodexWireAppsListParams -struct CodexWireAppsListParams: Codable, Equatable, Sendable { + +struct CodexWireAppsListParams: Codable, Equatable { /// Opaque pagination cursor returned by a previous call. let cursor: String? /// When true, bypass app caches and fetch the latest data from sources. @@ -330,7 +339,8 @@ struct CodexWireAppsListParams: Codable, Equatable, Sendable { /// EXPERIMENTAL - app list response. // MARK: - CodexWireAppsListResponse -struct CodexWireAppsListResponse: Codable, Equatable, Sendable { + +struct CodexWireAppsListResponse: Codable, Equatable { let data: [CodexWireAppInfo] /// Opaque cursor to pass to the next call to continue after the last item. If None, there /// are no more items to return. @@ -345,7 +355,8 @@ struct CodexWireAppsListResponse: Codable, Equatable, Sendable { /// EXPERIMENTAL - collaboration mode presets response. // MARK: - CodexWireCollaborationModeListResponse -struct CodexWireCollaborationModeListResponse: Codable, Equatable, Sendable { + +struct CodexWireCollaborationModeListResponse: Codable, Equatable { let data: [CodexWireCollaborationModeMask] } @@ -357,7 +368,8 @@ struct CodexWireCollaborationModeListResponse: Codable, Equatable, Sendable { /// EXPERIMENTAL - collaboration mode preset metadata for clients. // MARK: - CodexWireCollaborationModeMask -struct CodexWireCollaborationModeMask: Codable, Equatable, Sendable { + +struct CodexWireCollaborationModeMask: Codable, Equatable { let mode: CodexWireModeKind? let model: String? let name: String @@ -370,9 +382,9 @@ struct CodexWireCollaborationModeMask: Codable, Equatable, Sendable { } /// Initial collaboration mode to use when the TUI starts. -enum CodexWireModeKind: String, Codable, Equatable, Sendable { +enum CodexWireModeKind: String, Codable, Equatable { case modeKindDefault = "default" - case plan = "plan" + case plan } // @@ -386,7 +398,8 @@ enum CodexWireModeKind: String, Codable, Equatable, Sendable { /// These notifications are connection-scoped. If the originating connection closes, the /// server terminates the process. // MARK: - CodexWireCommandExecOutputDeltaNotification -struct CodexWireCommandExecOutputDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireCommandExecOutputDeltaNotification: Codable, Equatable { /// `true` on the final streamed chunk for a stream when `outputBytesCap` truncated later /// output on that stream. let capReached: Bool @@ -415,9 +428,9 @@ struct CodexWireCommandExecOutputDeltaNotification: Codable, Equatable, Sendable /// Output stream this chunk belongs to. /// /// Stream label for `process/outputDelta` notifications. -enum CodexWireOutputStream: String, Codable, Equatable, Sendable { - case stderr = "stderr" - case stdout = "stdout" +enum CodexWireOutputStream: String, Codable, Equatable { + case stderr + case stdout } // @@ -427,7 +440,8 @@ enum CodexWireOutputStream: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireCommandExecutionOutputDeltaNotification -struct CodexWireCommandExecutionOutputDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireCommandExecutionOutputDeltaNotification: Codable, Equatable { let delta, itemID, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -445,7 +459,8 @@ struct CodexWireCommandExecutionOutputDeltaNotification: Codable, Equatable, Sen // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigReadParams -struct CodexWireConfigReadParams: Codable, Equatable, Sendable { + +struct CodexWireConfigReadParams: Codable, Equatable { /// Optional working directory to resolve project config layers. If specified, return the /// effective config as seen from that directory (i.e., including any project layers between /// `cwd` and the project/repo root). @@ -460,7 +475,8 @@ struct CodexWireConfigReadParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigReadResponse -struct CodexWireConfigReadResponse: Codable, Equatable, Sendable { + +struct CodexWireConfigReadResponse: Codable, Equatable { let config: CodexWireConfig let layers: [CodexWireConfigLayer]? let origins: [String: CodexWireConfigLayerMetadata] @@ -473,32 +489,8 @@ struct CodexWireConfigReadResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfig -struct CodexWireConfig: Codable, Equatable, Sendable { - let analytics: CodexWireAnalyticsConfig? - let approvalPolicy: CodexWireApprovalPolicyUnion? - /// [UNSTABLE] Optional default for where approval requests are routed for review. - let approvalsReviewer: CodexWireApprovalsReviewer? - let apps: CodexWireAppsConfig? - let compactPrompt: String? - let desktop: [String: CodexWireJSONValue]? - let developerInstructions: String? - let forcedChatgptWorkspaceID: CodexWireForcedChatgptWorkspaceIDS? - let forcedLoginMethod: CodexWireForcedLoginMethod? - let instructions, model: String? - let modelAutoCompactTokenLimit: Int? - let modelAutoCompactTokenLimitScope: CodexWireAutoCompactTokenLimitScope? - let modelContextWindow: Int? - let modelProvider: String? - let modelReasoningEffort: String? - let modelReasoningSummary: CodexWireReasoningSummary? - let modelVerbosity: CodexWireVerbosity? - let reviewModel: String? - let sandboxMode: CodexWireSandboxMode? - let sandboxWorkspaceWrite: CodexWireSandboxWorkspaceWrite? - let serviceTier: String? - let tools: CodexWireToolsV2? - let webSearch: CodexWireWebSearchMode? +struct CodexWireConfig: Codable, Equatable { enum CodingKeys: String, CodingKey { case analytics case approvalPolicy = "approval_policy" @@ -524,6 +516,31 @@ struct CodexWireConfig: Codable, Equatable, Sendable { case tools case webSearch = "web_search" } + + let analytics: CodexWireAnalyticsConfig? + let approvalPolicy: CodexWireApprovalPolicyUnion? + /// [UNSTABLE] Optional default for where approval requests are routed for review. + let approvalsReviewer: CodexWireApprovalsReviewer? + let apps: CodexWireAppsConfig? + let compactPrompt: String? + let desktop: [String: CodexWireJSONValue]? + let developerInstructions: String? + let forcedChatgptWorkspaceID: CodexWireForcedChatgptWorkspaceIDS? + let forcedLoginMethod: CodexWireForcedLoginMethod? + let instructions, model: String? + let modelAutoCompactTokenLimit: Int? + let modelAutoCompactTokenLimitScope: CodexWireAutoCompactTokenLimitScope? + let modelContextWindow: Int? + let modelProvider: String? + let modelReasoningEffort: String? + let modelReasoningSummary: CodexWireReasoningSummary? + let modelVerbosity: CodexWireVerbosity? + let reviewModel: String? + let sandboxMode: CodexWireSandboxMode? + let sandboxWorkspaceWrite: CodexWireSandboxWorkspaceWrite? + let serviceTier: String? + let tools: CodexWireToolsV2? + let webSearch: CodexWireWebSearchMode? } // @@ -533,11 +550,12 @@ struct CodexWireConfig: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAnalyticsConfig -struct CodexWireAnalyticsConfig: Codable, Equatable, Sendable { + +struct CodexWireAnalyticsConfig: Codable, Equatable { let enabled: Bool? } -enum CodexWireApprovalPolicyUnion: Codable, Equatable, Sendable { +enum CodexWireApprovalPolicyUnion: Codable, Equatable { case codexWireGranularAskForApproval(CodexWireGranularAskForApproval) case enumeration(CodexWireApprovalPolicyEnum) case null @@ -562,12 +580,12 @@ enum CodexWireApprovalPolicyUnion: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireGranularAskForApproval(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) - case .null: - try container.encodeNil() + case let .codexWireGranularAskForApproval(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) + case .null: + try container.encodeNil() } } } @@ -579,7 +597,8 @@ enum CodexWireApprovalPolicyUnion: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGranularAskForApproval -struct CodexWireGranularAskForApproval: Codable, Equatable, Sendable { + +struct CodexWireGranularAskForApproval: Codable, Equatable { let granular: CodexWireGranular } @@ -590,7 +609,8 @@ struct CodexWireGranularAskForApproval: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGranular -struct CodexWireGranular: Codable, Equatable, Sendable { + +struct CodexWireGranular: Codable, Equatable { let mcpElicitations: Bool let requestPermissions: Bool? let rules, sandboxApproval: Bool @@ -605,11 +625,11 @@ struct CodexWireGranular: Codable, Equatable, Sendable { } } -enum CodexWireApprovalPolicyEnum: String, Codable, Equatable, Sendable { - case never = "never" +enum CodexWireApprovalPolicyEnum: String, Codable, Equatable { + case never case onFailure = "on-failure" case onRequest = "on-request" - case untrusted = "untrusted" + case untrusted } /// Configures who approval requests are routed to for review. Examples include sandbox @@ -619,10 +639,10 @@ enum CodexWireApprovalPolicyEnum: String, Codable, Equatable, Sendable { /// value `guardian_subagent` is accepted for compatibility. /// /// Reviewer currently used for approval requests on this thread. -enum CodexWireApprovalsReviewer: String, Codable, Equatable, Sendable { +enum CodexWireApprovalsReviewer: String, Codable, Equatable { case autoReview = "auto_review" case guardianSubagent = "guardian_subagent" - case user = "user" + case user } // @@ -632,7 +652,8 @@ enum CodexWireApprovalsReviewer: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppsConfig -struct CodexWireAppsConfig: Codable, Equatable, Sendable { + +struct CodexWireAppsConfig: Codable, Equatable { let appsConfigDefault: CodexWireAppsDefaultConfig? enum CodingKeys: String, CodingKey { @@ -647,17 +668,20 @@ struct CodexWireAppsConfig: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppsDefaultConfig -struct CodexWireAppsDefaultConfig: Codable, Equatable, Sendable { + +struct CodexWireAppsDefaultConfig: Codable, Equatable { + let approvalsReviewer: CodexWireApprovalsReviewer? let destructiveEnabled, enabled, openWorldEnabled: Bool? enum CodingKeys: String, CodingKey { + case approvalsReviewer = "approvals_reviewer" case destructiveEnabled = "destructive_enabled" case enabled case openWorldEnabled = "open_world_enabled" } } -enum CodexWireForcedChatgptWorkspaceIDS: Codable, Equatable, Sendable { +enum CodexWireForcedChatgptWorkspaceIDS: Codable, Equatable { case string(String) case stringArray([String]) case null @@ -682,46 +706,46 @@ enum CodexWireForcedChatgptWorkspaceIDS: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .string(let x): - try container.encode(x) - case .stringArray(let x): - try container.encode(x) - case .null: - try container.encodeNil() + case let .string(x): + try container.encode(x) + case let .stringArray(x): + try container.encode(x) + case .null: + try container.encodeNil() } } } -enum CodexWireForcedLoginMethod: String, Codable, Equatable, Sendable { - case api = "api" - case chatgpt = "chatgpt" +enum CodexWireForcedLoginMethod: String, Codable, Equatable { + case api + case chatgpt } /// Count the full active context against the limit. /// /// Count sampled output and later growth after the carried window prefix. -enum CodexWireAutoCompactTokenLimitScope: String, Codable, Equatable, Sendable { +enum CodexWireAutoCompactTokenLimitScope: String, Codable, Equatable { case bodyAfterPrefix = "body_after_prefix" - case total = "total" + case total } /// Option to disable reasoning summaries. -enum CodexWireReasoningSummary: String, Codable, Equatable, Sendable { - case auto = "auto" - case concise = "concise" - case detailed = "detailed" - case none = "none" +enum CodexWireReasoningSummary: String, Codable, Equatable { + case auto + case concise + case detailed + case none } /// Controls output length/detail on GPT-5 models via the Responses API. Serialized with /// lowercase values to match the OpenAI API. -enum CodexWireVerbosity: String, Codable, Equatable, Sendable { - case high = "high" - case low = "low" - case medium = "medium" +enum CodexWireVerbosity: String, Codable, Equatable { + case high + case low + case medium } -enum CodexWireSandboxMode: String, Codable, Equatable, Sendable { +enum CodexWireSandboxMode: String, Codable, Equatable { case dangerFullAccess = "danger-full-access" case readOnly = "read-only" case workspaceWrite = "workspace-write" @@ -734,7 +758,8 @@ enum CodexWireSandboxMode: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSandboxWorkspaceWrite -struct CodexWireSandboxWorkspaceWrite: Codable, Equatable, Sendable { + +struct CodexWireSandboxWorkspaceWrite: Codable, Equatable { let excludeSlashTmp, excludeTmpdirEnvVar, networkAccess: Bool? let writableRoots: [String]? @@ -753,7 +778,8 @@ struct CodexWireSandboxWorkspaceWrite: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireToolsV2 -struct CodexWireToolsV2: Codable, Equatable, Sendable { + +struct CodexWireToolsV2: Codable, Equatable { let webSearch: CodexWireWebSearchToolConfig? enum CodingKeys: String, CodingKey { @@ -768,7 +794,8 @@ struct CodexWireToolsV2: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireWebSearchToolConfig -struct CodexWireWebSearchToolConfig: Codable, Equatable, Sendable { + +struct CodexWireWebSearchToolConfig: Codable, Equatable { let allowedDomains: [String]? let contextSize: CodexWireVerbosity? let location: CodexWireWebSearchLocation? @@ -787,14 +814,15 @@ struct CodexWireWebSearchToolConfig: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireWebSearchLocation -struct CodexWireWebSearchLocation: Codable, Equatable, Sendable { + +struct CodexWireWebSearchLocation: Codable, Equatable { let city, country, region, timezone: String? } -enum CodexWireWebSearchMode: String, Codable, Equatable, Sendable { - case cached = "cached" - case disabled = "disabled" - case live = "live" +enum CodexWireWebSearchMode: String, Codable, Equatable { + case cached + case disabled + case live } // @@ -804,7 +832,8 @@ enum CodexWireWebSearchMode: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigLayer -struct CodexWireConfigLayer: Codable, Equatable, Sendable { + +struct CodexWireConfigLayer: Codable, Equatable { let config: CodexWireJSONValue let disabledReason: String? let name: CodexWireConfigLayerSource @@ -836,7 +865,8 @@ struct CodexWireConfigLayer: Codable, Equatable, Sendable { /// variant as a "best effort" while we phase out `managed_config.toml` in favor of /// `requirements.toml`. // MARK: - CodexWireConfigLayerSource -struct CodexWireConfigLayerSource: Codable, Equatable, Sendable { + +struct CodexWireConfigLayerSource: Codable, Equatable { let domain, key: String? let type: CodexWireConfigLayerSourceType /// This is the path to the system config.toml file, though it is not guaranteed to exist. @@ -854,15 +884,15 @@ struct CodexWireConfigLayerSource: Codable, Equatable, Sendable { let dotCodexFolder: String? } -enum CodexWireConfigLayerSourceType: String, Codable, Equatable, Sendable { - case enterpriseManaged = "enterpriseManaged" - case legacyManagedConfigTomlFromFile = "legacyManagedConfigTomlFromFile" - case legacyManagedConfigTomlFromMdm = "legacyManagedConfigTomlFromMdm" - case mdm = "mdm" - case project = "project" - case sessionFlags = "sessionFlags" - case system = "system" - case user = "user" +enum CodexWireConfigLayerSourceType: String, Codable, Equatable { + case enterpriseManaged + case legacyManagedConfigTomlFromFile + case legacyManagedConfigTomlFromMdm + case mdm + case project + case sessionFlags + case system + case user } // @@ -872,7 +902,8 @@ enum CodexWireConfigLayerSourceType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigLayerMetadata -struct CodexWireConfigLayerMetadata: Codable, Equatable, Sendable { + +struct CodexWireConfigLayerMetadata: Codable, Equatable { let name: CodexWireConfigLayerSource let version: String } @@ -884,7 +915,8 @@ struct CodexWireConfigLayerMetadata: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigRequirementsReadResponse -struct CodexWireConfigRequirementsReadResponse: Codable, Equatable, Sendable { + +struct CodexWireConfigRequirementsReadResponse: Codable, Equatable { /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). let requirements: CodexWireConfigRequirements? } @@ -896,7 +928,8 @@ struct CodexWireConfigRequirementsReadResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigRequirements -struct CodexWireConfigRequirements: Codable, Equatable, Sendable { + +struct CodexWireConfigRequirements: Codable, Equatable { let allowAppshots: Bool? let allowedApprovalPolicies: [CodexWireAskForApproval]? let allowedApprovalsReviewers: [CodexWireApprovalsReviewer]? @@ -904,7 +937,7 @@ struct CodexWireConfigRequirements: Codable, Equatable, Sendable { let allowedSandboxModes: [CodexWireSandboxMode]? let allowedWebSearchModes: [CodexWireWebSearchMode]? let allowedWindowsSandboxImplementations: [CodexWireWindowsSandboxSetupMode]? - let allowManagedHooksOnly: Bool? + let allowManagedHooksOnly, allowRemoteControl: Bool? let computerUse: CodexWireComputerUseRequirements? let defaultPermissions: String? let enforceResidency: CodexWireResidencyRequirement? @@ -913,7 +946,7 @@ struct CodexWireConfigRequirements: Codable, Equatable, Sendable { let network: CodexWireNetworkRequirements? } -enum CodexWireAskForApproval: Codable, Equatable, Sendable { +enum CodexWireAskForApproval: Codable, Equatable { case codexWireGranularAskForApproval(CodexWireGranularAskForApproval) case enumeration(CodexWireApprovalPolicyEnum) @@ -933,17 +966,17 @@ enum CodexWireAskForApproval: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireGranularAskForApproval(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) + case let .codexWireGranularAskForApproval(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) } } } -enum CodexWireWindowsSandboxSetupMode: String, Codable, Equatable, Sendable { - case elevated = "elevated" - case unelevated = "unelevated" +enum CodexWireWindowsSandboxSetupMode: String, Codable, Equatable { + case elevated + case unelevated } // @@ -953,12 +986,13 @@ enum CodexWireWindowsSandboxSetupMode: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireComputerUseRequirements -struct CodexWireComputerUseRequirements: Codable, Equatable, Sendable { + +struct CodexWireComputerUseRequirements: Codable, Equatable { let allowLockedComputerUse: Bool? } -enum CodexWireResidencyRequirement: String, Codable, Equatable, Sendable { - case us = "us" +enum CodexWireResidencyRequirement: String, Codable, Equatable { + case us } // @@ -968,7 +1002,8 @@ enum CodexWireResidencyRequirement: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireManagedHooksRequirements -struct CodexWireManagedHooksRequirements: Codable, Equatable, Sendable { + +struct CodexWireManagedHooksRequirements: Codable, Equatable { let managedDir: String? let permissionRequest, postCompact, postToolUse, preCompact: [CodexWireConfiguredHookMatcherGroup] let preToolUse, sessionStart, stop, subagentStart: [CodexWireConfiguredHookMatcherGroup] @@ -998,7 +1033,8 @@ struct CodexWireManagedHooksRequirements: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfiguredHookMatcherGroup -struct CodexWireConfiguredHookMatcherGroup: Codable, Equatable, Sendable { + +struct CodexWireConfiguredHookMatcherGroup: Codable, Equatable { let hooks: [CodexWireConfiguredHookHandler] let matcher: String? } @@ -1010,7 +1046,8 @@ struct CodexWireConfiguredHookMatcherGroup: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfiguredHookHandler -struct CodexWireConfiguredHookHandler: Codable, Equatable, Sendable { + +struct CodexWireConfiguredHookHandler: Codable, Equatable { let async: Bool? let command: String? let commandWindows, statusMessage: String? @@ -1024,10 +1061,10 @@ struct CodexWireConfiguredHookHandler: Codable, Equatable, Sendable { } } -enum CodexWireHookHandlerType: String, Codable, Equatable, Sendable { - case agent = "agent" - case command = "command" - case prompt = "prompt" +enum CodexWireHookHandlerType: String, Codable, Equatable { + case agent + case command + case prompt } // @@ -1037,7 +1074,8 @@ enum CodexWireHookHandlerType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireNetworkRequirements -struct CodexWireNetworkRequirements: Codable, Equatable, Sendable { + +struct CodexWireNetworkRequirements: Codable, Equatable { /// Legacy compatibility view derived from `domains`. let allowedDomains: [String]? let allowLocalBinding: Bool? @@ -1058,9 +1096,9 @@ struct CodexWireNetworkRequirements: Codable, Equatable, Sendable { let unixSockets: [String: CodexWireNetworkPermission]? } -enum CodexWireNetworkPermission: String, Codable, Equatable, Sendable { - case allow = "allow" - case deny = "deny" +enum CodexWireNetworkPermission: String, Codable, Equatable { + case allow + case deny } // @@ -1070,7 +1108,8 @@ enum CodexWireNetworkPermission: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireConfigWarningNotification -struct CodexWireConfigWarningNotification: Codable, Equatable, Sendable { + +struct CodexWireConfigWarningNotification: Codable, Equatable { /// Optional extra guidance or error details. let details: String? /// Optional path to the config file that triggered the warning. @@ -1088,7 +1127,8 @@ struct CodexWireConfigWarningNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTextRange -struct CodexWireTextRange: Codable, Equatable, Sendable { + +struct CodexWireTextRange: Codable, Equatable { let end, start: CodexWireTextPosition } @@ -1099,7 +1139,8 @@ struct CodexWireTextRange: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTextPosition -struct CodexWireTextPosition: Codable, Equatable, Sendable { + +struct CodexWireTextPosition: Codable, Equatable { /// 1-based column number (in Unicode scalar values). let column: Int /// 1-based line number. @@ -1114,7 +1155,8 @@ struct CodexWireTextPosition: Codable, Equatable, Sendable { /// Deprecated: Use `ContextCompaction` item type instead. // MARK: - CodexWireContextCompactedNotification -struct CodexWireContextCompactedNotification: Codable, Equatable, Sendable { + +struct CodexWireContextCompactedNotification: Codable, Equatable { let threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -1130,7 +1172,8 @@ struct CodexWireContextCompactedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireDeprecationNoticeNotification -struct CodexWireDeprecationNoticeNotification: Codable, Equatable, Sendable { + +struct CodexWireDeprecationNoticeNotification: Codable, Equatable { /// Optional extra guidance, such as migration steps or rationale. let details: String? /// Concise summary of what is deprecated. @@ -1144,7 +1187,8 @@ struct CodexWireDeprecationNoticeNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireErrorNotification -struct CodexWireErrorNotification: Codable, Equatable, Sendable { + +struct CodexWireErrorNotification: Codable, Equatable { let error: CodexWireTurnError let threadID, turnID: String let willRetry: Bool @@ -1164,13 +1208,14 @@ struct CodexWireErrorNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnError -struct CodexWireTurnError: Codable, Equatable, Sendable { + +struct CodexWireTurnError: Codable, Equatable { let additionalDetails: String? let codexErrorInfo: CodexWireCodexErrorInfoUnion? let message: String } -enum CodexWireCodexErrorInfoUnion: Codable, Equatable, Sendable { +enum CodexWireCodexErrorInfoUnion: Codable, Equatable { case codexWireCodexErrorInfo(CodexWireCodexErrorInfo) case enumeration(CodexWireCodexErrorInfoEnum) case null @@ -1195,12 +1240,12 @@ enum CodexWireCodexErrorInfoUnion: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireCodexErrorInfo(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) - case .null: - try container.encodeNil() + case let .codexWireCodexErrorInfo(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) + case .null: + try container.encodeNil() } } } @@ -1220,7 +1265,8 @@ enum CodexWireCodexErrorInfoUnion: Codable, Equatable, Sendable { /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn /// cannot accept same-turn steering, for example `/review` or manual `/compact`. // MARK: - CodexWireCodexErrorInfo -struct CodexWireCodexErrorInfo: Codable, Equatable, Sendable { + +struct CodexWireCodexErrorInfo: Codable, Equatable { let httpConnectionFailed: CodexWireHTTPConnectionFailed? let responseStreamConnectionFailed: CodexWireResponseStreamConnectionFailed? let responseStreamDisconnected: CodexWireResponseStreamDisconnected? @@ -1235,13 +1281,14 @@ struct CodexWireCodexErrorInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireActiveTurnNotSteerable -struct CodexWireActiveTurnNotSteerable: Codable, Equatable, Sendable { + +struct CodexWireActiveTurnNotSteerable: Codable, Equatable { let turnKind: CodexWireNonSteerableTurnKind } -enum CodexWireNonSteerableTurnKind: String, Codable, Equatable, Sendable { - case compact = "compact" - case review = "review" +enum CodexWireNonSteerableTurnKind: String, Codable, Equatable { + case compact + case review } // @@ -1251,7 +1298,8 @@ enum CodexWireNonSteerableTurnKind: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHTTPConnectionFailed -struct CodexWireHTTPConnectionFailed: Codable, Equatable, Sendable { + +struct CodexWireHTTPConnectionFailed: Codable, Equatable { let httpStatusCode: Int? } @@ -1262,7 +1310,8 @@ struct CodexWireHTTPConnectionFailed: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireResponseStreamConnectionFailed -struct CodexWireResponseStreamConnectionFailed: Codable, Equatable, Sendable { + +struct CodexWireResponseStreamConnectionFailed: Codable, Equatable { let httpStatusCode: Int? } @@ -1273,7 +1322,8 @@ struct CodexWireResponseStreamConnectionFailed: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireResponseStreamDisconnected -struct CodexWireResponseStreamDisconnected: Codable, Equatable, Sendable { + +struct CodexWireResponseStreamDisconnected: Codable, Equatable { let httpStatusCode: Int? } @@ -1284,21 +1334,22 @@ struct CodexWireResponseStreamDisconnected: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireResponseTooManyFailedAttempts -struct CodexWireResponseTooManyFailedAttempts: Codable, Equatable, Sendable { + +struct CodexWireResponseTooManyFailedAttempts: Codable, Equatable { let httpStatusCode: Int? } -enum CodexWireCodexErrorInfoEnum: String, Codable, Equatable, Sendable { - case badRequest = "badRequest" - case contextWindowExceeded = "contextWindowExceeded" - case cyberPolicy = "cyberPolicy" - case internalServerError = "internalServerError" - case other = "other" - case sandboxError = "sandboxError" - case serverOverloaded = "serverOverloaded" - case threadRollbackFailed = "threadRollbackFailed" - case unauthorized = "unauthorized" - case usageLimitExceeded = "usageLimitExceeded" +enum CodexWireCodexErrorInfoEnum: String, Codable, Equatable { + case badRequest + case contextWindowExceeded + case cyberPolicy + case internalServerError + case other + case sandboxError + case serverOverloaded + case threadRollbackFailed + case unauthorized + case usageLimitExceeded } // @@ -1311,7 +1362,8 @@ enum CodexWireCodexErrorInfoEnum: String, Codable, Equatable, Sendable { /// /// The server no longer emits this notification. // MARK: - CodexWireFileChangeOutputDeltaNotification -struct CodexWireFileChangeOutputDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireFileChangeOutputDeltaNotification: Codable, Equatable { let delta, itemID, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -1329,7 +1381,8 @@ struct CodexWireFileChangeOutputDeltaNotification: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireFileChangePatchUpdatedNotification -struct CodexWireFileChangePatchUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireFileChangePatchUpdatedNotification: Codable, Equatable { let changes: [CodexWireFileUpdateChange] let itemID, threadID, turnID: String @@ -1348,7 +1401,8 @@ struct CodexWireFileChangePatchUpdatedNotification: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireFileUpdateChange -struct CodexWireFileUpdateChange: Codable, Equatable, Sendable { + +struct CodexWireFileUpdateChange: Codable, Equatable { let diff: String let kind: CodexWirePatchChangeKind let path: String @@ -1361,7 +1415,8 @@ struct CodexWireFileUpdateChange: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePatchChangeKind -struct CodexWirePatchChangeKind: Codable, Equatable, Sendable { + +struct CodexWirePatchChangeKind: Codable, Equatable { let type: CodexWirePatchChangeKindType let movePath: String? @@ -1371,10 +1426,10 @@ struct CodexWirePatchChangeKind: Codable, Equatable, Sendable { } } -enum CodexWirePatchChangeKindType: String, Codable, Equatable, Sendable { - case add = "add" - case delete = "delete" - case update = "update" +enum CodexWirePatchChangeKindType: String, Codable, Equatable { + case add + case delete + case update } // @@ -1385,7 +1440,8 @@ enum CodexWirePatchChangeKindType: String, Codable, Equatable, Sendable { /// Filesystem watch notification emitted for `fs/watch` subscribers. // MARK: - CodexWireFSChangedNotification -struct CodexWireFSChangedNotification: Codable, Equatable, Sendable { + +struct CodexWireFSChangedNotification: Codable, Equatable { /// File or directory paths associated with this event. let changedPaths: [String] /// Watch identifier previously provided to `fs/watch`. @@ -1405,7 +1461,8 @@ struct CodexWireFSChangedNotification: Codable, Equatable, Sendable { /// Request metadata for an absolute path. // MARK: - CodexWireFSGetMetadataParams -struct CodexWireFSGetMetadataParams: Codable, Equatable, Sendable { + +struct CodexWireFSGetMetadataParams: Codable, Equatable { /// Absolute path to inspect. let path: String } @@ -1418,7 +1475,8 @@ struct CodexWireFSGetMetadataParams: Codable, Equatable, Sendable { /// Metadata returned by `fs/getMetadata`. // MARK: - CodexWireFSGetMetadataResponse -struct CodexWireFSGetMetadataResponse: Codable, Equatable, Sendable { + +struct CodexWireFSGetMetadataResponse: Codable, Equatable { /// File creation time in Unix milliseconds when available, otherwise `0`. let createdAtMS: Int /// Whether the path resolves to a directory. @@ -1445,7 +1503,8 @@ struct CodexWireFSGetMetadataResponse: Codable, Equatable, Sendable { /// List direct child names for a directory. // MARK: - CodexWireFSReadDirectoryParams -struct CodexWireFSReadDirectoryParams: Codable, Equatable, Sendable { + +struct CodexWireFSReadDirectoryParams: Codable, Equatable { /// Absolute directory path to read. let path: String } @@ -1458,7 +1517,8 @@ struct CodexWireFSReadDirectoryParams: Codable, Equatable, Sendable { /// Directory entries returned by `fs/readDirectory`. // MARK: - CodexWireFSReadDirectoryResponse -struct CodexWireFSReadDirectoryResponse: Codable, Equatable, Sendable { + +struct CodexWireFSReadDirectoryResponse: Codable, Equatable { /// Direct child entries in the requested directory. let entries: [CodexWireFSReadDirectoryEntry] } @@ -1471,7 +1531,8 @@ struct CodexWireFSReadDirectoryResponse: Codable, Equatable, Sendable { /// A directory entry returned by `fs/readDirectory`. // MARK: - CodexWireFSReadDirectoryEntry -struct CodexWireFSReadDirectoryEntry: Codable, Equatable, Sendable { + +struct CodexWireFSReadDirectoryEntry: Codable, Equatable { /// Direct child entry name only, not an absolute or relative path. let fileName: String /// Whether this entry resolves to a directory. @@ -1488,7 +1549,8 @@ struct CodexWireFSReadDirectoryEntry: Codable, Equatable, Sendable { /// Read a file from the host filesystem. // MARK: - CodexWireFSReadFileParams -struct CodexWireFSReadFileParams: Codable, Equatable, Sendable { + +struct CodexWireFSReadFileParams: Codable, Equatable { /// Absolute path to read. let path: String } @@ -1501,7 +1563,8 @@ struct CodexWireFSReadFileParams: Codable, Equatable, Sendable { /// Base64-encoded file contents returned by `fs/readFile`. // MARK: - CodexWireFSReadFileResponse -struct CodexWireFSReadFileResponse: Codable, Equatable, Sendable { + +struct CodexWireFSReadFileResponse: Codable, Equatable { /// File contents encoded as base64. let dataBase64: String } @@ -1514,7 +1577,8 @@ struct CodexWireFSReadFileResponse: Codable, Equatable, Sendable { /// Stop filesystem watch notifications for a prior `fs/watch`. // MARK: - CodexWireFSUnwatchParams -struct CodexWireFSUnwatchParams: Codable, Equatable, Sendable { + +struct CodexWireFSUnwatchParams: Codable, Equatable { /// Watch identifier previously provided to `fs/watch`. let watchID: String @@ -1531,7 +1595,8 @@ struct CodexWireFSUnwatchParams: Codable, Equatable, Sendable { /// Start filesystem watch notifications for an absolute path. // MARK: - CodexWireFSWatchParams -struct CodexWireFSWatchParams: Codable, Equatable, Sendable { + +struct CodexWireFSWatchParams: Codable, Equatable { /// Absolute file or directory path to watch. let path: String /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. @@ -1551,7 +1616,8 @@ struct CodexWireFSWatchParams: Codable, Equatable, Sendable { /// Successful response for `fs/watch`. // MARK: - CodexWireFSWatchResponse -struct CodexWireFSWatchResponse: Codable, Equatable, Sendable { + +struct CodexWireFSWatchResponse: Codable, Equatable { /// Canonicalized path associated with the watch. let path: String } @@ -1563,7 +1629,8 @@ struct CodexWireFSWatchResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGetAccountTokenUsageResponse -struct CodexWireGetAccountTokenUsageResponse: Codable, Equatable, Sendable { + +struct CodexWireGetAccountTokenUsageResponse: Codable, Equatable { let dailyUsageBuckets: [CodexWireAccountTokenUsageDailyBucket]? let summary: CodexWireAccountTokenUsageSummary } @@ -1575,7 +1642,8 @@ struct CodexWireGetAccountTokenUsageResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAccountTokenUsageDailyBucket -struct CodexWireAccountTokenUsageDailyBucket: Codable, Equatable, Sendable { + +struct CodexWireAccountTokenUsageDailyBucket: Codable, Equatable { let startDate: String let tokens: Int } @@ -1587,7 +1655,8 @@ struct CodexWireAccountTokenUsageDailyBucket: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAccountTokenUsageSummary -struct CodexWireAccountTokenUsageSummary: Codable, Equatable, Sendable { + +struct CodexWireAccountTokenUsageSummary: Codable, Equatable { let currentStreakDays, lifetimeTokens, longestRunningTurnSEC, longestStreakDays: Int? let peakDailyTokens: Int? @@ -1605,7 +1674,8 @@ struct CodexWireAccountTokenUsageSummary: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGuardianWarningNotification -struct CodexWireGuardianWarningNotification: Codable, Equatable, Sendable { + +struct CodexWireGuardianWarningNotification: Codable, Equatable { /// Concise guardian warning message for the user. let message: String /// Thread target for the guardian warning. @@ -1624,7 +1694,8 @@ struct CodexWireGuardianWarningNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHookCompletedNotification -struct CodexWireHookCompletedNotification: Codable, Equatable, Sendable { + +struct CodexWireHookCompletedNotification: Codable, Equatable { let run: CodexWireHookRunSummary let threadID: String let turnID: String? @@ -1643,7 +1714,8 @@ struct CodexWireHookCompletedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHookRunSummary -struct CodexWireHookRunSummary: Codable, Equatable, Sendable { + +struct CodexWireHookRunSummary: Codable, Equatable { let completedAt: Int? let displayOrder: Int let durationMS: Int? @@ -1673,62 +1745,63 @@ struct CodexWireHookRunSummary: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHookOutputEntry -struct CodexWireHookOutputEntry: Codable, Equatable, Sendable { + +struct CodexWireHookOutputEntry: Codable, Equatable { let kind: CodexWireHookOutputEntryKind let text: String } -enum CodexWireHookOutputEntryKind: String, Codable, Equatable, Sendable { - case context = "context" - case error = "error" - case feedback = "feedback" - case stop = "stop" - case warning = "warning" +enum CodexWireHookOutputEntryKind: String, Codable, Equatable { + case context + case error + case feedback + case stop + case warning } -enum CodexWireHookEventName: String, Codable, Equatable, Sendable { - case permissionRequest = "permissionRequest" - case postCompact = "postCompact" - case postToolUse = "postToolUse" - case preCompact = "preCompact" - case preToolUse = "preToolUse" - case sessionStart = "sessionStart" - case stop = "stop" - case subagentStart = "subagentStart" - case subagentStop = "subagentStop" - case userPromptSubmit = "userPromptSubmit" +enum CodexWireHookEventName: String, Codable, Equatable { + case permissionRequest + case postCompact + case postToolUse + case preCompact + case preToolUse + case sessionStart + case stop + case subagentStart + case subagentStop + case userPromptSubmit } -enum CodexWireHookExecutionMode: String, Codable, Equatable, Sendable { - case async = "async" - case sync = "sync" +enum CodexWireHookExecutionMode: String, Codable, Equatable { + case async + case sync } -enum CodexWireHookScope: String, Codable, Equatable, Sendable { - case thread = "thread" - case turn = "turn" +enum CodexWireHookScope: String, Codable, Equatable { + case thread + case turn } -enum CodexWireHookSource: String, Codable, Equatable, Sendable { - case cloudManagedConfig = "cloudManagedConfig" - case cloudRequirements = "cloudRequirements" - case legacyManagedConfigFile = "legacyManagedConfigFile" - case legacyManagedConfigMdm = "legacyManagedConfigMdm" - case mdm = "mdm" - case plugin = "plugin" - case project = "project" - case sessionFlags = "sessionFlags" - case system = "system" - case unknown = "unknown" - case user = "user" +enum CodexWireHookSource: String, Codable, Equatable { + case cloudManagedConfig + case cloudRequirements + case legacyManagedConfigFile + case legacyManagedConfigMdm + case mdm + case plugin + case project + case sessionFlags + case system + case unknown + case user } -enum CodexWireHookRunStatus: String, Codable, Equatable, Sendable { - case blocked = "blocked" - case completed = "completed" - case failed = "failed" - case running = "running" - case stopped = "stopped" +enum CodexWireHookRunStatus: String, Codable, Equatable { + case blocked + case completed + case failed + case running + case stopped } // @@ -1738,7 +1811,8 @@ enum CodexWireHookRunStatus: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHookStartedNotification -struct CodexWireHookStartedNotification: Codable, Equatable, Sendable { + +struct CodexWireHookStartedNotification: Codable, Equatable { let run: CodexWireHookRunSummary let threadID: String let turnID: String? @@ -1757,7 +1831,8 @@ struct CodexWireHookStartedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireInitializeParams -struct CodexWireInitializeParams: Codable, Equatable, Sendable { + +struct CodexWireInitializeParams: Codable, Equatable { let capabilities: CodexWireInitializeCapabilities? let clientInfo: CodexWireClientInfo } @@ -1770,7 +1845,8 @@ struct CodexWireInitializeParams: Codable, Equatable, Sendable { /// Client-declared capabilities negotiated during initialize. // MARK: - CodexWireInitializeCapabilities -struct CodexWireInitializeCapabilities: Codable, Equatable, Sendable { + +struct CodexWireInitializeCapabilities: Codable, Equatable { /// Opt into receiving experimental API methods and fields. let experimentalAPI: Bool? /// Exact notification method names that should be suppressed for this connection (for @@ -1792,7 +1868,8 @@ struct CodexWireInitializeCapabilities: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireClientInfo -struct CodexWireClientInfo: Codable, Equatable, Sendable { + +struct CodexWireClientInfo: Codable, Equatable { let name: String let title: String? let version: String @@ -1805,7 +1882,8 @@ struct CodexWireClientInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireItemCompletedNotification -struct CodexWireItemCompletedNotification: Codable, Equatable, Sendable { + +struct CodexWireItemCompletedNotification: Codable, Equatable { /// Unix timestamp (in milliseconds) when this item lifecycle completed. let completedAtMS: Int? let item: CodexWireThreadItem @@ -1828,7 +1906,25 @@ struct CodexWireItemCompletedNotification: Codable, Equatable, Sendable { /// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and /// may not match the concatenation of `PlanDelta` text. // MARK: - CodexWireThreadItem -struct CodexWireThreadItem: Codable, Equatable, Sendable { + +struct CodexWireThreadItem: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case clientID = "clientId" + case content, id, type, fragments, memoryCitation, phase, text, summary, aggregatedOutput, command, commandActions, cwd + case durationMS = "durationMs" + case exitCode + case processID = "processId" + case source, status, changes, arguments, error + case mcpAppResourceURI = "mcpAppResourceUri" + case pluginID = "pluginId" + case result, server, tool, contentItems, namespace, success, agentsStates, model, prompt, reasoningEffort + case receiverThreadIDS = "receiverThreadIds" + case senderThreadID = "senderThreadId" + case agentPath + case agentThreadID = "agentThreadId" + case kind, action, query, path, revisedPrompt, savedPath, review + } + let clientID: String? let content: [CodexWireContent]? /// Unique identifier for this collab tool call. @@ -1886,25 +1982,12 @@ struct CodexWireThreadItem: Codable, Equatable, Sendable { let receiverThreadIDS: [String]? /// Thread ID of the agent issuing the collab request. let senderThreadID: String? + let agentPath, agentThreadID: String? + let kind: CodexWireSubAgentActivityKind? let action: CodexWireWebSearchAction? let query, path: String? let revisedPrompt, savedPath: String? let review: String? - - enum CodingKeys: String, CodingKey { - case clientID = "clientId" - case content, id, type, fragments, memoryCitation, phase, text, summary, aggregatedOutput, command, commandActions, cwd - case durationMS = "durationMs" - case exitCode - case processID = "processId" - case source, status, changes, arguments, error - case mcpAppResourceURI = "mcpAppResourceUri" - case pluginID = "pluginId" - case result, server, tool, contentItems, namespace, success, agentsStates, model, prompt, reasoningEffort - case receiverThreadIDS = "receiverThreadIds" - case senderThreadID = "senderThreadId" - case action, query, path, revisedPrompt, savedPath, review - } } // @@ -1914,18 +1997,19 @@ struct CodexWireThreadItem: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireWebSearchAction -struct CodexWireWebSearchAction: Codable, Equatable, Sendable { + +struct CodexWireWebSearchAction: Codable, Equatable { let queries: [String]? let query: String? let type: CodexWireWebSearchActionType let url, pattern: String? } -enum CodexWireWebSearchActionType: String, Codable, Equatable, Sendable { - case findInPage = "findInPage" - case openPage = "openPage" - case other = "other" - case search = "search" +enum CodexWireWebSearchActionType: String, Codable, Equatable { + case findInPage + case openPage + case other + case search } // @@ -1935,19 +2019,20 @@ enum CodexWireWebSearchActionType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireCollabAgentState -struct CodexWireCollabAgentState: Codable, Equatable, Sendable { + +struct CodexWireCollabAgentState: Codable, Equatable { let message: String? let status: CodexWireCollabAgentStatus } -enum CodexWireCollabAgentStatus: String, Codable, Equatable, Sendable { - case completed = "completed" - case errored = "errored" - case interrupted = "interrupted" - case notFound = "notFound" - case pendingInit = "pendingInit" - case running = "running" - case shutdown = "shutdown" +enum CodexWireCollabAgentStatus: String, Codable, Equatable { + case completed + case errored + case interrupted + case notFound + case pendingInit + case running + case shutdown } // @@ -1957,7 +2042,8 @@ enum CodexWireCollabAgentStatus: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireCommandAction -struct CodexWireCommandAction: Codable, Equatable, Sendable { + +struct CodexWireCommandAction: Codable, Equatable { let command: String let name: String? let path: String? @@ -1965,14 +2051,14 @@ struct CodexWireCommandAction: Codable, Equatable, Sendable { let query: String? } -enum CodexWireCommandActionType: String, Codable, Equatable, Sendable { - case listFiles = "listFiles" - case read = "read" - case search = "search" - case unknown = "unknown" +enum CodexWireCommandActionType: String, Codable, Equatable { + case listFiles + case read + case search + case unknown } -enum CodexWireContent: Codable, Equatable, Sendable { +enum CodexWireContent: Codable, Equatable { case codexWireUserInput(CodexWireUserInput) case string(String) @@ -1992,10 +2078,10 @@ enum CodexWireContent: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireUserInput(let x): - try container.encode(x) - case .string(let x): - try container.encode(x) + case let .codexWireUserInput(x): + try container.encode(x) + case let .string(x): + try container.encode(x) } } } @@ -2007,7 +2093,8 @@ enum CodexWireContent: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireUserInput -struct CodexWireUserInput: Codable, Equatable, Sendable { + +struct CodexWireUserInput: Codable, Equatable { let text: String? /// UI-defined spans within `text` used to render or persist special elements. let textElements: [CodexWireTextElement]? @@ -2022,11 +2109,11 @@ struct CodexWireUserInput: Codable, Equatable, Sendable { } } -enum CodexWireImageDetail: String, Codable, Equatable, Sendable { - case auto = "auto" - case high = "high" - case low = "low" - case original = "original" +enum CodexWireImageDetail: String, Codable, Equatable { + case auto + case high + case low + case original } // @@ -2036,7 +2123,8 @@ enum CodexWireImageDetail: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTextElement -struct CodexWireTextElement: Codable, Equatable, Sendable { + +struct CodexWireTextElement: Codable, Equatable { /// Byte range in the parent `text` buffer that this element occupies. let byteRange: CodexWireByteRange /// Optional human-readable placeholder for the element, displayed in the UI. @@ -2051,16 +2139,17 @@ struct CodexWireTextElement: Codable, Equatable, Sendable { /// Byte range in the parent `text` buffer that this element occupies. // MARK: - CodexWireByteRange -struct CodexWireByteRange: Codable, Equatable, Sendable { + +struct CodexWireByteRange: Codable, Equatable { let end, start: Int } -enum CodexWireUserInputType: String, Codable, Equatable, Sendable { - case image = "image" - case localImage = "localImage" - case mention = "mention" - case skill = "skill" - case text = "text" +enum CodexWireUserInputType: String, Codable, Equatable { + case image + case localImage + case mention + case skill + case text } // @@ -2070,7 +2159,8 @@ enum CodexWireUserInputType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireDynamicToolCallOutputContentItem -struct CodexWireDynamicToolCallOutputContentItem: Codable, Equatable, Sendable { + +struct CodexWireDynamicToolCallOutputContentItem: Codable, Equatable { let text: String? let type: CodexWireInputDynamicToolCallOutputContentItemType let imageURL: String? @@ -2081,9 +2171,9 @@ struct CodexWireDynamicToolCallOutputContentItem: Codable, Equatable, Sendable { } } -enum CodexWireInputDynamicToolCallOutputContentItemType: String, Codable, Equatable, Sendable { - case inputImage = "inputImage" - case inputText = "inputText" +enum CodexWireInputDynamicToolCallOutputContentItemType: String, Codable, Equatable { + case inputImage + case inputText } // @@ -2093,7 +2183,8 @@ enum CodexWireInputDynamicToolCallOutputContentItemType: String, Codable, Equata // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPToolCallError -struct CodexWireMCPToolCallError: Codable, Equatable, Sendable { + +struct CodexWireMCPToolCallError: Codable, Equatable { let message: String } @@ -2104,7 +2195,8 @@ struct CodexWireMCPToolCallError: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireHookPromptFragment -struct CodexWireHookPromptFragment: Codable, Equatable, Sendable { + +struct CodexWireHookPromptFragment: Codable, Equatable { let hookRunID, text: String enum CodingKeys: String, CodingKey { @@ -2113,6 +2205,12 @@ struct CodexWireHookPromptFragment: Codable, Equatable, Sendable { } } +enum CodexWireSubAgentActivityKind: String, Codable, Equatable { + case interacted + case interrupted + case started +} + // // Hashable or Equatable: // The compiler will not be able to synthesize the implementation of Hashable or Equatable @@ -2120,7 +2218,8 @@ struct CodexWireHookPromptFragment: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMemoryCitation -struct CodexWireMemoryCitation: Codable, Equatable, Sendable { + +struct CodexWireMemoryCitation: Codable, Equatable { let entries: [CodexWireMemoryCitationEntry] let threadIDS: [String] @@ -2137,7 +2236,8 @@ struct CodexWireMemoryCitation: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMemoryCitationEntry -struct CodexWireMemoryCitationEntry: Codable, Equatable, Sendable { + +struct CodexWireMemoryCitationEntry: Codable, Equatable { let lineEnd, lineStart: Int let note, path: String } @@ -2147,12 +2247,12 @@ struct CodexWireMemoryCitationEntry: Codable, Equatable, Sendable { /// Additional tool calls or assistant output may follow before turn completion. /// /// The assistant's terminal answer text for the current turn. -enum CodexWireMessagePhase: String, Codable, Equatable, Sendable { - case commentary = "commentary" +enum CodexWireMessagePhase: String, Codable, Equatable { + case commentary case finalAnswer = "final_answer" } -enum CodexWireResult: Codable, Equatable, Sendable { +enum CodexWireResult: Codable, Equatable { case codexWireMCPToolCallResult(CodexWireMCPToolCallResult) case string(String) case null @@ -2177,12 +2277,12 @@ enum CodexWireResult: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireMCPToolCallResult(let x): - try container.encode(x) - case .string(let x): - try container.encode(x) - case .null: - try container.encodeNil() + case let .codexWireMCPToolCallResult(x): + try container.encode(x) + case let .string(x): + try container.encode(x) + case .null: + try container.encodeNil() } } } @@ -2194,7 +2294,8 @@ enum CodexWireResult: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPToolCallResult -struct CodexWireMCPToolCallResult: Codable, Equatable, Sendable { + +struct CodexWireMCPToolCallResult: Codable, Equatable { let meta: CodexWireJSONValue? let content: [CodexWireJSONValue] let structuredContent: CodexWireJSONValue? @@ -2205,30 +2306,31 @@ struct CodexWireMCPToolCallResult: Codable, Equatable, Sendable { } } -enum CodexWireCommandExecutionSource: String, Codable, Equatable, Sendable { - case agent = "agent" - case unifiedExecInteraction = "unifiedExecInteraction" - case unifiedExecStartup = "unifiedExecStartup" - case userShell = "userShell" +enum CodexWireCommandExecutionSource: String, Codable, Equatable { + case agent + case unifiedExecInteraction + case unifiedExecStartup + case userShell } -enum CodexWireThreadItemType: String, Codable, Equatable, Sendable { - case agentMessage = "agentMessage" - case collabAgentToolCall = "collabAgentToolCall" - case commandExecution = "commandExecution" - case contextCompaction = "contextCompaction" - case dynamicToolCall = "dynamicToolCall" - case enteredReviewMode = "enteredReviewMode" - case exitedReviewMode = "exitedReviewMode" - case fileChange = "fileChange" - case hookPrompt = "hookPrompt" - case imageGeneration = "imageGeneration" - case imageView = "imageView" - case mcpToolCall = "mcpToolCall" - case plan = "plan" - case reasoning = "reasoning" - case userMessage = "userMessage" - case webSearch = "webSearch" +enum CodexWireThreadItemType: String, Codable, Equatable { + case agentMessage + case collabAgentToolCall + case commandExecution + case contextCompaction + case dynamicToolCall + case enteredReviewMode + case exitedReviewMode + case fileChange + case hookPrompt + case imageGeneration + case imageView + case mcpToolCall + case plan + case reasoning + case subAgentActivity + case userMessage + case webSearch } // @@ -2240,7 +2342,8 @@ enum CodexWireThreadItemType: String, Codable, Equatable, Sendable { /// [UNSTABLE] Temporary notification payload for approval auto-review. This shape is /// expected to change soon. // MARK: - CodexWireItemGuardianApprovalReviewCompletedNotification -struct CodexWireItemGuardianApprovalReviewCompletedNotification: Codable, Equatable, Sendable { + +struct CodexWireItemGuardianApprovalReviewCompletedNotification: Codable, Equatable { let action: CodexWireGuardianApprovalReviewAction /// Unix timestamp (in milliseconds) when this review completed. let completedAtMS: Int? @@ -2283,7 +2386,8 @@ struct CodexWireItemGuardianApprovalReviewCompletedNotification: Codable, Equata // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGuardianApprovalReviewAction -struct CodexWireGuardianApprovalReviewAction: Codable, Equatable, Sendable { + +struct CodexWireGuardianApprovalReviewAction: Codable, Equatable { let command, cwd: String? let source: CodexWireGuardianCommandSource? let type: CodexWireGuardianApprovalReviewActionType @@ -2309,9 +2413,9 @@ struct CodexWireGuardianApprovalReviewAction: Codable, Equatable, Sendable { } } -enum CodexWireNetworkApprovalProtocol: String, Codable, Equatable, Sendable { - case http = "http" - case https = "https" +enum CodexWireNetworkApprovalProtocol: String, Codable, Equatable { + case http + case https case socks5TCP = "socks5Tcp" case socks5UDP = "socks5Udp" } @@ -2323,7 +2427,8 @@ enum CodexWireNetworkApprovalProtocol: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRequestPermissionProfile -struct CodexWireRequestPermissionProfile: Codable, Equatable, Sendable { + +struct CodexWireRequestPermissionProfile: Codable, Equatable { let fileSystem: CodexWireAdditionalFileSystemPermissions? let network: CodexWireAdditionalNetworkPermissions? } @@ -2335,7 +2440,8 @@ struct CodexWireRequestPermissionProfile: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAdditionalFileSystemPermissions -struct CodexWireAdditionalFileSystemPermissions: Codable, Equatable, Sendable { + +struct CodexWireAdditionalFileSystemPermissions: Codable, Equatable { let entries: [CodexWireFileSystemSandboxEntry]? let globScanMaxDepth: Int? /// This will be removed in favor of `entries`. @@ -2351,15 +2457,16 @@ struct CodexWireAdditionalFileSystemPermissions: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireFileSystemSandboxEntry -struct CodexWireFileSystemSandboxEntry: Codable, Equatable, Sendable { + +struct CodexWireFileSystemSandboxEntry: Codable, Equatable { let access: CodexWireFileSystemAccessMode let path: CodexWireFileSystemPath } -enum CodexWireFileSystemAccessMode: String, Codable, Equatable, Sendable { - case deny = "deny" - case read = "read" - case write = "write" +enum CodexWireFileSystemAccessMode: String, Codable, Equatable { + case deny + case read + case write } // @@ -2369,17 +2476,18 @@ enum CodexWireFileSystemAccessMode: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireFileSystemPath -struct CodexWireFileSystemPath: Codable, Equatable, Sendable { + +struct CodexWireFileSystemPath: Codable, Equatable { let path: String? let type: CodexWireFileSystemPathType let pattern: String? let value: CodexWireFileSystemSpecialPath? } -enum CodexWireFileSystemPathType: String, Codable, Equatable, Sendable { +enum CodexWireFileSystemPathType: String, Codable, Equatable { case globPattern = "glob_pattern" - case path = "path" - case special = "special" + case path + case special } // @@ -2389,19 +2497,20 @@ enum CodexWireFileSystemPathType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireFileSystemSpecialPath -struct CodexWireFileSystemSpecialPath: Codable, Equatable, Sendable { + +struct CodexWireFileSystemSpecialPath: Codable, Equatable { let kind: CodexWireKind let subpath: String? let path: String? } -enum CodexWireKind: String, Codable, Equatable, Sendable { - case minimal = "minimal" +enum CodexWireKind: String, Codable, Equatable { + case minimal case projectRoots = "project_roots" - case root = "root" + case root case slashTmp = "slash_tmp" - case tmpdir = "tmpdir" - case unknown = "unknown" + case tmpdir + case unknown } // @@ -2411,27 +2520,28 @@ enum CodexWireKind: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAdditionalNetworkPermissions -struct CodexWireAdditionalNetworkPermissions: Codable, Equatable, Sendable { + +struct CodexWireAdditionalNetworkPermissions: Codable, Equatable { let enabled: Bool? } -enum CodexWireGuardianCommandSource: String, Codable, Equatable, Sendable { - case shell = "shell" - case unifiedExec = "unifiedExec" +enum CodexWireGuardianCommandSource: String, Codable, Equatable { + case shell + case unifiedExec } -enum CodexWireGuardianApprovalReviewActionType: String, Codable, Equatable, Sendable { - case applyPatch = "applyPatch" - case command = "command" - case execve = "execve" - case mcpToolCall = "mcpToolCall" - case networkAccess = "networkAccess" - case requestPermissions = "requestPermissions" +enum CodexWireGuardianApprovalReviewActionType: String, Codable, Equatable { + case applyPatch + case command + case execve + case mcpToolCall + case networkAccess + case requestPermissions } /// [UNSTABLE] Source that produced a terminal approval auto-review decision. -enum CodexWireAutoReviewDecisionSource: String, Codable, Equatable, Sendable { - case agent = "agent" +enum CodexWireAutoReviewDecisionSource: String, Codable, Equatable { + case agent } // @@ -2443,7 +2553,8 @@ enum CodexWireAutoReviewDecisionSource: String, Codable, Equatable, Sendable { /// [UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` /// notifications. This shape is expected to change soon. // MARK: - CodexWireGuardianApprovalReview -struct CodexWireGuardianApprovalReview: Codable, Equatable, Sendable { + +struct CodexWireGuardianApprovalReview: Codable, Equatable { let rationale: String? let riskLevel: CodexWireGuardianRiskLevel? let status: CodexWireGuardianApprovalReviewStatus @@ -2451,28 +2562,28 @@ struct CodexWireGuardianApprovalReview: Codable, Equatable, Sendable { } /// [UNSTABLE] Risk level assigned by approval auto-review. -enum CodexWireGuardianRiskLevel: String, Codable, Equatable, Sendable { - case critical = "critical" - case high = "high" - case low = "low" - case medium = "medium" +enum CodexWireGuardianRiskLevel: String, Codable, Equatable { + case critical + case high + case low + case medium } /// [UNSTABLE] Lifecycle state for an approval auto-review. -enum CodexWireGuardianApprovalReviewStatus: String, Codable, Equatable, Sendable { - case aborted = "aborted" - case approved = "approved" - case denied = "denied" - case inProgress = "inProgress" - case timedOut = "timedOut" +enum CodexWireGuardianApprovalReviewStatus: String, Codable, Equatable { + case aborted + case approved + case denied + case inProgress + case timedOut } /// [UNSTABLE] Authorization level assigned by approval auto-review. -enum CodexWireGuardianUserAuthorization: String, Codable, Equatable, Sendable { - case high = "high" - case low = "low" - case medium = "medium" - case unknown = "unknown" +enum CodexWireGuardianUserAuthorization: String, Codable, Equatable { + case high + case low + case medium + case unknown } // @@ -2484,7 +2595,8 @@ enum CodexWireGuardianUserAuthorization: String, Codable, Equatable, Sendable { /// [UNSTABLE] Temporary notification payload for approval auto-review. This shape is /// expected to change soon. // MARK: - CodexWireItemGuardianApprovalReviewStartedNotification -struct CodexWireItemGuardianApprovalReviewStartedNotification: Codable, Equatable, Sendable { + +struct CodexWireItemGuardianApprovalReviewStartedNotification: Codable, Equatable { let action: CodexWireGuardianApprovalReviewAction let review: CodexWireGuardianApprovalReview /// Stable identifier for this review. @@ -2522,7 +2634,8 @@ struct CodexWireItemGuardianApprovalReviewStartedNotification: Codable, Equatabl // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireItemStartedNotification -struct CodexWireItemStartedNotification: Codable, Equatable, Sendable { + +struct CodexWireItemStartedNotification: Codable, Equatable { let item: CodexWireThreadItem /// Unix timestamp (in milliseconds) when this item lifecycle started. let startedAtMS: Int? @@ -2543,7 +2656,8 @@ struct CodexWireItemStartedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireListMCPServerStatusParams -struct CodexWireListMCPServerStatusParams: Codable, Equatable, Sendable { + +struct CodexWireListMCPServerStatusParams: Codable, Equatable { /// Opaque pagination cursor returned by a previous call. let cursor: String? /// Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when @@ -2559,9 +2673,9 @@ struct CodexWireListMCPServerStatusParams: Codable, Equatable, Sendable { } } -enum CodexWireMCPServerStatusDetail: String, Codable, Equatable, Sendable { - case full = "full" - case toolsAndAuthOnly = "toolsAndAuthOnly" +enum CodexWireMCPServerStatusDetail: String, Codable, Equatable { + case full + case toolsAndAuthOnly } // @@ -2571,7 +2685,8 @@ enum CodexWireMCPServerStatusDetail: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireListMCPServerStatusResponse -struct CodexWireListMCPServerStatusResponse: Codable, Equatable, Sendable { + +struct CodexWireListMCPServerStatusResponse: Codable, Equatable { let data: [CodexWireMCPServerStatus] /// Opaque cursor to pass to the next call to continue after the last item. If None, there /// are no more items to return. @@ -2585,7 +2700,8 @@ struct CodexWireListMCPServerStatusResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPServerStatus -struct CodexWireMCPServerStatus: Codable, Equatable, Sendable { + +struct CodexWireMCPServerStatus: Codable, Equatable { let authStatus: CodexWireMCPAuthStatus let name: String let resources: [CodexWireResource] @@ -2594,11 +2710,11 @@ struct CodexWireMCPServerStatus: Codable, Equatable, Sendable { let tools: [String: CodexWireTool] } -enum CodexWireMCPAuthStatus: String, Codable, Equatable, Sendable { - case bearerToken = "bearerToken" - case notLoggedIn = "notLoggedIn" - case oAuth = "oAuth" - case unsupported = "unsupported" +enum CodexWireMCPAuthStatus: String, Codable, Equatable { + case bearerToken + case notLoggedIn + case oAuth + case unsupported } // @@ -2609,7 +2725,8 @@ enum CodexWireMCPAuthStatus: String, Codable, Equatable, Sendable { /// A template description for resources available on the server. // MARK: - CodexWireResourceTemplate -struct CodexWireResourceTemplate: Codable, Equatable, Sendable { + +struct CodexWireResourceTemplate: Codable, Equatable { let annotations: CodexWireJSONValue? let description, mimeType: String? let name: String @@ -2625,7 +2742,8 @@ struct CodexWireResourceTemplate: Codable, Equatable, Sendable { /// A known resource that the server is capable of reading. // MARK: - CodexWireResource -struct CodexWireResource: Codable, Equatable, Sendable { + +struct CodexWireResource: Codable, Equatable { let meta, annotations: CodexWireJSONValue? let description: String? let icons: [CodexWireJSONValue]? @@ -2649,7 +2767,8 @@ struct CodexWireResource: Codable, Equatable, Sendable { /// Presentation metadata advertised by an initialized MCP server. // MARK: - CodexWireMCPServerInfo -struct CodexWireMCPServerInfo: Codable, Equatable, Sendable { + +struct CodexWireMCPServerInfo: Codable, Equatable { let description: String? let icons: [CodexWireJSONValue]? let name: String @@ -2671,7 +2790,8 @@ struct CodexWireMCPServerInfo: Codable, Equatable, Sendable { /// Definition for a tool the client can call. // MARK: - CodexWireTool -struct CodexWireTool: Codable, Equatable, Sendable { + +struct CodexWireTool: Codable, Equatable { let meta, annotations: CodexWireJSONValue? let description: String? let icons: [CodexWireJSONValue]? @@ -2693,7 +2813,8 @@ struct CodexWireTool: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPResourceReadParams -struct CodexWireMCPResourceReadParams: Codable, Equatable, Sendable { + +struct CodexWireMCPResourceReadParams: Codable, Equatable { let server: String let threadID: String? let uri: String @@ -2712,7 +2833,8 @@ struct CodexWireMCPResourceReadParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPResourceReadResponse -struct CodexWireMCPResourceReadResponse: Codable, Equatable, Sendable { + +struct CodexWireMCPResourceReadResponse: Codable, Equatable { let contents: [CodexWireResourceContent] } @@ -2724,7 +2846,8 @@ struct CodexWireMCPResourceReadResponse: Codable, Equatable, Sendable { /// Contents returned when reading a resource from an MCP server. // MARK: - CodexWireResourceContent -struct CodexWireResourceContent: Codable, Equatable, Sendable { + +struct CodexWireResourceContent: Codable, Equatable { let meta: CodexWireJSONValue? let mimeType: String? let text: String? @@ -2745,7 +2868,8 @@ struct CodexWireResourceContent: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPServerStatusUpdatedNotification -struct CodexWireMCPServerStatusUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireMCPServerStatusUpdatedNotification: Codable, Equatable { let error: String? let name: String let status: CodexWireMCPServerStartupState @@ -2757,11 +2881,11 @@ struct CodexWireMCPServerStatusUpdatedNotification: Codable, Equatable, Sendable } } -enum CodexWireMCPServerStartupState: String, Codable, Equatable, Sendable { - case cancelled = "cancelled" - case failed = "failed" - case ready = "ready" - case starting = "starting" +enum CodexWireMCPServerStartupState: String, Codable, Equatable { + case cancelled + case failed + case ready + case starting } // @@ -2771,7 +2895,8 @@ enum CodexWireMCPServerStartupState: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMCPToolCallProgressNotification -struct CodexWireMCPToolCallProgressNotification: Codable, Equatable, Sendable { + +struct CodexWireMCPToolCallProgressNotification: Codable, Equatable { let itemID, message, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -2789,7 +2914,8 @@ struct CodexWireMCPToolCallProgressNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelListParams -struct CodexWireModelListParams: Codable, Equatable, Sendable { + +struct CodexWireModelListParams: Codable, Equatable { /// Opaque pagination cursor returned by a previous call. let cursor: String? /// When true, include models that are hidden from the default picker list. @@ -2805,7 +2931,8 @@ struct CodexWireModelListParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelListResponse -struct CodexWireModelListResponse: Codable, Equatable, Sendable { + +struct CodexWireModelListResponse: Codable, Equatable { let data: [CodexWireModel] /// Opaque cursor to pass to the next call to continue after the last item. If None, there /// are no more items to return. @@ -2819,7 +2946,8 @@ struct CodexWireModelListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModel -struct CodexWireModel: Codable, Equatable, Sendable { + +struct CodexWireModel: Codable, Equatable { /// Deprecated: use `serviceTiers` instead. let additionalSpeedTiers: [String]? let availabilityNux: CodexWireModelAvailabilityNux? @@ -2846,7 +2974,8 @@ struct CodexWireModel: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelAvailabilityNux -struct CodexWireModelAvailabilityNux: Codable, Equatable, Sendable { + +struct CodexWireModelAvailabilityNux: Codable, Equatable { let message: String } @@ -2855,9 +2984,9 @@ struct CodexWireModelAvailabilityNux: Codable, Equatable, Sendable { /// Plain text turns and tool payloads. /// /// Image attachments included in user turns. -enum CodexWireInputModality: String, Codable, Equatable, Sendable { - case image = "image" - case text = "text" +enum CodexWireInputModality: String, Codable, Equatable { + case image + case text } // @@ -2867,7 +2996,8 @@ enum CodexWireInputModality: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelServiceTier -struct CodexWireModelServiceTier: Codable, Equatable, Sendable { + +struct CodexWireModelServiceTier: Codable, Equatable { let description, id, name: String } @@ -2878,7 +3008,8 @@ struct CodexWireModelServiceTier: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReasoningEffortOption -struct CodexWireReasoningEffortOption: Codable, Equatable, Sendable { + +struct CodexWireReasoningEffortOption: Codable, Equatable { let description: String let reasoningEffort: String } @@ -2890,7 +3021,8 @@ struct CodexWireReasoningEffortOption: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelUpgradeInfo -struct CodexWireModelUpgradeInfo: Codable, Equatable, Sendable { + +struct CodexWireModelUpgradeInfo: Codable, Equatable { let migrationMarkdown: String? let model: String let modelLink, upgradeCopy: String? @@ -2903,7 +3035,8 @@ struct CodexWireModelUpgradeInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelReroutedNotification -struct CodexWireModelReroutedNotification: Codable, Equatable, Sendable { + +struct CodexWireModelReroutedNotification: Codable, Equatable { let fromModel: String let reason: CodexWireModelRerouteReason let threadID, toModel, turnID: String @@ -2916,8 +3049,8 @@ struct CodexWireModelReroutedNotification: Codable, Equatable, Sendable { } } -enum CodexWireModelRerouteReason: String, Codable, Equatable, Sendable { - case highRiskCyberActivity = "highRiskCyberActivity" +enum CodexWireModelRerouteReason: String, Codable, Equatable { + case highRiskCyberActivity } // @@ -2927,7 +3060,8 @@ enum CodexWireModelRerouteReason: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireModelVerificationNotification -struct CodexWireModelVerificationNotification: Codable, Equatable, Sendable { + +struct CodexWireModelVerificationNotification: Codable, Equatable { let threadID, turnID: String let verifications: [CodexWireModelVerification] @@ -2938,8 +3072,8 @@ struct CodexWireModelVerificationNotification: Codable, Equatable, Sendable { } } -enum CodexWireModelVerification: String, Codable, Equatable, Sendable { - case trustedAccessForCyber = "trustedAccessForCyber" +enum CodexWireModelVerification: String, Codable, Equatable { + case trustedAccessForCyber } // @@ -2951,7 +3085,8 @@ enum CodexWireModelVerification: String, Codable, Equatable, Sendable { /// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume /// concatenated deltas match the completed plan item content. // MARK: - CodexWirePlanDeltaNotification -struct CodexWirePlanDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWirePlanDeltaNotification: Codable, Equatable { let delta, itemID, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -2969,7 +3104,8 @@ struct CodexWirePlanDeltaNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginListParams -struct CodexWirePluginListParams: Codable, Equatable, Sendable { + +struct CodexWirePluginListParams: Codable, Equatable { /// Optional working directories used to discover repo marketplaces. When omitted, only /// home-scoped marketplaces and the official curated marketplace are considered. let cwds: [String]? @@ -2978,10 +3114,10 @@ struct CodexWirePluginListParams: Codable, Equatable, Sendable { let marketplaceKinds: [CodexWirePluginListMarketplaceKind]? } -enum CodexWirePluginListMarketplaceKind: String, Codable, Equatable, Sendable { - case local = "local" +enum CodexWirePluginListMarketplaceKind: String, Codable, Equatable { + case local case sharedWithMe = "shared-with-me" - case vertical = "vertical" + case vertical case workspaceDirectory = "workspace-directory" } @@ -2992,7 +3128,8 @@ enum CodexWirePluginListMarketplaceKind: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginListResponse -struct CodexWirePluginListResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginListResponse: Codable, Equatable { let featuredPluginIDS: [String]? let marketplaceLoadErrors: [CodexWireMarketplaceLoadErrorInfo]? let marketplaces: [CodexWirePluginMarketplaceEntry] @@ -3010,7 +3147,8 @@ struct CodexWirePluginListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMarketplaceLoadErrorInfo -struct CodexWireMarketplaceLoadErrorInfo: Codable, Equatable, Sendable { + +struct CodexWireMarketplaceLoadErrorInfo: Codable, Equatable { let marketplacePath, message: String } @@ -3021,7 +3159,8 @@ struct CodexWireMarketplaceLoadErrorInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginMarketplaceEntry -struct CodexWirePluginMarketplaceEntry: Codable, Equatable, Sendable { + +struct CodexWirePluginMarketplaceEntry: Codable, Equatable { let interface: CodexWireMarketplaceInterface? let name: String /// Local marketplace file path when the marketplace is backed by a local file. Remote-only @@ -3037,7 +3176,8 @@ struct CodexWirePluginMarketplaceEntry: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireMarketplaceInterface -struct CodexWireMarketplaceInterface: Codable, Equatable, Sendable { + +struct CodexWireMarketplaceInterface: Codable, Equatable { let displayName: String? } @@ -3048,7 +3188,8 @@ struct CodexWireMarketplaceInterface: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginSummary -struct CodexWirePluginSummary: Codable, Equatable, Sendable { + +struct CodexWirePluginSummary: Codable, Equatable { let authPolicy: CodexWirePluginAuthPolicy /// Availability state for installing and using the plugin. let availability: CodexWirePluginAvailability? @@ -3074,7 +3215,7 @@ struct CodexWirePluginSummary: Codable, Equatable, Sendable { } } -enum CodexWirePluginAuthPolicy: String, Codable, Equatable, Sendable { +enum CodexWirePluginAuthPolicy: String, Codable, Equatable { case onInstall = "ON_INSTALL" case onUse = "ON_USE" } @@ -3084,12 +3225,12 @@ enum CodexWirePluginAuthPolicy: String, Codable, Equatable, Sendable { /// Plugin-service currently sends `"ENABLED"` for available remote plugins. Codex app-server /// exposes `"AVAILABLE"` in its API; the alias keeps decoding compatible with that upstream /// response. -enum CodexWirePluginAvailability: String, Codable, Equatable, Sendable { +enum CodexWirePluginAvailability: String, Codable, Equatable { case available = "AVAILABLE" case disabledByAdmin = "DISABLED_BY_ADMIN" } -enum CodexWirePluginInstallPolicy: String, Codable, Equatable, Sendable { +enum CodexWirePluginInstallPolicy: String, Codable, Equatable { case available = "AVAILABLE" case installedByDefault = "INSTALLED_BY_DEFAULT" case notAvailable = "NOT_AVAILABLE" @@ -3102,7 +3243,8 @@ enum CodexWirePluginInstallPolicy: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginInterface -struct CodexWirePluginInterface: Codable, Equatable, Sendable { + +struct CodexWirePluginInterface: Codable, Equatable { let brandColor: String? let capabilities: [String] let category: String? @@ -3145,7 +3287,8 @@ struct CodexWirePluginInterface: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareContext -struct CodexWirePluginShareContext: Codable, Equatable, Sendable { + +struct CodexWirePluginShareContext: Codable, Equatable { let creatorAccountUserID, creatorName: String? let discoverability: CodexWirePluginShareDiscoverability? let remotePluginID: String @@ -3163,7 +3306,7 @@ struct CodexWirePluginShareContext: Codable, Equatable, Sendable { } } -enum CodexWirePluginShareDiscoverability: String, Codable, Equatable, Sendable { +enum CodexWirePluginShareDiscoverability: String, Codable, Equatable { case listed = "LISTED" case pluginShareDiscoverabilityPRIVATE = "PRIVATE" case unlisted = "UNLISTED" @@ -3176,7 +3319,8 @@ enum CodexWirePluginShareDiscoverability: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginSharePrincipal -struct CodexWirePluginSharePrincipal: Codable, Equatable, Sendable { + +struct CodexWirePluginSharePrincipal: Codable, Equatable { let name, principalID: String let principalType: CodexWirePluginSharePrincipalType let role: CodexWirePluginSharePrincipalRole @@ -3188,16 +3332,16 @@ struct CodexWirePluginSharePrincipal: Codable, Equatable, Sendable { } } -enum CodexWirePluginSharePrincipalType: String, Codable, Equatable, Sendable { - case group = "group" - case user = "user" - case workspace = "workspace" +enum CodexWirePluginSharePrincipalType: String, Codable, Equatable { + case group + case user + case workspace } -enum CodexWirePluginSharePrincipalRole: String, Codable, Equatable, Sendable { - case editor = "editor" - case owner = "owner" - case reader = "reader" +enum CodexWirePluginSharePrincipalRole: String, Codable, Equatable { + case editor + case owner + case reader } // @@ -3209,17 +3353,18 @@ enum CodexWirePluginSharePrincipalRole: String, Codable, Equatable, Sendable { /// The plugin is available in the remote catalog. Download metadata is kept server-side and /// is not exposed through the app-server API. // MARK: - CodexWirePluginSource -struct CodexWirePluginSource: Codable, Equatable, Sendable { + +struct CodexWirePluginSource: Codable, Equatable { let path: String? let type: CodexWirePluginSourceType let refName, sha: String? let url: String? } -enum CodexWirePluginSourceType: String, Codable, Equatable, Sendable { - case git = "git" - case local = "local" - case remote = "remote" +enum CodexWirePluginSourceType: String, Codable, Equatable { + case git + case local + case remote } // @@ -3229,7 +3374,8 @@ enum CodexWirePluginSourceType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginReadParams -struct CodexWirePluginReadParams: Codable, Equatable, Sendable { + +struct CodexWirePluginReadParams: Codable, Equatable { let marketplacePath: String? let pluginName: String let remoteMarketplaceName: String? @@ -3242,7 +3388,8 @@ struct CodexWirePluginReadParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginReadResponse -struct CodexWirePluginReadResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginReadResponse: Codable, Equatable { let plugin: CodexWirePluginDetail } @@ -3253,7 +3400,8 @@ struct CodexWirePluginReadResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginDetail -struct CodexWirePluginDetail: Codable, Equatable, Sendable { + +struct CodexWirePluginDetail: Codable, Equatable { let apps: [CodexWireAppSummary] let appTemplates: [CodexWireAppTemplateSummary] let description: String? @@ -3261,8 +3409,15 @@ struct CodexWirePluginDetail: Codable, Equatable, Sendable { let marketplaceName: String let marketplacePath: String? let mcpServers: [String] + let shareURL: String? let skills: [CodexWireSkillSummary] let summary: CodexWirePluginSummary + + enum CodingKeys: String, CodingKey { + case apps, appTemplates, description, hooks, marketplaceName, marketplacePath, mcpServers + case shareURL = "shareUrl" + case skills, summary + } } // @@ -3272,8 +3427,10 @@ struct CodexWirePluginDetail: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAppTemplateSummary -struct CodexWireAppTemplateSummary: Codable, Equatable, Sendable { - let canonicalConnectorID, description, logoURL, logoURLDark: String? + +struct CodexWireAppTemplateSummary: Codable, Equatable { + let canonicalConnectorID, category, description, logoURL: String? + let logoURLDark: String? let materializedAppIDS: [String] let name: String let reason: CodexWireAppTemplateUnavailableReason? @@ -3281,7 +3438,7 @@ struct CodexWireAppTemplateSummary: Codable, Equatable, Sendable { enum CodingKeys: String, CodingKey { case canonicalConnectorID = "canonicalConnectorId" - case description + case category, description case logoURL = "logoUrl" case logoURLDark = "logoUrlDark" case materializedAppIDS = "materializedAppIds" @@ -3290,7 +3447,7 @@ struct CodexWireAppTemplateSummary: Codable, Equatable, Sendable { } } -enum CodexWireAppTemplateUnavailableReason: String, Codable, Equatable, Sendable { +enum CodexWireAppTemplateUnavailableReason: String, Codable, Equatable { case noActiveWorkspace = "NO_ACTIVE_WORKSPACE" case notConfiguredForWorkspace = "NOT_CONFIGURED_FOR_WORKSPACE" } @@ -3303,17 +3460,17 @@ enum CodexWireAppTemplateUnavailableReason: String, Codable, Equatable, Sendable /// EXPERIMENTAL - app metadata summary for plugin responses. // MARK: - CodexWireAppSummary -struct CodexWireAppSummary: Codable, Equatable, Sendable { - let description: String? + +struct CodexWireAppSummary: Codable, Equatable { + let category, description: String? let id: String let installURL: String? let name: String - let needsAuth: Bool enum CodingKeys: String, CodingKey { - case description, id + case category, description, id case installURL = "installUrl" - case name, needsAuth + case name } } @@ -3324,7 +3481,8 @@ struct CodexWireAppSummary: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginHookSummary -struct CodexWirePluginHookSummary: Codable, Equatable, Sendable { + +struct CodexWirePluginHookSummary: Codable, Equatable { let eventName: CodexWireHookEventName let key: String } @@ -3336,7 +3494,8 @@ struct CodexWirePluginHookSummary: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillSummary -struct CodexWireSkillSummary: Codable, Equatable, Sendable { + +struct CodexWireSkillSummary: Codable, Equatable { let description: String let enabled: Bool let interface: CodexWireSkillInterface? @@ -3351,7 +3510,8 @@ struct CodexWireSkillSummary: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillInterface -struct CodexWireSkillInterface: Codable, Equatable, Sendable { + +struct CodexWireSkillInterface: Codable, Equatable { let brandColor, defaultPrompt, displayName, iconLarge: String? let iconSmall, shortDescription: String? } @@ -3363,7 +3523,8 @@ struct CodexWireSkillInterface: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareDeleteParams -struct CodexWirePluginShareDeleteParams: Codable, Equatable, Sendable { + +struct CodexWirePluginShareDeleteParams: Codable, Equatable { let remotePluginID: String enum CodingKeys: String, CodingKey { @@ -3378,7 +3539,8 @@ struct CodexWirePluginShareDeleteParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareListResponse -struct CodexWirePluginShareListResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginShareListResponse: Codable, Equatable { let data: [CodexWirePluginShareListItem] } @@ -3389,7 +3551,8 @@ struct CodexWirePluginShareListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareListItem -struct CodexWirePluginShareListItem: Codable, Equatable, Sendable { + +struct CodexWirePluginShareListItem: Codable, Equatable { let localPluginPath: String? let plugin: CodexWirePluginSummary } @@ -3401,7 +3564,8 @@ struct CodexWirePluginShareListItem: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareSaveParams -struct CodexWirePluginShareSaveParams: Codable, Equatable, Sendable { + +struct CodexWirePluginShareSaveParams: Codable, Equatable { let discoverability: CodexWirePluginShareDiscoverability? let pluginPath: String let remotePluginID: String? @@ -3421,7 +3585,8 @@ struct CodexWirePluginShareSaveParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareTarget -struct CodexWirePluginShareTarget: Codable, Equatable, Sendable { + +struct CodexWirePluginShareTarget: Codable, Equatable { let principalID: String let principalType: CodexWirePluginSharePrincipalType let role: CodexWirePluginShareTargetRole @@ -3432,9 +3597,9 @@ struct CodexWirePluginShareTarget: Codable, Equatable, Sendable { } } -enum CodexWirePluginShareTargetRole: String, Codable, Equatable, Sendable { - case editor = "editor" - case reader = "reader" +enum CodexWirePluginShareTargetRole: String, Codable, Equatable { + case editor + case reader } // @@ -3444,7 +3609,8 @@ enum CodexWirePluginShareTargetRole: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareSaveResponse -struct CodexWirePluginShareSaveResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginShareSaveResponse: Codable, Equatable { let remotePluginID, shareURL: String enum CodingKeys: String, CodingKey { @@ -3460,7 +3626,8 @@ struct CodexWirePluginShareSaveResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareUpdateTargetsParams -struct CodexWirePluginShareUpdateTargetsParams: Codable, Equatable, Sendable { + +struct CodexWirePluginShareUpdateTargetsParams: Codable, Equatable { let discoverability: CodexWirePluginShareUpdateDiscoverability let remotePluginID: String let shareTargets: [CodexWirePluginShareTarget] @@ -3472,7 +3639,7 @@ struct CodexWirePluginShareUpdateTargetsParams: Codable, Equatable, Sendable { } } -enum CodexWirePluginShareUpdateDiscoverability: String, Codable, Equatable, Sendable { +enum CodexWirePluginShareUpdateDiscoverability: String, Codable, Equatable { case pluginShareUpdateDiscoverabilityPRIVATE = "PRIVATE" case unlisted = "UNLISTED" } @@ -3484,7 +3651,8 @@ enum CodexWirePluginShareUpdateDiscoverability: String, Codable, Equatable, Send // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginShareUpdateTargetsResponse -struct CodexWirePluginShareUpdateTargetsResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginShareUpdateTargetsResponse: Codable, Equatable { let discoverability: CodexWirePluginShareDiscoverability let principals: [CodexWirePluginSharePrincipal] } @@ -3496,7 +3664,8 @@ struct CodexWirePluginShareUpdateTargetsResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginSkillReadParams -struct CodexWirePluginSkillReadParams: Codable, Equatable, Sendable { + +struct CodexWirePluginSkillReadParams: Codable, Equatable { let remoteMarketplaceName, remotePluginID, skillName: String enum CodingKeys: String, CodingKey { @@ -3513,7 +3682,8 @@ struct CodexWirePluginSkillReadParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWirePluginSkillReadResponse -struct CodexWirePluginSkillReadResponse: Codable, Equatable, Sendable { + +struct CodexWirePluginSkillReadResponse: Codable, Equatable { let contents: String? } @@ -3525,7 +3695,8 @@ struct CodexWirePluginSkillReadResponse: Codable, Equatable, Sendable { /// Final process exit notification for `process/spawn`. // MARK: - CodexWireProcessExitedNotification -struct CodexWireProcessExitedNotification: Codable, Equatable, Sendable { + +struct CodexWireProcessExitedNotification: Codable, Equatable { /// Process exit code. let exitCode: Int /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. @@ -3558,7 +3729,8 @@ struct CodexWireProcessExitedNotification: Codable, Equatable, Sendable { /// Terminate a running `process/spawn` session. // MARK: - CodexWireProcessKillParams -struct CodexWireProcessKillParams: Codable, Equatable, Sendable { + +struct CodexWireProcessKillParams: Codable, Equatable { /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. let processHandle: String } @@ -3571,7 +3743,8 @@ struct CodexWireProcessKillParams: Codable, Equatable, Sendable { /// Base64-encoded output chunk emitted for a streaming `process/spawn` request. // MARK: - CodexWireProcessOutputDeltaNotification -struct CodexWireProcessOutputDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireProcessOutputDeltaNotification: Codable, Equatable { /// True on the final streamed chunk for this stream when output was truncated by /// `outputBytesCap`. let capReached: Bool @@ -3591,7 +3764,8 @@ struct CodexWireProcessOutputDeltaNotification: Codable, Equatable, Sendable { /// Resize a running PTY-backed `process/spawn` session. // MARK: - CodexWireProcessResizePtyParams -struct CodexWireProcessResizePtyParams: Codable, Equatable, Sendable { + +struct CodexWireProcessResizePtyParams: Codable, Equatable { /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. let processHandle: String /// New PTY size in character cells. @@ -3608,7 +3782,8 @@ struct CodexWireProcessResizePtyParams: Codable, Equatable, Sendable { /// /// PTY size in character cells for `process/spawn` PTY sessions. // MARK: - CodexWireProcessTerminalSize -struct CodexWireProcessTerminalSize: Codable, Equatable, Sendable { + +struct CodexWireProcessTerminalSize: Codable, Equatable { /// Terminal width in character cells. let cols: Int /// Terminal height in character cells. @@ -3628,7 +3803,14 @@ struct CodexWireProcessTerminalSize: Codable, Equatable, Sendable { /// `processHandle` has been registered. Process output and exit are reported via /// `process/outputDelta` and `process/exited` notifications. // MARK: - CodexWireProcessSpawnParams -struct CodexWireProcessSpawnParams: Codable, Equatable, Sendable { + +struct CodexWireProcessSpawnParams: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case command, cwd, env, outputBytesCap, processHandle, size, streamStdin, streamStdoutStderr + case timeoutMS = "timeoutMs" + case tty + } + /// Command argv vector. Empty arrays are rejected. let command: [String] /// Absolute working directory for the process. @@ -3663,12 +3845,6 @@ struct CodexWireProcessSpawnParams: Codable, Equatable, Sendable { /// /// This implies `streamStdin` and `streamStdoutStderr`. let tty: Bool? - - enum CodingKeys: String, CodingKey { - case command, cwd, env, outputBytesCap, processHandle, size, streamStdin, streamStdoutStderr - case timeoutMS = "timeoutMs" - case tty - } } // @@ -3679,7 +3855,8 @@ struct CodexWireProcessSpawnParams: Codable, Equatable, Sendable { /// Write stdin bytes to a running `process/spawn` session, close stdin, or both. // MARK: - CodexWireProcessWriteStdinParams -struct CodexWireProcessWriteStdinParams: Codable, Equatable, Sendable { + +struct CodexWireProcessWriteStdinParams: Codable, Equatable { /// Close stdin after writing `deltaBase64`, if present. let closeStdin: Bool? /// Optional base64-encoded stdin bytes to write. @@ -3695,7 +3872,8 @@ struct CodexWireProcessWriteStdinParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRawResponseItemCompletedNotification -struct CodexWireRawResponseItemCompletedNotification: Codable, Equatable, Sendable { + +struct CodexWireRawResponseItemCompletedNotification: Codable, Equatable { let item: CodexWireResponseItem let threadID, turnID: String @@ -3713,7 +3891,8 @@ struct CodexWireRawResponseItemCompletedNotification: Codable, Equatable, Sendab // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireResponseItem -struct CodexWireResponseItem: Codable, Equatable, Sendable { + +struct CodexWireResponseItem: Codable, Equatable { let content: [CodexWireContentItem]? /// Legacy id field retained for compatibility with older payloads. let id: String? @@ -3753,7 +3932,8 @@ struct CodexWireResponseItem: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireResponsesAPIWebSearchAction -struct CodexWireResponsesAPIWebSearchAction: Codable, Equatable, Sendable { + +struct CodexWireResponsesAPIWebSearchAction: Codable, Equatable { let command: [String]? let env: [String: String]? let timeoutMS: Int? @@ -3771,12 +3951,12 @@ struct CodexWireResponsesAPIWebSearchAction: Codable, Equatable, Sendable { } } -enum CodexWireExecLocalShellActionType: String, Codable, Equatable, Sendable { - case exec = "exec" +enum CodexWireExecLocalShellActionType: String, Codable, Equatable { + case exec case findInPage = "find_in_page" case openPage = "open_page" - case other = "other" - case search = "search" + case other + case search } // @@ -3786,9 +3966,10 @@ enum CodexWireExecLocalShellActionType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireContentItem -struct CodexWireContentItem: Codable, Equatable, Sendable { + +struct CodexWireContentItem: Codable, Equatable { let text: String? - let type: CodexWireEncryptedContentAgentMessageInputContentType + let type: CodexWireType let detail: CodexWireImageDetail? let imageURL, encryptedContent: String? @@ -3799,16 +3980,16 @@ struct CodexWireContentItem: Codable, Equatable, Sendable { } } -enum CodexWireEncryptedContentAgentMessageInputContentType: String, Codable, Equatable, Sendable { +enum CodexWireType: String, Codable, Equatable { case encryptedContent = "encrypted_content" case inputImage = "input_image" case inputText = "input_text" case outputText = "output_text" case reasoningText = "reasoning_text" - case text = "text" + case text } -enum CodexWireFunctionCallOutputBody: Codable, Equatable, Sendable { +enum CodexWireFunctionCallOutputBody: Codable, Equatable { case codexWireFunctionCallOutputContentItemArray([CodexWireFunctionCallOutputContentItem]) case string(String) @@ -3828,10 +4009,10 @@ enum CodexWireFunctionCallOutputBody: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireFunctionCallOutputContentItemArray(let x): - try container.encode(x) - case .string(let x): - try container.encode(x) + case let .codexWireFunctionCallOutputContentItemArray(x): + try container.encode(x) + case let .string(x): + try container.encode(x) } } } @@ -3845,7 +4026,8 @@ enum CodexWireFunctionCallOutputBody: Codable, Equatable, Sendable { /// Responses API compatible content items that can be returned by a tool call. This is a /// subset of ContentItem with the types we support as function call outputs. // MARK: - CodexWireFunctionCallOutputContentItem -struct CodexWireFunctionCallOutputContentItem: Codable, Equatable, Sendable { + +struct CodexWireFunctionCallOutputContentItem: Codable, Equatable { let text: String? let type: CodexWireFunctionCallOutputContentItemType let detail: CodexWireImageDetail? @@ -3858,7 +4040,7 @@ struct CodexWireFunctionCallOutputContentItem: Codable, Equatable, Sendable { } } -enum CodexWireFunctionCallOutputContentItemType: String, Codable, Equatable, Sendable { +enum CodexWireFunctionCallOutputContentItemType: String, Codable, Equatable { case encryptedContent = "encrypted_content" case inputImage = "input_image" case inputText = "input_text" @@ -3871,18 +4053,19 @@ enum CodexWireFunctionCallOutputContentItemType: String, Codable, Equatable, Sen // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReasoningItemReasoningSummary -struct CodexWireReasoningItemReasoningSummary: Codable, Equatable, Sendable { + +struct CodexWireReasoningItemReasoningSummary: Codable, Equatable { let text: String let type: CodexWireSummaryTextReasoningItemReasoningSummaryType } -enum CodexWireSummaryTextReasoningItemReasoningSummaryType: String, Codable, Equatable, Sendable { +enum CodexWireSummaryTextReasoningItemReasoningSummaryType: String, Codable, Equatable { case summaryText = "summary_text" } -enum CodexWireResponseItemType: String, Codable, Equatable, Sendable { +enum CodexWireResponseItemType: String, Codable, Equatable { case agentMessage = "agent_message" - case compaction = "compaction" + case compaction case compactionTrigger = "compaction_trigger" case contextCompaction = "context_compaction" case customToolCall = "custom_tool_call" @@ -3891,9 +4074,9 @@ enum CodexWireResponseItemType: String, Codable, Equatable, Sendable { case functionCallOutput = "function_call_output" case imageGenerationCall = "image_generation_call" case localShellCall = "local_shell_call" - case message = "message" - case other = "other" - case reasoning = "reasoning" + case message + case other + case reasoning case toolSearchCall = "tool_search_call" case toolSearchOutput = "tool_search_output" case webSearchCall = "web_search_call" @@ -3906,7 +4089,8 @@ enum CodexWireResponseItemType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReasoningSummaryPartAddedNotification -struct CodexWireReasoningSummaryPartAddedNotification: Codable, Equatable, Sendable { + +struct CodexWireReasoningSummaryPartAddedNotification: Codable, Equatable { let itemID: String let summaryIndex: Int let threadID, turnID: String @@ -3926,7 +4110,8 @@ struct CodexWireReasoningSummaryPartAddedNotification: Codable, Equatable, Senda // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReasoningSummaryTextDeltaNotification -struct CodexWireReasoningSummaryTextDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireReasoningSummaryTextDeltaNotification: Codable, Equatable { let delta, itemID: String let summaryIndex: Int let threadID, turnID: String @@ -3947,7 +4132,8 @@ struct CodexWireReasoningSummaryTextDeltaNotification: Codable, Equatable, Senda // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReasoningTextDeltaNotification -struct CodexWireReasoningTextDeltaNotification: Codable, Equatable, Sendable { + +struct CodexWireReasoningTextDeltaNotification: Codable, Equatable { let contentIndex: Int let delta, itemID, threadID, turnID: String @@ -3966,7 +4152,8 @@ struct CodexWireReasoningTextDeltaNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlClientsListParams -struct CodexWireRemoteControlClientsListParams: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlClientsListParams: Codable, Equatable { let cursor: String? let environmentID: String let limit: Int? @@ -3979,9 +4166,9 @@ struct CodexWireRemoteControlClientsListParams: Codable, Equatable, Sendable { } } -enum CodexWireRemoteControlClientsListOrder: String, Codable, Equatable, Sendable { - case asc = "asc" - case desc = "desc" +enum CodexWireRemoteControlClientsListOrder: String, Codable, Equatable { + case asc + case desc } // @@ -3991,7 +4178,8 @@ enum CodexWireRemoteControlClientsListOrder: String, Codable, Equatable, Sendabl // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlClientsListResponse -struct CodexWireRemoteControlClientsListResponse: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlClientsListResponse: Codable, Equatable { let data: [CodexWireRemoteControlClient] let nextCursor: String? } @@ -4003,7 +4191,8 @@ struct CodexWireRemoteControlClientsListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlClient -struct CodexWireRemoteControlClient: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlClient: Codable, Equatable { let appVersion: String? let clientID: String let deviceModel, deviceType, displayName: String? @@ -4024,7 +4213,8 @@ struct CodexWireRemoteControlClient: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlClientsRevokeParams -struct CodexWireRemoteControlClientsRevokeParams: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlClientsRevokeParams: Codable, Equatable { let clientID, environmentID: String enum CodingKeys: String, CodingKey { @@ -4040,7 +4230,8 @@ struct CodexWireRemoteControlClientsRevokeParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlPairingStartParams -struct CodexWireRemoteControlPairingStartParams: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlPairingStartParams: Codable, Equatable { let manualCode: Bool? } @@ -4051,7 +4242,8 @@ struct CodexWireRemoteControlPairingStartParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlPairingStartResponse -struct CodexWireRemoteControlPairingStartResponse: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlPairingStartResponse: Codable, Equatable { let environmentID: String let expiresAt: Int let manualPairingCode: String? @@ -4070,7 +4262,8 @@ struct CodexWireRemoteControlPairingStartResponse: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlPairingStatusParams -struct CodexWireRemoteControlPairingStatusParams: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlPairingStatusParams: Codable, Equatable { let manualPairingCode, pairingCode: String? } @@ -4081,7 +4274,8 @@ struct CodexWireRemoteControlPairingStatusParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireRemoteControlPairingStatusResponse -struct CodexWireRemoteControlPairingStatusResponse: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlPairingStatusResponse: Codable, Equatable { let claimed: Bool } @@ -4093,7 +4287,8 @@ struct CodexWireRemoteControlPairingStatusResponse: Codable, Equatable, Sendable /// Current remote-control connection status and remote identity exposed to clients. // MARK: - CodexWireRemoteControlStatusChangedNotification -struct CodexWireRemoteControlStatusChangedNotification: Codable, Equatable, Sendable { + +struct CodexWireRemoteControlStatusChangedNotification: Codable, Equatable { let environmentID: String? let installationID, serverName: String let status: CodexWireRemoteControlConnectionStatus @@ -4105,11 +4300,11 @@ struct CodexWireRemoteControlStatusChangedNotification: Codable, Equatable, Send } } -enum CodexWireRemoteControlConnectionStatus: String, Codable, Equatable, Sendable { - case connected = "connected" - case connecting = "connecting" - case disabled = "disabled" - case errored = "errored" +enum CodexWireRemoteControlConnectionStatus: String, Codable, Equatable { + case connected + case connecting + case disabled + case errored } // @@ -4119,7 +4314,8 @@ enum CodexWireRemoteControlConnectionStatus: String, Codable, Equatable, Sendabl // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReviewStartParams -struct CodexWireReviewStartParams: Codable, Equatable, Sendable { + +struct CodexWireReviewStartParams: Codable, Equatable { /// Where to run the review: inline (default) on the current thread or detached on a new /// thread (returned in `reviewThreadId`). let delivery: CodexWireReviewDelivery? @@ -4132,9 +4328,9 @@ struct CodexWireReviewStartParams: Codable, Equatable, Sendable { } } -enum CodexWireReviewDelivery: String, Codable, Equatable, Sendable { - case detached = "detached" - case inline = "inline" +enum CodexWireReviewDelivery: String, Codable, Equatable { + case detached + case inline } // @@ -4151,7 +4347,8 @@ enum CodexWireReviewDelivery: String, Codable, Equatable, Sendable { /// /// Arbitrary instructions, equivalent to the old free-form prompt. // MARK: - CodexWireReviewTarget -struct CodexWireReviewTarget: Codable, Equatable, Sendable { + +struct CodexWireReviewTarget: Codable, Equatable { let type: CodexWireReviewTargetType let branch, sha: String? /// Optional human-readable label (e.g., commit subject) for UIs. @@ -4159,11 +4356,11 @@ struct CodexWireReviewTarget: Codable, Equatable, Sendable { let instructions: String? } -enum CodexWireReviewTargetType: String, Codable, Equatable, Sendable { - case baseBranch = "baseBranch" - case commit = "commit" - case custom = "custom" - case uncommittedChanges = "uncommittedChanges" +enum CodexWireReviewTargetType: String, Codable, Equatable { + case baseBranch + case commit + case custom + case uncommittedChanges } // @@ -4173,7 +4370,8 @@ enum CodexWireReviewTargetType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireReviewStartResponse -struct CodexWireReviewStartResponse: Codable, Equatable, Sendable { + +struct CodexWireReviewStartResponse: Codable, Equatable { /// Identifies the thread where the review runs. /// /// For inline reviews, this is the original thread id. For detached reviews, this is the id @@ -4194,7 +4392,8 @@ struct CodexWireReviewStartResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurn -struct CodexWireTurn: Codable, Equatable, Sendable { + +struct CodexWireTurn: Codable, Equatable { /// Unix timestamp (in seconds) when the turn completed. let completedAt: Int? /// Duration between turn start and completion in milliseconds, if known. @@ -4225,17 +4424,17 @@ struct CodexWireTurn: Codable, Equatable, Sendable { /// /// `items` contains every ThreadItem available from persisted app-server history for this /// turn. -enum CodexWireTurnItemsView: String, Codable, Equatable, Sendable { - case full = "full" - case notLoaded = "notLoaded" - case summary = "summary" +enum CodexWireTurnItemsView: String, Codable, Equatable { + case full + case notLoaded + case summary } -enum CodexWireTurnStatus: String, Codable, Equatable, Sendable { - case completed = "completed" - case failed = "failed" - case inProgress = "inProgress" - case interrupted = "interrupted" +enum CodexWireTurnStatus: String, Codable, Equatable { + case completed + case failed + case inProgress + case interrupted } // @@ -4245,7 +4444,8 @@ enum CodexWireTurnStatus: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireServerRequestResolvedNotification -struct CodexWireServerRequestResolvedNotification: Codable, Equatable, Sendable { + +struct CodexWireServerRequestResolvedNotification: Codable, Equatable { let requestID: CodexWireRequestID let threadID: String @@ -4255,7 +4455,7 @@ struct CodexWireServerRequestResolvedNotification: Codable, Equatable, Sendable } } -enum CodexWireRequestID: Codable, Equatable, Sendable { +enum CodexWireRequestID: Codable, Equatable { case integer(Int) case string(String) @@ -4275,10 +4475,10 @@ enum CodexWireRequestID: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .integer(let x): - try container.encode(x) - case .string(let x): - try container.encode(x) + case let .integer(x): + try container.encode(x) + case let .string(x): + try container.encode(x) } } } @@ -4290,7 +4490,8 @@ enum CodexWireRequestID: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillsExtraRootsSetParams -struct CodexWireSkillsExtraRootsSetParams: Codable, Equatable, Sendable { + +struct CodexWireSkillsExtraRootsSetParams: Codable, Equatable { let extraRoots: [String] } @@ -4301,7 +4502,8 @@ struct CodexWireSkillsExtraRootsSetParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillsListParams -struct CodexWireSkillsListParams: Codable, Equatable, Sendable { + +struct CodexWireSkillsListParams: Codable, Equatable { /// When empty, defaults to the current session working directory. let cwds: [String]? /// When true, bypass the skills cache and re-scan skills from disk. @@ -4315,7 +4517,8 @@ struct CodexWireSkillsListParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillsListResponse -struct CodexWireSkillsListResponse: Codable, Equatable, Sendable { + +struct CodexWireSkillsListResponse: Codable, Equatable { let data: [CodexWireSkillsListEntry] } @@ -4326,7 +4529,8 @@ struct CodexWireSkillsListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillsListEntry -struct CodexWireSkillsListEntry: Codable, Equatable, Sendable { + +struct CodexWireSkillsListEntry: Codable, Equatable { let cwd: String let errors: [CodexWireSkillErrorInfo] let skills: [CodexWireSkillMetadata] @@ -4339,7 +4543,8 @@ struct CodexWireSkillsListEntry: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillErrorInfo -struct CodexWireSkillErrorInfo: Codable, Equatable, Sendable { + +struct CodexWireSkillErrorInfo: Codable, Equatable { let message, path: String } @@ -4350,7 +4555,8 @@ struct CodexWireSkillErrorInfo: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillMetadata -struct CodexWireSkillMetadata: Codable, Equatable, Sendable { + +struct CodexWireSkillMetadata: Codable, Equatable { let dependencies: CodexWireSkillDependencies? let description: String let enabled: Bool @@ -4368,7 +4574,8 @@ struct CodexWireSkillMetadata: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillDependencies -struct CodexWireSkillDependencies: Codable, Equatable, Sendable { + +struct CodexWireSkillDependencies: Codable, Equatable { let tools: [CodexWireSkillToolDependency] } @@ -4379,18 +4586,19 @@ struct CodexWireSkillDependencies: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSkillToolDependency -struct CodexWireSkillToolDependency: Codable, Equatable, Sendable { + +struct CodexWireSkillToolDependency: Codable, Equatable { let command, description, transport: String? let type: String let url: String? let value: String } -enum CodexWireSkillScope: String, Codable, Equatable, Sendable { - case admin = "admin" - case repo = "repo" - case system = "system" - case user = "user" +enum CodexWireSkillScope: String, Codable, Equatable { + case admin + case repo + case system + case user } // @@ -4400,7 +4608,8 @@ enum CodexWireSkillScope: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadApproveGuardianDeniedActionParams -struct CodexWireThreadApproveGuardianDeniedActionParams: Codable, Equatable, Sendable { + +struct CodexWireThreadApproveGuardianDeniedActionParams: Codable, Equatable { /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. let event: CodexWireJSONValue let threadID: String @@ -4418,7 +4627,8 @@ struct CodexWireThreadApproveGuardianDeniedActionParams: Codable, Equatable, Sen // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadArchiveParams -struct CodexWireThreadArchiveParams: Codable, Equatable, Sendable { + +struct CodexWireThreadArchiveParams: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4433,7 +4643,8 @@ struct CodexWireThreadArchiveParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadArchivedNotification -struct CodexWireThreadArchivedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadArchivedNotification: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4448,7 +4659,8 @@ struct CodexWireThreadArchivedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadClosedNotification -struct CodexWireThreadClosedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadClosedNotification: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4463,7 +4675,8 @@ struct CodexWireThreadClosedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadCompactStartParams -struct CodexWireThreadCompactStartParams: Codable, Equatable, Sendable { + +struct CodexWireThreadCompactStartParams: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4478,7 +4691,8 @@ struct CodexWireThreadCompactStartParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalClearParams -struct CodexWireThreadGoalClearParams: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalClearParams: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4493,7 +4707,8 @@ struct CodexWireThreadGoalClearParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalClearResponse -struct CodexWireThreadGoalClearResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalClearResponse: Codable, Equatable { let cleared: Bool } @@ -4504,7 +4719,8 @@ struct CodexWireThreadGoalClearResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalClearedNotification -struct CodexWireThreadGoalClearedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalClearedNotification: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4519,7 +4735,8 @@ struct CodexWireThreadGoalClearedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalGetParams -struct CodexWireThreadGoalGetParams: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalGetParams: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -4534,7 +4751,8 @@ struct CodexWireThreadGoalGetParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalGetResponse -struct CodexWireThreadGoalGetResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalGetResponse: Codable, Equatable { let goal: CodexWireThreadGoal? } @@ -4545,7 +4763,8 @@ struct CodexWireThreadGoalGetResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoal -struct CodexWireThreadGoal: Codable, Equatable, Sendable { + +struct CodexWireThreadGoal: Codable, Equatable { let createdAt: Int let objective: String let status: CodexWireThreadGoalStatus @@ -4561,13 +4780,13 @@ struct CodexWireThreadGoal: Codable, Equatable, Sendable { } } -enum CodexWireThreadGoalStatus: String, Codable, Equatable, Sendable { - case active = "active" - case blocked = "blocked" - case budgetLimited = "budgetLimited" - case complete = "complete" - case paused = "paused" - case usageLimited = "usageLimited" +enum CodexWireThreadGoalStatus: String, Codable, Equatable { + case active + case blocked + case budgetLimited + case complete + case paused + case usageLimited } // @@ -4577,7 +4796,8 @@ enum CodexWireThreadGoalStatus: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalSetParams -struct CodexWireThreadGoalSetParams: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalSetParams: Codable, Equatable { let objective: String? let status: CodexWireThreadGoalStatus? let threadID: String @@ -4597,7 +4817,8 @@ struct CodexWireThreadGoalSetParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalSetResponse -struct CodexWireThreadGoalSetResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalSetResponse: Codable, Equatable { let goal: CodexWireThreadGoal } @@ -4608,7 +4829,8 @@ struct CodexWireThreadGoalSetResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadGoalUpdatedNotification -struct CodexWireThreadGoalUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadGoalUpdatedNotification: Codable, Equatable { let goal: CodexWireThreadGoal let threadID: String let turnID: String? @@ -4627,7 +4849,8 @@ struct CodexWireThreadGoalUpdatedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadLoadedListParams -struct CodexWireThreadLoadedListParams: Codable, Equatable, Sendable { + +struct CodexWireThreadLoadedListParams: Codable, Equatable { /// Opaque pagination cursor returned by a previous call. let cursor: String? /// Optional page size; defaults to no limit. @@ -4641,7 +4864,8 @@ struct CodexWireThreadLoadedListParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadLoadedListResponse -struct CodexWireThreadLoadedListResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadLoadedListResponse: Codable, Equatable { /// Thread ids for sessions currently loaded in memory. let data: [String] /// Opaque cursor to pass to the next call to continue after the last item. if None, there @@ -4656,7 +4880,8 @@ struct CodexWireThreadLoadedListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadMetadataUpdateParams -struct CodexWireThreadMetadataUpdateParams: Codable, Equatable, Sendable { + +struct CodexWireThreadMetadataUpdateParams: Codable, Equatable { /// Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it /// to `null` to clear it, or provide a string to replace the stored value. let gitInfo: CodexWireThreadMetadataGitInfoUpdateParams? @@ -4675,7 +4900,8 @@ struct CodexWireThreadMetadataUpdateParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadMetadataGitInfoUpdateParams -struct CodexWireThreadMetadataGitInfoUpdateParams: Codable, Equatable, Sendable { + +struct CodexWireThreadMetadataGitInfoUpdateParams: Codable, Equatable { /// Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a /// non-empty string to replace it. let branch: String? @@ -4700,7 +4926,8 @@ struct CodexWireThreadMetadataGitInfoUpdateParams: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadMetadataUpdateResponse -struct CodexWireThreadMetadataUpdateResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadMetadataUpdateResponse: Codable, Equatable { let thread: CodexWireThread } @@ -4716,7 +4943,18 @@ struct CodexWireThreadMetadataUpdateResponse: Codable, Equatable, Sendable { /// agent interactions, such as command executions. This is the same behavior as /// `thread/resume`. // MARK: - CodexWireThread -struct CodexWireThread: Codable, Equatable, Sendable { + +struct CodexWireThread: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case agentNickname, agentRole, cliVersion, createdAt, cwd, ephemeral + case forkedFromID = "forkedFromId" + case gitInfo, id, modelProvider, name + case parentThreadID = "parentThreadId" + case path, preview + case sessionID = "sessionId" + case source, status, threadSource, turns, updatedAt + } + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. let agentNickname: String? /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. @@ -4751,23 +4989,13 @@ struct CodexWireThread: Codable, Equatable, Sendable { /// Current runtime status for the thread. let status: CodexWireThreadStatus /// Optional analytics source classification for this thread. - let threadSource: CodexWireThreadSource? + let threadSource: String? /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` /// (when `includeTurns` is true) responses. For all other responses and notifications /// returning a Thread, the turns field will be an empty list. let turns: [CodexWireTurn] /// Unix timestamp (in seconds) when the thread was last updated. let updatedAt: Int - - enum CodingKeys: String, CodingKey { - case agentNickname, agentRole, cliVersion, createdAt, cwd, ephemeral - case forkedFromID = "forkedFromId" - case gitInfo, id, modelProvider, name - case parentThreadID = "parentThreadId" - case path, preview - case sessionID = "sessionId" - case source, status, threadSource, turns, updatedAt - } } // @@ -4777,7 +5005,8 @@ struct CodexWireThread: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireGitInfo -struct CodexWireGitInfo: Codable, Equatable, Sendable { + +struct CodexWireGitInfo: Codable, Equatable { let branch, originURL, sha: String? enum CodingKeys: String, CodingKey { @@ -4788,7 +5017,7 @@ struct CodexWireGitInfo: Codable, Equatable, Sendable { } /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). -enum CodexWireSessionSourceUnion: Codable, Equatable, Sendable { +enum CodexWireSessionSourceUnion: Codable, Equatable { case codexWireSessionSource(CodexWireSessionSource) case enumeration(CodexWireSessionSourceEnum) @@ -4808,10 +5037,10 @@ enum CodexWireSessionSourceUnion: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireSessionSource(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) + case let .codexWireSessionSource(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) } } } @@ -4823,12 +5052,13 @@ enum CodexWireSessionSourceUnion: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSessionSource -struct CodexWireSessionSource: Codable, Equatable, Sendable { + +struct CodexWireSessionSource: Codable, Equatable { let custom: String? let subAgent: CodexWireSubAgentSourceUnion? } -enum CodexWireSubAgentSourceUnion: Codable, Equatable, Sendable { +enum CodexWireSubAgentSourceUnion: Codable, Equatable { case codexWireSubAgentSource(CodexWireSubAgentSource) case enumeration(CodexWireSubAgentSourceEnum) @@ -4848,10 +5078,10 @@ enum CodexWireSubAgentSourceUnion: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .codexWireSubAgentSource(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) + case let .codexWireSubAgentSource(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) } } } @@ -4863,7 +5093,8 @@ enum CodexWireSubAgentSourceUnion: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireSubAgentSource -struct CodexWireSubAgentSource: Codable, Equatable, Sendable { + +struct CodexWireSubAgentSource: Codable, Equatable { let threadSpawn: CodexWireThreadSpawn? let other: String? @@ -4880,7 +5111,8 @@ struct CodexWireSubAgentSource: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadSpawn -struct CodexWireThreadSpawn: Codable, Equatable, Sendable { + +struct CodexWireThreadSpawn: Codable, Equatable { let agentNickname, agentPath, agentRole: String? let depth: Int let parentThreadID: String @@ -4894,18 +5126,18 @@ struct CodexWireThreadSpawn: Codable, Equatable, Sendable { } } -enum CodexWireSubAgentSourceEnum: String, Codable, Equatable, Sendable { - case compact = "compact" +enum CodexWireSubAgentSourceEnum: String, Codable, Equatable { + case compact case memoryConsolidation = "memory_consolidation" - case review = "review" + case review } -enum CodexWireSessionSourceEnum: String, Codable, Equatable, Sendable { - case appServer = "appServer" - case cli = "cli" - case exec = "exec" - case unknown = "unknown" - case vscode = "vscode" +enum CodexWireSessionSourceEnum: String, Codable, Equatable { + case appServer + case cli + case exec + case unknown + case vscode } // @@ -4916,27 +5148,22 @@ enum CodexWireSessionSourceEnum: String, Codable, Equatable, Sendable { /// Current runtime status for the thread. // MARK: - CodexWireThreadStatus -struct CodexWireThreadStatus: Codable, Equatable, Sendable { + +struct CodexWireThreadStatus: Codable, Equatable { let type: CodexWireThreadStatusType let activeFlags: [CodexWireThreadActiveFlag]? } -enum CodexWireThreadActiveFlag: String, Codable, Equatable, Sendable { - case waitingOnApproval = "waitingOnApproval" - case waitingOnUserInput = "waitingOnUserInput" +enum CodexWireThreadActiveFlag: String, Codable, Equatable { + case waitingOnApproval + case waitingOnUserInput } -enum CodexWireThreadStatusType: String, Codable, Equatable, Sendable { - case active = "active" - case idle = "idle" - case notLoaded = "notLoaded" - case systemError = "systemError" -} - -enum CodexWireThreadSource: String, Codable, Equatable, Sendable { - case memoryConsolidation = "memory_consolidation" - case subagent = "subagent" - case user = "user" +enum CodexWireThreadStatusType: String, Codable, Equatable { + case active + case idle + case notLoaded + case systemError } // @@ -4946,7 +5173,8 @@ enum CodexWireThreadSource: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadNameUpdatedNotification -struct CodexWireThreadNameUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadNameUpdatedNotification: Codable, Equatable { let threadID: String let threadName: String? @@ -4963,7 +5191,8 @@ struct CodexWireThreadNameUpdatedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadRollbackParams -struct CodexWireThreadRollbackParams: Codable, Equatable, Sendable { + +struct CodexWireThreadRollbackParams: Codable, Equatable { /// The number of turns to drop from the end of the thread. Must be >= 1. /// /// This only modifies the thread's history and does not revert local file changes that have @@ -4984,7 +5213,8 @@ struct CodexWireThreadRollbackParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadRollbackResponse -struct CodexWireThreadRollbackResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadRollbackResponse: Codable, Equatable { /// The updated thread after applying the rollback, with `turns` populated. /// /// The ThreadItems stored in each Turn are lossy since we explicitly do not persist all @@ -5000,7 +5230,8 @@ struct CodexWireThreadRollbackResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadSearchParams -struct CodexWireThreadSearchParams: Codable, Equatable, Sendable { + +struct CodexWireThreadSearchParams: Codable, Equatable { /// Optional archived filter; when set to true, only archived threads are returned. If false /// or null, only non-archived threads are returned. let archived: Bool? @@ -5019,22 +5250,22 @@ struct CodexWireThreadSearchParams: Codable, Equatable, Sendable { let sourceKinds: [CodexWireThreadSourceKind]? } -enum CodexWireThreadSortKey: String, Codable, Equatable, Sendable { +enum CodexWireThreadSortKey: String, Codable, Equatable { case createdAt = "created_at" case updatedAt = "updated_at" } -enum CodexWireThreadSourceKind: String, Codable, Equatable, Sendable { - case appServer = "appServer" - case cli = "cli" - case exec = "exec" - case subAgent = "subAgent" - case subAgentCompact = "subAgentCompact" - case subAgentOther = "subAgentOther" - case subAgentReview = "subAgentReview" - case subAgentThreadSpawn = "subAgentThreadSpawn" - case unknown = "unknown" - case vscode = "vscode" +enum CodexWireThreadSourceKind: String, Codable, Equatable { + case appServer + case cli + case exec + case subAgent + case subAgentCompact + case subAgentOther + case subAgentReview + case subAgentThreadSpawn + case unknown + case vscode } // @@ -5044,7 +5275,8 @@ enum CodexWireThreadSourceKind: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadSearchResponse -struct CodexWireThreadSearchResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadSearchResponse: Codable, Equatable { /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated /// when the page contains at least one thread. Use it with the opposite `sortDirection`; for /// timestamp sorts it anchors at the start of the page timestamp so same-second updates are @@ -5063,7 +5295,8 @@ struct CodexWireThreadSearchResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadSearchResult -struct CodexWireThreadSearchResult: Codable, Equatable, Sendable { + +struct CodexWireThreadSearchResult: Codable, Equatable { let snippet: String let thread: CodexWireThread } @@ -5075,7 +5308,8 @@ struct CodexWireThreadSearchResult: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadSetNameParams -struct CodexWireThreadSetNameParams: Codable, Equatable, Sendable { + +struct CodexWireThreadSetNameParams: Codable, Equatable { let name, threadID: String enum CodingKeys: String, CodingKey { @@ -5091,7 +5325,8 @@ struct CodexWireThreadSetNameParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadShellCommandParams -struct CodexWireThreadShellCommandParams: Codable, Equatable, Sendable { + +struct CodexWireThreadShellCommandParams: Codable, Equatable { /// Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, /// this intentionally preserves shell syntax such as pipes, redirects, and quoting. This /// runs unsandboxed with full access rather than inheriting the thread sandbox policy. @@ -5111,7 +5346,8 @@ struct CodexWireThreadShellCommandParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadStartParams -struct CodexWireThreadStartParams: Codable, Equatable, Sendable { + +struct CodexWireThreadStartParams: Codable, Equatable { let approvalPolicy: CodexWireApprovalPolicyUnion? /// Override where approval requests are routed for review on this thread and subsequent /// turns. @@ -5140,10 +5376,12 @@ struct CodexWireThreadStartParams: Codable, Equatable, Sendable { /// Replace the thread's runtime workspace roots. Paths must be absolute. let runtimeWorkspaceRoots: [String]? let sandbox: CodexWireSandboxMode? + /// Capability roots selected for this thread by the hosting platform. + let selectedCapabilityRoots: [CodexWireSelectedCapabilityRoot]? let serviceName, serviceTier: String? let sessionStartSource: CodexWireThreadStartSource? /// Optional client-supplied analytics source classification for this thread. - let threadSource: CodexWireThreadSource? + let threadSource: String? } // @@ -5153,7 +5391,8 @@ struct CodexWireThreadStartParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireDynamicToolSpec -struct CodexWireDynamicToolSpec: Codable, Equatable, Sendable { + +struct CodexWireDynamicToolSpec: Codable, Equatable { let deferLoading: Bool? let description: String let inputSchema: CodexWireJSONValue @@ -5168,7 +5407,8 @@ struct CodexWireDynamicToolSpec: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnEnvironmentParams -struct CodexWireTurnEnvironmentParams: Codable, Equatable, Sendable { + +struct CodexWireTurnEnvironmentParams: Codable, Equatable { let cwd, environmentID: String enum CodingKeys: String, CodingKey { @@ -5177,15 +5417,58 @@ struct CodexWireTurnEnvironmentParams: Codable, Equatable, Sendable { } } -enum CodexWirePersonality: String, Codable, Equatable, Sendable { - case friendly = "friendly" - case none = "none" - case pragmatic = "pragmatic" +enum CodexWirePersonality: String, Codable, Equatable { + case friendly + case none + case pragmatic +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of CodexWireJSONValue, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// A user-selected root that can expose one or more runtime capabilities. +// MARK: - CodexWireSelectedCapabilityRoot + +struct CodexWireSelectedCapabilityRoot: Codable, Equatable { + /// Stable identifier supplied by the capability selection platform. + let id: String + /// Where the selected root can be resolved. + let location: CodexWireCapabilityRootLocation +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of CodexWireJSONValue, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// Where the selected root can be resolved. +/// +/// Location used to resolve a selected capability root. +/// +/// A path owned by an execution environment. +// MARK: - CodexWireCapabilityRootLocation + +struct CodexWireCapabilityRootLocation: Codable, Equatable { + let environmentID, path: String + let type: CodexWireEnvironmentCapabilityRootLocationType + + enum CodingKeys: String, CodingKey { + case environmentID = "environmentId" + case path, type + } +} + +enum CodexWireEnvironmentCapabilityRootLocationType: String, Codable, Equatable { + case environment } -enum CodexWireThreadStartSource: String, Codable, Equatable, Sendable { - case clear = "clear" - case startup = "startup" +enum CodexWireThreadStartSource: String, Codable, Equatable { + case clear + case startup } // @@ -5195,7 +5478,8 @@ enum CodexWireThreadStartSource: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadStartResponse -struct CodexWireThreadStartResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadStartResponse: Codable, Equatable { /// Named or implicit built-in profile that produced the active permissions, when known. let activePermissionProfile: CodexWireActivePermissionProfile? let approvalPolicy: CodexWireAskForApproval @@ -5222,7 +5506,8 @@ struct CodexWireThreadStartResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireActivePermissionProfile -struct CodexWireActivePermissionProfile: Codable, Equatable, Sendable { + +struct CodexWireActivePermissionProfile: Codable, Equatable { /// Parent profile identifier from the selected permissions profile's `extends` setting, when /// present. let extends: String? @@ -5240,14 +5525,15 @@ struct CodexWireActivePermissionProfile: Codable, Equatable, Sendable { /// Legacy sandbox policy retained for compatibility. Experimental clients should prefer /// `activePermissionProfile` for profile provenance. // MARK: - CodexWireSandboxPolicy -struct CodexWireSandboxPolicy: Codable, Equatable, Sendable { + +struct CodexWireSandboxPolicy: Codable, Equatable { let type: CodexWireSandboxPolicyType let networkAccess: CodexWireNetworkAccessUnion? let excludeSlashTmp, excludeTmpdirEnvVar: Bool? let writableRoots: [String]? } -enum CodexWireNetworkAccessUnion: Codable, Equatable, Sendable { +enum CodexWireNetworkAccessUnion: Codable, Equatable { case bool(Bool) case enumeration(CodexWireNetworkAccess) @@ -5267,24 +5553,24 @@ enum CodexWireNetworkAccessUnion: Codable, Equatable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .bool(let x): - try container.encode(x) - case .enumeration(let x): - try container.encode(x) + case let .bool(x): + try container.encode(x) + case let .enumeration(x): + try container.encode(x) } } } -enum CodexWireNetworkAccess: String, Codable, Equatable, Sendable { - case enabled = "enabled" - case restricted = "restricted" +enum CodexWireNetworkAccess: String, Codable, Equatable { + case enabled + case restricted } -enum CodexWireSandboxPolicyType: String, Codable, Equatable, Sendable { - case dangerFullAccess = "dangerFullAccess" - case externalSandbox = "externalSandbox" - case readOnly = "readOnly" - case workspaceWrite = "workspaceWrite" +enum CodexWireSandboxPolicyType: String, Codable, Equatable { + case dangerFullAccess + case externalSandbox + case readOnly + case workspaceWrite } // @@ -5294,7 +5580,8 @@ enum CodexWireSandboxPolicyType: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadStartedNotification -struct CodexWireThreadStartedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadStartedNotification: Codable, Equatable { let thread: CodexWireThread } @@ -5305,7 +5592,8 @@ struct CodexWireThreadStartedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadStatusChangedNotification -struct CodexWireThreadStatusChangedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadStatusChangedNotification: Codable, Equatable { let status: CodexWireThreadStatus let threadID: String @@ -5322,7 +5610,8 @@ struct CodexWireThreadStatusChangedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTokenUsageUpdatedNotification -struct CodexWireThreadTokenUsageUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadTokenUsageUpdatedNotification: Codable, Equatable { let threadID: String let tokenUsage: CodexWireThreadTokenUsage let turnID: String @@ -5341,7 +5630,8 @@ struct CodexWireThreadTokenUsageUpdatedNotification: Codable, Equatable, Sendabl // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTokenUsage -struct CodexWireThreadTokenUsage: Codable, Equatable, Sendable { + +struct CodexWireThreadTokenUsage: Codable, Equatable { let last: CodexWireTokenUsageBreakdown let modelContextWindow: Int? let total: CodexWireTokenUsageBreakdown @@ -5354,7 +5644,8 @@ struct CodexWireThreadTokenUsage: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTokenUsageBreakdown -struct CodexWireTokenUsageBreakdown: Codable, Equatable, Sendable { + +struct CodexWireTokenUsageBreakdown: Codable, Equatable { let cachedInputTokens, inputTokens, outputTokens, reasoningOutputTokens: Int let totalTokens: Int } @@ -5366,7 +5657,8 @@ struct CodexWireTokenUsageBreakdown: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTurnsItemsListParams -struct CodexWireThreadTurnsItemsListParams: Codable, Equatable, Sendable { + +struct CodexWireThreadTurnsItemsListParams: Codable, Equatable { /// Opaque cursor to pass to the next call to continue after the last item. let cursor: String? /// Optional item page size. @@ -5389,7 +5681,8 @@ struct CodexWireThreadTurnsItemsListParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTurnsItemsListResponse -struct CodexWireThreadTurnsItemsListResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadTurnsItemsListResponse: Codable, Equatable { /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated /// when the page contains at least one item. let backwardsCursor: String? @@ -5406,7 +5699,8 @@ struct CodexWireThreadTurnsItemsListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTurnsListParams -struct CodexWireThreadTurnsListParams: Codable, Equatable, Sendable { + +struct CodexWireThreadTurnsListParams: Codable, Equatable { /// Opaque cursor to pass to the next call to continue after the last turn. let cursor: String? /// How much item detail to include for each returned turn; defaults to summary. @@ -5430,7 +5724,8 @@ struct CodexWireThreadTurnsListParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadTurnsListResponse -struct CodexWireThreadTurnsListResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadTurnsListResponse: Codable, Equatable { /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated /// when the page contains at least one turn. Use it with the opposite `sortDirection` to /// include the anchor turn again and catch updates to that turn. @@ -5448,7 +5743,8 @@ struct CodexWireThreadTurnsListResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadUnarchiveParams -struct CodexWireThreadUnarchiveParams: Codable, Equatable, Sendable { + +struct CodexWireThreadUnarchiveParams: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -5463,7 +5759,8 @@ struct CodexWireThreadUnarchiveParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadUnarchiveResponse -struct CodexWireThreadUnarchiveResponse: Codable, Equatable, Sendable { + +struct CodexWireThreadUnarchiveResponse: Codable, Equatable { let thread: CodexWireThread } @@ -5474,7 +5771,8 @@ struct CodexWireThreadUnarchiveResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireThreadUnarchivedNotification -struct CodexWireThreadUnarchivedNotification: Codable, Equatable, Sendable { + +struct CodexWireThreadUnarchivedNotification: Codable, Equatable { let threadID: String enum CodingKeys: String, CodingKey { @@ -5489,7 +5787,8 @@ struct CodexWireThreadUnarchivedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnCompletedNotification -struct CodexWireTurnCompletedNotification: Codable, Equatable, Sendable { + +struct CodexWireTurnCompletedNotification: Codable, Equatable { let threadID: String let turn: CodexWireTurn @@ -5508,7 +5807,8 @@ struct CodexWireTurnCompletedNotification: Codable, Equatable, Sendable { /// Notification that the turn-level unified diff has changed. Contains the latest aggregated /// diff across all file changes in the turn. // MARK: - CodexWireTurnDiffUpdatedNotification -struct CodexWireTurnDiffUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireTurnDiffUpdatedNotification: Codable, Equatable { let diff, threadID, turnID: String enum CodingKeys: String, CodingKey { @@ -5525,7 +5825,8 @@ struct CodexWireTurnDiffUpdatedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnModerationMetadataNotification -struct CodexWireTurnModerationMetadataNotification: Codable, Equatable, Sendable { + +struct CodexWireTurnModerationMetadataNotification: Codable, Equatable { let metadata: CodexWireJSONValue let threadID, turnID: String @@ -5543,7 +5844,8 @@ struct CodexWireTurnModerationMetadataNotification: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnPlanUpdatedNotification -struct CodexWireTurnPlanUpdatedNotification: Codable, Equatable, Sendable { + +struct CodexWireTurnPlanUpdatedNotification: Codable, Equatable { let explanation: String? let plan: [CodexWireTurnPlanStep] let threadID, turnID: String @@ -5562,15 +5864,16 @@ struct CodexWireTurnPlanUpdatedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnPlanStep -struct CodexWireTurnPlanStep: Codable, Equatable, Sendable { + +struct CodexWireTurnPlanStep: Codable, Equatable { let status: CodexWireTurnPlanStepStatus let step: String } -enum CodexWireTurnPlanStepStatus: String, Codable, Equatable, Sendable { - case completed = "completed" - case inProgress = "inProgress" - case pending = "pending" +enum CodexWireTurnPlanStepStatus: String, Codable, Equatable { + case completed + case inProgress + case pending } // @@ -5580,7 +5883,15 @@ enum CodexWireTurnPlanStepStatus: String, Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnStartParams -struct CodexWireTurnStartParams: Codable, Equatable, Sendable { + +struct CodexWireTurnStartParams: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case additionalContext, approvalPolicy, approvalsReviewer + case clientUserMessageID = "clientUserMessageId" + case collaborationMode, cwd, effort, environments, input, model, outputSchema, permissions, personality, responsesapiClientMetadata, runtimeWorkspaceRoots, sandboxPolicy, serviceTier, summary + case threadID = "threadId" + } + /// Optional client-provided context fragments keyed by an opaque source identifier. let additionalContext: [String: CodexWireAdditionalContextEntry]? /// Override the approval policy for this turn and subsequent turns. @@ -5614,7 +5925,13 @@ struct CodexWireTurnStartParams: Codable, Equatable, Sendable { let permissions: String? /// Override the personality for this turn and subsequent turns. let personality: CodexWirePersonality? - /// Optional turn-scoped Responses API client metadata. + /// Optional metadata to enrich Codex's ResponsesAPI turn metadata. + /// + /// Entries are flattened into the JSON string sent as + /// `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests. + /// + /// They are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys + /// such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden. let responsesapiClientMetadata: [String: String]? /// Replace the thread's runtime workspace roots for this turn and subsequent turns. Paths /// must be absolute. @@ -5626,13 +5943,6 @@ struct CodexWireTurnStartParams: Codable, Equatable, Sendable { /// Override the reasoning summary for this turn and subsequent turns. let summary: CodexWireReasoningSummary? let threadID: String - - enum CodingKeys: String, CodingKey { - case additionalContext, approvalPolicy, approvalsReviewer - case clientUserMessageID = "clientUserMessageId" - case collaborationMode, cwd, effort, environments, input, model, outputSchema, permissions, personality, responsesapiClientMetadata, runtimeWorkspaceRoots, sandboxPolicy, serviceTier, summary - case threadID = "threadId" - } } // @@ -5642,14 +5952,15 @@ struct CodexWireTurnStartParams: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireAdditionalContextEntry -struct CodexWireAdditionalContextEntry: Codable, Equatable, Sendable { + +struct CodexWireAdditionalContextEntry: Codable, Equatable { let kind: CodexWireAdditionalContextKind let value: String } -enum CodexWireAdditionalContextKind: String, Codable, Equatable, Sendable { - case application = "application" - case untrusted = "untrusted" +enum CodexWireAdditionalContextKind: String, Codable, Equatable { + case application + case untrusted } // @@ -5660,7 +5971,8 @@ enum CodexWireAdditionalContextKind: String, Codable, Equatable, Sendable { /// Collaboration mode for a Codex session. // MARK: - CodexWireCollaborationMode -struct CodexWireCollaborationMode: Codable, Equatable, Sendable { + +struct CodexWireCollaborationMode: Codable, Equatable { let mode: CodexWireModeKind let settings: CodexWireSettings } @@ -5673,7 +5985,8 @@ struct CodexWireCollaborationMode: Codable, Equatable, Sendable { /// Settings for a collaboration mode. // MARK: - CodexWireSettings -struct CodexWireSettings: Codable, Equatable, Sendable { + +struct CodexWireSettings: Codable, Equatable { let developerInstructions: String? let model: String let reasoningEffort: String? @@ -5692,7 +6005,8 @@ struct CodexWireSettings: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireDangerFullAccessSandboxPolicyClass -struct CodexWireDangerFullAccessSandboxPolicyClass: Codable, Equatable, Sendable { + +struct CodexWireDangerFullAccessSandboxPolicyClass: Codable, Equatable { let type: CodexWireSandboxPolicyType let networkAccess: CodexWireNetworkAccessUnion? let excludeSlashTmp, excludeTmpdirEnvVar: Bool? @@ -5706,7 +6020,8 @@ struct CodexWireDangerFullAccessSandboxPolicyClass: Codable, Equatable, Sendable // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnStartResponse -struct CodexWireTurnStartResponse: Codable, Equatable, Sendable { + +struct CodexWireTurnStartResponse: Codable, Equatable { let turn: CodexWireTurn } @@ -5717,7 +6032,8 @@ struct CodexWireTurnStartResponse: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireTurnStartedNotification -struct CodexWireTurnStartedNotification: Codable, Equatable, Sendable { + +struct CodexWireTurnStartedNotification: Codable, Equatable { let threadID: String let turn: CodexWireTurn @@ -5734,7 +6050,8 @@ struct CodexWireTurnStartedNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireWarningNotification -struct CodexWireWarningNotification: Codable, Equatable, Sendable { + +struct CodexWireWarningNotification: Codable, Equatable { /// Concise warning message for the user. let message: String /// Optional thread target when the warning applies to a specific thread. @@ -5753,16 +6070,18 @@ struct CodexWireWarningNotification: Codable, Equatable, Sendable { // synthesized for types that have collections (such as arrays or dictionaries). // MARK: - CodexWireWindowsSandboxReadinessResponse -struct CodexWireWindowsSandboxReadinessResponse: Codable, Equatable, Sendable { + +struct CodexWireWindowsSandboxReadinessResponse: Codable, Equatable { let status: CodexWireWindowsSandboxReadiness } -enum CodexWireWindowsSandboxReadiness: String, Codable, Equatable, Sendable { - case notConfigured = "notConfigured" - case ready = "ready" - case updateRequired = "updateRequired" +enum CodexWireWindowsSandboxReadiness: String, Codable, Equatable { + case notConfigured + case ready + case updateRequired } +// swiftformat:disable redundantSendable indirect enum CodexWireJSONValue: Codable, Equatable, Sendable { case null case bool(Bool) @@ -5801,20 +6120,22 @@ indirect enum CodexWireJSONValue: Codable, Equatable, Sendable { var container = encoder.singleValueContainer() switch self { - case .null: - try container.encodeNil() - case let .bool(value): - try container.encode(value) - case let .integer(value): - try container.encode(value) - case let .double(value): - try container.encode(value) - case let .string(value): - try container.encode(value) - case let .array(value): - try container.encode(value) - case let .object(value): - try container.encode(value) + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .integer(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) } } } + +// swiftformat:enable redundantSendable diff --git a/Sources/SwiftASB/Generated/CodexWire/Latest/CodexWireInitializeResponse.swift b/Sources/SwiftASB/Generated/CodexWire/Latest/CodexWireInitializeResponse.swift index e309b14..ccbec0d 100644 --- a/Sources/SwiftASB/Generated/CodexWire/Latest/CodexWireInitializeResponse.swift +++ b/Sources/SwiftASB/Generated/CodexWire/Latest/CodexWireInitializeResponse.swift @@ -1,9 +1,9 @@ import Foundation -// The current bundled v2 schema dump does not expose InitializeResponse, so we -// keep this tiny handshake shape hand-owned alongside the generated v2 wire -// snapshot until upstream schema convergence makes it unnecessary. -struct CodexWireInitializeResponse: Codable, Equatable, Sendable { +/// The current bundled v2 schema dump does not expose InitializeResponse, so we +/// keep this tiny handshake shape hand-owned alongside the generated v2 wire +/// snapshot until upstream schema convergence makes it unnecessary. +struct CodexWireInitializeResponse: Codable, Equatable { /// Absolute path to the server's $CODEX_HOME directory. let codexHome: String /// Platform family for the running app-server target, for example "unix" or "windows". diff --git a/Sources/SwiftASB/History/ThreadHistoryStore.swift b/Sources/SwiftASB/History/ThreadHistoryStore.swift index 6704626..bdba6f8 100644 --- a/Sources/SwiftASB/History/ThreadHistoryStore.swift +++ b/Sources/SwiftASB/History/ThreadHistoryStore.swift @@ -2,18 +2,18 @@ import Foundation actor ThreadHistoryStore { - enum Completeness: String, Sendable { + enum Completeness: String { case partial case serverParity case richerThanServer } - enum LocalState: String, Sendable { + enum LocalState: String { case available case removed } - struct Configuration: Sendable { + struct Configuration { let inMemory: Bool let storeURL: URL @@ -32,8 +32,8 @@ actor ThreadHistoryStore { } } - struct ThreadSnapshot: Sendable, Equatable { - struct DefaultsSnapshot: Sendable, Equatable { + struct ThreadSnapshot: Equatable { + struct DefaultsSnapshot: Equatable { let approvalPolicy: String let approvalsReviewer: String let currentDirectoryPath: String @@ -45,12 +45,12 @@ actor ThreadHistoryStore { let serviceTier: String? } - struct StateSnapshot: Sendable, Equatable { + struct StateSnapshot: Equatable { let completeness: String let localState: String } - struct RollbackSnapshot: Sendable, Equatable { + struct RollbackSnapshot: Equatable { let id: String let previousNewestTurnID: String? let recordedAt: Int @@ -60,8 +60,8 @@ actor ThreadHistoryStore { let serverUpdatedAt: Int } - struct TurnSnapshot: Sendable, Equatable { - struct TokenUsageSnapshot: Codable, Sendable, Equatable { + struct TurnSnapshot: Equatable { + struct TokenUsageSnapshot: Codable, Equatable { let cachedInputTokens: Int? let inputTokens: Int? let outputTokens: Int? @@ -70,7 +70,7 @@ actor ThreadHistoryStore { let modelContextWindow: Int? } - struct ItemSnapshot: Sendable, Equatable { + struct ItemSnapshot: Equatable { let id: String let orderIndex: Int let kind: String @@ -120,8 +120,8 @@ actor ThreadHistoryStore { let updatedAt: Int } - struct SandboxPolicySnapshot: Codable, Sendable, Equatable { - enum NetworkAccessSnapshot: Codable, Sendable, Equatable { + struct SandboxPolicySnapshot: Codable, Equatable { + enum NetworkAccessSnapshot: Codable, Equatable { case explicit(Bool) case enabled case restricted @@ -134,27 +134,12 @@ actor ThreadHistoryStore { let writableRoots: [String] } - private struct ActiveTurnBuilder: Sendable { - struct ActiveItem: Sendable { - let orderIndex: Int - var latestItem: CodexTurnItem - var streamedText: String - } - - let threadID: String - var diff: String? - var items: [String: ActiveItem] - var nextItemOrderIndex: Int - let orderIndex: Int - var turn: CodexAppServer.TurnInfo - } - - struct HydratedTurn: Sendable { + struct HydratedTurn { let turn: CodexAppServer.TurnInfo let items: [CodexTurnItem] } - struct ThreadListSnapshot: Sendable, Equatable { + struct ThreadListSnapshot: Equatable { let id: String let cliVersion: String let createdAt: Int @@ -177,1502 +162,1538 @@ actor ThreadHistoryStore { let updatedAt: Int } - private struct HydrationMergeOutcome: Sendable { + private struct ActiveTurnBuilder { + struct ActiveItem { + let orderIndex: Int + var latestItem: CodexTurnItem + var streamedText: String + } + + let threadID: String + var diff: String? + var items: [String: ActiveItem] + var nextItemOrderIndex: Int + let orderIndex: Int + var turn: CodexAppServer.TurnInfo + } + + private struct HydrationMergeOutcome { let preservedRicherLocalDetail: Bool } + private struct MergedString { + let value: String? + let preservedExistingDetail: Bool + } + + private static let sharedManagedObjectModel: NSManagedObjectModel = makeManagedObjectModel() + private let container: NSPersistentContainer private var activeTurns: [String: ActiveTurnBuilder] = [:] private var nextTurnOrderByThreadID: [String: Int] = [:] - private static let sharedManagedObjectModel: NSManagedObjectModel = makeManagedObjectModel() init(configuration: Configuration = .live()) throws { - self.container = try Self.makePersistentContainer(configuration: configuration) + container = try Self.makePersistentContainer(configuration: configuration) } - func detachPersistentStoresForTeardown() throws { - container.viewContext.performAndWait { - container.viewContext.reset() - } + private static func stableTurnID(threadID: String, turnID: String) -> String { + "\(threadID)::\(turnID)" + } - let coordinator = container.persistentStoreCoordinator - for store in coordinator.persistentStores { - try coordinator.remove(store) - } + private static func stableItemID(threadID: String, turnID: String, itemID: String) -> String { + "\(threadID)::\(turnID)::\(itemID)" } - func recordThreadStarted(session: CodexAppServer.ThreadSession) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) - Self.applyThreadInfo(session.thread, to: thread) - thread.isArchived = false + private static func applyThreadInfo(_ info: CodexAppServer.ThreadInfo, to thread: HistoryThread) { + thread.id = info.id + thread.cliVersion = info.cliVersion + thread.createdAt = Int64(info.createdAt) + thread.currentDirectoryPath = info.currentDirectoryPath + thread.ephemeral = info.ephemeral + thread.forkedFromThreadID = info.forkedFromThreadID + thread.gitBranch = info.projectInfo.repository?.branch + thread.gitOriginURL = info.projectInfo.repository?.originURL + thread.gitSHA = info.projectInfo.repository?.sha + thread.modelProvider = info.modelProvider + thread.name = info.name + thread.preview = info.preview + thread.sourceData = try? encode(info.source) + thread.statusType = info.status.type.rawValue + thread.statusFlagsData = try? encode(info.status.activeFlags.map(\.rawValue)) + thread.updatedAt = Int64(info.updatedAt) + } - let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) - Self.applyThreadDefaults(session, to: defaults) + private static func ensureThreadPersistenceScaffolding( + for thread: HistoryThread, + in context: NSManagedObjectContext + ) { + if thread.defaults == nil { + let defaults = HistoryThreadDefaults(context: context) + defaults.approvalPolicy = "" + defaults.approvalsReviewer = "" + defaults.currentDirectoryPath = thread.currentDirectoryPath + defaults.model = "" + defaults.modelProvider = thread.modelProvider defaults.thread = thread thread.defaults = defaults + } - let state = thread.state ?? HistoryThreadState(context: context) + if thread.state == nil { + let state = HistoryThreadState(context: context) state.completeness = Completeness.partial.rawValue state.localState = LocalState.available.rawValue state.thread = thread thread.state = state - - try context.saveIfChanged() } } - func recordThreadResumed( - session: CodexAppServer.ThreadSession, - turns: [HydratedTurn] - ) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) - Self.applyThreadInfo(session.thread, to: thread) - - let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) - Self.applyThreadDefaults(session, to: defaults) - defaults.thread = thread - thread.defaults = defaults - - let state = thread.state ?? HistoryThreadState(context: context) - state.localState = LocalState.available.rawValue - state.thread = thread - thread.state = state + private static func localState(for thread: HistoryThread) -> LocalState { + guard let rawValue = thread.state?.localState, + let state = LocalState(rawValue: rawValue) + else { + return .available + } - if turns.isEmpty { - if state.completeness.isEmpty { - state.completeness = Completeness.partial.rawValue - } - } else { - let outcome = try Self.upsertHydratedTurns( - turns, - for: thread, - in: context - ) - state.completeness = outcome.preservedRicherLocalDetail - ? Completeness.richerThanServer.rawValue - : Completeness.serverParity.rawValue - } + return state + } - try context.saveIfChanged() - } + private static func canMarkRemoved(_ thread: HistoryThread) -> Bool { + thread.isClosed || thread.statusType == CodexAppServer.ThreadStatusType.notLoaded.rawValue + } - if let maxOrderIndex = try snapshot(threadID: session.thread.id)?.turns.map(\.orderIndex).max() { - nextTurnOrderByThreadID[session.thread.id] = maxOrderIndex + 1 + private static func upsertHydratedTurns( + _ hydratedTurns: [HydratedTurn], + for thread: HistoryThread, + in context: NSManagedObjectContext + ) throws -> HydrationMergeOutcome { + guard !hydratedTurns.isEmpty else { + return .init(preservedRicherLocalDetail: false) } - } - func recordThreadForked( - session: CodexAppServer.ThreadSession, - turns: [HydratedTurn] - ) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) - Self.applyThreadInfo(session.thread, to: thread) - thread.isArchived = false - if thread.forkedFromTurnID == nil { - thread.forkedFromTurnID = turns.last?.turn.id - } + var preservedRicherLocalDetail = false - let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) - Self.applyThreadDefaults(session, to: defaults) - defaults.thread = thread - thread.defaults = defaults + for hydratedTurn in hydratedTurns { + let turnObject = try fetchOrInsertTurn( + turnID: hydratedTurn.turn.id, + threadID: thread.id, + in: context + ) + preservedRicherLocalDetail = Self.mergeHydratedTurnInfo( + hydratedTurn.turn, + into: turnObject + ) || preservedRicherLocalDetail + turnObject.thread = thread - let state = thread.state ?? HistoryThreadState(context: context) - state.localState = LocalState.available.rawValue - state.thread = thread - thread.state = state + let existingTurnItems = ((turnObject.items as? Set) ?? []) + let incomingItemIDs = Set(hydratedTurn.items.map(\.id)) + if existingTurnItems.contains(where: { !incomingItemIDs.contains($0.itemID) }) { + preservedRicherLocalDetail = true + } - if turns.isEmpty { - if state.completeness.isEmpty { - state.completeness = Completeness.partial.rawValue - } - } else { - let outcome = try Self.upsertHydratedTurns( - turns, - for: thread, - in: context + for (itemIndex, item) in hydratedTurn.items.enumerated() { + let stableID = stableItemID( + threadID: thread.id, + turnID: hydratedTurn.turn.id, + itemID: item.id ) - state.completeness = outcome.preservedRicherLocalDetail - ? Completeness.richerThanServer.rawValue - : Completeness.serverParity.rawValue + let itemObject = try fetchOrInsertItem(stableID: stableID, in: context) + preservedRicherLocalDetail = Self.mergeHydratedItem( + item, + stableID: stableID, + orderIndex: itemIndex, + into: itemObject + ) || preservedRicherLocalDetail + itemObject.turn = turnObject } - - try context.saveIfChanged() } - if let maxOrderIndex = try snapshot(threadID: session.thread.id)?.turns.map(\.orderIndex).max() { - nextTurnOrderByThreadID[session.thread.id] = maxOrderIndex + 1 + let turnRequest = HistoryTurn.fetchRequest() + turnRequest.predicate = NSPredicate(format: "thread == %@", thread) + let persistedTurns = try Self.fetchTurns(for: thread, in: context) + + for (turnIndex, turn) in persistedTurns.enumerated() { + turn.orderIndex = Int64(turnIndex) } + + return .init(preservedRicherLocalDetail: preservedRicherLocalDetail) } - func recordThreadMetadataUpdated(_ threadInfo: CodexAppServer.ThreadInfo) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - let thread = try Self.fetchOrInsertThread(id: threadInfo.id, in: context) - Self.applyThreadInfo(threadInfo, to: thread) - if let state = thread.state { - state.completeness = state.completeness.isEmpty ? Completeness.partial.rawValue : state.completeness - state.localState = LocalState.available.rawValue - } else { - let state = HistoryThreadState(context: context) - state.completeness = Completeness.partial.rawValue - state.localState = LocalState.available.rawValue - state.thread = thread - thread.state = state + private static func fetchTurns( + for thread: HistoryThread, + in context: NSManagedObjectContext + ) throws -> [HistoryTurn] { + let turnRequest = HistoryTurn.fetchRequest() + turnRequest.predicate = NSPredicate(format: "thread == %@", thread) + return try context.fetch(turnRequest) + .sorted { + let lhsStartedAt = $0.startedAt + let rhsStartedAt = $1.startedAt + if lhsStartedAt == rhsStartedAt { + return $0.id < $1.id + } + return lhsStartedAt < rhsStartedAt } - try context.saveIfChanged() - } } - func reconcileThreadListPage( - _ threads: [CodexAppServer.ThreadInfo], - archived: Bool? + private static func reindexTurns( + for thread: HistoryThread, + in context: NSManagedObjectContext ) throws { - guard !threads.isEmpty else { return } - - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - for threadInfo in threads { - let thread = try Self.fetchOrInsertThread(id: threadInfo.id, in: context) - Self.applyThreadInfo(threadInfo, to: thread) - Self.ensureThreadPersistenceScaffolding(for: thread, in: context) - thread.state?.localState = LocalState.available.rawValue - if let archived { - thread.isArchived = archived - } - } - try context.saveIfChanged() + let persistedTurns = try Self.fetchTurns(for: thread, in: context) + for (turnIndex, turn) in persistedTurns.enumerated() { + turn.orderIndex = Int64(turnIndex) } } - func recordThreadStatusChanged( - threadID: String, - status: CodexAppServer.ThreadStatus - ) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - thread.statusType = status.type.rawValue - thread.statusFlagsData = try Self.encode(status.activeFlags.map(\.rawValue)) - try context.saveIfChanged() - } + private static func makeTurnSnapshot( + _ turn: HistoryTurn, + in context: NSManagedObjectContext + ) throws -> ThreadSnapshot.TurnSnapshot { + let itemRequest = HistoryItem.fetchRequest() + itemRequest.predicate = NSPredicate(format: "turn == %@", turn) + let items = try (context.fetch(itemRequest)) + .sorted { $0.orderIndex < $1.orderIndex } + .map { + ThreadSnapshot.TurnSnapshot.ItemSnapshot( + id: $0.itemID, + orderIndex: Int($0.orderIndex), + kind: $0.kind, + command: $0.command, + path: $0.path, + serverName: $0.serverName, + status: $0.status, + streamedText: $0.streamedText, + text: $0.text, + toolName: $0.toolName + ) + } + let tokenUsage = try Self.decode( + ThreadSnapshot.TurnSnapshot.TokenUsageSnapshot.self, + from: turn.tokenUsageData + ) + return .init( + id: turn.turnID, + completedAt: turn.completedAt == 0 ? nil : Int(turn.completedAt), + diff: turn.diff, + durationMS: turn.durationMS == 0 ? nil : Int(turn.durationMS), + errorMessage: turn.errorMessage, + items: items, + orderIndex: Int(turn.orderIndex), + startedAt: turn.startedAt == 0 ? nil : Int(turn.startedAt), + status: turn.status, + tokenUsage: tokenUsage + ) } - func recordThreadArchived(threadID: String, isArchived: Bool) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - thread.isArchived = isArchived - try context.saveIfChanged() - } + private static func makeThreadListSnapshot( + _ thread: HistoryThread + ) throws -> ThreadListSnapshot { + try .init( + id: thread.id, + cliVersion: thread.cliVersion, + createdAt: Int(thread.createdAt), + currentDirectoryPath: thread.currentDirectoryPath, + ephemeral: thread.ephemeral, + forkedFromThreadID: thread.forkedFromThreadID, + gitBranch: thread.gitBranch, + gitOriginURL: thread.gitOriginURL, + gitSHA: thread.gitSHA, + isArchived: thread.isArchived, + isClosed: thread.isClosed, + localState: localState(for: thread), + lastCompletedTurnAt: lastCompletedTurnAt(for: thread), + modelProvider: thread.modelProvider, + name: thread.name, + preview: thread.preview, + source: decode(CodexAppServer.ThreadSource.self, from: thread.sourceData) ?? .unknown, + statusFlags: decode([String].self, from: thread.statusFlagsData) ?? [], + statusType: thread.statusType, + updatedAt: Int(thread.updatedAt) + ) } - func recordThreadNameUpdated(threadID: String, name: String?) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - thread.name = name - try context.saveIfChanged() + private static func lastCompletedTurnAt(for thread: HistoryThread) -> Int? { + guard let turns = thread.turns as? Set else { + return nil } + + return turns + .filter { $0.status == CodexAppServer.TurnStatus.completed.rawValue } + .map { Int($0.completedAt) } + .max() } - func recordThreadClosed(threadID: String) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - thread.isClosed = true - try context.saveIfChanged() - } + private static func applyThreadDefaults( + _ session: CodexAppServer.ThreadSession, + to defaults: HistoryThreadDefaults + ) { + defaults.approvalPolicy = session.approvalPolicy.persistedLabel + defaults.approvalsReviewer = session.approvalsReviewer.rawValue + defaults.currentDirectoryPath = session.currentDirectoryPath + defaults.instructionSourcesData = try? encode(session.instructionSources) + defaults.model = session.model + defaults.modelProvider = session.modelProvider + defaults.reasoningEffort = session.reasoningEffort?.wireValue + defaults.sandboxPolicyData = try? encode(SandboxPolicySnapshot(session.sandboxPolicy)) + defaults.serviceTier = session.serviceTier?.rawValue } - func markMissingThreadsRemoved( - visibleThreadIDs: Set, - archived: Bool - ) throws { - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - let request = HistoryThread.fetchRequest() - request.predicate = NSPredicate(format: "isArchived == %@", NSNumber(value: archived)) - let threads = try context.fetch(request) - for thread in threads where !visibleThreadIDs.contains(thread.id) && Self.canMarkRemoved(thread) { - Self.ensureThreadPersistenceScaffolding(for: thread, in: context) - thread.state?.localState = LocalState.removed.rawValue - } - try context.saveIfChanged() - } + private static func applyTurnInfo( + _ info: CodexAppServer.TurnInfo, + orderIndex: Int, + to turn: HistoryTurn + ) { + turn.turnID = info.id + turn.completedAt = Int64(info.completedAt ?? 0) + turn.durationMS = Int64(info.durationMS ?? 0) + turn.errorMessage = info.errorMessage + turn.orderIndex = Int64(orderIndex) + turn.startedAt = Int64(info.startedAt ?? 0) + turn.status = info.status.rawValue } - func recordTurnStarted(threadID: String, turn: CodexAppServer.TurnInfo) throws { - let orderIndex: Int - if var existingBuilder = activeTurns[turn.id] { - existingBuilder.turn = turn - activeTurns[turn.id] = existingBuilder - orderIndex = existingBuilder.orderIndex - } else { - orderIndex = nextTurnOrderByThreadID[threadID] ?? 0 - nextTurnOrderByThreadID[threadID] = orderIndex + 1 - activeTurns[turn.id] = .init( - threadID: threadID, - diff: nil, - items: [:], - nextItemOrderIndex: 0, - orderIndex: orderIndex, - turn: turn - ) + private static func mergeHydratedTurnInfo( + _ info: CodexAppServer.TurnInfo, + into turn: HistoryTurn + ) -> Bool { + var preservedRicherLocalDetail = false + turn.turnID = info.id + if let completedAt = info.completedAt { + turn.completedAt = Int64(completedAt) + } else if turn.completedAt != 0 { + preservedRicherLocalDetail = true } - - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - throw ThreadHistoryStoreError.missingThread( - "Cannot record turn \(turn.id) because thread \(threadID) is missing from the local history store." - ) - } - let turnObject = try Self.fetchOrInsertTurn( - turnID: turn.id, - threadID: threadID, - in: context - ) - Self.applyTurnInfo(turn, orderIndex: orderIndex, to: turnObject) - turnObject.thread = thread - try context.saveIfChanged() + if let durationMS = info.durationMS { + turn.durationMS = Int64(durationMS) + } else if turn.durationMS != 0 { + preservedRicherLocalDetail = true + } + if let errorMessage = info.errorMessage, !errorMessage.isEmpty { + turn.errorMessage = errorMessage + } else if let existingErrorMessage = turn.errorMessage, !existingErrorMessage.isEmpty { + preservedRicherLocalDetail = true + } + if let startedAt = info.startedAt { + turn.startedAt = Int64(startedAt) + } else if turn.startedAt != 0 { + preservedRicherLocalDetail = true + } + let mergedStatus = mergedTurnStatus(existing: turn.status, incoming: info.status.rawValue) + if mergedStatus == turn.status && mergedStatus != info.status.rawValue { + preservedRicherLocalDetail = true } + turn.status = mergedStatus + return preservedRicherLocalDetail } - func recordTurnDiffUpdated(turnID: String, diff: String) throws { - guard var builder = activeTurns[turnID] else { return } - builder.diff = diff - activeTurns[turnID] = builder - let threadID = builder.threadID - - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let turn = try Self.fetchTurn( - turnID: turnID, - threadID: threadID, - in: context - ) else { return } - turn.diff = diff - try context.saveIfChanged() - } + private static func applyItem( + _ item: CodexTurnItem, + stableID: String, + orderIndex: Int, + streamedText: String?, + to persistedItem: HistoryItem + ) { + persistedItem.id = stableID + persistedItem.itemID = item.id + persistedItem.command = item.command + persistedItem.kind = item.kind.rawValue + persistedItem.orderIndex = Int64(orderIndex) + persistedItem.path = item.path + persistedItem.serverName = item.serverName + persistedItem.status = item.status + persistedItem.streamedText = streamedText + persistedItem.text = item.text + persistedItem.toolName = item.toolName } - func recordItemStarted( - threadID: String, - turnID: String, - item: CodexTurnItem - ) throws { - guard var builder = activeTurns[turnID] else { return } - let orderIndex = builder.nextItemOrderIndex - builder.nextItemOrderIndex += 1 - builder.items[item.id] = .init( - orderIndex: orderIndex, - latestItem: item, - streamedText: item.text ?? "" - ) - activeTurns[turnID] = builder + private static func mergeHydratedItem( + _ item: CodexTurnItem, + stableID: String, + orderIndex: Int, + into persistedItem: HistoryItem + ) -> Bool { + var preservedRicherLocalDetail = false + persistedItem.id = stableID + persistedItem.itemID = item.id + persistedItem.kind = item.kind.rawValue + persistedItem.orderIndex = Int64(orderIndex) + let mergedCommand = mergeIncomingPreferredString(existing: persistedItem.command, incoming: item.command) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedCommand.preservedExistingDetail + persistedItem.command = mergedCommand.value + let mergedPath = mergeIncomingPreferredString(existing: persistedItem.path, incoming: item.path) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedPath.preservedExistingDetail + persistedItem.path = mergedPath.value + let mergedServerName = mergeIncomingPreferredString(existing: persistedItem.serverName, incoming: item.serverName) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedServerName.preservedExistingDetail + persistedItem.serverName = mergedServerName.value + let mergedStatus = mergeItemStatus(existing: persistedItem.status, incoming: item.status) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedStatus.preservedExistingDetail + persistedItem.status = mergedStatus.value + let mergedStreamedText = mergeStreamedText(existing: persistedItem.streamedText, incoming: item.text) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedStreamedText.preservedExistingDetail + persistedItem.streamedText = mergedStreamedText.value + let mergedText = mergeCanonicalText(existing: persistedItem.text, incoming: item.text) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedText.preservedExistingDetail + persistedItem.text = mergedText.value + let mergedToolName = mergeIncomingPreferredString(existing: persistedItem.toolName, incoming: item.toolName) + preservedRicherLocalDetail = preservedRicherLocalDetail || mergedToolName.preservedExistingDetail + persistedItem.toolName = mergedToolName.value + return preservedRicherLocalDetail + } - let stableID = Self.stableItemID(threadID: threadID, turnID: turnID, itemID: item.id) - let streamedText = builder.items[item.id]?.streamedText + private static func mergeIncomingPreferredString(existing: String?, incoming: String?) -> MergedString { + let existing = normalizedMeaningfulString(existing) + let incoming = normalizedMeaningfulString(incoming) - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let turn = try Self.fetchTurn(turnID: turnID, threadID: threadID, in: context) else { - throw ThreadHistoryStoreError.missingTurn( - "Cannot record item \(item.id) because turn \(turnID) is missing from the local history store." - ) - } - let persistedItem = try Self.fetchOrInsertItem(stableID: stableID, in: context) - Self.applyItem(item, stableID: stableID, orderIndex: orderIndex, streamedText: streamedText, to: persistedItem) - persistedItem.turn = turn - try context.saveIfChanged() + switch (existing, incoming) { + case let (nil, incoming): + return .init(value: incoming, preservedExistingDetail: false) + case let (existing, nil): + return .init(value: existing, preservedExistingDetail: existing != nil) + case let (.some(_), .some(incoming)): + return .init(value: incoming, preservedExistingDetail: false) } } - func recordItemDelta(turnID: String, itemID: String, delta: String) throws { - guard var builder = activeTurns[turnID], var activeItem = builder.items[itemID] else { return } - activeItem.streamedText += delta - builder.items[itemID] = activeItem - activeTurns[turnID] = builder + private static func mergeCanonicalText(existing: String?, incoming: String?) -> MergedString { + let existing = normalizedMeaningfulString(existing) + let incoming = normalizedMeaningfulString(incoming) - let stableID = Self.stableItemID(threadID: builder.threadID, turnID: turnID, itemID: itemID) - let streamedText = activeItem.streamedText + switch (existing, incoming) { + case let (nil, incoming): + return .init(value: incoming, preservedExistingDetail: false) + case let (existing, nil): + return .init(value: existing, preservedExistingDetail: existing != nil) + case let (.some(existing), .some(incoming)): + if incoming == existing { + return .init(value: incoming, preservedExistingDetail: false) + } + if existing.hasPrefix(incoming) { + return .init(value: existing, preservedExistingDetail: true) + } + return .init(value: incoming, preservedExistingDetail: false) + } + } - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let item = try Self.fetchItem(stableID: stableID, in: context) else { return } - item.streamedText = streamedText - try context.saveIfChanged() + private static func mergeStreamedText(existing: String?, incoming: String?) -> MergedString { + let existing = normalizedMeaningfulString(existing) + let incoming = normalizedMeaningfulString(incoming) + + switch (existing, incoming) { + case let (nil, incoming): + return .init(value: incoming, preservedExistingDetail: false) + case let (existing, nil): + return .init(value: existing, preservedExistingDetail: existing != nil) + case let (.some(existing), .some(incoming)): + if incoming == existing { + return .init(value: incoming, preservedExistingDetail: false) + } + if incoming.hasPrefix(existing) { + return .init(value: incoming, preservedExistingDetail: false) + } + return .init(value: existing, preservedExistingDetail: true) } } - func recordItemReplacement(turnID: String, itemID: String, text: String, path: String?) throws { - guard var builder = activeTurns[turnID], var activeItem = builder.items[itemID] else { return } - activeItem.streamedText = text - if let path { - let latestItem = activeItem.latestItem - activeItem.latestItem = CodexTurnItem( - id: latestItem.id, - kind: latestItem.kind, - command: latestItem.command, - path: path, - serverName: latestItem.serverName, - text: latestItem.text, - status: latestItem.status, - toolName: latestItem.toolName - ) + private static func mergeItemStatus(existing: String?, incoming: String?) -> MergedString { + let existing = normalizedMeaningfulString(existing) + let incoming = normalizedMeaningfulString(incoming) + + switch (existing, incoming) { + case let (nil, incoming): + return .init(value: incoming, preservedExistingDetail: false) + case let (existing, nil): + return .init(value: existing, preservedExistingDetail: existing != nil) + case let (.some(existing), .some(incoming)): + let normalizedExisting = existing.lowercased() + let normalizedIncoming = incoming.lowercased() + let incomingTerminal = isTerminalItemStatus(normalizedIncoming) + let existingTerminal = isTerminalItemStatus(normalizedExisting) + + if incomingTerminal { + return .init(value: incoming, preservedExistingDetail: false) + } + if existingTerminal { + return .init(value: existing, preservedExistingDetail: normalizedIncoming != normalizedExisting) + } + return .init(value: incoming, preservedExistingDetail: false) } - builder.items[itemID] = activeItem - activeTurns[turnID] = builder + } - let stableID = Self.stableItemID(threadID: builder.threadID, turnID: turnID, itemID: itemID) - let streamedText = activeItem.streamedText - let replacementPath = activeItem.latestItem.path + private static func normalizedMeaningfulString(_ value: String?) -> String? { + guard let value else { return nil } - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let item = try Self.fetchItem(stableID: stableID, in: context) else { return } - item.path = replacementPath - item.streamedText = streamedText - try context.saveIfChanged() + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value + } + + private static func mergedTurnStatus(existing: String, incoming: String) -> String { + let incomingTerminal = isTerminalTurnStatus(incoming) + let existingTerminal = isTerminalTurnStatus(existing) + + if incomingTerminal { + return incoming + } + if existingTerminal { + return existing } + return incoming } - func recordItemCompleted( - threadID: String, - turnID: String, - item: CodexTurnItem - ) throws { - guard var builder = activeTurns[turnID], var activeItem = builder.items[item.id] else { return } - activeItem.latestItem = item - if activeItem.streamedText.isEmpty, let text = item.text { - activeItem.streamedText = text + private static func isTerminalTurnStatus(_ status: String) -> Bool { + switch status { + case CodexAppServer.TurnStatus.completed.rawValue, + CodexAppServer.TurnStatus.failed.rawValue, + CodexAppServer.TurnStatus.interrupted.rawValue: + true + default: + false } - builder.items[item.id] = activeItem - activeTurns[turnID] = builder + } - let stableID = Self.stableItemID(threadID: threadID, turnID: turnID, itemID: item.id) - let orderIndex = activeItem.orderIndex - let streamedText = activeItem.streamedText + private static func isTerminalItemStatus(_ status: String) -> Bool { + switch status { + case "completed", "failed", "error", "errored", "interrupted", "cancelled", "canceled": + true + default: + false + } + } - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let persistedItem = try Self.fetchItem(stableID: stableID, in: context) else { return } - Self.applyItem(item, stableID: stableID, orderIndex: orderIndex, streamedText: streamedText, to: persistedItem) - try context.saveIfChanged() + private static func fetchOrInsertThread(id: String, in context: NSManagedObjectContext) throws -> HistoryThread { + if let existing = try fetchThread(id: id, in: context) { + return existing } + let thread = HistoryThread(context: context) + thread.id = id + thread.cliVersion = "" + thread.createdAt = 0 + thread.currentDirectoryPath = "" + thread.ephemeral = false + thread.forkedFromThreadID = nil + thread.forkedFromTurnID = nil + thread.isArchived = false + thread.isClosed = false + thread.modelProvider = "" + thread.preview = "" + thread.statusType = CodexAppServer.ThreadStatusType.notLoaded.rawValue + thread.updatedAt = 0 + return thread } - func recordTurnTokenUsageUpdated( + private static func fetchThread(id: String, in context: NSManagedObjectContext) throws -> HistoryThread? { + let request = HistoryThread.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "id == %@", id) + return try context.fetch(request).first + } + + private static func fetchOrInsertTurn( + turnID: String, threadID: String, + in context: NSManagedObjectContext + ) throws -> HistoryTurn { + if let existing = try fetchTurn(turnID: turnID, threadID: threadID, in: context) { + return existing + } + let turn = HistoryTurn(context: context) + turn.id = stableTurnID(threadID: threadID, turnID: turnID) + turn.turnID = turnID + return turn + } + + private static func fetchTurn( turnID: String, - usage: CodexThreadTokenUsageUpdated.Usage, - modelContextWindow: Int? - ) throws { - let snapshot = ThreadSnapshot.TurnSnapshot.TokenUsageSnapshot( - cachedInputTokens: usage.cachedInputTokens, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - reasoningOutputTokens: usage.reasoningOutputTokens, - totalTokens: usage.totalTokens, - modelContextWindow: modelContextWindow + threadID: String, + in context: NSManagedObjectContext + ) throws -> HistoryTurn? { + let request = HistoryTurn.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "id == %@", + stableTurnID(threadID: threadID, turnID: turnID) ) + return try context.fetch(request).first + } - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let turn = try Self.fetchTurn(turnID: turnID, threadID: threadID, in: context) else { return } - turn.tokenUsageData = try Self.encode(snapshot) - if turn.thread == nil, let thread = try Self.fetchThread(id: threadID, in: context) { - turn.thread = thread - } - try context.saveIfChanged() + private static func fetchOrInsertItem(stableID: String, in context: NSManagedObjectContext) throws -> HistoryItem { + if let existing = try fetchItem(stableID: stableID, in: context) { + return existing } + let item = HistoryItem(context: context) + item.id = stableID + item.itemID = stableID + return item } - func recordTurnCompleted( - threadID: String, - turn: CodexAppServer.TurnInfo - ) throws { - let builder = activeTurns.removeValue(forKey: turn.id) + private static func fetchItem(stableID: String, in context: NSManagedObjectContext) throws -> HistoryItem? { + let request = HistoryItem.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "id == %@", stableID) + return try context.fetch(request).first + } - let context = container.newBackgroundContext() - try context.performAndWaitReturning { - guard let turnObject = try Self.fetchTurn(turnID: turn.id, threadID: threadID, in: context) else { return } - Self.applyTurnInfo(turn, orderIndex: Int(turnObject.orderIndex), to: turnObject) - if let builder { - turnObject.diff = builder.diff - } - if turnObject.thread == nil, let thread = try Self.fetchThread(id: threadID, in: context) { - turnObject.thread = thread - } - try context.saveIfChanged() - } + private static func encode(_ value: T) throws -> Data { + try JSONEncoder().encode(value) } - func snapshot(threadID: String) throws -> ThreadSnapshot? { - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return nil - } - guard let defaults = thread.defaults, let state = thread.state else { - return nil - } + private static func decode(_ type: T.Type, from data: Data?) throws -> T? { + guard let data else { return nil } - let instructionSources = try Self.decode([String].self, from: defaults.instructionSourcesData) ?? [] - let sandboxPolicy = try Self.decode(SandboxPolicySnapshot.self, from: defaults.sandboxPolicyData) - ?? .init( - type: "", - networkAccess: nil, - excludeSlashTmp: nil, - excludeTmpdirEnvVar: nil, - writableRoots: [] - ) - let turnRequest = HistoryTurn.fetchRequest() - turnRequest.predicate = NSPredicate(format: "thread == %@", thread) - let turns = try context.fetch(turnRequest) - .sorted { $0.orderIndex < $1.orderIndex } - .map { turn in - try Self.makeTurnSnapshot(turn, in: context) - } - let rollbackRequest = HistoryThreadRollback.fetchRequest() - rollbackRequest.predicate = NSPredicate(format: "thread == %@", thread) - let rollbacks = try context.fetch(rollbackRequest) - .sorted { $0.recordedAt < $1.recordedAt } - .map { rollback in - ThreadSnapshot.RollbackSnapshot( - id: rollback.id, - previousNewestTurnID: rollback.previousNewestTurnID, - recordedAt: Int(rollback.recordedAt), - removedTurnIDs: try Self.decode([String].self, from: rollback.removedTurnIDsData) ?? [], - requestedTurnCount: Int(rollback.requestedTurnCount), - resultingNewestTurnID: rollback.resultingNewestTurnID, - serverUpdatedAt: Int(rollback.serverUpdatedAt) - ) - } + return try JSONDecoder().decode(type, from: data) + } - return .init( - id: thread.id, - cliVersion: thread.cliVersion, - createdAt: Int(thread.createdAt), - currentDirectoryPath: thread.currentDirectoryPath, - defaults: .init( - approvalPolicy: defaults.approvalPolicy, - approvalsReviewer: defaults.approvalsReviewer, - currentDirectoryPath: defaults.currentDirectoryPath, - instructionSources: instructionSources, - model: defaults.model, - modelProvider: defaults.modelProvider, - reasoningEffort: defaults.reasoningEffort, - sandboxPolicy: sandboxPolicy, - serviceTier: defaults.serviceTier - ), - ephemeral: thread.ephemeral, - forkedFromThreadID: thread.forkedFromThreadID, - forkedFromTurnID: thread.forkedFromTurnID, - gitBranch: thread.gitBranch, - gitOriginURL: thread.gitOriginURL, - gitSHA: thread.gitSHA, - isArchived: thread.isArchived, - isClosed: thread.isClosed, - modelProvider: thread.modelProvider, - name: thread.name, - preview: thread.preview, - rollbacks: rollbacks, - source: (try Self.decode(CodexAppServer.ThreadSource.self, from: thread.sourceData)) ?? .unknown, - state: .init( - completeness: state.completeness, - localState: Self.localState(for: thread).rawValue - ), - statusFlags: (try Self.decode([String].self, from: thread.statusFlagsData)) ?? [], - statusType: thread.statusType, - turns: turns, - updatedAt: Int(thread.updatedAt) + private static func makePersistentContainer(configuration: Configuration) throws -> NSPersistentContainer { + let model = sharedManagedObjectModel + let container = NSPersistentContainer(name: "ThreadHistoryStore", managedObjectModel: model) + let description = NSPersistentStoreDescription(url: configuration.storeURL) + description.type = configuration.inMemory ? NSInMemoryStoreType : NSSQLiteStoreType + description.shouldAddStoreAsynchronously = false + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = true + + if !configuration.inMemory { + try FileManager.default.createDirectory( + at: configuration.storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true ) } - } - func threadListSnapshots() throws -> [ThreadListSnapshot] { - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - let request = HistoryThread.fetchRequest() - let threads = try context.fetch(request) - return try threads.map(Self.makeThreadListSnapshot) + container.persistentStoreDescriptions = [description] + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error } + if let loadError { + throw loadError + } + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return container } - func turnSnapshot( - threadID: String, - turnID: String - ) throws -> ThreadSnapshot.TurnSnapshot? { - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return nil - } - - let request = HistoryTurn.fetchRequest() - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "thread == %@ AND turnID == %@", - thread, - turnID - ) + private static func makeManagedObjectModel() -> NSManagedObjectModel { + let model = NSManagedObjectModel() - guard let turn = try context.fetch(request).first else { - return nil - } + let threadEntity = NSEntityDescription() + threadEntity.name = "HistoryThread" + threadEntity.managedObjectClassName = NSStringFromClass(HistoryThread.self) + threadEntity.properties = [ + attribute("id", .stringAttributeType, isOptional: false), + attribute("cliVersion", .stringAttributeType, isOptional: false), + attribute("createdAt", .integer64AttributeType, isOptional: false), + attribute("currentDirectoryPath", .stringAttributeType, isOptional: false), + attribute("ephemeral", .booleanAttributeType, isOptional: false), + attribute("forkedFromThreadID", .stringAttributeType, isOptional: true), + attribute("forkedFromTurnID", .stringAttributeType, isOptional: true), + attribute("gitBranch", .stringAttributeType, isOptional: true), + attribute("gitOriginURL", .stringAttributeType, isOptional: true), + attribute("gitSHA", .stringAttributeType, isOptional: true), + attribute("modelProvider", .stringAttributeType, isOptional: false), + attribute("name", .stringAttributeType, isOptional: true), + attribute("preview", .stringAttributeType, isOptional: false), + attribute("sourceData", .binaryDataAttributeType, isOptional: true), + attribute("statusType", .stringAttributeType, isOptional: false), + attribute("statusFlagsData", .binaryDataAttributeType, isOptional: true), + attribute("updatedAt", .integer64AttributeType, isOptional: false), + attribute("isArchived", .booleanAttributeType, isOptional: false), + attribute("isClosed", .booleanAttributeType, isOptional: false), + ] + threadEntity.uniquenessConstraints = [["id"]] - return try Self.makeTurnSnapshot(turn, in: context) - } - } + let defaultsEntity = NSEntityDescription() + defaultsEntity.name = "HistoryThreadDefaults" + defaultsEntity.managedObjectClassName = NSStringFromClass(HistoryThreadDefaults.self) + defaultsEntity.properties = [ + attribute("approvalPolicy", .stringAttributeType, isOptional: false), + attribute("approvalsReviewer", .stringAttributeType, isOptional: false), + attribute("currentDirectoryPath", .stringAttributeType, isOptional: false), + attribute("instructionSourcesData", .binaryDataAttributeType, isOptional: true), + attribute("model", .stringAttributeType, isOptional: false), + attribute("modelProvider", .stringAttributeType, isOptional: false), + attribute("reasoningEffort", .stringAttributeType, isOptional: true), + attribute("sandboxPolicyData", .binaryDataAttributeType, isOptional: true), + attribute("serviceTier", .stringAttributeType, isOptional: true), + ] - func recentTurnSnapshots( - threadID: String, - limit: Int - ) throws -> [ThreadSnapshot.TurnSnapshot] { - guard limit > 0 else { return [] } + let stateEntity = NSEntityDescription() + stateEntity.name = "HistoryThreadState" + stateEntity.managedObjectClassName = NSStringFromClass(HistoryThreadState.self) + stateEntity.properties = [ + attribute("completeness", .stringAttributeType, isOptional: false), + attribute( + "localState", + .stringAttributeType, + isOptional: false, + defaultValue: LocalState.available.rawValue + ), + ] - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return [] - } + let rollbackEntity = NSEntityDescription() + rollbackEntity.name = "HistoryThreadRollback" + rollbackEntity.managedObjectClassName = NSStringFromClass(HistoryThreadRollback.self) + rollbackEntity.properties = [ + attribute("id", .stringAttributeType, isOptional: false), + attribute("previousNewestTurnID", .stringAttributeType, isOptional: true), + attribute("recordedAt", .integer64AttributeType, isOptional: false), + attribute("removedTurnIDsData", .binaryDataAttributeType, isOptional: true), + attribute("requestedTurnCount", .integer64AttributeType, isOptional: false), + attribute("resultingNewestTurnID", .stringAttributeType, isOptional: true), + attribute("serverUpdatedAt", .integer64AttributeType, isOptional: false), + ] + rollbackEntity.uniquenessConstraints = [["id"]] - let request = HistoryTurn.fetchRequest() - request.predicate = NSPredicate(format: "thread == %@", thread) - request.fetchLimit = limit - request.sortDescriptors = [ - NSSortDescriptor(key: "orderIndex", ascending: false) - ] + let turnEntity = NSEntityDescription() + turnEntity.name = "HistoryTurn" + turnEntity.managedObjectClassName = NSStringFromClass(HistoryTurn.self) + turnEntity.properties = [ + attribute("id", .stringAttributeType, isOptional: false), + attribute("completedAt", .integer64AttributeType, isOptional: false), + attribute("diff", .stringAttributeType, isOptional: true), + attribute("durationMS", .integer64AttributeType, isOptional: false), + attribute("errorMessage", .stringAttributeType, isOptional: true), + attribute("orderIndex", .integer64AttributeType, isOptional: false), + attribute("startedAt", .integer64AttributeType, isOptional: false), + attribute("status", .stringAttributeType, isOptional: false), + attribute("tokenUsageData", .binaryDataAttributeType, isOptional: true), + attribute("turnID", .stringAttributeType, isOptional: false), + ] + turnEntity.uniquenessConstraints = [["id"]] - let turns = try context.fetch(request) - return try turns - .sorted { $0.orderIndex > $1.orderIndex } - .map { try Self.makeTurnSnapshot($0, in: context) } - } - } + let itemEntity = NSEntityDescription() + itemEntity.name = "HistoryItem" + itemEntity.managedObjectClassName = NSStringFromClass(HistoryItem.self) + itemEntity.properties = [ + attribute("id", .stringAttributeType, isOptional: false), + attribute("itemID", .stringAttributeType, isOptional: false), + attribute("command", .stringAttributeType, isOptional: true), + attribute("kind", .stringAttributeType, isOptional: false), + attribute("orderIndex", .integer64AttributeType, isOptional: false), + attribute("path", .stringAttributeType, isOptional: true), + attribute("serverName", .stringAttributeType, isOptional: true), + attribute("status", .stringAttributeType, isOptional: true), + attribute("streamedText", .stringAttributeType, isOptional: true), + attribute("text", .stringAttributeType, isOptional: true), + attribute("toolName", .stringAttributeType, isOptional: true), + ] + itemEntity.uniquenessConstraints = [["id"]] - func olderTurnSnapshots( - threadID: String, - olderThanOrderIndex: Int, - limit: Int - ) throws -> [ThreadSnapshot.TurnSnapshot] { - guard limit > 0 else { return [] } + let threadToDefaults = relationship(name: "defaults", destination: defaultsEntity, minCount: 0, maxCount: 1, deleteRule: .cascadeDeleteRule) + let defaultsToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) + threadToDefaults.inverseRelationship = defaultsToThread + defaultsToThread.inverseRelationship = threadToDefaults - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return [] - } + let threadToState = relationship(name: "state", destination: stateEntity, minCount: 0, maxCount: 1, deleteRule: .cascadeDeleteRule) + let stateToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) + threadToState.inverseRelationship = stateToThread + stateToThread.inverseRelationship = threadToState - let request = HistoryTurn.fetchRequest() - request.predicate = NSPredicate( - format: "thread == %@ AND orderIndex < %d", - thread, - olderThanOrderIndex - ) - request.fetchLimit = limit - request.sortDescriptors = [ - NSSortDescriptor(key: "orderIndex", ascending: false) - ] + let threadToTurns = relationship(name: "turns", destination: turnEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) + let turnToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) + threadToTurns.inverseRelationship = turnToThread + turnToThread.inverseRelationship = threadToTurns - return try context.fetch(request).map { try Self.makeTurnSnapshot($0, in: context) } - } - } + let threadToRollbacks = relationship(name: "rollbacks", destination: rollbackEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) + let rollbackToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) + threadToRollbacks.inverseRelationship = rollbackToThread + rollbackToThread.inverseRelationship = threadToRollbacks - func newerTurnSnapshots( - threadID: String, - newerThanOrderIndex: Int, - limit: Int - ) throws -> [ThreadSnapshot.TurnSnapshot] { - guard limit > 0 else { return [] } + let turnToItems = relationship(name: "items", destination: itemEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) + let itemToTurn = relationship(name: "turn", destination: turnEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) + turnToItems.inverseRelationship = itemToTurn + itemToTurn.inverseRelationship = turnToItems - let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return [] - } + threadEntity.properties.append(contentsOf: [threadToDefaults, threadToState, threadToTurns, threadToRollbacks]) + defaultsEntity.properties.append(defaultsToThread) + stateEntity.properties.append(stateToThread) + rollbackEntity.properties.append(rollbackToThread) + turnEntity.properties.append(contentsOf: [turnToThread, turnToItems]) + itemEntity.properties.append(itemToTurn) - let request = HistoryTurn.fetchRequest() - request.predicate = NSPredicate( - format: "thread == %@ AND orderIndex > %d", - thread, - newerThanOrderIndex - ) - request.fetchLimit = limit - request.sortDescriptors = [ - NSSortDescriptor(key: "orderIndex", ascending: true) - ] + model.entities = [threadEntity, defaultsEntity, stateEntity, rollbackEntity, turnEntity, itemEntity] + return model + } - return try context.fetch(request) - .map { try Self.makeTurnSnapshot($0, in: context) } - .sorted { $0.orderIndex > $1.orderIndex } + func detachPersistentStoresForTeardown() throws { + container.viewContext.performAndWait { + container.viewContext.reset() } - } - func turnSnapshots( - threadID: String, - turnIDs: [String] - ) throws -> [ThreadSnapshot.TurnSnapshot] { - guard !turnIDs.isEmpty else { return [] } + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try coordinator.remove(store) + } + } + func recordThreadStarted(session: CodexAppServer.ThreadSession) throws { let context = container.newBackgroundContext() - return try context.performAndWaitReturning { - guard let thread = try Self.fetchThread(id: threadID, in: context) else { - return [] - } + try context.performAndWaitReturning { + let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) + Self.applyThreadInfo(session.thread, to: thread) + thread.isArchived = false - let request = HistoryTurn.fetchRequest() - request.predicate = NSPredicate( - format: "thread == %@ AND turnID IN %@", - thread, - turnIDs - ) + let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) + Self.applyThreadDefaults(session, to: defaults) + defaults.thread = thread + thread.defaults = defaults - let fetchedTurns = try context.fetch(request) - let byTurnID = try Dictionary( - uniqueKeysWithValues: fetchedTurns.map { turn in - (turn.turnID, try Self.makeTurnSnapshot(turn, in: context)) - } - ) + let state = thread.state ?? HistoryThreadState(context: context) + state.completeness = Completeness.partial.rawValue + state.localState = LocalState.available.rawValue + state.thread = thread + thread.state = state - return turnIDs.compactMap { byTurnID[$0] } + try context.saveIfChanged() } } - func hydrateThreadRead( - thread: CodexAppServer.ThreadInfo, + func recordThreadResumed( + session: CodexAppServer.ThreadSession, turns: [HydratedTurn] ) throws { let context = container.newBackgroundContext() try context.performAndWaitReturning { - let threadObject = try Self.fetchOrInsertThread(id: thread.id, in: context) - Self.applyThreadInfo(thread, to: threadObject) - Self.ensureThreadPersistenceScaffolding(for: threadObject, in: context) - threadObject.state?.localState = LocalState.available.rawValue - let outcome = try Self.upsertHydratedTurns( - turns, - for: threadObject, - in: context - ) - threadObject.state?.completeness = outcome.preservedRicherLocalDetail - ? Completeness.richerThanServer.rawValue - : Completeness.serverParity.rawValue + let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) + Self.applyThreadInfo(session.thread, to: thread) + + let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) + Self.applyThreadDefaults(session, to: defaults) + defaults.thread = thread + thread.defaults = defaults + + let state = thread.state ?? HistoryThreadState(context: context) + state.localState = LocalState.available.rawValue + state.thread = thread + thread.state = state + + if turns.isEmpty { + if state.completeness.isEmpty { + state.completeness = Completeness.partial.rawValue + } + } else { + let outcome = try Self.upsertHydratedTurns( + turns, + for: thread, + in: context + ) + state.completeness = outcome.preservedRicherLocalDetail + ? Completeness.richerThanServer.rawValue + : Completeness.serverParity.rawValue + } + try context.saveIfChanged() } - if let maxOrderIndex = try snapshot(threadID: thread.id)?.turns.map(\.orderIndex).max() { - nextTurnOrderByThreadID[thread.id] = maxOrderIndex + 1 + if let maxOrderIndex = try snapshot(threadID: session.thread.id)?.turns.map(\.orderIndex).max() { + nextTurnOrderByThreadID[session.thread.id] = maxOrderIndex + 1 } } - func recordThreadRollback( - requestedTurnCount: Int, - thread: CodexAppServer.ThreadInfo, + func recordThreadForked( + session: CodexAppServer.ThreadSession, turns: [HydratedTurn] ) throws { - let threadID = thread.id - let visibleTurnIDs = Set(turns.map(\.turn.id)) - let context = container.newBackgroundContext() - let maxOrderIndex = try context.performAndWaitReturning { - let threadObject = try Self.fetchOrInsertThread(id: thread.id, in: context) - Self.applyThreadInfo(thread, to: threadObject) - Self.ensureThreadPersistenceScaffolding(for: threadObject, in: context) - threadObject.state?.localState = LocalState.available.rawValue - - let existingTurns = try Self.fetchTurns(for: threadObject, in: context) - let previousNewestTurnID = existingTurns.last?.turnID - let removedTurnIDs = existingTurns - .map(\.turnID) - .filter { !visibleTurnIDs.contains($0) } + try context.performAndWaitReturning { + let thread = try Self.fetchOrInsertThread(id: session.thread.id, in: context) + Self.applyThreadInfo(session.thread, to: thread) + thread.isArchived = false + if thread.forkedFromTurnID == nil { + thread.forkedFromTurnID = turns.last?.turn.id + } - let rollback = HistoryThreadRollback(context: context) - rollback.id = UUID().uuidString - rollback.previousNewestTurnID = previousNewestTurnID - rollback.recordedAt = Int64(Date().timeIntervalSince1970) - rollback.removedTurnIDsData = try Self.encode(removedTurnIDs) - rollback.requestedTurnCount = Int64(requestedTurnCount) - rollback.resultingNewestTurnID = turns.last?.turn.id - rollback.serverUpdatedAt = Int64(thread.updatedAt) - rollback.thread = threadObject + let defaults = thread.defaults ?? HistoryThreadDefaults(context: context) + Self.applyThreadDefaults(session, to: defaults) + defaults.thread = thread + thread.defaults = defaults - _ = try Self.upsertHydratedTurns(turns, for: threadObject, in: context) + let state = thread.state ?? HistoryThreadState(context: context) + state.localState = LocalState.available.rawValue + state.thread = thread + thread.state = state - for turnObject in existingTurns where !visibleTurnIDs.contains(turnObject.turnID) { - context.delete(turnObject) + if turns.isEmpty { + if state.completeness.isEmpty { + state.completeness = Completeness.partial.rawValue + } + } else { + let outcome = try Self.upsertHydratedTurns( + turns, + for: thread, + in: context + ) + state.completeness = outcome.preservedRicherLocalDetail + ? Completeness.richerThanServer.rawValue + : Completeness.serverParity.rawValue } - try Self.reindexTurns(for: threadObject, in: context) - threadObject.state?.completeness = Completeness.serverParity.rawValue try context.saveIfChanged() + } - return try Self.fetchTurns(for: threadObject, in: context) - .map(\.orderIndex) - .max() - .map(Int.init) + if let maxOrderIndex = try snapshot(threadID: session.thread.id)?.turns.map(\.orderIndex).max() { + nextTurnOrderByThreadID[session.thread.id] = maxOrderIndex + 1 } + } - if let maxOrderIndex { - nextTurnOrderByThreadID[threadID] = maxOrderIndex + 1 - } else { - nextTurnOrderByThreadID[threadID] = 0 + func recordThreadMetadataUpdated(_ threadInfo: CodexAppServer.ThreadInfo) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + let thread = try Self.fetchOrInsertThread(id: threadInfo.id, in: context) + Self.applyThreadInfo(threadInfo, to: thread) + if let state = thread.state { + state.completeness = state.completeness.isEmpty ? Completeness.partial.rawValue : state.completeness + state.localState = LocalState.available.rawValue + } else { + let state = HistoryThreadState(context: context) + state.completeness = Completeness.partial.rawValue + state.localState = LocalState.available.rawValue + state.thread = thread + thread.state = state + } + try context.saveIfChanged() } - activeTurns = activeTurns.filter { visibleTurnIDs.contains($0.key) } } - func hydrateHistoricalTurns( - threadID: String, - turns: [HydratedTurn] + func reconcileThreadListPage( + _ threads: [CodexAppServer.ThreadInfo], + archived: Bool? ) throws { + guard !threads.isEmpty else { return } + let context = container.newBackgroundContext() try context.performAndWaitReturning { - let thread = try Self.fetchOrInsertThread(id: threadID, in: context) - Self.ensureThreadPersistenceScaffolding(for: thread, in: context) - thread.state?.localState = LocalState.available.rawValue - let outcome = try Self.upsertHydratedTurns( - turns, - for: thread, - in: context - ) - if let state = thread.state, state.completeness != Completeness.richerThanServer.rawValue { - if outcome.preservedRicherLocalDetail { - state.completeness = Completeness.richerThanServer.rawValue - } else if state.completeness.isEmpty { - state.completeness = Completeness.partial.rawValue + for threadInfo in threads { + let thread = try Self.fetchOrInsertThread(id: threadInfo.id, in: context) + Self.applyThreadInfo(threadInfo, to: thread) + Self.ensureThreadPersistenceScaffolding(for: thread, in: context) + thread.state?.localState = LocalState.available.rawValue + if let archived { + thread.isArchived = archived } } try context.saveIfChanged() } - - if let maxOrderIndex = try snapshot(threadID: threadID)?.turns.map(\.orderIndex).max() { - nextTurnOrderByThreadID[threadID] = maxOrderIndex + 1 - } } - private static func stableTurnID(threadID: String, turnID: String) -> String { - "\(threadID)::\(turnID)" - } + func recordThreadStatusChanged( + threadID: String, + status: CodexAppServer.ThreadStatus + ) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - private static func stableItemID(threadID: String, turnID: String, itemID: String) -> String { - "\(threadID)::\(turnID)::\(itemID)" + thread.statusType = status.type.rawValue + thread.statusFlagsData = try Self.encode(status.activeFlags.map(\.rawValue)) + try context.saveIfChanged() + } } - private static func applyThreadInfo(_ info: CodexAppServer.ThreadInfo, to thread: HistoryThread) { - thread.id = info.id - thread.cliVersion = info.cliVersion - thread.createdAt = Int64(info.createdAt) - thread.currentDirectoryPath = info.currentDirectoryPath - thread.ephemeral = info.ephemeral - thread.forkedFromThreadID = info.forkedFromThreadID - thread.gitBranch = info.projectInfo.repository?.branch - thread.gitOriginURL = info.projectInfo.repository?.originURL - thread.gitSHA = info.projectInfo.repository?.sha - thread.modelProvider = info.modelProvider - thread.name = info.name - thread.preview = info.preview - thread.sourceData = try? encode(info.source) - thread.statusType = info.status.type.rawValue - thread.statusFlagsData = try? encode(info.status.activeFlags.map(\.rawValue)) - thread.updatedAt = Int64(info.updatedAt) - } + func recordThreadArchived(threadID: String, isArchived: Bool) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } - private static func ensureThreadPersistenceScaffolding( - for thread: HistoryThread, - in context: NSManagedObjectContext - ) { - if thread.defaults == nil { - let defaults = HistoryThreadDefaults(context: context) - defaults.approvalPolicy = "" - defaults.approvalsReviewer = "" - defaults.currentDirectoryPath = thread.currentDirectoryPath - defaults.model = "" - defaults.modelProvider = thread.modelProvider - defaults.thread = thread - thread.defaults = defaults + thread.isArchived = isArchived + try context.saveIfChanged() } + } - if thread.state == nil { - let state = HistoryThreadState(context: context) - state.completeness = Completeness.partial.rawValue - state.localState = LocalState.available.rawValue - state.thread = thread - thread.state = state + func recordThreadNameUpdated(threadID: String, name: String?) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } + + thread.name = name + try context.saveIfChanged() } } - private static func localState(for thread: HistoryThread) -> LocalState { - guard let rawValue = thread.state?.localState, - let state = LocalState(rawValue: rawValue) - else { - return .available + func recordThreadClosed(threadID: String) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { return } + + thread.isClosed = true + try context.saveIfChanged() } - return state } - private static func canMarkRemoved(_ thread: HistoryThread) -> Bool { - thread.isClosed || thread.statusType == CodexAppServer.ThreadStatusType.notLoaded.rawValue + func markMissingThreadsRemoved( + visibleThreadIDs: Set, + archived: Bool + ) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + let request = HistoryThread.fetchRequest() + request.predicate = NSPredicate(format: "isArchived == %@", NSNumber(value: archived)) + let threads = try context.fetch(request) + for thread in threads where !visibleThreadIDs.contains(thread.id) && Self.canMarkRemoved(thread) { + Self.ensureThreadPersistenceScaffolding(for: thread, in: context) + thread.state?.localState = LocalState.removed.rawValue + } + try context.saveIfChanged() + } } - private static func upsertHydratedTurns( - _ hydratedTurns: [HydratedTurn], - for thread: HistoryThread, - in context: NSManagedObjectContext - ) throws -> HydrationMergeOutcome { - guard !hydratedTurns.isEmpty else { - return .init(preservedRicherLocalDetail: false) + func recordTurnStarted(threadID: String, turn: CodexAppServer.TurnInfo) throws { + let orderIndex: Int + if var existingBuilder = activeTurns[turn.id] { + existingBuilder.turn = turn + activeTurns[turn.id] = existingBuilder + orderIndex = existingBuilder.orderIndex + } else { + orderIndex = nextTurnOrderByThreadID[threadID] ?? 0 + nextTurnOrderByThreadID[threadID] = orderIndex + 1 + activeTurns[turn.id] = .init( + threadID: threadID, + diff: nil, + items: [:], + nextItemOrderIndex: 0, + orderIndex: orderIndex, + turn: turn + ) } - var preservedRicherLocalDetail = false + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + throw ThreadHistoryStoreError.missingThread( + "Cannot record turn \(turn.id) because thread \(threadID) is missing from the local history store." + ) + } - for hydratedTurn in hydratedTurns { - let turnObject = try fetchOrInsertTurn( - turnID: hydratedTurn.turn.id, - threadID: thread.id, + let turnObject = try Self.fetchOrInsertTurn( + turnID: turn.id, + threadID: threadID, in: context ) - preservedRicherLocalDetail = Self.mergeHydratedTurnInfo( - hydratedTurn.turn, - into: turnObject - ) || preservedRicherLocalDetail + Self.applyTurnInfo(turn, orderIndex: orderIndex, to: turnObject) turnObject.thread = thread - - let existingTurnItems = ((turnObject.items as? Set) ?? []) - let incomingItemIDs = Set(hydratedTurn.items.map(\.id)) - if existingTurnItems.contains(where: { !incomingItemIDs.contains($0.itemID) }) { - preservedRicherLocalDetail = true - } - - for (itemIndex, item) in hydratedTurn.items.enumerated() { - let stableID = stableItemID( - threadID: thread.id, - turnID: hydratedTurn.turn.id, - itemID: item.id - ) - let itemObject = try fetchOrInsertItem(stableID: stableID, in: context) - preservedRicherLocalDetail = Self.mergeHydratedItem( - item, - stableID: stableID, - orderIndex: itemIndex, - into: itemObject - ) || preservedRicherLocalDetail - itemObject.turn = turnObject - } + try context.saveIfChanged() } + } - let turnRequest = HistoryTurn.fetchRequest() - turnRequest.predicate = NSPredicate(format: "thread == %@", thread) - let persistedTurns = try Self.fetchTurns(for: thread, in: context) + func recordTurnDiffUpdated(turnID: String, diff: String) throws { + guard var builder = activeTurns[turnID] else { return } - for (turnIndex, turn) in persistedTurns.enumerated() { - turn.orderIndex = Int64(turnIndex) - } + builder.diff = diff + activeTurns[turnID] = builder + let threadID = builder.threadID - return .init(preservedRicherLocalDetail: preservedRicherLocalDetail) - } + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let turn = try Self.fetchTurn( + turnID: turnID, + threadID: threadID, + in: context + ) else { return } - private static func fetchTurns( - for thread: HistoryThread, - in context: NSManagedObjectContext - ) throws -> [HistoryTurn] { - let turnRequest = HistoryTurn.fetchRequest() - turnRequest.predicate = NSPredicate(format: "thread == %@", thread) - return try context.fetch(turnRequest) - .sorted { - let lhsStartedAt = $0.startedAt - let rhsStartedAt = $1.startedAt - if lhsStartedAt == rhsStartedAt { - return $0.id < $1.id - } - return lhsStartedAt < rhsStartedAt - } + turn.diff = diff + try context.saveIfChanged() + } } - private static func reindexTurns( - for thread: HistoryThread, - in context: NSManagedObjectContext + func recordItemStarted( + threadID: String, + turnID: String, + item: CodexTurnItem ) throws { - let persistedTurns = try Self.fetchTurns(for: thread, in: context) - for (turnIndex, turn) in persistedTurns.enumerated() { - turn.orderIndex = Int64(turnIndex) - } - } + guard var builder = activeTurns[turnID] else { return } - private static func makeTurnSnapshot( - _ turn: HistoryTurn, - in context: NSManagedObjectContext - ) throws -> ThreadSnapshot.TurnSnapshot { - let itemRequest = HistoryItem.fetchRequest() - itemRequest.predicate = NSPredicate(format: "turn == %@", turn) - let items = (try context.fetch(itemRequest)) - .sorted { $0.orderIndex < $1.orderIndex } - .map { - ThreadSnapshot.TurnSnapshot.ItemSnapshot( - id: $0.itemID, - orderIndex: Int($0.orderIndex), - kind: $0.kind, - command: $0.command, - path: $0.path, - serverName: $0.serverName, - status: $0.status, - streamedText: $0.streamedText, - text: $0.text, - toolName: $0.toolName + let orderIndex = builder.nextItemOrderIndex + builder.nextItemOrderIndex += 1 + builder.items[item.id] = .init( + orderIndex: orderIndex, + latestItem: item, + streamedText: item.text ?? "" + ) + activeTurns[turnID] = builder + + let stableID = Self.stableItemID(threadID: threadID, turnID: turnID, itemID: item.id) + let streamedText = builder.items[item.id]?.streamedText + + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let turn = try Self.fetchTurn(turnID: turnID, threadID: threadID, in: context) else { + throw ThreadHistoryStoreError.missingTurn( + "Cannot record item \(item.id) because turn \(turnID) is missing from the local history store." ) } - let tokenUsage = try Self.decode( - ThreadSnapshot.TurnSnapshot.TokenUsageSnapshot.self, - from: turn.tokenUsageData - ) - return .init( - id: turn.turnID, - completedAt: turn.completedAt == 0 ? nil : Int(turn.completedAt), - diff: turn.diff, - durationMS: turn.durationMS == 0 ? nil : Int(turn.durationMS), - errorMessage: turn.errorMessage, - items: items, - orderIndex: Int(turn.orderIndex), - startedAt: turn.startedAt == 0 ? nil : Int(turn.startedAt), - status: turn.status, - tokenUsage: tokenUsage - ) + + let persistedItem = try Self.fetchOrInsertItem(stableID: stableID, in: context) + Self.applyItem(item, stableID: stableID, orderIndex: orderIndex, streamedText: streamedText, to: persistedItem) + persistedItem.turn = turn + try context.saveIfChanged() + } } - private static func makeThreadListSnapshot( - _ thread: HistoryThread - ) throws -> ThreadListSnapshot { - .init( - id: thread.id, - cliVersion: thread.cliVersion, - createdAt: Int(thread.createdAt), - currentDirectoryPath: thread.currentDirectoryPath, - ephemeral: thread.ephemeral, - forkedFromThreadID: thread.forkedFromThreadID, - gitBranch: thread.gitBranch, - gitOriginURL: thread.gitOriginURL, - gitSHA: thread.gitSHA, - isArchived: thread.isArchived, - isClosed: thread.isClosed, - localState: localState(for: thread), - lastCompletedTurnAt: Self.lastCompletedTurnAt(for: thread), - modelProvider: thread.modelProvider, - name: thread.name, - preview: thread.preview, - source: try decode(CodexAppServer.ThreadSource.self, from: thread.sourceData) ?? .unknown, - statusFlags: try decode([String].self, from: thread.statusFlagsData) ?? [], - statusType: thread.statusType, - updatedAt: Int(thread.updatedAt) - ) + func recordItemDelta(turnID: String, itemID: String, delta: String) throws { + guard var builder = activeTurns[turnID], var activeItem = builder.items[itemID] else { return } + + activeItem.streamedText += delta + builder.items[itemID] = activeItem + activeTurns[turnID] = builder + + let stableID = Self.stableItemID(threadID: builder.threadID, turnID: turnID, itemID: itemID) + let streamedText = activeItem.streamedText + + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let item = try Self.fetchItem(stableID: stableID, in: context) else { return } + + item.streamedText = streamedText + try context.saveIfChanged() + } } - private static func lastCompletedTurnAt(for thread: HistoryThread) -> Int? { - guard let turns = thread.turns as? Set else { - return nil + func recordItemReplacement(turnID: String, itemID: String, text: String, path: String?) throws { + guard var builder = activeTurns[turnID], var activeItem = builder.items[itemID] else { return } + + activeItem.streamedText = text + if let path { + let latestItem = activeItem.latestItem + activeItem.latestItem = CodexTurnItem( + id: latestItem.id, + kind: latestItem.kind, + command: latestItem.command, + path: path, + serverName: latestItem.serverName, + text: latestItem.text, + status: latestItem.status, + toolName: latestItem.toolName + ) } + builder.items[itemID] = activeItem + activeTurns[turnID] = builder - return turns - .filter { $0.status == CodexAppServer.TurnStatus.completed.rawValue } - .map { Int($0.completedAt) } - .max() - } + let stableID = Self.stableItemID(threadID: builder.threadID, turnID: turnID, itemID: itemID) + let streamedText = activeItem.streamedText + let replacementPath = activeItem.latestItem.path - private static func applyThreadDefaults( - _ session: CodexAppServer.ThreadSession, - to defaults: HistoryThreadDefaults - ) { - defaults.approvalPolicy = session.approvalPolicy.persistedLabel - defaults.approvalsReviewer = session.approvalsReviewer.rawValue - defaults.currentDirectoryPath = session.currentDirectoryPath - defaults.instructionSourcesData = try? encode(session.instructionSources) - defaults.model = session.model - defaults.modelProvider = session.modelProvider - defaults.reasoningEffort = session.reasoningEffort?.wireValue - defaults.sandboxPolicyData = try? encode(SandboxPolicySnapshot(session.sandboxPolicy)) - defaults.serviceTier = session.serviceTier?.rawValue - } + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let item = try Self.fetchItem(stableID: stableID, in: context) else { return } - private static func applyTurnInfo( - _ info: CodexAppServer.TurnInfo, - orderIndex: Int, - to turn: HistoryTurn - ) { - turn.turnID = info.id - turn.completedAt = Int64(info.completedAt ?? 0) - turn.durationMS = Int64(info.durationMS ?? 0) - turn.errorMessage = info.errorMessage - turn.orderIndex = Int64(orderIndex) - turn.startedAt = Int64(info.startedAt ?? 0) - turn.status = info.status.rawValue + item.path = replacementPath + item.streamedText = streamedText + try context.saveIfChanged() + } } - private static func mergeHydratedTurnInfo( - _ info: CodexAppServer.TurnInfo, - into turn: HistoryTurn - ) -> Bool { - var preservedRicherLocalDetail = false - turn.turnID = info.id - if let completedAt = info.completedAt { - turn.completedAt = Int64(completedAt) - } else if turn.completedAt != 0 { - preservedRicherLocalDetail = true - } - if let durationMS = info.durationMS { - turn.durationMS = Int64(durationMS) - } else if turn.durationMS != 0 { - preservedRicherLocalDetail = true - } - if let errorMessage = info.errorMessage, !errorMessage.isEmpty { - turn.errorMessage = errorMessage - } else if let existingErrorMessage = turn.errorMessage, !existingErrorMessage.isEmpty { - preservedRicherLocalDetail = true - } - if let startedAt = info.startedAt { - turn.startedAt = Int64(startedAt) - } else if turn.startedAt != 0 { - preservedRicherLocalDetail = true + func recordItemCompleted( + threadID: String, + turnID: String, + item: CodexTurnItem + ) throws { + guard var builder = activeTurns[turnID], var activeItem = builder.items[item.id] else { return } + + activeItem.latestItem = item + if activeItem.streamedText.isEmpty, let text = item.text { + activeItem.streamedText = text } - let mergedStatus = mergedTurnStatus(existing: turn.status, incoming: info.status.rawValue) - if mergedStatus == turn.status && mergedStatus != info.status.rawValue { - preservedRicherLocalDetail = true + builder.items[item.id] = activeItem + activeTurns[turnID] = builder + + let stableID = Self.stableItemID(threadID: threadID, turnID: turnID, itemID: item.id) + let orderIndex = activeItem.orderIndex + let streamedText = activeItem.streamedText + + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let persistedItem = try Self.fetchItem(stableID: stableID, in: context) else { return } + + Self.applyItem(item, stableID: stableID, orderIndex: orderIndex, streamedText: streamedText, to: persistedItem) + try context.saveIfChanged() } - turn.status = mergedStatus - return preservedRicherLocalDetail } - private static func applyItem( - _ item: CodexTurnItem, - stableID: String, - orderIndex: Int, - streamedText: String?, - to persistedItem: HistoryItem - ) { - persistedItem.id = stableID - persistedItem.itemID = item.id - persistedItem.command = item.command - persistedItem.kind = item.kind.rawValue - persistedItem.orderIndex = Int64(orderIndex) - persistedItem.path = item.path - persistedItem.serverName = item.serverName - persistedItem.status = item.status - persistedItem.streamedText = streamedText - persistedItem.text = item.text - persistedItem.toolName = item.toolName - } + func recordTurnTokenUsageUpdated( + threadID: String, + turnID: String, + usage: CodexThreadTokenUsageUpdated.Usage, + modelContextWindow: Int? + ) throws { + let snapshot = ThreadSnapshot.TurnSnapshot.TokenUsageSnapshot( + cachedInputTokens: usage.cachedInputTokens, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + reasoningOutputTokens: usage.reasoningOutputTokens, + totalTokens: usage.totalTokens, + modelContextWindow: modelContextWindow + ) - private static func mergeHydratedItem( - _ item: CodexTurnItem, - stableID: String, - orderIndex: Int, - into persistedItem: HistoryItem - ) -> Bool { - var preservedRicherLocalDetail = false - persistedItem.id = stableID - persistedItem.itemID = item.id - persistedItem.kind = item.kind.rawValue - persistedItem.orderIndex = Int64(orderIndex) - let mergedCommand = mergeIncomingPreferredString(existing: persistedItem.command, incoming: item.command) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedCommand.preservedExistingDetail - persistedItem.command = mergedCommand.value - let mergedPath = mergeIncomingPreferredString(existing: persistedItem.path, incoming: item.path) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedPath.preservedExistingDetail - persistedItem.path = mergedPath.value - let mergedServerName = mergeIncomingPreferredString(existing: persistedItem.serverName, incoming: item.serverName) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedServerName.preservedExistingDetail - persistedItem.serverName = mergedServerName.value - let mergedStatus = mergeItemStatus(existing: persistedItem.status, incoming: item.status) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedStatus.preservedExistingDetail - persistedItem.status = mergedStatus.value - let mergedStreamedText = mergeStreamedText(existing: persistedItem.streamedText, incoming: item.text) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedStreamedText.preservedExistingDetail - persistedItem.streamedText = mergedStreamedText.value - let mergedText = mergeCanonicalText(existing: persistedItem.text, incoming: item.text) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedText.preservedExistingDetail - persistedItem.text = mergedText.value - let mergedToolName = mergeIncomingPreferredString(existing: persistedItem.toolName, incoming: item.toolName) - preservedRicherLocalDetail = preservedRicherLocalDetail || mergedToolName.preservedExistingDetail - persistedItem.toolName = mergedToolName.value - return preservedRicherLocalDetail + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let turn = try Self.fetchTurn(turnID: turnID, threadID: threadID, in: context) else { return } + + turn.tokenUsageData = try Self.encode(snapshot) + if turn.thread == nil, let thread = try Self.fetchThread(id: threadID, in: context) { + turn.thread = thread + } + try context.saveIfChanged() + } } - private struct MergedString: Sendable { - let value: String? - let preservedExistingDetail: Bool + func recordTurnCompleted( + threadID: String, + turn: CodexAppServer.TurnInfo + ) throws { + let builder = activeTurns.removeValue(forKey: turn.id) + + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + guard let turnObject = try Self.fetchTurn(turnID: turn.id, threadID: threadID, in: context) else { return } + + Self.applyTurnInfo(turn, orderIndex: Int(turnObject.orderIndex), to: turnObject) + if let builder { + turnObject.diff = builder.diff + } + if turnObject.thread == nil, let thread = try Self.fetchThread(id: threadID, in: context) { + turnObject.thread = thread + } + try context.saveIfChanged() + } } - private static func mergeIncomingPreferredString(existing: String?, incoming: String?) -> MergedString { - let existing = normalizedMeaningfulString(existing) - let incoming = normalizedMeaningfulString(incoming) + func snapshot(threadID: String) throws -> ThreadSnapshot? { + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return nil + } + guard let defaults = thread.defaults, let state = thread.state else { + return nil + } + + let instructionSources = try Self.decode([String].self, from: defaults.instructionSourcesData) ?? [] + let sandboxPolicy = try Self.decode(SandboxPolicySnapshot.self, from: defaults.sandboxPolicyData) + ?? .init( + type: "", + networkAccess: nil, + excludeSlashTmp: nil, + excludeTmpdirEnvVar: nil, + writableRoots: [] + ) + let turnRequest = HistoryTurn.fetchRequest() + turnRequest.predicate = NSPredicate(format: "thread == %@", thread) + let turns = try context.fetch(turnRequest) + .sorted { $0.orderIndex < $1.orderIndex } + .map { turn in + try Self.makeTurnSnapshot(turn, in: context) + } + let rollbackRequest = HistoryThreadRollback.fetchRequest() + rollbackRequest.predicate = NSPredicate(format: "thread == %@", thread) + let rollbacks = try context.fetch(rollbackRequest) + .sorted { $0.recordedAt < $1.recordedAt } + .map { rollback in + try ThreadSnapshot.RollbackSnapshot( + id: rollback.id, + previousNewestTurnID: rollback.previousNewestTurnID, + recordedAt: Int(rollback.recordedAt), + removedTurnIDs: Self.decode([String].self, from: rollback.removedTurnIDsData) ?? [], + requestedTurnCount: Int(rollback.requestedTurnCount), + resultingNewestTurnID: rollback.resultingNewestTurnID, + serverUpdatedAt: Int(rollback.serverUpdatedAt) + ) + } - switch (existing, incoming) { - case let (nil, incoming): - return .init(value: incoming, preservedExistingDetail: false) - case let (existing, nil): - return .init(value: existing, preservedExistingDetail: existing != nil) - case let (.some(_), .some(incoming)): - return .init(value: incoming, preservedExistingDetail: false) + return try .init( + id: thread.id, + cliVersion: thread.cliVersion, + createdAt: Int(thread.createdAt), + currentDirectoryPath: thread.currentDirectoryPath, + defaults: .init( + approvalPolicy: defaults.approvalPolicy, + approvalsReviewer: defaults.approvalsReviewer, + currentDirectoryPath: defaults.currentDirectoryPath, + instructionSources: instructionSources, + model: defaults.model, + modelProvider: defaults.modelProvider, + reasoningEffort: defaults.reasoningEffort, + sandboxPolicy: sandboxPolicy, + serviceTier: defaults.serviceTier + ), + ephemeral: thread.ephemeral, + forkedFromThreadID: thread.forkedFromThreadID, + forkedFromTurnID: thread.forkedFromTurnID, + gitBranch: thread.gitBranch, + gitOriginURL: thread.gitOriginURL, + gitSHA: thread.gitSHA, + isArchived: thread.isArchived, + isClosed: thread.isClosed, + modelProvider: thread.modelProvider, + name: thread.name, + preview: thread.preview, + rollbacks: rollbacks, + source: (Self.decode(CodexAppServer.ThreadSource.self, from: thread.sourceData)) ?? .unknown, + state: .init( + completeness: state.completeness, + localState: Self.localState(for: thread).rawValue + ), + statusFlags: (Self.decode([String].self, from: thread.statusFlagsData)) ?? [], + statusType: thread.statusType, + turns: turns, + updatedAt: Int(thread.updatedAt) + ) } } - private static func mergeCanonicalText(existing: String?, incoming: String?) -> MergedString { - let existing = normalizedMeaningfulString(existing) - let incoming = normalizedMeaningfulString(incoming) - - switch (existing, incoming) { - case let (nil, incoming): - return .init(value: incoming, preservedExistingDetail: false) - case let (existing, nil): - return .init(value: existing, preservedExistingDetail: existing != nil) - case let (.some(existing), .some(incoming)): - if incoming == existing { - return .init(value: incoming, preservedExistingDetail: false) - } - if existing.hasPrefix(incoming) { - return .init(value: existing, preservedExistingDetail: true) - } - return .init(value: incoming, preservedExistingDetail: false) + func threadListSnapshots() throws -> [ThreadListSnapshot] { + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + let request = HistoryThread.fetchRequest() + let threads = try context.fetch(request) + return try threads.map(Self.makeThreadListSnapshot) } } - private static func mergeStreamedText(existing: String?, incoming: String?) -> MergedString { - let existing = normalizedMeaningfulString(existing) - let incoming = normalizedMeaningfulString(incoming) - - switch (existing, incoming) { - case let (nil, incoming): - return .init(value: incoming, preservedExistingDetail: false) - case let (existing, nil): - return .init(value: existing, preservedExistingDetail: existing != nil) - case let (.some(existing), .some(incoming)): - if incoming == existing { - return .init(value: incoming, preservedExistingDetail: false) - } - if incoming.hasPrefix(existing) { - return .init(value: incoming, preservedExistingDetail: false) + func turnSnapshot( + threadID: String, + turnID: String + ) throws -> ThreadSnapshot.TurnSnapshot? { + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return nil } - return .init(value: existing, preservedExistingDetail: true) - } - } - private static func mergeItemStatus(existing: String?, incoming: String?) -> MergedString { - let existing = normalizedMeaningfulString(existing) - let incoming = normalizedMeaningfulString(incoming) + let request = HistoryTurn.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "thread == %@ AND turnID == %@", + thread, + turnID + ) - switch (existing, incoming) { - case let (nil, incoming): - return .init(value: incoming, preservedExistingDetail: false) - case let (existing, nil): - return .init(value: existing, preservedExistingDetail: existing != nil) - case let (.some(existing), .some(incoming)): - let normalizedExisting = existing.lowercased() - let normalizedIncoming = incoming.lowercased() - let incomingTerminal = isTerminalItemStatus(normalizedIncoming) - let existingTerminal = isTerminalItemStatus(normalizedExisting) - - if incomingTerminal { - return .init(value: incoming, preservedExistingDetail: false) - } - if existingTerminal { - return .init(value: existing, preservedExistingDetail: normalizedIncoming != normalizedExisting) + guard let turn = try context.fetch(request).first else { + return nil } - return .init(value: incoming, preservedExistingDetail: false) + + return try Self.makeTurnSnapshot(turn, in: context) } } - private static func normalizedMeaningfulString(_ value: String?) -> String? { - guard let value else { return nil } - return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value - } + func recentTurnSnapshots( + threadID: String, + limit: Int + ) throws -> [ThreadSnapshot.TurnSnapshot] { + guard limit > 0 else { return [] } - private static func mergedTurnStatus(existing: String, incoming: String) -> String { - let incomingTerminal = isTerminalTurnStatus(incoming) - let existingTerminal = isTerminalTurnStatus(existing) + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return [] + } - if incomingTerminal { - return incoming - } - if existingTerminal { - return existing - } - return incoming - } + let request = HistoryTurn.fetchRequest() + request.predicate = NSPredicate(format: "thread == %@", thread) + request.fetchLimit = limit + request.sortDescriptors = [ + NSSortDescriptor(key: "orderIndex", ascending: false), + ] - private static func isTerminalTurnStatus(_ status: String) -> Bool { - switch status { - case CodexAppServer.TurnStatus.completed.rawValue, - CodexAppServer.TurnStatus.failed.rawValue, - CodexAppServer.TurnStatus.interrupted.rawValue: - true - default: - false + let turns = try context.fetch(request) + return try turns + .sorted { $0.orderIndex > $1.orderIndex } + .map { try Self.makeTurnSnapshot($0, in: context) } } } - private static func isTerminalItemStatus(_ status: String) -> Bool { - switch status { - case "completed", "failed", "error", "errored", "interrupted", "cancelled", "canceled": - true - default: - false - } - } + func olderTurnSnapshots( + threadID: String, + olderThanOrderIndex: Int, + limit: Int + ) throws -> [ThreadSnapshot.TurnSnapshot] { + guard limit > 0 else { return [] } - private static func fetchOrInsertThread(id: String, in context: NSManagedObjectContext) throws -> HistoryThread { - if let existing = try fetchThread(id: id, in: context) { - return existing - } - let thread = HistoryThread(context: context) - thread.id = id - thread.cliVersion = "" - thread.createdAt = 0 - thread.currentDirectoryPath = "" - thread.ephemeral = false - thread.forkedFromThreadID = nil - thread.forkedFromTurnID = nil - thread.isArchived = false - thread.isClosed = false - thread.modelProvider = "" - thread.preview = "" - thread.statusType = CodexAppServer.ThreadStatusType.notLoaded.rawValue - thread.updatedAt = 0 - return thread - } + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return [] + } - private static func fetchThread(id: String, in context: NSManagedObjectContext) throws -> HistoryThread? { - let request = HistoryThread.fetchRequest() - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "id == %@", id) - return try context.fetch(request).first - } + let request = HistoryTurn.fetchRequest() + request.predicate = NSPredicate( + format: "thread == %@ AND orderIndex < %d", + thread, + olderThanOrderIndex + ) + request.fetchLimit = limit + request.sortDescriptors = [ + NSSortDescriptor(key: "orderIndex", ascending: false), + ] - private static func fetchOrInsertTurn( - turnID: String, - threadID: String, - in context: NSManagedObjectContext - ) throws -> HistoryTurn { - if let existing = try fetchTurn(turnID: turnID, threadID: threadID, in: context) { - return existing + return try context.fetch(request).map { try Self.makeTurnSnapshot($0, in: context) } } - let turn = HistoryTurn(context: context) - turn.id = stableTurnID(threadID: threadID, turnID: turnID) - turn.turnID = turnID - return turn } - private static func fetchTurn( - turnID: String, + func newerTurnSnapshots( threadID: String, - in context: NSManagedObjectContext - ) throws -> HistoryTurn? { - let request = HistoryTurn.fetchRequest() - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "id == %@", - stableTurnID(threadID: threadID, turnID: turnID) - ) - return try context.fetch(request).first - } + newerThanOrderIndex: Int, + limit: Int + ) throws -> [ThreadSnapshot.TurnSnapshot] { + guard limit > 0 else { return [] } - private static func fetchOrInsertItem(stableID: String, in context: NSManagedObjectContext) throws -> HistoryItem { - if let existing = try fetchItem(stableID: stableID, in: context) { - return existing + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return [] + } + + let request = HistoryTurn.fetchRequest() + request.predicate = NSPredicate( + format: "thread == %@ AND orderIndex > %d", + thread, + newerThanOrderIndex + ) + request.fetchLimit = limit + request.sortDescriptors = [ + NSSortDescriptor(key: "orderIndex", ascending: true), + ] + + return try context.fetch(request) + .map { try Self.makeTurnSnapshot($0, in: context) } + .sorted { $0.orderIndex > $1.orderIndex } } - let item = HistoryItem(context: context) - item.id = stableID - item.itemID = stableID - return item } - private static func fetchItem(stableID: String, in context: NSManagedObjectContext) throws -> HistoryItem? { - let request = HistoryItem.fetchRequest() - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "id == %@", stableID) - return try context.fetch(request).first - } + func turnSnapshots( + threadID: String, + turnIDs: [String] + ) throws -> [ThreadSnapshot.TurnSnapshot] { + guard !turnIDs.isEmpty else { return [] } - private static func encode(_ value: T) throws -> Data { - try JSONEncoder().encode(value) - } + let context = container.newBackgroundContext() + return try context.performAndWaitReturning { + guard let thread = try Self.fetchThread(id: threadID, in: context) else { + return [] + } + + let request = HistoryTurn.fetchRequest() + request.predicate = NSPredicate( + format: "thread == %@ AND turnID IN %@", + thread, + turnIDs + ) - private static func decode(_ type: T.Type, from data: Data?) throws -> T? { - guard let data else { return nil } - return try JSONDecoder().decode(type, from: data) - } + let fetchedTurns = try context.fetch(request) + let byTurnID = try Dictionary( + uniqueKeysWithValues: fetchedTurns.map { turn in + try (turn.turnID, Self.makeTurnSnapshot(turn, in: context)) + } + ) - private static func makePersistentContainer(configuration: Configuration) throws -> NSPersistentContainer { - let model = sharedManagedObjectModel - let container = NSPersistentContainer(name: "ThreadHistoryStore", managedObjectModel: model) - let description = NSPersistentStoreDescription(url: configuration.storeURL) - description.type = configuration.inMemory ? NSInMemoryStoreType : NSSQLiteStoreType - description.shouldAddStoreAsynchronously = false - description.shouldMigrateStoreAutomatically = true - description.shouldInferMappingModelAutomatically = true + return turnIDs.compactMap { byTurnID[$0] } + } + } - if !configuration.inMemory { - try FileManager.default.createDirectory( - at: configuration.storeURL.deletingLastPathComponent(), - withIntermediateDirectories: true + func hydrateThreadRead( + thread: CodexAppServer.ThreadInfo, + turns: [HydratedTurn] + ) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + let threadObject = try Self.fetchOrInsertThread(id: thread.id, in: context) + Self.applyThreadInfo(thread, to: threadObject) + Self.ensureThreadPersistenceScaffolding(for: threadObject, in: context) + threadObject.state?.localState = LocalState.available.rawValue + let outcome = try Self.upsertHydratedTurns( + turns, + for: threadObject, + in: context ) + threadObject.state?.completeness = outcome.preservedRicherLocalDetail + ? Completeness.richerThanServer.rawValue + : Completeness.serverParity.rawValue + try context.saveIfChanged() } - container.persistentStoreDescriptions = [description] - var loadError: Error? - container.loadPersistentStores { _, error in - loadError = error - } - if let loadError { - throw loadError + if let maxOrderIndex = try snapshot(threadID: thread.id)?.turns.map(\.orderIndex).max() { + nextTurnOrderByThreadID[thread.id] = maxOrderIndex + 1 } - container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - return container } - private static func makeManagedObjectModel() -> NSManagedObjectModel { - let model = NSManagedObjectModel() - - let threadEntity = NSEntityDescription() - threadEntity.name = "HistoryThread" - threadEntity.managedObjectClassName = NSStringFromClass(HistoryThread.self) - threadEntity.properties = [ - attribute("id", .stringAttributeType, isOptional: false), - attribute("cliVersion", .stringAttributeType, isOptional: false), - attribute("createdAt", .integer64AttributeType, isOptional: false), - attribute("currentDirectoryPath", .stringAttributeType, isOptional: false), - attribute("ephemeral", .booleanAttributeType, isOptional: false), - attribute("forkedFromThreadID", .stringAttributeType, isOptional: true), - attribute("forkedFromTurnID", .stringAttributeType, isOptional: true), - attribute("gitBranch", .stringAttributeType, isOptional: true), - attribute("gitOriginURL", .stringAttributeType, isOptional: true), - attribute("gitSHA", .stringAttributeType, isOptional: true), - attribute("modelProvider", .stringAttributeType, isOptional: false), - attribute("name", .stringAttributeType, isOptional: true), - attribute("preview", .stringAttributeType, isOptional: false), - attribute("sourceData", .binaryDataAttributeType, isOptional: true), - attribute("statusType", .stringAttributeType, isOptional: false), - attribute("statusFlagsData", .binaryDataAttributeType, isOptional: true), - attribute("updatedAt", .integer64AttributeType, isOptional: false), - attribute("isArchived", .booleanAttributeType, isOptional: false), - attribute("isClosed", .booleanAttributeType, isOptional: false), - ] - threadEntity.uniquenessConstraints = [["id"]] - - let defaultsEntity = NSEntityDescription() - defaultsEntity.name = "HistoryThreadDefaults" - defaultsEntity.managedObjectClassName = NSStringFromClass(HistoryThreadDefaults.self) - defaultsEntity.properties = [ - attribute("approvalPolicy", .stringAttributeType, isOptional: false), - attribute("approvalsReviewer", .stringAttributeType, isOptional: false), - attribute("currentDirectoryPath", .stringAttributeType, isOptional: false), - attribute("instructionSourcesData", .binaryDataAttributeType, isOptional: true), - attribute("model", .stringAttributeType, isOptional: false), - attribute("modelProvider", .stringAttributeType, isOptional: false), - attribute("reasoningEffort", .stringAttributeType, isOptional: true), - attribute("sandboxPolicyData", .binaryDataAttributeType, isOptional: true), - attribute("serviceTier", .stringAttributeType, isOptional: true), - ] - - let stateEntity = NSEntityDescription() - stateEntity.name = "HistoryThreadState" - stateEntity.managedObjectClassName = NSStringFromClass(HistoryThreadState.self) - stateEntity.properties = [ - attribute("completeness", .stringAttributeType, isOptional: false), - attribute( - "localState", - .stringAttributeType, - isOptional: false, - defaultValue: LocalState.available.rawValue - ), - ] + func recordThreadRollback( + requestedTurnCount: Int, + thread: CodexAppServer.ThreadInfo, + turns: [HydratedTurn] + ) throws { + let threadID = thread.id + let visibleTurnIDs = Set(turns.map(\.turn.id)) - let rollbackEntity = NSEntityDescription() - rollbackEntity.name = "HistoryThreadRollback" - rollbackEntity.managedObjectClassName = NSStringFromClass(HistoryThreadRollback.self) - rollbackEntity.properties = [ - attribute("id", .stringAttributeType, isOptional: false), - attribute("previousNewestTurnID", .stringAttributeType, isOptional: true), - attribute("recordedAt", .integer64AttributeType, isOptional: false), - attribute("removedTurnIDsData", .binaryDataAttributeType, isOptional: true), - attribute("requestedTurnCount", .integer64AttributeType, isOptional: false), - attribute("resultingNewestTurnID", .stringAttributeType, isOptional: true), - attribute("serverUpdatedAt", .integer64AttributeType, isOptional: false), - ] - rollbackEntity.uniquenessConstraints = [["id"]] + let context = container.newBackgroundContext() + let maxOrderIndex = try context.performAndWaitReturning { + let threadObject = try Self.fetchOrInsertThread(id: thread.id, in: context) + Self.applyThreadInfo(thread, to: threadObject) + Self.ensureThreadPersistenceScaffolding(for: threadObject, in: context) + threadObject.state?.localState = LocalState.available.rawValue - let turnEntity = NSEntityDescription() - turnEntity.name = "HistoryTurn" - turnEntity.managedObjectClassName = NSStringFromClass(HistoryTurn.self) - turnEntity.properties = [ - attribute("id", .stringAttributeType, isOptional: false), - attribute("completedAt", .integer64AttributeType, isOptional: false), - attribute("diff", .stringAttributeType, isOptional: true), - attribute("durationMS", .integer64AttributeType, isOptional: false), - attribute("errorMessage", .stringAttributeType, isOptional: true), - attribute("orderIndex", .integer64AttributeType, isOptional: false), - attribute("startedAt", .integer64AttributeType, isOptional: false), - attribute("status", .stringAttributeType, isOptional: false), - attribute("tokenUsageData", .binaryDataAttributeType, isOptional: true), - attribute("turnID", .stringAttributeType, isOptional: false), - ] - turnEntity.uniquenessConstraints = [["id"]] + let existingTurns = try Self.fetchTurns(for: threadObject, in: context) + let previousNewestTurnID = existingTurns.last?.turnID + let removedTurnIDs = existingTurns + .map(\.turnID) + .filter { !visibleTurnIDs.contains($0) } - let itemEntity = NSEntityDescription() - itemEntity.name = "HistoryItem" - itemEntity.managedObjectClassName = NSStringFromClass(HistoryItem.self) - itemEntity.properties = [ - attribute("id", .stringAttributeType, isOptional: false), - attribute("itemID", .stringAttributeType, isOptional: false), - attribute("command", .stringAttributeType, isOptional: true), - attribute("kind", .stringAttributeType, isOptional: false), - attribute("orderIndex", .integer64AttributeType, isOptional: false), - attribute("path", .stringAttributeType, isOptional: true), - attribute("serverName", .stringAttributeType, isOptional: true), - attribute("status", .stringAttributeType, isOptional: true), - attribute("streamedText", .stringAttributeType, isOptional: true), - attribute("text", .stringAttributeType, isOptional: true), - attribute("toolName", .stringAttributeType, isOptional: true), - ] - itemEntity.uniquenessConstraints = [["id"]] + let rollback = HistoryThreadRollback(context: context) + rollback.id = UUID().uuidString + rollback.previousNewestTurnID = previousNewestTurnID + rollback.recordedAt = Int64(Date().timeIntervalSince1970) + rollback.removedTurnIDsData = try Self.encode(removedTurnIDs) + rollback.requestedTurnCount = Int64(requestedTurnCount) + rollback.resultingNewestTurnID = turns.last?.turn.id + rollback.serverUpdatedAt = Int64(thread.updatedAt) + rollback.thread = threadObject - let threadToDefaults = relationship(name: "defaults", destination: defaultsEntity, minCount: 0, maxCount: 1, deleteRule: .cascadeDeleteRule) - let defaultsToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) - threadToDefaults.inverseRelationship = defaultsToThread - defaultsToThread.inverseRelationship = threadToDefaults + _ = try Self.upsertHydratedTurns(turns, for: threadObject, in: context) - let threadToState = relationship(name: "state", destination: stateEntity, minCount: 0, maxCount: 1, deleteRule: .cascadeDeleteRule) - let stateToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) - threadToState.inverseRelationship = stateToThread - stateToThread.inverseRelationship = threadToState + for turnObject in existingTurns where !visibleTurnIDs.contains(turnObject.turnID) { + context.delete(turnObject) + } - let threadToTurns = relationship(name: "turns", destination: turnEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) - let turnToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) - threadToTurns.inverseRelationship = turnToThread - turnToThread.inverseRelationship = threadToTurns + try Self.reindexTurns(for: threadObject, in: context) + threadObject.state?.completeness = Completeness.serverParity.rawValue + try context.saveIfChanged() - let threadToRollbacks = relationship(name: "rollbacks", destination: rollbackEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) - let rollbackToThread = relationship(name: "thread", destination: threadEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) - threadToRollbacks.inverseRelationship = rollbackToThread - rollbackToThread.inverseRelationship = threadToRollbacks + return try Self.fetchTurns(for: threadObject, in: context) + .map(\.orderIndex) + .max() + .map(Int.init) + } - let turnToItems = relationship(name: "items", destination: itemEntity, minCount: 0, maxCount: 0, deleteRule: .cascadeDeleteRule) - let itemToTurn = relationship(name: "turn", destination: turnEntity, minCount: 0, maxCount: 1, deleteRule: .nullifyDeleteRule) - turnToItems.inverseRelationship = itemToTurn - itemToTurn.inverseRelationship = turnToItems + if let maxOrderIndex { + nextTurnOrderByThreadID[threadID] = maxOrderIndex + 1 + } else { + nextTurnOrderByThreadID[threadID] = 0 + } + activeTurns = activeTurns.filter { visibleTurnIDs.contains($0.key) } + } - threadEntity.properties.append(contentsOf: [threadToDefaults, threadToState, threadToTurns, threadToRollbacks]) - defaultsEntity.properties.append(defaultsToThread) - stateEntity.properties.append(stateToThread) - rollbackEntity.properties.append(rollbackToThread) - turnEntity.properties.append(contentsOf: [turnToThread, turnToItems]) - itemEntity.properties.append(itemToTurn) + func hydrateHistoricalTurns( + threadID: String, + turns: [HydratedTurn] + ) throws { + let context = container.newBackgroundContext() + try context.performAndWaitReturning { + let thread = try Self.fetchOrInsertThread(id: threadID, in: context) + Self.ensureThreadPersistenceScaffolding(for: thread, in: context) + thread.state?.localState = LocalState.available.rawValue + let outcome = try Self.upsertHydratedTurns( + turns, + for: thread, + in: context + ) + if let state = thread.state, state.completeness != Completeness.richerThanServer.rawValue { + if outcome.preservedRicherLocalDetail { + state.completeness = Completeness.richerThanServer.rawValue + } else if state.completeness.isEmpty { + state.completeness = Completeness.partial.rawValue + } + } + try context.saveIfChanged() + } - model.entities = [threadEntity, defaultsEntity, stateEntity, rollbackEntity, turnEntity, itemEntity] - return model + if let maxOrderIndex = try snapshot(threadID: threadID)?.turns.map(\.orderIndex).max() { + nextTurnOrderByThreadID[threadID] = maxOrderIndex + 1 + } } } @@ -1726,6 +1747,7 @@ private extension NSManagedObjectContext { "Core Data did not produce a result while synchronously executing work on the managed object context." ) } + return try outcome.get() } @@ -1743,16 +1765,16 @@ private final class ManagedObjectContextResultBox: @unchecked Senda private extension CodexAppServer.ApprovalPolicy { var persistedLabel: String { switch self { - case .never: - "never" - case .onFailure: - "onFailure" - case .onRequest: - "onRequest" - case .untrusted: - "untrusted" - case .granular: - "granular" + case .never: + "never" + case .onFailure: + "onFailure" + case .onRequest: + "onRequest" + case .untrusted: + "untrusted" + case .granular: + "granular" } } } @@ -1772,12 +1794,12 @@ private extension ThreadHistoryStore.SandboxPolicySnapshot { private extension ThreadHistoryStore.SandboxPolicySnapshot.NetworkAccessSnapshot { init(_ access: CodexAppServer.NetworkAccess) { switch access { - case let .explicit(value): - self = .explicit(value) - case .enabled: - self = .enabled - case .restricted: - self = .restricted + case let .explicit(value): + self = .explicit(value) + case .enabled: + self = .enabled + case .restricted: + self = .restricted } } } diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift index a03de42..78668aa 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift @@ -7,78 +7,78 @@ import Foundation -enum CodexAppServerProtocolEvent: Equatable, Sendable { - case appListUpdated(CodexWireAppListUpdatedNotification) - case skillsChanged([String: CodexWireJSONValue]) - case mcpServerStatusUpdated(CodexWireMCPServerStatusUpdatedNotification) - case configWarning(CodexWireConfigWarningNotification) - case deprecationNotice(CodexWireDeprecationNoticeNotification) - case remoteControlStatusChanged(CodexWireRemoteControlStatusChangedNotification) - case threadStarted(CodexWireThreadStartedNotification) - case threadStatusChanged(CodexWireThreadStatusChangedNotification) - case threadArchived(CodexWireThreadArchivedNotification) - case threadUnarchived(CodexWireThreadUnarchivedNotification) - case threadClosed(CodexWireThreadClosedNotification) - case threadNameUpdated(CodexWireThreadNameUpdatedNotification) - case threadTokenUsageUpdated(CodexWireThreadTokenUsageUpdatedNotification) - case threadGoalUpdated(CodexWireThreadGoalUpdatedNotification) - case threadGoalCleared(CodexWireThreadGoalClearedNotification) - case fsChanged(CodexWireFSChangedNotification) - case hookStarted(CodexWireHookStartedNotification) - case hookCompleted(CodexWireHookCompletedNotification) - case warning(CodexWireWarningNotification) - case guardianWarning(CodexWireGuardianWarningNotification) - case modelRerouted(CodexWireModelReroutedNotification) - case modelVerification(CodexWireModelVerificationNotification) - case turnStarted(CodexWireTurnStartedNotification) - case turnDiffUpdated(CodexWireTurnDiffUpdatedNotification) - case turnPlanUpdated(CodexWireTurnPlanUpdatedNotification) - case turnCompleted(CodexWireTurnCompletedNotification) - case itemStarted(CodexWireItemStartedNotification) - case itemCompleted(CodexWireItemCompletedNotification) - case itemGuardianApprovalReviewStarted(CodexWireItemGuardianApprovalReviewStartedNotification) - case itemGuardianApprovalReviewCompleted(CodexProtocolGuardianApprovalReviewCompletedNotification) - case commandExecOutputDelta(CodexWireCommandExecOutputDeltaNotification) - case commandExecutionOutputDelta(CodexWireCommandExecutionOutputDeltaNotification) - case fileChangeOutputDelta(CodexWireFileChangeOutputDeltaNotification) - case fileChangePatchUpdated(CodexWireFileChangePatchUpdatedNotification) - case agentMessageDelta(CodexWireAgentMessageDeltaNotification) - case planDelta(CodexWirePlanDeltaNotification) - case reasoningSummaryPartAdded(CodexWireReasoningSummaryPartAddedNotification) - case reasoningSummaryTextDelta(CodexWireReasoningSummaryTextDeltaNotification) - case reasoningTextDelta(CodexWireReasoningTextDeltaNotification) - case commandExecutionApprovalRequested(CodexProtocolCommandExecutionApprovalRequest) - case fileChangeApprovalRequested(CodexProtocolFileChangeApprovalRequest) - case permissionsApprovalRequested(CodexProtocolPermissionsApprovalRequest) - case toolUserInputRequested(CodexProtocolToolUserInputRequest) - case mcpServerElicitationRequested(CodexProtocolMCPServerElicitationRequest) - case serverRequestResolved(CodexWireServerRequestResolvedNotification) -} - -struct CodexProtocolThreadCompactStartResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolThreadShellCommandResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolThreadArchiveResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolThreadSetNameResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolThreadApproveGuardianDeniedActionResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolGuardianApprovalReviewCompletedNotification: Equatable, Sendable { - let event: CodexWireJSONValue - let notification: CodexWireItemGuardianApprovalReviewCompletedNotification -} - -struct CodexProtocolModelProviderCapabilitiesReadParams: Encodable, Equatable, Sendable {} - -struct CodexProtocolModelProviderCapabilitiesReadResponse: Decodable, Equatable, Sendable { +enum CodexAppServerProtocolEvent: Equatable { + case appListUpdated(CodexWireAppListUpdatedNotification) + case skillsChanged([String: CodexWireJSONValue]) + case mcpServerStatusUpdated(CodexWireMCPServerStatusUpdatedNotification) + case configWarning(CodexWireConfigWarningNotification) + case deprecationNotice(CodexWireDeprecationNoticeNotification) + case remoteControlStatusChanged(CodexWireRemoteControlStatusChangedNotification) + case threadStarted(CodexWireThreadStartedNotification) + case threadStatusChanged(CodexWireThreadStatusChangedNotification) + case threadArchived(CodexWireThreadArchivedNotification) + case threadUnarchived(CodexWireThreadUnarchivedNotification) + case threadClosed(CodexWireThreadClosedNotification) + case threadNameUpdated(CodexWireThreadNameUpdatedNotification) + case threadTokenUsageUpdated(CodexWireThreadTokenUsageUpdatedNotification) + case threadGoalUpdated(CodexWireThreadGoalUpdatedNotification) + case threadGoalCleared(CodexWireThreadGoalClearedNotification) + case fsChanged(CodexWireFSChangedNotification) + case hookStarted(CodexWireHookStartedNotification) + case hookCompleted(CodexWireHookCompletedNotification) + case warning(CodexWireWarningNotification) + case guardianWarning(CodexWireGuardianWarningNotification) + case modelRerouted(CodexWireModelReroutedNotification) + case modelVerification(CodexWireModelVerificationNotification) + case turnStarted(CodexWireTurnStartedNotification) + case turnDiffUpdated(CodexWireTurnDiffUpdatedNotification) + case turnPlanUpdated(CodexWireTurnPlanUpdatedNotification) + case turnCompleted(CodexWireTurnCompletedNotification) + case itemStarted(CodexWireItemStartedNotification) + case itemCompleted(CodexWireItemCompletedNotification) + case itemGuardianApprovalReviewStarted(CodexWireItemGuardianApprovalReviewStartedNotification) + case itemGuardianApprovalReviewCompleted(CodexProtocolGuardianApprovalReviewCompletedNotification) + case commandExecOutputDelta(CodexWireCommandExecOutputDeltaNotification) + case commandExecutionOutputDelta(CodexWireCommandExecutionOutputDeltaNotification) + case fileChangeOutputDelta(CodexWireFileChangeOutputDeltaNotification) + case fileChangePatchUpdated(CodexWireFileChangePatchUpdatedNotification) + case agentMessageDelta(CodexWireAgentMessageDeltaNotification) + case planDelta(CodexWirePlanDeltaNotification) + case reasoningSummaryPartAdded(CodexWireReasoningSummaryPartAddedNotification) + case reasoningSummaryTextDelta(CodexWireReasoningSummaryTextDeltaNotification) + case reasoningTextDelta(CodexWireReasoningTextDeltaNotification) + case commandExecutionApprovalRequested(CodexProtocolCommandExecutionApprovalRequest) + case fileChangeApprovalRequested(CodexProtocolFileChangeApprovalRequest) + case permissionsApprovalRequested(CodexProtocolPermissionsApprovalRequest) + case toolUserInputRequested(CodexProtocolToolUserInputRequest) + case mcpServerElicitationRequested(CodexProtocolMCPServerElicitationRequest) + case serverRequestResolved(CodexWireServerRequestResolvedNotification) +} + +struct CodexProtocolThreadCompactStartResponse: Decodable, Equatable {} + +struct CodexProtocolThreadShellCommandResponse: Decodable, Equatable {} + +struct CodexProtocolThreadArchiveResponse: Decodable, Equatable {} + +struct CodexProtocolThreadSetNameResponse: Decodable, Equatable {} + +struct CodexProtocolThreadApproveGuardianDeniedActionResponse: Decodable, Equatable {} + +struct CodexProtocolGuardianApprovalReviewCompletedNotification: Equatable { + let event: CodexWireJSONValue + let notification: CodexWireItemGuardianApprovalReviewCompletedNotification +} + +struct CodexProtocolModelProviderCapabilitiesReadParams: Encodable, Equatable {} + +struct CodexProtocolModelProviderCapabilitiesReadResponse: Decodable, Equatable { let imageGeneration: Bool let namespaceTools: Bool let webSearch: Bool } -struct CodexProtocolCommandExecParams: Encodable, Equatable, Sendable { +struct CodexProtocolCommandExecParams: Encodable, Equatable { let command: [String] let cwd: String? let disableOutputCap: Bool? @@ -102,81 +102,81 @@ struct CodexProtocolCommandExecParams: Encodable, Equatable, Sendable { case tty } - struct TerminalSize: Encodable, Equatable, Sendable { + struct TerminalSize: Encodable, Equatable { let cols: Int let rows: Int } } -struct CodexProtocolCommandExecResponse: Decodable, Equatable, Sendable { +struct CodexProtocolCommandExecResponse: Decodable, Equatable { let exitCode: Int let stderr: String let stdout: String } -struct CodexProtocolCollaborationModeListParams: Encodable, Equatable, Sendable {} +struct CodexProtocolCollaborationModeListParams: Encodable, Equatable {} -struct CodexProtocolConfigBatchWriteParams: Encodable, Equatable, Sendable { +struct CodexProtocolConfigBatchWriteParams: Encodable, Equatable { let edits: [CodexProtocolConfigEdit] let expectedVersion: String? let filePath: String? let reloadUserConfig: Bool? } -struct CodexProtocolConfigEdit: Encodable, Equatable, Sendable { +struct CodexProtocolConfigEdit: Encodable, Equatable { let keyPath: String let mergeStrategy: CodexProtocolConfigMergeStrategy let value: CodexWireJSONValue } -enum CodexProtocolConfigMergeStrategy: String, Encodable, Equatable, Sendable { +enum CodexProtocolConfigMergeStrategy: String, Encodable, Equatable { case replace case upsert } -struct CodexProtocolConfigWriteResponse: Decodable, Equatable, Sendable { +struct CodexProtocolConfigWriteResponse: Decodable, Equatable { let filePath: String let overriddenMetadata: CodexWireJSONValue? let status: CodexProtocolConfigWriteStatus let version: String } -enum CodexProtocolConfigWriteStatus: String, Decodable, Equatable, Sendable { +enum CodexProtocolConfigWriteStatus: String, Decodable, Equatable { case ok case okOverridden } -struct CodexProtocolFSWriteFileParams: Encodable, Equatable, Sendable { +struct CodexProtocolFSWriteFileParams: Encodable, Equatable { let dataBase64: String let path: String } -struct CodexProtocolFSCreateDirectoryParams: Encodable, Equatable, Sendable { +struct CodexProtocolFSCreateDirectoryParams: Encodable, Equatable { let path: String let recursive: Bool? } -struct CodexProtocolFSRemoveParams: Encodable, Equatable, Sendable { +struct CodexProtocolFSRemoveParams: Encodable, Equatable { let force: Bool? let path: String let recursive: Bool? } -struct CodexProtocolFSCopyParams: Encodable, Equatable, Sendable { +struct CodexProtocolFSCopyParams: Encodable, Equatable { let destinationPath: String let recursive: Bool? let sourcePath: String } -struct CodexProtocolFSWriteFileResponse: Decodable, Equatable, Sendable {} +struct CodexProtocolFSWriteFileResponse: Decodable, Equatable {} -struct CodexProtocolFSCreateDirectoryResponse: Decodable, Equatable, Sendable {} +struct CodexProtocolFSCreateDirectoryResponse: Decodable, Equatable {} -struct CodexProtocolFSRemoveResponse: Decodable, Equatable, Sendable {} +struct CodexProtocolFSRemoveResponse: Decodable, Equatable {} -struct CodexProtocolFSCopyResponse: Decodable, Equatable, Sendable {} +struct CodexProtocolFSCopyResponse: Decodable, Equatable {} -struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable { let gitInfo: GitInfo? let threadID: String @@ -185,7 +185,7 @@ struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable, Sendable { case threadID = "threadId" } - struct GitInfo: Encodable, Equatable, Sendable { + struct GitInfo: Encodable, Equatable { let branch: FieldUpdate let originURL: FieldUpdate let sha: FieldUpdate @@ -204,7 +204,7 @@ struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable, Sendable { } } - enum FieldUpdate: Equatable, Sendable { + enum FieldUpdate: Equatable { case unchanged case clear case replace(String) @@ -217,27 +217,27 @@ private extension KeyedEncodingContainer { forKey key: Key ) throws { switch update { - case .unchanged: - return - case .clear: - try encodeNil(forKey: key) - case let .replace(value): - try encode(value, forKey: key) + case .unchanged: + return + case .clear: + try encodeNil(forKey: key) + case let .replace(value): + try encode(value, forKey: key) } } } -struct CodexProtocolTurnInterruptParams: Encodable, Equatable, Sendable { - let threadID: String - let turnID: String +struct CodexProtocolTurnInterruptParams: Encodable, Equatable { + let threadID: String + let turnID: String - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - } + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + } } -struct CodexProtocolThreadReadParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadReadParams: Encodable, Equatable { let includeTurns: Bool? let threadID: String @@ -247,11 +247,11 @@ struct CodexProtocolThreadReadParams: Encodable, Equatable, Sendable { } } -struct CodexProtocolThreadReadResponse: Decodable, Equatable, Sendable { +struct CodexProtocolThreadReadResponse: Decodable, Equatable { let thread: CodexWireThread } -struct CodexProtocolThreadResumeParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadResumeParams: Encodable, Equatable { let approvalPolicy: CodexWireApprovalPolicyUnion? let approvalsReviewer: CodexWireApprovalsReviewer? let baseInstructions: String? @@ -287,7 +287,7 @@ struct CodexProtocolThreadResumeParams: Encodable, Equatable, Sendable { } } -struct CodexProtocolThreadForkParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadForkParams: Encodable, Equatable { let approvalPolicy: CodexWireApprovalPolicyUnion? let approvalsReviewer: CodexWireApprovalsReviewer? let baseInstructions: String? @@ -325,7 +325,7 @@ struct CodexProtocolThreadForkParams: Encodable, Equatable, Sendable { } } -struct CodexProtocolThreadListParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadListParams: Encodable, Equatable { let archived: Bool? let cursor: String? let cwd: String? @@ -349,30 +349,30 @@ struct CodexProtocolThreadListParams: Encodable, Equatable, Sendable { } } -enum CodexProtocolThreadListSortDirection: String, Codable, Equatable, Sendable { +enum CodexProtocolThreadListSortDirection: String, Codable, Equatable { case asc case desc } -enum CodexProtocolThreadListSortKey: String, Codable, Equatable, Sendable { +enum CodexProtocolThreadListSortKey: String, Codable, Equatable { case createdAt = "created_at" case updatedAt = "updated_at" } -enum CodexProtocolThreadListSourceKind: String, Codable, Equatable, Sendable { - case appServer = "appServer" +enum CodexProtocolThreadListSourceKind: String, Codable, Equatable { + case appServer case cli case exec case unknown case vscode } -struct CodexProtocolThreadListResponse: Decodable, Equatable, Sendable { +struct CodexProtocolThreadListResponse: Decodable, Equatable { let data: [CodexWireThread] let nextCursor: String? } -struct CodexProtocolThreadTurnsListParams: Encodable, Equatable, Sendable { +struct CodexProtocolThreadTurnsListParams: Encodable, Equatable { let cursor: String? let itemsView: CodexWireTurnItemsView? let limit: Int? @@ -388,37 +388,35 @@ struct CodexProtocolThreadTurnsListParams: Encodable, Equatable, Sendable { } } -enum CodexProtocolThreadTurnsSortDirection: String, Codable, Equatable, Sendable { +enum CodexProtocolThreadTurnsSortDirection: String, Codable, Equatable { case asc case desc } -struct CodexProtocolThreadTurnsListResponse: Decodable, Equatable, Sendable { +struct CodexProtocolThreadTurnsListResponse: Decodable, Equatable { let backwardsCursor: String? let data: [CodexWireTurn] let nextCursor: String? } -struct CodexProtocolHooksListParams: Encodable, Equatable, Sendable { +struct CodexProtocolHooksListParams: Encodable, Equatable { let cwds: [String]? } -struct CodexProtocolHooksListResponse: Decodable, Equatable, Sendable { - let data: [Entry] - - struct Entry: Decodable, Equatable, Sendable { +struct CodexProtocolHooksListResponse: Decodable, Equatable { + struct Entry: Decodable, Equatable { let cwd: String let errors: [ErrorInfo] let hooks: [HookMetadata] let warnings: [String] } - struct ErrorInfo: Decodable, Equatable, Sendable { + struct ErrorInfo: Decodable, Equatable { let message: String let path: String } - struct HookMetadata: Decodable, Equatable, Sendable { + struct HookMetadata: Decodable, Equatable { let command: String? let displayOrder: Int let enabled: Bool @@ -450,7 +448,7 @@ struct CodexProtocolHooksListResponse: Decodable, Equatable, Sendable { } } - enum EventName: String, Decodable, Equatable, Sendable { + enum EventName: String, Decodable, Equatable { case permissionRequest case postCompact case postToolUse @@ -461,13 +459,13 @@ struct CodexProtocolHooksListResponse: Decodable, Equatable, Sendable { case userPromptSubmit } - enum HandlerType: String, Decodable, Equatable, Sendable { + enum HandlerType: String, Decodable, Equatable { case agent case command case prompt } - enum Source: String, Decodable, Equatable, Sendable { + enum Source: String, Decodable, Equatable { case cloudRequirements case legacyManagedConfigFile case legacyManagedConfigMdm @@ -479,318 +477,320 @@ struct CodexProtocolHooksListResponse: Decodable, Equatable, Sendable { case unknown case user } + + let data: [Entry] +} + +struct CodexProtocolTurnSteerParams: Encodable, Equatable { + let expectedTurnID: String + let input: [CodexWireUserInput] + let threadID: String + + enum CodingKeys: String, CodingKey { + case expectedTurnID = "expectedTurnId" + case input + case threadID = "threadId" + } +} + +struct CodexProtocolTurnSteerResponse: Decodable, Equatable { + let turnID: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + } +} + +struct CodexProtocolTurnInterruptResponse: Decodable, Equatable {} + +struct CodexProtocolCommandExecutionApprovalRequest: Decodable, Equatable { + let approvalID: String? + let command: String? + let commandActions: [CodexProtocolCommandAction]? + let cwd: String? + let itemID: String + let networkApprovalContext: CodexWireJSONValue? + let proposedExecpolicyAmendment: [String]? + let proposedNetworkPolicyAmendments: [CodexProtocolNetworkPolicyAmendment]? + let reason: String? + var requestID: CodexRPCRequestID = .string("unbound") + let threadID: String + let turnID: String + + enum CodingKeys: String, CodingKey { + case approvalID = "approvalId" + case command + case commandActions + case cwd + case itemID = "itemId" + case networkApprovalContext + case proposedExecpolicyAmendment + case proposedNetworkPolicyAmendments + case reason + case threadID = "threadId" + case turnID = "turnId" + } +} + +struct CodexProtocolFileChangeApprovalRequest: Decodable, Equatable { + let grantRoot: String? + let itemID: String + let reason: String? + var requestID: CodexRPCRequestID = .string("unbound") + let threadID: String + let turnID: String + + enum CodingKeys: String, CodingKey { + case grantRoot + case itemID = "itemId" + case reason + case threadID = "threadId" + case turnID = "turnId" + } +} + +struct CodexProtocolPermissionsApprovalRequest: Decodable, Equatable { + let itemID: String + let permissions: CodexProtocolPermissionProfile + let reason: String? + var requestID: CodexRPCRequestID = .string("unbound") + let threadID: String + let turnID: String + + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case permissions + case reason + case threadID = "threadId" + case turnID = "turnId" + } +} + +struct CodexProtocolToolUserInputRequest: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case itemID = "itemId" + case questions + case threadID = "threadId" + case turnID = "turnId" + } + + struct Question: Decodable, Equatable { + let header: String + let id: String + let isOther: Bool + let isSecret: Bool + let options: [Option]? + let question: String + + enum CodingKeys: String, CodingKey { + case header + case id + case isOther + case isSecret + case options + case question + } + + struct Option: Decodable, Equatable { + let description: String + let label: String + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + header = try container.decode(String.self, forKey: .header) + id = try container.decode(String.self, forKey: .id) + isOther = try container.decodeIfPresent(Bool.self, forKey: .isOther) ?? false + isSecret = try container.decodeIfPresent(Bool.self, forKey: .isSecret) ?? false + options = try container.decodeIfPresent([Option].self, forKey: .options) + question = try container.decode(String.self, forKey: .question) + } + } + + let itemID: String + let questions: [Question] + var requestID: CodexRPCRequestID = .string("unbound") + let threadID: String + let turnID: String +} + +struct CodexProtocolMCPServerElicitationRequest: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case serverName + case threadID = "threadId" + case turnID = "turnId" + case mode + } + + enum Mode: Equatable { + case form(Form) + case url(URLPrompt) + } + + struct Form: Decodable, Equatable { + let message: String + let requestedSchema: CodexWireJSONValue + + enum CodingKeys: String, CodingKey { + case message + case requestedSchema + } + } + + struct URLPrompt: Decodable, Equatable { + let elicitationID: String + let message: String + let url: String + + enum CodingKeys: String, CodingKey { + case elicitationID = "elicitationId" + case message + case url + } + } + + let mode: Mode + let requestID: CodexRPCRequestID + let serverName: String + let threadID: String + let turnID: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + serverName = try container.decode(String.self, forKey: .serverName) + threadID = try container.decode(String.self, forKey: .threadID) + turnID = try container.decodeIfPresent(String.self, forKey: .turnID) + requestID = .string("unbound") + + let modeValue = try container.decode(String.self, forKey: .mode) + switch modeValue { + case "form": + mode = try .form(Form(from: decoder)) + case "url": + mode = try .url(URLPrompt(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .mode, + in: container, + debugDescription: "Unsupported MCP elicitation mode \(modeValue)." + ) + } + } +} + +enum CodexProtocolCommandAction: Decodable, Equatable { + case read(Read) + case listFiles(ListFiles) + case search(Search) + case unknown(Unknown) + + struct Read: Decodable, Equatable { + let command: String + let name: String + let path: String + + enum CodingKeys: String, CodingKey { + case command + case name + case path + } + } + + struct ListFiles: Decodable, Equatable { + let command: String + let path: String? + + enum CodingKeys: String, CodingKey { + case command + case path + } + } + + struct Search: Decodable, Equatable { + let command: String + let path: String? + let query: String? + + enum CodingKeys: String, CodingKey { + case command + case path + case query + } + } + + struct Unknown: Decodable, Equatable { + let command: String + + enum CodingKeys: String, CodingKey { + case command + } + } + + private enum CodingKeys: String, CodingKey { + case command + case name + case path + case query + case type + } + + 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 "read": + self = try .read( + .init( + command: container.decode(String.self, forKey: .command), + name: container.decode(String.self, forKey: .name), + path: container.decode(String.self, forKey: .path) + ) + ) + case "listFiles": + self = try .listFiles( + .init( + command: container.decode(String.self, forKey: .command), + path: container.decodeIfPresent(String.self, forKey: .path) + ) + ) + case "search": + self = try .search( + .init( + command: container.decode(String.self, forKey: .command), + path: container.decodeIfPresent(String.self, forKey: .path), + query: container.decodeIfPresent(String.self, forKey: .query) + ) + ) + case "unknown": + self = try .unknown( + .init(command: container.decode(String.self, forKey: .command)) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported command action type \(type)." + ) + } + } +} + +struct CodexProtocolNetworkPolicyAmendment: Decodable, Equatable { + let action: String + let host: String } -struct CodexProtocolTurnSteerParams: Encodable, Equatable, Sendable { - let expectedTurnID: String - let input: [CodexWireUserInput] - let threadID: String - - enum CodingKeys: String, CodingKey { - case expectedTurnID = "expectedTurnId" - case input - case threadID = "threadId" - } -} - -struct CodexProtocolTurnSteerResponse: Decodable, Equatable, Sendable { - let turnID: String - - enum CodingKeys: String, CodingKey { - case turnID = "turnId" - } -} - -struct CodexProtocolTurnInterruptResponse: Decodable, Equatable, Sendable {} - -struct CodexProtocolCommandExecutionApprovalRequest: Decodable, Equatable, Sendable { - let approvalID: String? - let command: String? - let commandActions: [CodexProtocolCommandAction]? - let cwd: String? - let itemID: String - let networkApprovalContext: CodexWireJSONValue? - let proposedExecpolicyAmendment: [String]? - let proposedNetworkPolicyAmendments: [CodexProtocolNetworkPolicyAmendment]? - let reason: String? - var requestID: CodexRPCRequestID = .string("unbound") - let threadID: String - let turnID: String - - enum CodingKeys: String, CodingKey { - case approvalID = "approvalId" - case command - case commandActions - case cwd - case itemID = "itemId" - case networkApprovalContext - case proposedExecpolicyAmendment - case proposedNetworkPolicyAmendments - case reason - case threadID = "threadId" - case turnID = "turnId" - } -} - -struct CodexProtocolFileChangeApprovalRequest: Decodable, Equatable, Sendable { - let grantRoot: String? - let itemID: String - let reason: String? - var requestID: CodexRPCRequestID = .string("unbound") - let threadID: String - let turnID: String - - enum CodingKeys: String, CodingKey { - case grantRoot - case itemID = "itemId" - case reason - case threadID = "threadId" - case turnID = "turnId" - } -} - -struct CodexProtocolPermissionsApprovalRequest: Decodable, Equatable, Sendable { - let itemID: String - let permissions: CodexProtocolPermissionProfile - let reason: String? - var requestID: CodexRPCRequestID = .string("unbound") - let threadID: String - let turnID: String - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case permissions - case reason - case threadID = "threadId" - case turnID = "turnId" - } -} - -struct CodexProtocolToolUserInputRequest: Decodable, Equatable, Sendable { - let itemID: String - let questions: [Question] - var requestID: CodexRPCRequestID = .string("unbound") - let threadID: String - let turnID: String - - enum CodingKeys: String, CodingKey { - case itemID = "itemId" - case questions - case threadID = "threadId" - case turnID = "turnId" - } - - struct Question: Decodable, Equatable, Sendable { - let header: String - let id: String - let isOther: Bool - let isSecret: Bool - let options: [Option]? - let question: String - - enum CodingKeys: String, CodingKey { - case header - case id - case isOther - case isSecret - case options - case question - } - - struct Option: Decodable, Equatable, Sendable { - let description: String - let label: String - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - header = try container.decode(String.self, forKey: .header) - id = try container.decode(String.self, forKey: .id) - isOther = try container.decodeIfPresent(Bool.self, forKey: .isOther) ?? false - isSecret = try container.decodeIfPresent(Bool.self, forKey: .isSecret) ?? false - options = try container.decodeIfPresent([Option].self, forKey: .options) - question = try container.decode(String.self, forKey: .question) - } - } -} - -struct CodexProtocolMCPServerElicitationRequest: Decodable, Equatable, Sendable { - let mode: Mode - let requestID: CodexRPCRequestID - let serverName: String - let threadID: String - let turnID: String? - - enum CodingKeys: String, CodingKey { - case serverName - case threadID = "threadId" - case turnID = "turnId" - case mode - } - - enum Mode: Equatable, Sendable { - case form(Form) - case url(URLPrompt) - } - - struct Form: Decodable, Equatable, Sendable { - let message: String - let requestedSchema: CodexWireJSONValue - - enum CodingKeys: String, CodingKey { - case message - case requestedSchema - } - } - - struct URLPrompt: Decodable, Equatable, Sendable { - let elicitationID: String - let message: String - let url: String - - enum CodingKeys: String, CodingKey { - case elicitationID = "elicitationId" - case message - case url - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - serverName = try container.decode(String.self, forKey: .serverName) - threadID = try container.decode(String.self, forKey: .threadID) - turnID = try container.decodeIfPresent(String.self, forKey: .turnID) - requestID = .string("unbound") - - let modeValue = try container.decode(String.self, forKey: .mode) - switch modeValue { - case "form": - mode = .form(try Form(from: decoder)) - case "url": - mode = .url(try URLPrompt(from: decoder)) - default: - throw DecodingError.dataCorruptedError( - forKey: .mode, - in: container, - debugDescription: "Unsupported MCP elicitation mode \(modeValue)." - ) - } - } -} - -enum CodexProtocolCommandAction: Decodable, Equatable, Sendable { - case read(Read) - case listFiles(ListFiles) - case search(Search) - case unknown(Unknown) - - struct Read: Decodable, Equatable, Sendable { - let command: String - let name: String - let path: String - - enum CodingKeys: String, CodingKey { - case command - case name - case path - } - } - - struct ListFiles: Decodable, Equatable, Sendable { - let command: String - let path: String? - - enum CodingKeys: String, CodingKey { - case command - case path - } - } - - struct Search: Decodable, Equatable, Sendable { - let command: String - let path: String? - let query: String? - - enum CodingKeys: String, CodingKey { - case command - case path - case query - } - } - - struct Unknown: Decodable, Equatable, Sendable { - let command: String - - enum CodingKeys: String, CodingKey { - case command - } - } - - private enum CodingKeys: String, CodingKey { - case command - case name - case path - case query - case type - } - - 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 "read": - self = .read( - .init( - command: try container.decode(String.self, forKey: .command), - name: try container.decode(String.self, forKey: .name), - path: try container.decode(String.self, forKey: .path) - ) - ) - case "listFiles": - self = .listFiles( - .init( - command: try container.decode(String.self, forKey: .command), - path: try container.decodeIfPresent(String.self, forKey: .path) - ) - ) - case "search": - self = .search( - .init( - command: try container.decode(String.self, forKey: .command), - path: try container.decodeIfPresent(String.self, forKey: .path), - query: try container.decodeIfPresent(String.self, forKey: .query) - ) - ) - case "unknown": - self = .unknown( - .init(command: try container.decode(String.self, forKey: .command)) - ) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unsupported command action type \(type)." - ) - } - } -} - -struct CodexProtocolNetworkPolicyAmendment: Decodable, Equatable, Sendable { - let action: String - let host: String -} - -struct CodexProtocolPermissionProfile: Decodable, Equatable, Sendable { - let fileSystem: FileSystem? - let network: Network? - - struct FileSystem: Decodable, Equatable, Sendable { - let read: [String]? - let write: [String]? - } - - struct Network: Decodable, Equatable, Sendable { - let enabled: Bool? - } +struct CodexProtocolPermissionProfile: Decodable, Equatable { + let fileSystem: FileSystem? + let network: Network? + + struct FileSystem: Decodable, Equatable { + let read: [String]? + let write: [String]? + } + + struct Network: Decodable, Equatable { + let enabled: Bool? + } } diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift index cb8df11..e797f38 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift @@ -1,9 +1,9 @@ import Foundation struct CodexAppServerProtocol { - enum Method: String, Sendable, Codable { - case initialize = "initialize" - case initialized = "initialized" + enum Method: String, Codable { + case initialize + case initialized case threadCompactStart = "thread/compact/start" case threadArchive = "thread/archive" case threadFork = "thread/fork" @@ -1119,387 +1119,387 @@ struct CodexAppServerProtocol { _ serverEvent: CodexRPCServerEvent ) throws -> CodexAppServerProtocolEvent? { switch serverEvent { - case let .request(id, method, payload): - switch method { - case "item/commandExecution/requestApproval": - return .commandExecutionApprovalRequested( - try decodeServerRequest( - payload, - method: method, - id: id, - requestType: CodexProtocolCommandExecutionApprovalRequest.self - ) - ) - case "item/fileChange/requestApproval": - return .fileChangeApprovalRequested( - try decodeServerRequest( - payload, - method: method, - id: id, - requestType: CodexProtocolFileChangeApprovalRequest.self - ) - ) - case "item/permissions/requestApproval": - return .permissionsApprovalRequested( - try decodeServerRequest( - payload, - method: method, - id: id, - requestType: CodexProtocolPermissionsApprovalRequest.self - ) - ) - case "item/tool/requestUserInput": - return .toolUserInputRequested( - try decodeServerRequest( - payload, - method: method, - id: id, - requestType: CodexProtocolToolUserInputRequest.self - ) - ) - case "mcpServer/elicitation/request": - return .mcpServerElicitationRequested( - try decodeMCPServerElicitationRequest( - payload, - method: method, - id: id - ) - ) - default: - return nil - } - case let .notification(method, payload): - switch method { - case "app/list/updated": - return .appListUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireAppListUpdatedNotification.self - ) - ) - case "skills/changed": - return .skillsChanged( - try decodeNotification( - payload, - method: method, - resultType: [String: CodexWireJSONValue].self - ) - ) - case "mcpServer/startupStatus/updated": - return .mcpServerStatusUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireMCPServerStatusUpdatedNotification.self - ) - ) - case "config/warning": - return .configWarning( - try decodeNotification( - payload, - method: method, - resultType: CodexWireConfigWarningNotification.self - ) - ) - case "deprecation/notice": - return .deprecationNotice( - try decodeNotification( - payload, - method: method, - resultType: CodexWireDeprecationNoticeNotification.self - ) - ) - case "remoteControl/status/changed": - return .remoteControlStatusChanged( - try decodeNotification( - payload, - method: method, - resultType: CodexWireRemoteControlStatusChangedNotification.self - ) - ) - case "thread/started": - return .threadStarted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadStartedNotification.self - ) - ) - case "thread/status/changed": - return .threadStatusChanged( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadStatusChangedNotification.self - ) - ) - case "thread/archived": - return .threadArchived( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadArchivedNotification.self - ) - ) - case "thread/unarchived": - return .threadUnarchived( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadUnarchivedNotification.self - ) - ) - case "thread/closed": - return .threadClosed( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadClosedNotification.self - ) - ) - case "thread/name/updated": - return .threadNameUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadNameUpdatedNotification.self - ) - ) - case "thread/tokenUsage/updated": - return .threadTokenUsageUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadTokenUsageUpdatedNotification.self - ) - ) - case "thread/goal/updated": - return .threadGoalUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadGoalUpdatedNotification.self - ) - ) - case "thread/goal/cleared": - return .threadGoalCleared( - try decodeNotification( - payload, - method: method, - resultType: CodexWireThreadGoalClearedNotification.self - ) - ) - case "fs/changed": - return .fsChanged( - try decodeNotification( - payload, - method: method, - resultType: CodexWireFSChangedNotification.self - ) - ) - case "hook/started": - return .hookStarted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireHookStartedNotification.self - ) - ) - case "hook/completed": - return .hookCompleted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireHookCompletedNotification.self - ) - ) - case "warning": - return .warning( - try decodeNotification( - payload, - method: method, - resultType: CodexWireWarningNotification.self - ) - ) - case "guardianWarning": - return .guardianWarning( - try decodeNotification( - payload, - method: method, - resultType: CodexWireGuardianWarningNotification.self - ) - ) - case "model/rerouted": - return .modelRerouted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireModelReroutedNotification.self - ) - ) - case "model/verification": - return .modelVerification( - try decodeNotification( - payload, - method: method, - resultType: CodexWireModelVerificationNotification.self - ) - ) - case "turn/started": - return .turnStarted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireTurnStartedNotification.self - ) - ) - case "turn/diff/updated": - return .turnDiffUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireTurnDiffUpdatedNotification.self - ) - ) - case "turn/plan/updated": - return .turnPlanUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireTurnPlanUpdatedNotification.self - ) - ) - case "turn/completed": - return .turnCompleted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireTurnCompletedNotification.self - ) - ) - case "item/started": - return .itemStarted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireItemStartedNotification.self - ) - ) - case "item/completed": - return .itemCompleted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireItemCompletedNotification.self - ) - ) - case "item/autoApprovalReview/started": - return .itemGuardianApprovalReviewStarted( - try decodeNotification( - payload, - method: method, - resultType: CodexWireItemGuardianApprovalReviewStartedNotification.self - ) - ) - case "item/autoApprovalReview/completed": - return .itemGuardianApprovalReviewCompleted( - .init( - event: try decodeNotification( - payload, - method: method, - resultType: CodexWireJSONValue.self - ), - notification: try decodeNotification( - payload, - method: method, - resultType: CodexWireItemGuardianApprovalReviewCompletedNotification.self + case let .request(id, method, payload): + switch method { + case "item/commandExecution/requestApproval": + return try .commandExecutionApprovalRequested( + decodeServerRequest( + payload, + method: method, + id: id, + requestType: CodexProtocolCommandExecutionApprovalRequest.self + ) ) - ) - ) - case "item/commandExecution/outputDelta": - return .commandExecutionOutputDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireCommandExecutionOutputDeltaNotification.self - ) - ) - case "command/exec/outputDelta": - return .commandExecOutputDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireCommandExecOutputDeltaNotification.self - ) - ) - case "item/fileChange/outputDelta": - return .fileChangeOutputDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireFileChangeOutputDeltaNotification.self - ) - ) - case "item/fileChange/patchUpdated": - return .fileChangePatchUpdated( - try decodeNotification( - payload, - method: method, - resultType: CodexWireFileChangePatchUpdatedNotification.self - ) - ) - case "item/agentMessage/delta": - return .agentMessageDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireAgentMessageDeltaNotification.self - ) - ) - case "item/plan/delta": - return .planDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWirePlanDeltaNotification.self - ) - ) - case "item/reasoning/summaryPartAdded": - return .reasoningSummaryPartAdded( - try decodeNotification( - payload, - method: method, - resultType: CodexWireReasoningSummaryPartAddedNotification.self - ) - ) - case "item/reasoning/summaryTextDelta": - return .reasoningSummaryTextDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireReasoningSummaryTextDeltaNotification.self - ) - ) - case "item/reasoning/textDelta": - return .reasoningTextDelta( - try decodeNotification( - payload, - method: method, - resultType: CodexWireReasoningTextDeltaNotification.self - ) - ) - case "serverRequest/resolved": - return .serverRequestResolved( - try decodeNotification( - payload, - method: method, - resultType: CodexWireServerRequestResolvedNotification.self - ) - ) - default: - return nil - } + case "item/fileChange/requestApproval": + return try .fileChangeApprovalRequested( + decodeServerRequest( + payload, + method: method, + id: id, + requestType: CodexProtocolFileChangeApprovalRequest.self + ) + ) + case "item/permissions/requestApproval": + return try .permissionsApprovalRequested( + decodeServerRequest( + payload, + method: method, + id: id, + requestType: CodexProtocolPermissionsApprovalRequest.self + ) + ) + case "item/tool/requestUserInput": + return try .toolUserInputRequested( + decodeServerRequest( + payload, + method: method, + id: id, + requestType: CodexProtocolToolUserInputRequest.self + ) + ) + case "mcpServer/elicitation/request": + return try .mcpServerElicitationRequested( + decodeMCPServerElicitationRequest( + payload, + method: method, + id: id + ) + ) + default: + return nil + } + case let .notification(method, payload): + switch method { + case "app/list/updated": + return try .appListUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireAppListUpdatedNotification.self + ) + ) + case "skills/changed": + return try .skillsChanged( + decodeNotification( + payload, + method: method, + resultType: [String: CodexWireJSONValue].self + ) + ) + case "mcpServer/startupStatus/updated": + return try .mcpServerStatusUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireMCPServerStatusUpdatedNotification.self + ) + ) + case "config/warning": + return try .configWarning( + decodeNotification( + payload, + method: method, + resultType: CodexWireConfigWarningNotification.self + ) + ) + case "deprecation/notice": + return try .deprecationNotice( + decodeNotification( + payload, + method: method, + resultType: CodexWireDeprecationNoticeNotification.self + ) + ) + case "remoteControl/status/changed": + return try .remoteControlStatusChanged( + decodeNotification( + payload, + method: method, + resultType: CodexWireRemoteControlStatusChangedNotification.self + ) + ) + case "thread/started": + return try .threadStarted( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadStartedNotification.self + ) + ) + case "thread/status/changed": + return try .threadStatusChanged( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadStatusChangedNotification.self + ) + ) + case "thread/archived": + return try .threadArchived( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadArchivedNotification.self + ) + ) + case "thread/unarchived": + return try .threadUnarchived( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadUnarchivedNotification.self + ) + ) + case "thread/closed": + return try .threadClosed( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadClosedNotification.self + ) + ) + case "thread/name/updated": + return try .threadNameUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadNameUpdatedNotification.self + ) + ) + case "thread/tokenUsage/updated": + return try .threadTokenUsageUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadTokenUsageUpdatedNotification.self + ) + ) + case "thread/goal/updated": + return try .threadGoalUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadGoalUpdatedNotification.self + ) + ) + case "thread/goal/cleared": + return try .threadGoalCleared( + decodeNotification( + payload, + method: method, + resultType: CodexWireThreadGoalClearedNotification.self + ) + ) + case "fs/changed": + return try .fsChanged( + decodeNotification( + payload, + method: method, + resultType: CodexWireFSChangedNotification.self + ) + ) + case "hook/started": + return try .hookStarted( + decodeNotification( + payload, + method: method, + resultType: CodexWireHookStartedNotification.self + ) + ) + case "hook/completed": + return try .hookCompleted( + decodeNotification( + payload, + method: method, + resultType: CodexWireHookCompletedNotification.self + ) + ) + case "warning": + return try .warning( + decodeNotification( + payload, + method: method, + resultType: CodexWireWarningNotification.self + ) + ) + case "guardianWarning": + return try .guardianWarning( + decodeNotification( + payload, + method: method, + resultType: CodexWireGuardianWarningNotification.self + ) + ) + case "model/rerouted": + return try .modelRerouted( + decodeNotification( + payload, + method: method, + resultType: CodexWireModelReroutedNotification.self + ) + ) + case "model/verification": + return try .modelVerification( + decodeNotification( + payload, + method: method, + resultType: CodexWireModelVerificationNotification.self + ) + ) + case "turn/started": + return try .turnStarted( + decodeNotification( + payload, + method: method, + resultType: CodexWireTurnStartedNotification.self + ) + ) + case "turn/diff/updated": + return try .turnDiffUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireTurnDiffUpdatedNotification.self + ) + ) + case "turn/plan/updated": + return try .turnPlanUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireTurnPlanUpdatedNotification.self + ) + ) + case "turn/completed": + return try .turnCompleted( + decodeNotification( + payload, + method: method, + resultType: CodexWireTurnCompletedNotification.self + ) + ) + case "item/started": + return try .itemStarted( + decodeNotification( + payload, + method: method, + resultType: CodexWireItemStartedNotification.self + ) + ) + case "item/completed": + return try .itemCompleted( + decodeNotification( + payload, + method: method, + resultType: CodexWireItemCompletedNotification.self + ) + ) + case "item/autoApprovalReview/started": + return try .itemGuardianApprovalReviewStarted( + decodeNotification( + payload, + method: method, + resultType: CodexWireItemGuardianApprovalReviewStartedNotification.self + ) + ) + case "item/autoApprovalReview/completed": + return try .itemGuardianApprovalReviewCompleted( + .init( + event: decodeNotification( + payload, + method: method, + resultType: CodexWireJSONValue.self + ), + notification: decodeNotification( + payload, + method: method, + resultType: CodexWireItemGuardianApprovalReviewCompletedNotification.self + ) + ) + ) + case "item/commandExecution/outputDelta": + return try .commandExecutionOutputDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireCommandExecutionOutputDeltaNotification.self + ) + ) + case "command/exec/outputDelta": + return try .commandExecOutputDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireCommandExecOutputDeltaNotification.self + ) + ) + case "item/fileChange/outputDelta": + return try .fileChangeOutputDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireFileChangeOutputDeltaNotification.self + ) + ) + case "item/fileChange/patchUpdated": + return try .fileChangePatchUpdated( + decodeNotification( + payload, + method: method, + resultType: CodexWireFileChangePatchUpdatedNotification.self + ) + ) + case "item/agentMessage/delta": + return try .agentMessageDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireAgentMessageDeltaNotification.self + ) + ) + case "item/plan/delta": + return try .planDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWirePlanDeltaNotification.self + ) + ) + case "item/reasoning/summaryPartAdded": + return try .reasoningSummaryPartAdded( + decodeNotification( + payload, + method: method, + resultType: CodexWireReasoningSummaryPartAddedNotification.self + ) + ) + case "item/reasoning/summaryTextDelta": + return try .reasoningSummaryTextDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireReasoningSummaryTextDeltaNotification.self + ) + ) + case "item/reasoning/textDelta": + return try .reasoningTextDelta( + decodeNotification( + payload, + method: method, + resultType: CodexWireReasoningTextDeltaNotification.self + ) + ) + case "serverRequest/resolved": + return try .serverRequestResolved( + decodeNotification( + payload, + method: method, + resultType: CodexWireServerRequestResolvedNotification.self + ) + ) + default: + return nil + } } } @@ -1668,8 +1668,8 @@ extension CodexProtocolToolUserInputRequest: RequestIDBindable { } } -extension CodexProtocolMCPServerElicitationRequest { - fileprivate func settingRequestID(_ requestID: CodexRPCRequestID) -> Self { +private extension CodexProtocolMCPServerElicitationRequest { + func settingRequestID(_ requestID: CodexRPCRequestID) -> Self { .init( mode: mode, requestID: requestID, @@ -1679,7 +1679,7 @@ extension CodexProtocolMCPServerElicitationRequest { ) } - fileprivate init( + init( mode: Mode, requestID: CodexRPCRequestID, serverName: String, diff --git a/Sources/SwiftASB/Protocol/CodexProtocolError.swift b/Sources/SwiftASB/Protocol/CodexProtocolError.swift index 9c11a15..5e9a6d7 100644 --- a/Sources/SwiftASB/Protocol/CodexProtocolError.swift +++ b/Sources/SwiftASB/Protocol/CodexProtocolError.swift @@ -1,33 +1,33 @@ import Foundation -internal enum CodexProtocolError: Error, Sendable, LocalizedError, Equatable { +enum CodexProtocolError: Error, LocalizedError, Equatable { case requestEncodingFailed(method: String, reason: String) case responseDecodingFailed(context: String, reason: String) case eventDecodingFailed(method: String, reason: String) case responseIDMismatch(expected: CodexRPCRequestID, actual: CodexRPCRequestID) case rpcError(id: CodexRPCRequestID, code: Int, message: String, data: CodexWireJSONValue?) - internal var errorDescription: String? { + var errorDescription: String? { switch self { - case let .requestEncodingFailed(method, reason): - return "Failed to encode Codex app-server request for \(method): \(reason)" - case let .responseDecodingFailed(context, reason): - return "Failed to decode Codex app-server response for \(context): \(reason)" - case let .eventDecodingFailed(method, reason): - return "Failed to decode Codex app-server notification for \(method): \(reason)" - case let .responseIDMismatch(expected, actual): - return """ - Received a Codex app-server response for request ID \(actual.description), \ - but the caller was waiting for \(expected.description). - """ - case let .rpcError(id, code, message, data): - if let data { + case let .requestEncodingFailed(method, reason): + return "Failed to encode Codex app-server request for \(method): \(reason)" + case let .responseDecodingFailed(context, reason): + return "Failed to decode Codex app-server response for \(context): \(reason)" + case let .eventDecodingFailed(method, reason): + return "Failed to decode Codex app-server notification for \(method): \(reason)" + case let .responseIDMismatch(expected, actual): return """ - Codex app-server returned an RPC error for request \(id.description) \ - (code \(code)): \(message). Error data: \(data) + Received a Codex app-server response for request ID \(actual.description), \ + but the caller was waiting for \(expected.description). """ - } - return "Codex app-server returned an RPC error for request \(id.description) (code \(code)): \(message)" + case let .rpcError(id, code, message, data): + if let data { + return """ + Codex app-server returned an RPC error for request \(id.description) \ + (code \(code)): \(message). Error data: \(data) + """ + } + return "Codex app-server returned an RPC error for request \(id.description) (code \(code)): \(message)" } } } diff --git a/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift b/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift index 9dc8862..94d2f04 100644 --- a/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift +++ b/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift @@ -1,44 +1,44 @@ import Foundation -internal enum CodexRPCInboundMessage: Equatable, Sendable { +enum CodexRPCInboundMessage: Equatable { case response(id: CodexRPCRequestID, payload: Data) case serverEvent(CodexRPCServerEvent) } -internal enum CodexRPCServerEvent: Equatable, Sendable { +enum CodexRPCServerEvent: Equatable { case notification(method: String, payload: Data) case request(id: CodexRPCRequestID, method: String, payload: Data) } -internal enum CodexRPCEnvelope { - internal static func classifyInboundMessage(_ data: Data) throws -> CodexRPCInboundMessage { +enum CodexRPCEnvelope { + static func classifyInboundMessage(_ data: Data) throws -> CodexRPCInboundMessage { let object = try parseJSONObject(from: data) let method = object["method"] as? String let requestID = try object["id"].map(parseRequestID) switch (method, requestID) { - case let (.some(method), .some(id)): - return .serverEvent( - .request( - id: id, - method: method, - payload: try extractParamsPayload(from: object) + case let (.some(method), .some(id)): + return try .serverEvent( + .request( + id: id, + method: method, + payload: extractParamsPayload(from: object) + ) ) - ) - case let (.some(method), .none): - return .serverEvent( - .notification( - method: method, - payload: try extractParamsPayload(from: object) + case let (.some(method), .none): + return try .serverEvent( + .notification( + method: method, + payload: extractParamsPayload(from: object) + ) + ) + case let (.none, .some(id)): + return .response(id: id, payload: data) + case (.none, .none): + throw CodexTransportError.invalidJSONRPCEnvelope( + reason: "Expected the inbound message to contain either a method, an ID, or both." ) - ) - case let (.none, .some(id)): - return .response(id: id, payload: data) - case (.none, .none): - throw CodexTransportError.invalidJSONRPCEnvelope( - reason: "Expected the inbound message to contain either a method, an ID, or both." - ) } } @@ -49,6 +49,7 @@ internal enum CodexRPCEnvelope { reason: "Expected the inbound JSON-RPC payload to be a top-level object." ) } + return object } @@ -78,12 +79,12 @@ internal enum CodexRPCEnvelope { reason: "JSON-RPC numeric request ID \(number) is outside the supported Swift Int range." ) } - guard let integerValue = Int(exactly: NSDecimalNumber(decimal: decimalValue)) else { throw CodexTransportError.invalidJSONRPCEnvelope( reason: "JSON-RPC numeric request ID \(number) could not be represented exactly as a Swift Int." ) } + return .int(integerValue) } diff --git a/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift b/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift index 0dc38be..9a178bd 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift @@ -1,8 +1,8 @@ import Foundation -extension CodexAppServer { +public extension CodexAppServer { /// Compatibility policy applied by the ergonomic startup call. - public enum StartupCompatibilityPolicy: Sendable, Equatable { + enum StartupCompatibilityPolicy: Sendable, Equatable { /// Require the selected Codex CLI version to be inside SwiftASB's /// documented reviewed support window before initializing. case requireReviewedSupportWindow @@ -13,7 +13,7 @@ extension CodexAppServer { } /// One-call startup request for launching and initializing the app-server. - public struct StartupRequest: Sendable, Equatable { + struct StartupRequest: Sendable, Equatable { public var compatibilityPolicy: StartupCompatibilityPolicy public var initializeRequest: InitializeRequest @@ -50,13 +50,13 @@ extension CodexAppServer { } /// Successful one-call startup result. - public struct StartupSession: Sendable, Equatable { + struct StartupSession: Sendable, Equatable { public let cliExecutableDiagnostics: CLIExecutableDiagnostics public let initializeSession: InitializeSession } /// Diagnostics for the local Codex executable selected at startup. - public struct CLIExecutableDiagnostics: Sendable, Equatable { + struct CLIExecutableDiagnostics: Sendable, Equatable { /// Local install location SwiftASB used to find the Codex executable. public enum Source: Sendable, Equatable { case explicit @@ -80,7 +80,7 @@ extension CodexAppServer { } /// Runtime configuration used when SwiftASB launches the local Codex app-server. - public struct Configuration: Sendable, Equatable { + struct Configuration: Sendable, Equatable { public var codexExecutableURL: URL? public var arguments: [String] public var currentDirectoryURL: URL? @@ -111,7 +111,7 @@ extension CodexAppServer { } /// Client handshake payload sent to Codex after the app-server process starts. - public struct InitializeRequest: Sendable, Equatable { + struct InitializeRequest: Sendable, Equatable { public var capabilities: InitializeCapabilities public var clientInfo: ClientInfo @@ -129,7 +129,7 @@ extension CodexAppServer { } /// Optional client capabilities advertised during initialization. - public struct InitializeCapabilities: Sendable, Equatable { + struct InitializeCapabilities: Sendable, Equatable { public var experimentalAPI: Bool? public var optOutNotificationMethods: [String]? @@ -147,7 +147,7 @@ extension CodexAppServer { } /// Identifies the SwiftASB consumer during initialization. - public struct ClientInfo: Sendable, Equatable { + struct ClientInfo: Sendable, Equatable { public var name: String public var title: String? public var version: String @@ -167,11 +167,10 @@ extension CodexAppServer { } /// Session metadata returned by the app-server after initialization. - public struct InitializeSession: Sendable, Equatable { + struct InitializeSession: Sendable, Equatable { public let codexHome: String public let platformFamily: String public let platformOS: String public let userAgent: String } - } diff --git a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift index e60aff6..9c260e2 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift @@ -2,12 +2,6 @@ import Foundation /// App-server-owned extension, MCP, app, skill, plugin, and collaboration-mode surface. public struct CodexExtensions: Sendable { - let appServer: CodexAppServer - - init(appServer: CodexAppServer) { - self.appServer = appServer - } - /// Install intent for one Codex extension family. public enum InstallRequest: Sendable, Equatable { case mcp(MCP.ServerDefinition) @@ -18,15 +12,6 @@ public struct CodexExtensions: Sendable { case mcp(MCP.InstallResult) } - /// Installs one extension-family item through SwiftASB's preferred unified install surface. - @discardableResult - public func install(_ request: InstallRequest) async throws -> InstallResult { - switch request { - case let .mcp(definition): - return .mcp(try await mcp.install(definition)) - } - } - /// App and connector inventory family. public struct Apps: Sendable { private let appServer: CodexAppServer @@ -89,31 +74,6 @@ public struct CodexExtensions: Sendable { } } - /// MCP server configuration, status, and resource family. - public var mcp: MCP { - MCP(appServer: appServer) - } - - /// App and connector inventory family. - public var apps: Apps { - Apps(appServer: appServer) - } - - /// Skill inventory family. - public var skills: Skills { - Skills(appServer: appServer) - } - - /// Plugin and marketplace inventory and maintenance family. - public var plugins: Plugins { - Plugins(appServer: appServer) - } - - /// Collaboration-mode inventory family. - public var collaborationModes: CollaborationModes { - CollaborationModes(appServer: appServer) - } - /// Request used to list available apps and connectors. public struct AppListRequest: Sendable, Equatable { public var cursor: String? @@ -179,30 +139,15 @@ public struct CodexExtensions: Sendable { /// Request used to list skills visible from one or more working directories. public struct SkillListRequest: Sendable, Equatable { - public struct ExtraUserRootsForCurrentDirectory: Sendable, Equatable { - public var currentDirectoryPath: String - public var extraUserRoots: [String] - - public init(currentDirectoryPath: String, extraUserRoots: [String]) { - self.currentDirectoryPath = currentDirectoryPath - self.extraUserRoots = extraUserRoots - } - } - public var currentDirectoryPaths: [String]? public var forceReload: Bool? - /// Deprecated by Codex CLI 0.130.0. The app-server no longer accepts - /// per-cwd extra skill roots on `skills/list`. - public var perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? public init( currentDirectoryPaths: [String]? = nil, - forceReload: Bool? = nil, - perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? = nil + forceReload: Bool? = nil ) { self.currentDirectoryPaths = currentDirectoryPaths self.forceReload = forceReload - self.perCurrentDirectoryExtraUserRoots = perCurrentDirectoryExtraUserRoots } } @@ -338,7 +283,7 @@ public struct CodexExtensions: Sendable { ) { self.marketplaceName = marketplaceName self.currentDirectoryPaths = currentDirectoryPaths - self.timeoutMilliseconds = max(1_000, timeoutMilliseconds) + self.timeoutMilliseconds = max(1000, timeoutMilliseconds) } } @@ -371,11 +316,11 @@ public struct CodexExtensions: Sendable { } public struct AppSummary: Sendable, Equatable, Identifiable { + public let category: String? public let description: String? public let id: String public let installURL: String? public let name: String - public let needsAuth: Bool } public struct SkillSummary: Sendable, Equatable, Identifiable { @@ -407,6 +352,42 @@ public struct CodexExtensions: Sendable { public let reasoningEffort: CodexAppServer.ReasoningEffort? } + let appServer: CodexAppServer + + /// MCP server configuration, status, and resource family. + public var mcp: MCP { + MCP(appServer: appServer) + } + + /// App and connector inventory family. + public var apps: Apps { + Apps(appServer: appServer) + } + + /// Skill inventory family. + public var skills: Skills { + Skills(appServer: appServer) + } + + /// Plugin and marketplace inventory and maintenance family. + public var plugins: Plugins { + Plugins(appServer: appServer) + } + + /// Collaboration-mode inventory family. + public var collaborationModes: CollaborationModes { + CollaborationModes(appServer: appServer) + } + + /// Installs one extension-family item through SwiftASB's preferred unified install surface. + @discardableResult + public func install(_ request: InstallRequest) async throws -> InstallResult { + switch request { + case let .mcp(definition): + return try .mcp(await mcp.install(definition)) + } + } + @available(*, deprecated, message: "Use appServer.extensions.apps.list(...) instead.") public func listApps(_ request: AppListRequest = .init()) async throws -> AppListPage { try await apps.list(request) @@ -494,7 +475,7 @@ extension CodexAppServer { let result = try await executeCommand( .init( command: command, - outputBytesCap: 32_768, + outputBytesCap: 32768, timeoutMilliseconds: request.timeoutMilliseconds ) ) @@ -520,7 +501,7 @@ extension CodexAppServer { completedAt: completedAt, affectedPaths: affectedPaths, commands: [ - .init(argv: command) + .init(argv: command), ], appServerMethod: "command/exec", intentKind: "extensionMarketplaceUpgrade", @@ -648,14 +629,14 @@ extension CodexExtensions.SkillMetadata { extension CodexExtensions.SkillMetadata.Scope { init(wireValue: CodexWireSkillScope) { switch wireValue { - case .admin: - self = .admin - case .repo: - self = .repo - case .system: - self = .system - case .user: - self = .user + case .admin: + self = .admin + case .repo: + self = .repo + case .system: + self = .system + case .user: + self = .user } } } @@ -711,10 +692,10 @@ extension CodexExtensions.PluginSummary { extension CodexExtensions.PluginSummary.AuthPolicy { init(wireValue: CodexWirePluginAuthPolicy) { switch wireValue { - case .onInstall: - self = .onInstall - case .onUse: - self = .onUse + case .onInstall: + self = .onInstall + case .onUse: + self = .onUse } } } @@ -722,12 +703,12 @@ extension CodexExtensions.PluginSummary.AuthPolicy { extension CodexExtensions.PluginSummary.InstallPolicy { init(wireValue: CodexWirePluginInstallPolicy) { switch wireValue { - case .available: - self = .available - case .installedByDefault: - self = .installedByDefault - case .notAvailable: - self = .notAvailable + case .available: + self = .available + case .installedByDefault: + self = .installedByDefault + case .notAvailable: + self = .notAvailable } } } @@ -735,12 +716,12 @@ extension CodexExtensions.PluginSummary.InstallPolicy { extension CodexExtensions.PluginSummary.SourceKind { init(wireValue: CodexWirePluginSourceType) { switch wireValue { - case .git: - self = .git - case .local: - self = .local - case .remote: - self = .remote + case .git: + self = .git + case .local: + self = .local + case .remote: + self = .remote } } } @@ -787,26 +768,26 @@ extension CodexExtensions.PluginHookSummary { extension CodexAppServer.HookMetadata.EventName { init(wireValue: CodexWireHookEventName) { switch wireValue { - case .permissionRequest: - self = .permissionRequest - case .postCompact: - self = .postCompact - case .postToolUse: - self = .postToolUse - case .preCompact: - self = .preCompact - case .preToolUse: - self = .preToolUse - case .sessionStart: - self = .sessionStart - case .stop: - self = .stop - case .subagentStart: - self = .subagentStart - case .subagentStop: - self = .subagentStop - case .userPromptSubmit: - self = .userPromptSubmit + case .permissionRequest: + self = .permissionRequest + case .postCompact: + self = .postCompact + case .postToolUse: + self = .postToolUse + case .preCompact: + self = .preCompact + case .preToolUse: + self = .preToolUse + case .sessionStart: + self = .sessionStart + case .stop: + self = .stop + case .subagentStart: + self = .subagentStart + case .subagentStop: + self = .subagentStop + case .userPromptSubmit: + self = .userPromptSubmit } } } @@ -814,11 +795,11 @@ extension CodexAppServer.HookMetadata.EventName { extension CodexExtensions.AppSummary { init(wireValue: CodexWireAppSummary) { self.init( + category: wireValue.category, description: wireValue.description, id: wireValue.id, installURL: wireValue.installURL, - name: wireValue.name, - needsAuth: wireValue.needsAuth + name: wireValue.name ) } } @@ -858,10 +839,10 @@ extension CodexExtensions.CollaborationMode { extension CodexExtensions.CollaborationMode.Kind { init(wireValue: CodexWireModeKind) { switch wireValue { - case .modeKindDefault: - self = .defaultMode - case .plan: - self = .plan + case .modeKindDefault: + self = .defaultMode + case .plan: + self = .plan } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift b/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift index 4e71326..bd14dfb 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift @@ -1,7 +1,7 @@ import Foundation extension CodexAppServer { - struct CommandExecRequest: Sendable, Equatable { + struct CommandExecRequest: Equatable { var command: [String] var currentDirectoryPath: String? var environment: [String: String?] @@ -23,10 +23,9 @@ extension CodexAppServer { } } - struct CommandExecResult: Sendable, Equatable { + struct CommandExecResult: Equatable { var exitCode: Int var stdout: String var stderr: String } - } diff --git a/Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift b/Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift index d209863..bcc433c 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift @@ -1,8 +1,8 @@ import Foundation -extension CodexAppServer { +public extension CodexAppServer { /// Open JSON payload used only for genuinely dynamic app-server fields. - public enum JSONValue: Sendable, Equatable { + enum JSONValue: Sendable, Equatable { case null case bool(Bool) case integer(Int) @@ -13,7 +13,7 @@ extension CodexAppServer { } /// Codex approval policy option for thread and turn requests. - public enum ApprovalPolicy: Sendable, Equatable { + enum ApprovalPolicy: Sendable, Equatable { case never case onFailure case onRequest @@ -22,7 +22,7 @@ extension CodexAppServer { } /// Fine-grained approval settings used with `.granular`. - public struct GranularApprovalPolicy: Sendable, Equatable { + struct GranularApprovalPolicy: Sendable, Equatable { public var mcpElicitations: Bool public var requestPermissions: Bool? public var rules: Bool @@ -46,32 +46,32 @@ extension CodexAppServer { } /// Actor or reviewer that should evaluate approval requests. - public enum ApprovalsReviewer: String, Sendable, Equatable { + enum ApprovalsReviewer: String, Sendable, Equatable { case autoReview, guardianSubagent, user } /// Personality option passed to Codex for thread or turn behavior. - public enum Personality: String, Sendable, Equatable { + enum Personality: String, Sendable, Equatable { case friendly, none, pragmatic } /// Sandbox mode option passed to Codex when starting or resuming work. - public enum SandboxMode: String, Sendable, Equatable { + enum SandboxMode: String, Sendable, Equatable { case dangerFullAccess, readOnly, workspaceWrite } /// Service tier option passed to Codex requests. - public enum ServiceTier: String, Sendable, Equatable { + enum ServiceTier: String, Sendable, Equatable { case fast, flex } /// Source marker for a started session. - public enum SessionStartSource: String, Sendable, Equatable { + enum SessionStartSource: String, Sendable, Equatable { case clear, startup } /// Reasoning effort option passed to Codex requests. - public enum ReasoningEffort: Sendable, Equatable { + enum ReasoningEffort: Sendable, Equatable { case high case low case medium @@ -82,7 +82,7 @@ extension CodexAppServer { } /// Effective sandbox policy reported by the app-server. - public struct SandboxPolicy: Sendable, Equatable { + struct SandboxPolicy: Sendable, Equatable { public let type: SandboxPolicyType public let networkAccess: NetworkAccess? public let excludeSlashTmp: Bool? @@ -91,33 +91,32 @@ extension CodexAppServer { } /// Effective network access state reported by the app-server. - public enum NetworkAccess: Sendable, Equatable { + enum NetworkAccess: Sendable, Equatable { case explicit(Bool), enabled, restricted } /// Effective sandbox policy family reported by the app-server. - public enum SandboxPolicyType: String, Sendable, Equatable { + enum SandboxPolicyType: String, Sendable, Equatable { case dangerFullAccess, externalSandbox, readOnly, workspaceWrite } /// Reasoning summary option passed to Codex requests. - public enum ReasoningSummary: String, Sendable, Equatable { + enum ReasoningSummary: String, Sendable, Equatable { case auto, concise, detailed, none } /// Thread status family reported by the app-server. - public enum ThreadStatusType: String, Sendable, Equatable { + enum ThreadStatusType: String, Sendable, Equatable { case active, idle, notLoaded, systemError } /// Active work flags reported for a thread. - public enum ThreadActiveFlag: String, Sendable, Equatable { + enum ThreadActiveFlag: String, Sendable, Equatable { case waitingOnApproval, waitingOnUserInput } /// Turn status reported by the app-server. - public enum TurnStatus: String, Sendable, Equatable { + enum TurnStatus: String, Sendable, Equatable { case completed, failed, inProgress, interrupted } - } diff --git a/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift b/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift index 18d1134..b06f483 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift @@ -1,7 +1,7 @@ import Foundation extension CodexAppServer { - internal func refreshGitStatus( + func refreshGitStatus( for worktree: CodexWorkspace.WorktreeSnapshot ) async throws -> CodexWorkspace.GitStatusSnapshot? { let cwd = worktree.currentDirectoryPath.trimmingCharacters(in: .whitespacesAndNewlines) @@ -48,13 +48,14 @@ extension CodexAppServer { let result = try await executeCommand( .init( command: ["git", "-C", cwd] + arguments, - outputBytesCap: 65_536, - timeoutMilliseconds: 5_000 + outputBytesCap: 65536, + timeoutMilliseconds: 5000 ) ) guard result.exitCode == 0 else { return nil } + return CodexWorkspace.RepositoryInfo.normalizedFact(result.stdout) } @@ -63,12 +64,12 @@ extension CodexAppServer { hasCommandExecFacts: Bool ) -> CodexWorkspace.GitFactSource { switch (hasAppServerFacts, hasCommandExecFacts) { - case (true, true): - return .appServerAndCommandExec - case (true, false): - return .appServer - case (false, _): - return .commandExec + case (true, true): + return .appServerAndCommandExec + case (true, false): + return .appServer + case (false, _): + return .commandExec } } @@ -85,6 +86,7 @@ extension CodexAppServer { let purpose = columns.dropFirst(2).first.map(String.init).map(Self.remotePurpose) ?? .unknown let key = "\(name)||\(url)||\(purpose.rawValue)" guard seen.insert(key).inserted else { continue } + remotes.append(.init(name: name, url: url, purpose: purpose)) } @@ -93,12 +95,12 @@ extension CodexAppServer { private static func remotePurpose(_ marker: String) -> CodexWorkspace.GitRemoteInfo.Purpose { switch marker { - case "(fetch)": - return .fetch - case "(push)": - return .push - default: - return .unknown + case "(fetch)": + return .fetch + case "(push)": + return .push + default: + return .unknown } } @@ -143,6 +145,7 @@ extension CodexAppServer { private static func trackingCount(named name: String, in text: String) -> Int? { guard let range = text.range(of: "\(name) ") else { return nil } + let suffix = text[range.upperBound...] let digits = suffix.prefix { $0.isNumber } return digits.isEmpty ? nil : Int(digits) @@ -152,6 +155,7 @@ extension CodexAppServer { guard let range = text.range(of: "...") else { return (text, nil) } + return ( String(text[..( _ operation: @Sendable () async throws -> Value ) async -> Result { do { - return .success(try await operation()) + return try .success(await operation()) } catch { return .failure(error) } } extension CodexAppServer { - internal struct AppInventoryReadRequest: Sendable, Equatable { + struct AppInventoryReadRequest: Equatable { var appListLimit: Int? var extensionCurrentDirectoryPaths: [String]? var hookListCurrentDirectoryPaths: [String]? @@ -31,7 +31,7 @@ extension CodexAppServer { } } - internal struct AppInventorySnapshot: Sendable, Equatable { + struct AppInventorySnapshot: Equatable { var appListPage: SwiftASB.CodexExtensions.AppListPage? var collaborationModes: SwiftASB.CodexExtensions.CollaborationModeList? var errorDescriptions: [String] = [] @@ -46,7 +46,7 @@ extension CodexAppServer { } } - internal func readAppInventorySnapshot( + func readAppInventorySnapshot( _ request: AppInventoryReadRequest ) async -> AppInventorySnapshot { async let capabilitiesResult = snapshotResult { @@ -115,10 +115,10 @@ private extension CodexAppServer.AppInventorySnapshot { to keyPath: WritableKeyPath ) { switch result { - case let .success(value): - self[keyPath: keyPath] = value - case let .failure(error): - errorDescriptions.append(error.localizedDescription) + case let .success(value): + self[keyPath: keyPath] = value + case let .failure(error): + errorDescriptions.append(error.localizedDescription) } } @@ -127,10 +127,10 @@ private extension CodexAppServer.AppInventorySnapshot { to keyPath: WritableKeyPath ) { switch result { - case let .success(value): - self[keyPath: keyPath] = value - case let .failure(error): - errorDescriptions.append(error.localizedDescription) + case let .success(value): + self[keyPath: keyPath] = value + case let .failure(error): + errorDescriptions.append(error.localizedDescription) } } } @@ -175,6 +175,21 @@ public extension CodexExtensions { public private(set) var pluginListSnapshot: SwiftASB.CodexExtensions.PluginListSnapshot? public private(set) var skillListSnapshot: SwiftASB.CodexExtensions.SkillListSnapshot? + @ObservationIgnored + private let appServer: CodexAppServer + + @ObservationIgnored + private let configuration: Configuration + + @ObservationIgnored + private var eventTask: Task? + + @ObservationIgnored + private var refreshTask: Task? + + @ObservationIgnored + private var pendingRefresh = false + public var apps: [SwiftASB.CodexExtensions.AppInfo] { appListPage?.apps ?? [] } @@ -195,38 +210,23 @@ public extension CodexExtensions { collaborationModes?.modes ?? [] } - @ObservationIgnored - private let appServer: CodexAppServer - - @ObservationIgnored - private let configuration: Configuration - - @ObservationIgnored - private var eventTask: Task? - - @ObservationIgnored - private var refreshTask: Task? - - @ObservationIgnored - private var pendingRefresh = false - - internal init( + init( appServer: CodexAppServer, configuration: Configuration ) { self.appServer = appServer self.configuration = configuration - self.appListPage = nil - self.collaborationModes = nil - self.hookListSnapshot = nil - self.lastRefreshedAt = nil - self.latestErrorDescription = nil - self.mcpServerNextCursor = nil - self.mcpServers = [] - self.modelCapabilities = nil - self.phase = .idle - self.pluginListSnapshot = nil - self.skillListSnapshot = nil + appListPage = nil + collaborationModes = nil + hookListSnapshot = nil + lastRefreshedAt = nil + latestErrorDescription = nil + mcpServerNextCursor = nil + mcpServers = [] + modelCapabilities = nil + phase = .idle + pluginListSnapshot = nil + skillListSnapshot = nil if configuration.loadsOnCreation { refreshTask = Task { [weak self] in await self?.refresh() } @@ -302,6 +302,7 @@ public extension CodexExtensions { private func startEventTask() { eventTask = Task { [weak self] in guard let self else { return } + let events = await appServer.libraryEvents() for await event in events { if Task.isCancelled { diff --git a/Sources/SwiftASB/Public/CodexAppServer+Library.swift b/Sources/SwiftASB/Public/CodexAppServer+Library.swift index 79660b3..372eab9 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Library.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Library.swift @@ -2,7 +2,7 @@ import Foundation import Observation extension CodexAppServer { - internal enum LibraryEvent: Sendable, Equatable { + enum LibraryEvent: Equatable { case appSnapshotsChanged case threadChanged(threadID: String) case turnCompleted(threadID: String) @@ -25,6 +25,12 @@ public extension CodexAppServer { public var searchTerm: String? public var sortedBy: Library.SortedBy + var canMarkMissingThreadsRemoved: Bool { + currentDirectoryPath == nil + && modelProviders?.isEmpty != false + && searchTerm == nil + } + /// Creates a thread-list query descriptor. /// /// `limit` is normalized to at least `1`. Nil filters are left @@ -104,6 +110,13 @@ public extension CodexAppServer { ) } + private static func normalizedSearchTerm(_ searchTerm: String?) -> String? { + guard let searchTerm else { return nil } + + let trimmed = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + /// Returns the same query with a normalized page or local result limit. public func limited(to limit: Int) -> Self { .init( @@ -194,7 +207,7 @@ public extension CodexAppServer { ) } - internal func threadListRequest( + func threadListRequest( archived archiveFilter: Bool, cursor: String? = nil ) -> CodexAppServer.ThreadListRequest { @@ -209,18 +222,6 @@ public extension CodexAppServer { searchTerm: searchTerm ) } - - internal var canMarkMissingThreadsRemoved: Bool { - currentDirectoryPath == nil - && modelProviders?.isEmpty != false - && searchTerm == nil - } - - private static func normalizedSearchTerm(_ searchTerm: String?) -> String? { - guard let searchTerm else { return nil } - let trimmed = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } } } @@ -299,24 +300,24 @@ public extension CodexAppServer { case nameAscending case nameDescending - internal var appServerSort: ( + var appServerSort: ( key: CodexAppServer.ThreadListSortKey, direction: CodexAppServer.ThreadListSortDirection ) { switch self { - case .createdNewestFirst: - (.createdAt, .desc) - case .createdOldestFirst: - (.createdAt, .asc) - case .updatedNewestFirst, - .selectedNewestFirst, - .turnFinishedNewestFirst, - .turnFinishedOldestFirst, - .nameAscending, - .nameDescending: - (.updatedAt, .desc) - case .updatedOldestFirst: - (.updatedAt, .asc) + case .createdNewestFirst: + (.createdAt, .desc) + case .createdOldestFirst: + (.createdAt, .asc) + case .updatedNewestFirst, + .selectedNewestFirst, + .turnFinishedNewestFirst, + .turnFinishedOldestFirst, + .nameAscending, + .nameDescending: + (.updatedAt, .desc) + case .updatedOldestFirst: + (.updatedAt, .asc) } } } @@ -381,6 +382,7 @@ public extension CodexAppServer { public var selectedThreadID: String? { didSet { guard selectedThreadID != oldValue else { return } + if let selectedThreadID { recordSelection(threadID: selectedThreadID) } @@ -388,51 +390,25 @@ public extension CodexAppServer { scheduleSelectedGitStatusRefresh() } } + public var groupedBy: GroupedBy { didSet { applyVisibleState() } } + public var sortedBy: SortedBy { didSet { query.sortedBy = sortedBy applyVisibleState() } } + public private(set) var unarchivedThreads: [ThreadSnapshot] public private(set) var worktreeGroups: [ThreadGroup] public private(set) var snapshotCurrentDirectoryPaths: [String]? public private(set) var snapshotPhase: SnapshotPhase - public var isLoadingLocalSnapshot: Bool { - phase == .loadingLocalSnapshot - } - - public var isReconciling: Bool { - phase == .reconcilingUnarchived || phase == .reconcilingArchived - } - - public var isLoadingAppSnapshots: Bool { - snapshotPhase == .loading - } - - public var selectedThread: ThreadSnapshot? { - guard let selectedThreadID else { return nil } - return allThreads.first { $0.id == selectedThreadID } - } - - public var selectedWorktree: CodexWorkspace.WorktreeSnapshot? { - selectedThread?.worktree - } - - public var selectedRepository: CodexWorkspace.RepositoryInfo? { - selectedThread?.worktree.repository - } - - public var selectedGitStatus: CodexWorkspace.GitStatusSnapshot? { - selectedWorktree.flatMap { gitStatusByWorktreeID[$0.id] } - } - @ObservationIgnored private let appServer: CodexAppServer @@ -475,39 +451,69 @@ public extension CodexAppServer { @ObservationIgnored private var gitStatusTask: Task? - internal init( + public var isLoadingLocalSnapshot: Bool { + phase == .loadingLocalSnapshot + } + + public var isReconciling: Bool { + phase == .reconcilingUnarchived || phase == .reconcilingArchived + } + + public var isLoadingAppSnapshots: Bool { + snapshotPhase == .loading + } + + public var selectedThread: ThreadSnapshot? { + guard let selectedThreadID else { return nil } + + return allThreads.first { $0.id == selectedThreadID } + } + + public var selectedWorktree: CodexWorkspace.WorktreeSnapshot? { + selectedThread?.worktree + } + + public var selectedRepository: CodexWorkspace.RepositoryInfo? { + selectedThread?.worktree.repository + } + + public var selectedGitStatus: CodexWorkspace.GitStatusSnapshot? { + selectedWorktree.flatMap { gitStatusByWorktreeID[$0.id] } + } + + init( appServer: CodexAppServer, configuration: Configuration, initialThreads: [ThreadSnapshot] ) { self.appServer = appServer - self.allThreads = initialThreads - self.archivedThreads = [] - self.configuredHookListCurrentDirectoryPaths = configuration.hookListCurrentDirectoryPaths - self.featurePolicy = configuration.featurePolicy - self.gitStatusByWorktreeID = [:] - self.groups = [] - self.groupedBy = configuration.groupedBy - self.hookListSnapshot = nil - self.lastReconciledAt = nil - self.lastGitStatusReadAt = nil - self.lastSnapshotsReadAt = nil - self.latestGitStatusErrorDescription = nil - self.latestSnapshotErrorDescription = nil - self.latestErrorDescription = nil - self.maxPagesPerArchiveState = configuration.maxPagesPerArchiveState - self.mcpServers = [] - self.mcpServerNextCursor = nil - self.modelCapabilities = nil - self.phase = .idle - self.query = configuration.query - self.removedThreads = [] - self.selectedThreadID = nil - self.snapshotCurrentDirectoryPaths = nil - self.snapshotPhase = .idle - self.sortedBy = configuration.sortedBy - self.unarchivedThreads = [] - self.worktreeGroups = [] + allThreads = initialThreads + archivedThreads = [] + configuredHookListCurrentDirectoryPaths = configuration.hookListCurrentDirectoryPaths + featurePolicy = configuration.featurePolicy + gitStatusByWorktreeID = [:] + groups = [] + groupedBy = configuration.groupedBy + hookListSnapshot = nil + lastReconciledAt = nil + lastGitStatusReadAt = nil + lastSnapshotsReadAt = nil + latestGitStatusErrorDescription = nil + latestSnapshotErrorDescription = nil + latestErrorDescription = nil + maxPagesPerArchiveState = configuration.maxPagesPerArchiveState + mcpServers = [] + mcpServerNextCursor = nil + modelCapabilities = nil + phase = .idle + query = configuration.query + removedThreads = [] + selectedThreadID = nil + snapshotCurrentDirectoryPaths = nil + snapshotPhase = .idle + sortedBy = configuration.sortedBy + unarchivedThreads = [] + worktreeGroups = [] applyVisibleState() if configuration.reconcilesOnCreation { @@ -526,6 +532,183 @@ public extension CodexAppServer { gitStatusTask?.cancel() } + private static func sort( + _ threads: [ThreadSnapshot], + by sortedBy: SortedBy, + selectionOrderByThreadID: [String: Int] + ) -> [ThreadSnapshot] { + threads.sorted { lhs, rhs in + switch sortedBy { + case .updatedNewestFirst: + newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) + case .updatedOldestFirst: + oldest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) + case .createdNewestFirst: + newest(lhs.createdAt, rhs.createdAt, lhs.id, rhs.id) + case .createdOldestFirst: + oldest(lhs.createdAt, rhs.createdAt, lhs.id, rhs.id) + case .selectedNewestFirst: + compareSelection( + lhs, + rhs, + selectionOrderByThreadID: selectionOrderByThreadID + ) + case .turnFinishedNewestFirst: + newest( + lhs.lastCompletedTurnAt ?? Int.min, + rhs.lastCompletedTurnAt ?? Int.min, + lhs.id, + rhs.id + ) + case .turnFinishedOldestFirst: + oldest( + lhs.lastCompletedTurnAt ?? Int.max, + rhs.lastCompletedTurnAt ?? Int.max, + lhs.id, + rhs.id + ) + case .nameAscending: + compareNames(lhs, rhs, ascending: true) + case .nameDescending: + compareNames(lhs, rhs, ascending: false) + } + } + } + + private static func groups( + from threads: [ThreadSnapshot], + groupedBy: GroupedBy + ) -> [ThreadGroup] { + guard groupedBy != .none else { + return [] + } + + let grouped = Dictionary(grouping: threads) { thread in + switch groupedBy { + case .none: + "" + case .cwd: + thread.currentDirectoryPath + case .repository: + thread.projectInfo.id + } + } + + return grouped + .map { key, threads in + let projectInfo = projectInfo( + forGroupID: key, + threads: threads, + groupedBy: groupedBy + ) + return ThreadGroup( + id: key, + projectInfo: projectInfo, + title: projectInfo?.displayName ?? "Unknown Project", + threads: threads + ) + } + .sorted { lhs, rhs in + lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + } + + private static func projectInfo( + forGroupID id: String, + threads: [ThreadSnapshot], + groupedBy: GroupedBy + ) -> CodexWorkspace.ProjectInfo? { + guard groupedBy != .none else { + return nil + } + guard groupedBy == .repository else { + return .init(currentDirectoryPath: id) + } + guard let representative = threads.first else { + return .init(currentDirectoryPath: id) + } + guard representative.projectInfo.identitySource == .gitOrigin else { + return representative.projectInfo + } + + let repositories = threads.map(\.projectInfo.repository) + let branch = commonValue(repositories.map { $0?.branch }) + let sha = commonValue(repositories.map { $0?.sha }) + return .init( + currentDirectoryPath: representative.currentDirectoryPath, + repository: .init( + originURL: id, + branch: branch, + sha: sha + ) + ) + } + + private static func newest( + _ lhsValue: Int, + _ rhsValue: Int, + _ lhsID: String, + _ rhsID: String + ) -> Bool { + if lhsValue == rhsValue { + lhsID < rhsID + } else { + lhsValue > rhsValue + } + } + + private static func oldest( + _ lhsValue: Int, + _ rhsValue: Int, + _ lhsID: String, + _ rhsID: String + ) -> Bool { + if lhsValue == rhsValue { + lhsID < rhsID + } else { + lhsValue < rhsValue + } + } + + private static func compareNames( + _ lhs: ThreadSnapshot, + _ rhs: ThreadSnapshot, + ascending: Bool + ) -> Bool { + let lhsName = lhs.name ?? lhs.preview + let rhsName = rhs.name ?? rhs.preview + let comparison = lhsName.localizedStandardCompare(rhsName) + if comparison == .orderedSame { + return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) + } + return ascending + ? comparison == .orderedAscending + : comparison == .orderedDescending + } + + private static func compareSelection( + _ lhs: ThreadSnapshot, + _ rhs: ThreadSnapshot, + selectionOrderByThreadID: [String: Int] + ) -> Bool { + let lhsOrder = selectionOrderByThreadID[lhs.id] + let rhsOrder = selectionOrderByThreadID[rhs.id] + + switch (lhsOrder, rhsOrder) { + case let (lhsOrder?, rhsOrder?): + if lhsOrder == rhsOrder { + return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) + } + return lhsOrder > rhsOrder + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) + } + } + public func refresh() async { await refreshAll() } @@ -585,42 +768,6 @@ public extension CodexAppServer { } while pendingAppSnapshotRefresh } - private func loadAppSnapshotsOnce() async { - snapshotPhase = .loading - latestSnapshotErrorDescription = nil - - let hookCurrentDirectoryPaths = resolvedHookListCurrentDirectoryPaths() - snapshotCurrentDirectoryPaths = hookCurrentDirectoryPaths - - let snapshot = await appServer.readAppInventorySnapshot( - .init( - hookListCurrentDirectoryPaths: hookCurrentDirectoryPaths, - includesExtensions: false - ) - ) - - if let capabilities = snapshot.modelCapabilities { - modelCapabilities = capabilities - } - - if let page = snapshot.mcpServerStatusPage { - mcpServers = page.servers.map { status in - .init(status: status, scope: .global) - } - mcpServerNextCursor = page.nextCursor - } - - if let hookListSnapshot = snapshot.hookListSnapshot { - self.hookListSnapshot = hookListSnapshot - } - - lastSnapshotsReadAt = snapshot.succeededCompletely ? Date() : lastSnapshotsReadAt - latestSnapshotErrorDescription = snapshot.succeededCompletely - ? nil - : snapshot.errorDescriptions.joined(separator: "\n") - snapshotPhase = .idle - } - public func selectThread(_ threadID: String?) { selectedThreadID = threadID } @@ -683,6 +830,42 @@ public extension CodexAppServer { .filter { $0.worktree.repository?.originURL == originURL } } + private func loadAppSnapshotsOnce() async { + snapshotPhase = .loading + latestSnapshotErrorDescription = nil + + let hookCurrentDirectoryPaths = resolvedHookListCurrentDirectoryPaths() + snapshotCurrentDirectoryPaths = hookCurrentDirectoryPaths + + let snapshot = await appServer.readAppInventorySnapshot( + .init( + hookListCurrentDirectoryPaths: hookCurrentDirectoryPaths, + includesExtensions: false + ) + ) + + if let capabilities = snapshot.modelCapabilities { + modelCapabilities = capabilities + } + + if let page = snapshot.mcpServerStatusPage { + mcpServers = page.servers.map { status in + .init(status: status, scope: .global) + } + mcpServerNextCursor = page.nextCursor + } + + if let hookListSnapshot = snapshot.hookListSnapshot { + self.hookListSnapshot = hookListSnapshot + } + + lastSnapshotsReadAt = snapshot.succeededCompletely ? Date() : lastSnapshotsReadAt + latestSnapshotErrorDescription = snapshot.succeededCompletely + ? nil + : snapshot.errorDescriptions.joined(separator: "\n") + snapshotPhase = .idle + } + private func refreshArchiveScope(_ archived: Bool) async { if isReconciling || isLoadingLocalSnapshot { return @@ -720,6 +903,7 @@ public extension CodexAppServer { private func startEventTask() { eventTask = Task { [weak self] in guard let self else { return } + let events = await appServer.libraryEvents() for await event in events { if Task.isCancelled { @@ -791,6 +975,7 @@ public extension CodexAppServer { private func clearSelectionIfThreadDisappeared() { guard let selectedThreadID else { return } + if !allThreads.contains(where: { $0.id == selectedThreadID && $0.state != .removed }) { self.selectedThreadID = nil } @@ -818,186 +1003,6 @@ public extension CodexAppServer { selectionOrderByThreadID: selectionOrderByThreadID ) } - - private static func sort( - _ threads: [ThreadSnapshot], - by sortedBy: SortedBy, - selectionOrderByThreadID: [String: Int] - ) -> [ThreadSnapshot] { - threads.sorted { lhs, rhs in - switch sortedBy { - case .updatedNewestFirst: - newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) - case .updatedOldestFirst: - oldest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) - case .createdNewestFirst: - newest(lhs.createdAt, rhs.createdAt, lhs.id, rhs.id) - case .createdOldestFirst: - oldest(lhs.createdAt, rhs.createdAt, lhs.id, rhs.id) - case .selectedNewestFirst: - compareSelection( - lhs, - rhs, - selectionOrderByThreadID: selectionOrderByThreadID - ) - case .turnFinishedNewestFirst: - newest( - lhs.lastCompletedTurnAt ?? Int.min, - rhs.lastCompletedTurnAt ?? Int.min, - lhs.id, - rhs.id - ) - case .turnFinishedOldestFirst: - oldest( - lhs.lastCompletedTurnAt ?? Int.max, - rhs.lastCompletedTurnAt ?? Int.max, - lhs.id, - rhs.id - ) - case .nameAscending: - compareNames(lhs, rhs, ascending: true) - case .nameDescending: - compareNames(lhs, rhs, ascending: false) - } - } - } - - private static func groups( - from threads: [ThreadSnapshot], - groupedBy: GroupedBy - ) -> [ThreadGroup] { - guard groupedBy != .none else { - return [] - } - - let grouped = Dictionary(grouping: threads) { thread in - switch groupedBy { - case .none: - "" - case .cwd: - thread.currentDirectoryPath - case .repository: - thread.projectInfo.id - } - } - - return grouped - .map { key, threads in - let projectInfo = projectInfo( - forGroupID: key, - threads: threads, - groupedBy: groupedBy - ) - return ThreadGroup( - id: key, - projectInfo: projectInfo, - title: projectInfo?.displayName ?? "Unknown Project", - threads: threads - ) - } - .sorted { lhs, rhs in - lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending - } - } - - private static func projectInfo( - forGroupID id: String, - threads: [ThreadSnapshot], - groupedBy: GroupedBy - ) -> CodexWorkspace.ProjectInfo? { - guard groupedBy != .none else { - return nil - } - - guard groupedBy == .repository else { - return .init(currentDirectoryPath: id) - } - - guard let representative = threads.first else { - return .init(currentDirectoryPath: id) - } - - guard representative.projectInfo.identitySource == .gitOrigin else { - return representative.projectInfo - } - - let repositories = threads.map(\.projectInfo.repository) - let branch = commonValue(repositories.map { $0?.branch }) - let sha = commonValue(repositories.map { $0?.sha }) - return .init( - currentDirectoryPath: representative.currentDirectoryPath, - repository: .init( - originURL: id, - branch: branch, - sha: sha - ) - ) - } - - private static func newest( - _ lhsValue: Int, - _ rhsValue: Int, - _ lhsID: String, - _ rhsID: String - ) -> Bool { - if lhsValue == rhsValue { - lhsID < rhsID - } else { - lhsValue > rhsValue - } - } - - private static func oldest( - _ lhsValue: Int, - _ rhsValue: Int, - _ lhsID: String, - _ rhsID: String - ) -> Bool { - if lhsValue == rhsValue { - lhsID < rhsID - } else { - lhsValue < rhsValue - } - } - - private static func compareNames( - _ lhs: ThreadSnapshot, - _ rhs: ThreadSnapshot, - ascending: Bool - ) -> Bool { - let lhsName = lhs.name ?? lhs.preview - let rhsName = rhs.name ?? rhs.preview - let comparison = lhsName.localizedStandardCompare(rhsName) - if comparison == .orderedSame { - return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) - } - return ascending - ? comparison == .orderedAscending - : comparison == .orderedDescending - } - - private static func compareSelection( - _ lhs: ThreadSnapshot, - _ rhs: ThreadSnapshot, - selectionOrderByThreadID: [String: Int] - ) -> Bool { - let lhsOrder = selectionOrderByThreadID[lhs.id] - let rhsOrder = selectionOrderByThreadID[rhs.id] - - switch (lhsOrder, rhsOrder) { - case let (lhsOrder?, rhsOrder?): - if lhsOrder == rhsOrder { - return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) - } - return lhsOrder > rhsOrder - case (_?, nil): - return true - case (nil, _?): - return false - case (nil, nil): - return newest(lhs.updatedAt, rhs.updatedAt, lhs.id, rhs.id) - } - } } } @@ -1065,15 +1070,16 @@ extension CodexAppServer.Library.ThreadSnapshot { private extension CodexAppServer.Library.ThreadSnapshot.State { init(_ localState: ThreadHistoryStore.LocalState) { switch localState { - case .available: - self = .available - case .removed: - self = .removed + case .available: + self = .available + case .removed: + self = .removed } } } private func commonValue(_ values: [String?]) -> String? { guard let first = values.first else { return nil } + return values.allSatisfy { $0 == first } ? first : nil } diff --git a/Sources/SwiftASB/Public/CodexAppServer+MCP.swift b/Sources/SwiftASB/Public/CodexAppServer+MCP.swift index 766fddb..1af4ec0 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+MCP.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+MCP.swift @@ -1,21 +1,21 @@ public extension CodexAppServer { /// Request used to read app-server MCP status snapshots. - struct McpServerStatusListRequest: Sendable, Equatable { - public enum Detail: String, Sendable, Equatable { + internal struct McpServerStatusListRequest: Equatable { + enum Detail: String, Equatable { case full case toolsAndAuthOnly } - public var cursor: String? - public var detail: Detail? - public var limit: Int? - public var threadID: String? + var cursor: String? + var detail: Detail? + var limit: Int? + var threadID: String? /// Creates an MCP status-list request. /// /// Nil pagination and detail fields are omitted, which lets the /// app-server choose its default page and detail level. - public init( + init( cursor: String? = nil, limit: Int? = nil, detail: Detail? = nil, @@ -139,10 +139,10 @@ public extension CodexAppServer { extension CodexAppServer.McpServerStatusListRequest.Detail { var wireValue: CodexWireMCPServerStatusDetail { switch self { - case .full: - .full - case .toolsAndAuthOnly: - .toolsAndAuthOnly + case .full: + .full + case .toolsAndAuthOnly: + .toolsAndAuthOnly } } } @@ -178,14 +178,14 @@ extension CodexAppServer.McpServerSummary { extension CodexAppServer.McpServerStatus.AuthStatus { init(wireValue: CodexWireMCPAuthStatus) { switch wireValue { - case .bearerToken: - self = .bearerToken - case .notLoggedIn: - self = .notLoggedIn - case .oAuth: - self = .oAuth - case .unsupported: - self = .unsupported + case .bearerToken: + self = .bearerToken + case .notLoggedIn: + self = .notLoggedIn + case .oAuth: + self = .oAuth + case .unsupported: + self = .unsupported } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+Models.swift b/Sources/SwiftASB/Public/CodexAppServer+Models.swift index c365ea7..499c61f 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Models.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Models.swift @@ -65,7 +65,6 @@ public extension CodexAppServer { public let description: String public let reasoningEffort: ReasoningEffort } - } extension CodexAppServer.Model { @@ -108,10 +107,10 @@ extension CodexAppServer.ModelAvailabilityNux { extension CodexAppServer.InputModality { init(wireValue: CodexWireInputModality) { switch wireValue { - case .image: - self = .image - case .text: - self = .text + case .image: + self = .image + case .text: + self = .text } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift b/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift index 8dce17f..b44add3 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift @@ -24,29 +24,29 @@ enum CodexProtocolCommandExecutionApprovalDecision: Encodable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .accept: - try container.encode("accept") - case .acceptForSession: - try container.encode("acceptForSession") - case let .acceptWithExecpolicyAmendment(amendment): - try container.encode( - ["acceptWithExecpolicyAmendment": ["execpolicy_amendment": amendment]] - ) - case let .applyNetworkPolicyAmendment(amendment): - try container.encode( - [ - "applyNetworkPolicyAmendment": [ - "network_policy_amendment": [ - "action": amendment.action.wireValue, - "host": amendment.host, - ] + case .accept: + try container.encode("accept") + case .acceptForSession: + try container.encode("acceptForSession") + case let .acceptWithExecpolicyAmendment(amendment): + try container.encode( + ["acceptWithExecpolicyAmendment": ["execpolicy_amendment": amendment]] + ) + case let .applyNetworkPolicyAmendment(amendment): + try container.encode( + [ + "applyNetworkPolicyAmendment": [ + "network_policy_amendment": [ + "action": amendment.action.wireValue, + "host": amendment.host, + ], + ], ] - ] - ) - case .decline: - try container.encode("decline") - case .cancel: - try container.encode("cancel") + ) + case .decline: + try container.encode("decline") + case .cancel: + try container.encode("cancel") } } } @@ -191,18 +191,18 @@ extension CodexGuardianApprovalReviewAction { extension CodexGuardianApprovalReviewAction.ActionType { init(wireValue: CodexWireGuardianApprovalReviewActionType) { switch wireValue { - case .applyPatch: - self = .applyPatch - case .command: - self = .command - case .execve: - self = .execve - case .mcpToolCall: - self = .mcpToolCall - case .networkAccess: - self = .networkAccess - case .requestPermissions: - self = .requestPermissions + case .applyPatch: + self = .applyPatch + case .command: + self = .command + case .execve: + self = .execve + case .mcpToolCall: + self = .mcpToolCall + case .networkAccess: + self = .networkAccess + case .requestPermissions: + self = .requestPermissions } } } @@ -210,10 +210,10 @@ extension CodexGuardianApprovalReviewAction.ActionType { extension CodexGuardianApprovalReviewAction.CommandSource { init(wireValue: CodexWireGuardianCommandSource) { switch wireValue { - case .shell: - self = .shell - case .unifiedExec: - self = .unifiedExec + case .shell: + self = .shell + case .unifiedExec: + self = .unifiedExec } } } @@ -221,14 +221,14 @@ extension CodexGuardianApprovalReviewAction.CommandSource { extension CodexGuardianApprovalReviewAction.NetworkProtocol { init(wireValue: CodexWireNetworkApprovalProtocol) { switch wireValue { - case .http: - self = .http - case .https: - self = .https - case .socks5TCP: - self = .socks5TCP - case .socks5UDP: - self = .socks5UDP + case .http: + self = .http + case .https: + self = .https + case .socks5TCP: + self = .socks5TCP + case .socks5UDP: + self = .socks5UDP } } } @@ -247,14 +247,14 @@ extension CodexGuardianApprovalReview { extension CodexGuardianApprovalReview.RiskLevel { init(wireValue: CodexWireGuardianRiskLevel) { switch wireValue { - case .critical: - self = .critical - case .high: - self = .high - case .low: - self = .low - case .medium: - self = .medium + case .critical: + self = .critical + case .high: + self = .high + case .low: + self = .low + case .medium: + self = .medium } } } @@ -262,16 +262,16 @@ extension CodexGuardianApprovalReview.RiskLevel { extension CodexGuardianApprovalReview.Status { init(wireValue: CodexWireGuardianApprovalReviewStatus) { switch wireValue { - case .aborted: - self = .aborted - case .approved: - self = .approved - case .denied: - self = .denied - case .inProgress: - self = .inProgress - case .timedOut: - self = .timedOut + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut } } } @@ -279,14 +279,14 @@ extension CodexGuardianApprovalReview.Status { extension CodexGuardianApprovalReview.UserAuthorization { init(wireValue: CodexWireGuardianUserAuthorization) { switch wireValue { - case .high: - self = .high - case .low: - self = .low - case .medium: - self = .medium - case .unknown: - self = .unknown + case .high: + self = .high + case .low: + self = .low + case .medium: + self = .medium + case .unknown: + self = .unknown } } } @@ -335,21 +335,21 @@ extension CodexProtocolMCPServerElicitationRequest { extension CodexProtocolMCPServerElicitationRequest.Mode { var publicValue: CodexMcpServerElicitationRequest.Mode { switch self { - case let .form(form): - .form( - .init( - message: form.message, - requestedSchema: .init(wireValue: form.requestedSchema) + case let .form(form): + .form( + .init( + message: form.message, + requestedSchema: .init(wireValue: form.requestedSchema) + ) ) - ) - case let .url(prompt): - .url( - .init( - elicitationID: prompt.elicitationID, - message: prompt.message, - url: prompt.url + case let .url(prompt): + .url( + .init( + elicitationID: prompt.elicitationID, + message: prompt.message, + url: prompt.url + ) ) - ) } } } @@ -376,14 +376,14 @@ extension CodexProtocolToolUserInputRequest.Question.Option { extension CodexProtocolCommandAction { var publicValue: CodexCommandAction { switch self { - case let .read(action): - .read(.init(command: action.command, name: action.name, path: action.path)) - case let .listFiles(action): - .listFiles(.init(command: action.command, path: action.path)) - case let .search(action): - .search(.init(command: action.command, path: action.path, query: action.query)) - case let .unknown(action): - .unknown(.init(command: action.command)) + case let .read(action): + .read(.init(command: action.command, name: action.name, path: action.path)) + case let .listFiles(action): + .listFiles(.init(command: action.command, path: action.path)) + case let .search(action): + .search(.init(command: action.command, path: action.path, query: action.query)) + case let .unknown(action): + .unknown(.init(command: action.command)) } } } @@ -410,18 +410,18 @@ extension CodexCommandExecutionApprovalResponse { var protocolValue: CodexProtocolCommandExecutionApprovalDecisionPayload { let decision: CodexProtocolCommandExecutionApprovalDecision switch self { - case .accept: - decision = .accept - case .acceptForSession: - decision = .acceptForSession - case let .acceptWithExecPolicyAmendment(amendment): - decision = .acceptWithExecpolicyAmendment(amendment) - case let .applyNetworkPolicyAmendment(amendment): - decision = .applyNetworkPolicyAmendment(amendment) - case .decline: - decision = .decline - case .cancel: - decision = .cancel + case .accept: + decision = .accept + case .acceptForSession: + decision = .acceptForSession + case let .acceptWithExecPolicyAmendment(amendment): + decision = .acceptWithExecpolicyAmendment(amendment) + case let .applyNetworkPolicyAmendment(amendment): + decision = .applyNetworkPolicyAmendment(amendment) + case .decline: + decision = .decline + case .cancel: + decision = .cancel } return .init(decision: decision) @@ -467,10 +467,10 @@ extension CodexMcpServerElicitationResponse { extension CodexRPCRequestID { init(wireValue: CodexWireRequestID) { switch wireValue { - case let .integer(value): - self = .int(value) - case let .string(value): - self = .string(value) + case let .integer(value): + self = .int(value) + case let .string(value): + self = .string(value) } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift b/Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift index 3611a67..2bec669 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift @@ -1,8 +1,8 @@ import Foundation -extension CodexAppServer { +public extension CodexAppServer { /// Request used to create a new Codex thread. - public struct ThreadStartRequest: Sendable, Equatable { + struct ThreadStartRequest: Sendable, Equatable { public var approvalPolicy: ApprovalPolicy? public var approvalsReviewer: ApprovalsReviewer? public var baseInstructions: String? @@ -61,7 +61,7 @@ extension CodexAppServer { } /// Request used to reopen an existing Codex thread. - public struct ThreadResumeRequest: Sendable, Equatable { + struct ThreadResumeRequest: Sendable, Equatable { public var approvalPolicy: ApprovalPolicy? public var approvalsReviewer: ApprovalsReviewer? public var baseInstructions: String? @@ -120,7 +120,7 @@ extension CodexAppServer { } /// Request used to fork an existing Codex thread into a new thread. - public struct ThreadForkRequest: Sendable, Equatable { + struct ThreadForkRequest: Sendable, Equatable { public var approvalPolicy: ApprovalPolicy? public var approvalsReviewer: ApprovalsReviewer? public var baseInstructions: String? @@ -182,7 +182,7 @@ extension CodexAppServer { } /// App-server response and runtime defaults for a started, resumed, or forked thread. - public struct ThreadSession: Sendable, Equatable { + struct ThreadSession: Sendable, Equatable { public let activePermissionProfile: CodexWorkspace.ActivePermissionProfile? public let approvalPolicy: ApprovalPolicy public let approvalsReviewer: ApprovalsReviewer @@ -203,7 +203,7 @@ extension CodexAppServer { } /// Metadata for a stored or active Codex thread. - public struct ThreadInfo: Sendable, Equatable { + struct ThreadInfo: Sendable, Equatable { public let id: String public let cliVersion: String public let createdAt: Int @@ -225,7 +225,7 @@ extension CodexAppServer { } /// Request used to read a stored thread snapshot. - public struct ThreadReadRequest: Sendable, Equatable { + struct ThreadReadRequest: Sendable, Equatable { public var includeTurns: Bool public var threadID: String @@ -243,13 +243,13 @@ extension CodexAppServer { } /// Stored thread metadata plus the optional turns returned by a read request. - public struct ThreadReadResult: Sendable, Equatable { + struct ThreadReadResult: Sendable, Equatable { public let thread: ThreadInfo public let turns: [TurnInfo] } /// Request used to start app-server context compaction for a thread. - public struct ThreadCompactRequest: Sendable, Equatable { + struct ThreadCompactRequest: Sendable, Equatable { public var threadID: String /// Creates a thread-compaction request. @@ -259,19 +259,19 @@ extension CodexAppServer { } /// Sort key for stored-thread listing. - public enum ThreadListSortKey: String, Sendable, Equatable { + enum ThreadListSortKey: String, Sendable, Equatable { case createdAt case updatedAt } /// Sort direction for stored-thread listing. - public enum ThreadListSortDirection: String, Sendable, Equatable { + enum ThreadListSortDirection: String, Sendable, Equatable { case asc case desc } /// Source family filter for stored-thread listing. - public enum ThreadListSourceKind: String, Sendable, Equatable { + enum ThreadListSourceKind: String, Sendable, Equatable { case appServer case cli case exec @@ -280,7 +280,7 @@ extension CodexAppServer { } /// App-server-reported source for a stored or active thread. - public enum ThreadSource: Sendable, Equatable, Codable { + enum ThreadSource: Sendable, Equatable, Codable { /// Thread started by the Codex app-server owner. case appServer /// Thread started by the Codex CLI. @@ -357,7 +357,7 @@ extension CodexAppServer { } } - public struct ThreadListRequest: Sendable, Equatable { + struct ThreadListRequest: Sendable, Equatable { public var archived: Bool? public var cursor: String? public var currentDirectoryPath: String? @@ -396,25 +396,25 @@ extension CodexAppServer { } /// One page of stored-thread list results. - public struct ThreadListPage: Sendable, Equatable { + struct ThreadListPage: Sendable, Equatable { public let nextCursor: String? public let threads: [ThreadInfo] } /// Sort direction for stored turn-history paging. - public enum ThreadTurnsSortDirection: String, Sendable, Equatable { + enum ThreadTurnsSortDirection: String, Sendable, Equatable { case asc case desc } /// Amount of item detail to include when listing stored turns. - public enum TurnItemsView: String, Sendable, Equatable { + enum TurnItemsView: String, Sendable, Equatable { case full case notLoaded case summary } - public struct ThreadTurnsListRequest: Sendable, Equatable { + struct ThreadTurnsListRequest: Sendable, Equatable { public var cursor: String? public var itemsView: TurnItemsView? public var limit: Int? @@ -442,13 +442,13 @@ extension CodexAppServer { } /// One page of stored turn-history results. - public struct ThreadTurnsPage: Sendable, Equatable { + struct ThreadTurnsPage: Sendable, Equatable { public let backwardsCursor: String? public let nextCursor: String? public let turns: [TurnInfo] } - public struct ThreadTurnsItemsListRequest: Sendable, Equatable { + struct ThreadTurnsItemsListRequest: Sendable, Equatable { public var cursor: String? public var limit: Int? public var sortDirection: ThreadTurnsSortDirection? @@ -475,16 +475,15 @@ extension CodexAppServer { } /// One page of stored item-history results for a turn. - public struct ThreadTurnsItemsPage: Sendable, Equatable { + struct ThreadTurnsItemsPage: Sendable, Equatable { public let backwardsCursor: String? public let items: [CodexTurnItem] public let nextCursor: String? } /// Current app-server status for a thread. - public struct ThreadStatus: Sendable, Equatable { + struct ThreadStatus: Sendable, Equatable { public let type: ThreadStatusType public let activeFlags: [ThreadActiveFlag] } - } diff --git a/Sources/SwiftASB/Public/CodexAppServer+ThreadManagement.swift b/Sources/SwiftASB/Public/CodexAppServer+ThreadManagement.swift index 603d92d..af5f934 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+ThreadManagement.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+ThreadManagement.swift @@ -77,7 +77,6 @@ public extension CodexAppServer { case clear case replace(String) } - } extension CodexAppServer.ThreadMetadataUpdateRequest { @@ -102,12 +101,12 @@ extension CodexProtocolThreadMetadataUpdateParams.GitInfo { extension CodexProtocolThreadMetadataUpdateParams.FieldUpdate { init(_ update: CodexAppServer.ThreadMetadataFieldUpdate) { switch update { - case .unchanged: - self = .unchanged - case .clear: - self = .clear - case let .replace(value): - self = .replace(value) + case .unchanged: + self = .unchanged + case .clear: + self = .clear + case let .replace(value): + self = .replace(value) } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+TurnLifecycle.swift b/Sources/SwiftASB/Public/CodexAppServer+TurnLifecycle.swift index 9e478da..a46f0bc 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+TurnLifecycle.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+TurnLifecycle.swift @@ -1,8 +1,8 @@ import Foundation -extension CodexAppServer { +public extension CodexAppServer { /// Request used to start a turn in an existing thread. - public struct TurnStartRequest: Sendable, Equatable { + struct TurnStartRequest: Sendable, Equatable { public var approvalPolicy: ApprovalPolicy? public var approvalsReviewer: ApprovalsReviewer? public var collaborationMode: TurnCollaborationMode? @@ -58,7 +58,7 @@ extension CodexAppServer { /// The app-server currently treats collaboration presets as experimental, /// but plan mode is useful enough for SwiftASB to expose behind a small /// Swift-owned value instead of asking callers to emulate slash commands. - public struct TurnCollaborationMode: Sendable, Equatable { + struct TurnCollaborationMode: Sendable, Equatable { public enum Kind: String, Sendable, Equatable { case defaultMode = "default" case plan @@ -102,12 +102,12 @@ extension CodexAppServer { } /// App-server response for a newly started turn. - public struct TurnSession: Sendable, Equatable { + struct TurnSession: Sendable, Equatable { public let turn: TurnInfo } /// Metadata for a turn known to the app-server. - public struct TurnInfo: Sendable, Equatable { + struct TurnInfo: Sendable, Equatable { public let completedAt: Int? public let durationMS: Int? public let errorMessage: String? @@ -117,7 +117,7 @@ extension CodexAppServer { } /// One input item for a turn-start request. - public struct TurnInput: Sendable, Equatable { + struct TurnInput: Sendable, Equatable { public enum Kind: String, Sendable, Equatable { case image, localImage, mention, skill, text } @@ -152,5 +152,4 @@ extension CodexAppServer { .init(kind: .text, text: text) } } - } diff --git a/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift b/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift index 0cc2a03..52d5c89 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift @@ -3,12 +3,12 @@ import Foundation extension CodexTurnPlanUpdate.Step.Status { init(wireValue: CodexWireTurnPlanStepStatus) { switch wireValue { - case .completed: - self = .completed - case .inProgress: - self = .inProgress - case .pending: - self = .pending + case .completed: + self = .completed + case .inProgress: + self = .inProgress + case .pending: + self = .pending } } } @@ -31,38 +31,40 @@ extension CodexTurnItem { extension CodexTurnItem.Kind { init(wireValue: CodexWireThreadItemType) { switch wireValue { - case .agentMessage: - self = .agentMessage - case .collabAgentToolCall: - self = .collabAgentToolCall - case .commandExecution: - self = .commandExecution - case .contextCompaction: - self = .contextCompaction - case .dynamicToolCall: - self = .dynamicToolCall - case .enteredReviewMode: - self = .enteredReviewMode - case .exitedReviewMode: - self = .exitedReviewMode - case .fileChange: - self = .fileChange - case .hookPrompt: - self = .hookPrompt - case .imageGeneration: - self = .imageGeneration - case .imageView: - self = .imageView - case .mcpToolCall: - self = .mcpToolCall - case .plan: - self = .plan - case .reasoning: - self = .reasoning - case .userMessage: - self = .userMessage - case .webSearch: - self = .webSearch + case .agentMessage: + self = .agentMessage + case .collabAgentToolCall: + self = .collabAgentToolCall + case .commandExecution: + self = .commandExecution + case .contextCompaction: + self = .contextCompaction + case .dynamicToolCall: + self = .dynamicToolCall + case .enteredReviewMode: + self = .enteredReviewMode + case .exitedReviewMode: + self = .exitedReviewMode + case .fileChange: + self = .fileChange + case .hookPrompt: + self = .hookPrompt + case .imageGeneration: + self = .imageGeneration + case .imageView: + self = .imageView + case .mcpToolCall: + self = .mcpToolCall + case .plan: + self = .plan + case .reasoning: + self = .reasoning + case .subAgentActivity: + self = .subAgentActivity + case .userMessage: + self = .userMessage + case .webSearch: + self = .webSearch } } } @@ -82,10 +84,10 @@ extension CodexThreadTokenUsageUpdated.Usage { extension CodexThread.ReviewPlacement { var wireValue: CodexWireReviewDelivery { switch self { - case .inline: - .inline - case .detached: - .detached + case .inline: + .inline + case .detached: + .detached } } } @@ -93,38 +95,38 @@ extension CodexThread.ReviewPlacement { extension CodexThread.ReviewSubject { var wireValue: CodexWireReviewTarget { switch self { - case .uncommittedChanges: - CodexWireReviewTarget( - type: .uncommittedChanges, - branch: nil, - sha: nil, - title: nil, - instructions: nil - ) - case let .baseBranch(branch): - CodexWireReviewTarget( - type: .baseBranch, - branch: branch, - sha: nil, - title: nil, - instructions: nil - ) - case let .commit(sha, title): - CodexWireReviewTarget( - type: .commit, - branch: nil, - sha: sha, - title: title, - instructions: nil - ) - case let .custom(instructions): - CodexWireReviewTarget( - type: .custom, - branch: nil, - sha: nil, - title: nil, - instructions: instructions - ) + case .uncommittedChanges: + CodexWireReviewTarget( + type: .uncommittedChanges, + branch: nil, + sha: nil, + title: nil, + instructions: nil + ) + case let .baseBranch(branch): + CodexWireReviewTarget( + type: .baseBranch, + branch: branch, + sha: nil, + title: nil, + instructions: nil + ) + case let .commit(sha, title): + CodexWireReviewTarget( + type: .commit, + branch: nil, + sha: sha, + title: title, + instructions: nil + ) + case let .custom(instructions): + CodexWireReviewTarget( + type: .custom, + branch: nil, + sha: nil, + title: nil, + instructions: instructions + ) } } } @@ -166,6 +168,7 @@ extension CodexAppServer.ThreadStartRequest { personality: personality?.wireValue, runtimeWorkspaceRoots: nil, sandbox: sandboxMode?.wireValue, + selectedCapabilityRoots: nil, serviceName: serviceName, serviceTier: serviceTier?.wireValue, sessionStartSource: sessionStartSource?.wireValue, @@ -261,10 +264,10 @@ extension CodexAppServer.TurnCollaborationMode { extension CodexAppServer.TurnCollaborationMode.Kind { var wireValue: CodexWireModeKind { switch self { - case .defaultMode: - .modeKindDefault - case .plan: - .plan + case .defaultMode: + .modeKindDefault + case .plan: + .plan } } } @@ -286,16 +289,16 @@ extension CodexAppServer.TurnInput { extension CodexAppServer.TurnInput.Kind { var wireValue: CodexWireUserInputType { switch self { - case .image: - .image - case .localImage: - .localImage - case .mention: - .mention - case .skill: - .skill - case .text: - .text + case .image: + .image + case .localImage: + .localImage + case .mention: + .mention + case .skill: + .skill + case .text: + .text } } } @@ -303,39 +306,39 @@ extension CodexAppServer.TurnInput.Kind { extension CodexAppServer.JSONValue { init(wireValue: CodexWireJSONValue) { switch wireValue { - case .null: - self = .null - case let .bool(value): - self = .bool(value) - case let .integer(value): - self = .integer(value) - case let .double(value): - self = .double(value) - case let .string(value): - self = .string(value) - case let .array(value): - self = .array(value.map(Self.init(wireValue:))) - case let .object(value): - self = .object(value.mapValues(Self.init(wireValue:))) + case .null: + self = .null + case let .bool(value): + self = .bool(value) + case let .integer(value): + self = .integer(value) + case let .double(value): + self = .double(value) + case let .string(value): + self = .string(value) + case let .array(value): + self = .array(value.map(Self.init(wireValue:))) + case let .object(value): + self = .object(value.mapValues(Self.init(wireValue:))) } } var wireValue: CodexWireJSONValue { switch self { - case .null: - .null - case let .bool(value): - .bool(value) - case let .integer(value): - .integer(value) - case let .double(value): - .double(value) - case let .string(value): - .string(value) - case let .array(value): - .array(value.map(\.wireValue)) - case let .object(value): - .object(value.mapValues(\.wireValue)) + case .null: + .null + case let .bool(value): + .bool(value) + case let .integer(value): + .integer(value) + case let .double(value): + .double(value) + case let .string(value): + .string(value) + case let .array(value): + .array(value.map(\.wireValue)) + case let .object(value): + .object(value.mapValues(\.wireValue)) } } } @@ -343,40 +346,40 @@ extension CodexAppServer.JSONValue { extension CodexAppServer.ApprovalPolicy { init(wireValue: CodexWireAskForApproval) { switch wireValue { - case let .enumeration(value): - self = Self(wireEnum: value) - case let .codexWireGranularAskForApproval(value): - self = .granular(.init(wireValue: value.granular)) + case let .enumeration(value): + self = Self(wireEnum: value) + case let .codexWireGranularAskForApproval(value): + self = .granular(.init(wireValue: value.granular)) } } init(wireEnum: CodexWireApprovalPolicyEnum) { switch wireEnum { - case .never: - self = .never - case .onFailure: - self = .onFailure - case .onRequest: - self = .onRequest - case .untrusted: - self = .untrusted + case .never: + self = .never + case .onFailure: + self = .onFailure + case .onRequest: + self = .onRequest + case .untrusted: + self = .untrusted } } var wireValue: CodexWireApprovalPolicyUnion { switch self { - case .never: - .enumeration(.never) - case .onFailure: - .enumeration(.onFailure) - case .onRequest: - .enumeration(.onRequest) - case .untrusted: - .enumeration(.untrusted) - case let .granular(policy): - .codexWireGranularAskForApproval( - CodexWireGranularAskForApproval(granular: policy.wireValue) - ) + case .never: + .enumeration(.never) + case .onFailure: + .enumeration(.onFailure) + case .onRequest: + .enumeration(.onRequest) + case .untrusted: + .enumeration(.untrusted) + case let .granular(policy): + .codexWireGranularAskForApproval( + CodexWireGranularAskForApproval(granular: policy.wireValue) + ) } } } @@ -406,23 +409,23 @@ extension CodexAppServer.GranularApprovalPolicy { extension CodexAppServer.ApprovalsReviewer { init(wireValue: CodexWireApprovalsReviewer) { switch wireValue { - case .autoReview: - self = .autoReview - case .guardianSubagent: - self = .guardianSubagent - case .user: - self = .user + case .autoReview: + self = .autoReview + case .guardianSubagent: + self = .guardianSubagent + case .user: + self = .user } } var wireValue: CodexWireApprovalsReviewer { switch self { - case .autoReview: - .autoReview - case .guardianSubagent: - .guardianSubagent - case .user: - .user + case .autoReview: + .autoReview + case .guardianSubagent: + .guardianSubagent + case .user: + .user } } } @@ -430,23 +433,23 @@ extension CodexAppServer.ApprovalsReviewer { extension CodexAppServer.Personality { init(wireValue: CodexWirePersonality) { switch wireValue { - case .friendly: - self = .friendly - case .none: - self = .none - case .pragmatic: - self = .pragmatic + case .friendly: + self = .friendly + case .none: + self = .none + case .pragmatic: + self = .pragmatic } } var wireValue: CodexWirePersonality { switch self { - case .friendly: - .friendly - case .none: - .none - case .pragmatic: - .pragmatic + case .friendly: + .friendly + case .none: + .none + case .pragmatic: + .pragmatic } } } @@ -454,12 +457,12 @@ extension CodexAppServer.Personality { extension CodexAppServer.SandboxMode { var wireValue: CodexWireSandboxMode { switch self { - case .dangerFullAccess: - .dangerFullAccess - case .readOnly: - .readOnly - case .workspaceWrite: - .workspaceWrite + case .dangerFullAccess: + .dangerFullAccess + case .readOnly: + .readOnly + case .workspaceWrite: + .workspaceWrite } } } @@ -467,6 +470,7 @@ extension CodexAppServer.SandboxMode { extension CodexAppServer.ServiceTier { init?(wireValue: String?) { guard let wireValue else { return nil } + self.init(rawValue: wireValue) } @@ -478,10 +482,10 @@ extension CodexAppServer.ServiceTier { extension CodexAppServer.SessionStartSource { var wireValue: CodexWireThreadStartSource { switch self { - case .clear: - .clear - case .startup: - .startup + case .clear: + .clear + case .startup: + .startup } } } @@ -489,44 +493,45 @@ extension CodexAppServer.SessionStartSource { extension CodexAppServer.ReasoningEffort { init(wireValue: String) { switch wireValue { - case "high": - self = .high - case "low": - self = .low - case "medium": - self = .medium - case "minimal": - self = .minimal - case "none": - self = .none - case "xhigh": - self = .xhigh - default: - self = .unrecognized(wireValue) + case "high": + self = .high + case "low": + self = .low + case "medium": + self = .medium + case "minimal": + self = .minimal + case "none": + self = .none + case "xhigh": + self = .xhigh + default: + self = .unrecognized(wireValue) } } init?(wireValue: String?) { guard let wireValue else { return nil } + self.init(wireValue: wireValue) } var wireValue: String { switch self { - case .high: - "high" - case .low: - "low" - case .medium: - "medium" - case .minimal: - "minimal" - case .none: - "none" - case let .unrecognized(value): - value - case .xhigh: - "xhigh" + case .high: + "high" + case .low: + "low" + case .medium: + "medium" + case .minimal: + "minimal" + case .none: + "none" + case let .unrecognized(value): + value + case .xhigh: + "xhigh" } } } @@ -534,14 +539,14 @@ extension CodexAppServer.ReasoningEffort { extension CodexAppServer.ReasoningSummary { var wireValue: CodexWireReasoningSummary { switch self { - case .auto: - .auto - case .concise: - .concise - case .detailed: - .detailed - case .none: - .none + case .auto: + .auto + case .concise: + .concise + case .detailed: + .detailed + case .none: + .none } } } @@ -561,15 +566,15 @@ extension CodexAppServer.SandboxPolicy { extension CodexAppServer.NetworkAccess { init(wireValue: CodexWireNetworkAccessUnion) { switch wireValue { - case let .bool(value): - self = .explicit(value) - case let .enumeration(value): - switch value { - case .enabled: - self = .enabled - case .restricted: - self = .restricted - } + case let .bool(value): + self = .explicit(value) + case let .enumeration(value): + switch value { + case .enabled: + self = .enabled + case .restricted: + self = .restricted + } } } } @@ -577,14 +582,14 @@ extension CodexAppServer.NetworkAccess { extension CodexAppServer.SandboxPolicyType { init(wireValue: CodexWireSandboxPolicyType) { switch wireValue { - case .dangerFullAccess: - self = .dangerFullAccess - case .externalSandbox: - self = .externalSandbox - case .readOnly: - self = .readOnly - case .workspaceWrite: - self = .workspaceWrite + case .dangerFullAccess: + self = .dangerFullAccess + case .externalSandbox: + self = .externalSandbox + case .readOnly: + self = .readOnly + case .workspaceWrite: + self = .workspaceWrite } } } @@ -645,31 +650,31 @@ extension CodexAppServer.ThreadInfo { extension CodexAppServer.ThreadSource { init(wireValue: CodexWireSessionSourceUnion) { switch wireValue { - case let .enumeration(source): - self.init(wireValue: source) - case let .codexWireSessionSource(source): - if let custom = source.custom, !custom.isEmpty { - self = .custom(custom) - } else if let subAgent = source.subAgent { - self = .subAgent(.init(wireValue: subAgent)) - } else { - self = .unknown - } + case let .enumeration(source): + self.init(wireValue: source) + case let .codexWireSessionSource(source): + if let custom = source.custom, !custom.isEmpty { + self = .custom(custom) + } else if let subAgent = source.subAgent { + self = .subAgent(.init(wireValue: subAgent)) + } else { + self = .unknown + } } } private init(wireValue: CodexWireSessionSourceEnum) { switch wireValue { - case .appServer: - self = .appServer - case .cli: - self = .cli - case .exec: - self = .exec - case .unknown: - self = .unknown - case .vscode: - self = .vscode + case .appServer: + self = .appServer + case .cli: + self = .cli + case .exec: + self = .exec + case .unknown: + self = .unknown + case .vscode: + self = .vscode } } } @@ -677,19 +682,19 @@ extension CodexAppServer.ThreadSource { extension CodexAppServer.ThreadSource.SubAgentSource { init(wireValue: CodexWireSubAgentSourceUnion) { switch wireValue { - case let .enumeration(source): - self.init(kind: .init(wireValue: source)) - case let .codexWireSubAgentSource(source): - if let threadSpawn = source.threadSpawn { - self.init( - kind: .threadSpawn, - threadSpawn: .init(wireValue: threadSpawn) - ) - } else if let other = source.other, !other.isEmpty { - self.init(kind: .other, other: other) - } else { - self.init(kind: .unknown) - } + case let .enumeration(source): + self.init(kind: .init(wireValue: source)) + case let .codexWireSubAgentSource(source): + if let threadSpawn = source.threadSpawn { + self.init( + kind: .threadSpawn, + threadSpawn: .init(wireValue: threadSpawn) + ) + } else if let other = source.other, !other.isEmpty { + self.init(kind: .other, other: other) + } else { + self.init(kind: .unknown) + } } } } @@ -697,12 +702,12 @@ extension CodexAppServer.ThreadSource.SubAgentSource { extension CodexAppServer.ThreadSource.SubAgentSource.Kind { init(wireValue: CodexWireSubAgentSourceEnum) { switch wireValue { - case .compact: - self = .compact - case .memoryConsolidation: - self = .memoryConsolidation - case .review: - self = .review + case .compact: + self = .compact + case .memoryConsolidation: + self = .memoryConsolidation + case .review: + self = .review } } } @@ -733,16 +738,16 @@ extension CodexAppServer.CLIExecutableDiagnostics { extension CodexAppServer.CLIExecutableDiagnostics.Source { init(_ source: CodexCLIExecutableResolver.Source) { switch source { - case .explicit: - self = .explicit - case .path: - self = .path - case .homebrewAppleSilicon: - self = .homebrewAppleSilicon - case .homebrewIntel: - self = .homebrewIntel - case let .npmGlobal(prefix): - self = .npmGlobal(prefix: prefix) + case .explicit: + self = .explicit + case .path: + self = .path + case .homebrewAppleSilicon: + self = .homebrewAppleSilicon + case .homebrewIntel: + self = .homebrewIntel + case let .npmGlobal(prefix): + self = .npmGlobal(prefix: prefix) } } } @@ -750,12 +755,12 @@ extension CodexAppServer.CLIExecutableDiagnostics.Source { extension CodexAppServer.CLIExecutableDiagnostics.Compatibility { init(_ compatibility: CodexCLIExecutableResolver.Compatibility) { switch compatibility { - case let .supported(documentedWindow): - self = .supported(documentedWindow: documentedWindow) - case let .outsideDocumentedWindow(documentedWindow): - self = .outsideDocumentedWindow(documentedWindow: documentedWindow) - case let .unknownVersionFormat(documentedWindow): - self = .unknownVersionFormat(documentedWindow: documentedWindow) + case let .supported(documentedWindow): + self = .supported(documentedWindow: documentedWindow) + case let .outsideDocumentedWindow(documentedWindow): + self = .outsideDocumentedWindow(documentedWindow: documentedWindow) + case let .unknownVersionFormat(documentedWindow): + self = .unknownVersionFormat(documentedWindow: documentedWindow) } } } @@ -805,16 +810,16 @@ extension CodexThread.Dashboard.HookRun.Entry { extension CodexThread.Dashboard.HookRun.Entry.Kind { init(wireValue: CodexWireHookOutputEntryKind) { switch wireValue { - case .context: - self = .context - case .error: - self = .error - case .feedback: - self = .feedback - case .stop: - self = .stop - case .warning: - self = .warning + case .context: + self = .context + case .error: + self = .error + case .feedback: + self = .feedback + case .stop: + self = .stop + case .warning: + self = .warning } } } @@ -822,26 +827,26 @@ extension CodexThread.Dashboard.HookRun.Entry.Kind { extension CodexThread.Dashboard.HookRun.EventName { init(wireValue: CodexWireHookEventName) { switch wireValue { - case .permissionRequest: - self = .permissionRequest - case .postCompact: - self = .postCompact - case .postToolUse: - self = .postToolUse - case .preCompact: - self = .preCompact - case .preToolUse: - self = .preToolUse - case .sessionStart: - self = .sessionStart - case .stop: - self = .stop - case .subagentStart: - self = .subagentStart - case .subagentStop: - self = .subagentStop - case .userPromptSubmit: - self = .userPromptSubmit + case .permissionRequest: + self = .permissionRequest + case .postCompact: + self = .postCompact + case .postToolUse: + self = .postToolUse + case .preCompact: + self = .preCompact + case .preToolUse: + self = .preToolUse + case .sessionStart: + self = .sessionStart + case .stop: + self = .stop + case .subagentStart: + self = .subagentStart + case .subagentStop: + self = .subagentStop + case .userPromptSubmit: + self = .userPromptSubmit } } } @@ -849,10 +854,10 @@ extension CodexThread.Dashboard.HookRun.EventName { extension CodexThread.Dashboard.HookRun.ExecutionMode { init(wireValue: CodexWireHookExecutionMode) { switch wireValue { - case .async: - self = .async - case .sync: - self = .sync + case .async: + self = .async + case .sync: + self = .sync } } } @@ -860,12 +865,12 @@ extension CodexThread.Dashboard.HookRun.ExecutionMode { extension CodexThread.Dashboard.HookRun.HandlerType { init(wireValue: CodexWireHookHandlerType) { switch wireValue { - case .agent: - self = .agent - case .command: - self = .command - case .prompt: - self = .prompt + case .agent: + self = .agent + case .command: + self = .command + case .prompt: + self = .prompt } } } @@ -873,10 +878,10 @@ extension CodexThread.Dashboard.HookRun.HandlerType { extension CodexThread.Dashboard.HookRun.Scope { init(wireValue: CodexWireHookScope) { switch wireValue { - case .thread: - self = .thread - case .turn: - self = .turn + case .thread: + self = .thread + case .turn: + self = .turn } } } @@ -884,16 +889,16 @@ extension CodexThread.Dashboard.HookRun.Scope { extension CodexThread.Dashboard.HookRun.Status { init(wireValue: CodexWireHookRunStatus) { switch wireValue { - case .blocked: - self = .blocked - case .completed: - self = .completed - case .failed: - self = .failed - case .running: - self = .running - case .stopped: - self = .stopped + case .blocked: + self = .blocked + case .completed: + self = .completed + case .failed: + self = .failed + case .running: + self = .running + case .stopped: + self = .stopped } } } @@ -901,14 +906,14 @@ extension CodexThread.Dashboard.HookRun.Status { extension CodexAppServer.ThreadStatusType { init(wireValue: CodexWireThreadStatusType) { switch wireValue { - case .active: - self = .active - case .idle: - self = .idle - case .notLoaded: - self = .notLoaded - case .systemError: - self = .systemError + case .active: + self = .active + case .idle: + self = .idle + case .notLoaded: + self = .notLoaded + case .systemError: + self = .systemError } } } @@ -916,10 +921,10 @@ extension CodexAppServer.ThreadStatusType { extension CodexProtocolThreadTurnsSortDirection { init(_ direction: CodexAppServer.ThreadTurnsSortDirection) { switch direction { - case .asc: - self = .asc - case .desc: - self = .desc + case .asc: + self = .asc + case .desc: + self = .desc } } } @@ -927,10 +932,10 @@ extension CodexProtocolThreadTurnsSortDirection { extension CodexWireRemoteControlClientsListOrder { init(_ direction: CodexAppServer.ThreadTurnsSortDirection) { switch direction { - case .asc: - self = .asc - case .desc: - self = .desc + case .asc: + self = .asc + case .desc: + self = .desc } } } @@ -938,12 +943,12 @@ extension CodexWireRemoteControlClientsListOrder { extension CodexWireTurnItemsView { init(_ itemsView: CodexAppServer.TurnItemsView) { switch itemsView { - case .full: - self = .full - case .notLoaded: - self = .notLoaded - case .summary: - self = .summary + case .full: + self = .full + case .notLoaded: + self = .notLoaded + case .summary: + self = .summary } } } @@ -951,10 +956,10 @@ extension CodexWireTurnItemsView { extension CodexProtocolThreadListSortKey { init(_ key: CodexAppServer.ThreadListSortKey) { switch key { - case .createdAt: - self = .createdAt - case .updatedAt: - self = .updatedAt + case .createdAt: + self = .createdAt + case .updatedAt: + self = .updatedAt } } } @@ -962,10 +967,10 @@ extension CodexProtocolThreadListSortKey { extension CodexProtocolThreadListSortDirection { init(_ direction: CodexAppServer.ThreadListSortDirection) { switch direction { - case .asc: - self = .asc - case .desc: - self = .desc + case .asc: + self = .asc + case .desc: + self = .desc } } } @@ -973,16 +978,16 @@ extension CodexProtocolThreadListSortDirection { extension CodexProtocolThreadListSourceKind { init(_ sourceKind: CodexAppServer.ThreadListSourceKind) { switch sourceKind { - case .appServer: - self = .appServer - case .cli: - self = .cli - case .exec: - self = .exec - case .unknown: - self = .unknown - case .vscode: - self = .vscode + case .appServer: + self = .appServer + case .cli: + self = .cli + case .exec: + self = .exec + case .unknown: + self = .unknown + case .vscode: + self = .vscode } } } @@ -990,10 +995,10 @@ extension CodexProtocolThreadListSourceKind { extension CodexAppServer.ThreadActiveFlag { init(wireValue: CodexWireThreadActiveFlag) { switch wireValue { - case .waitingOnApproval: - self = .waitingOnApproval - case .waitingOnUserInput: - self = .waitingOnUserInput + case .waitingOnApproval: + self = .waitingOnApproval + case .waitingOnUserInput: + self = .waitingOnUserInput } } } @@ -1001,16 +1006,16 @@ extension CodexAppServer.ThreadActiveFlag { extension CodexThread.Dashboard.AutoReviewStatus { init(wireValue: CodexWireGuardianApprovalReviewStatus) { switch wireValue { - case .aborted: - self = .aborted - case .approved: - self = .approved - case .denied: - self = .denied - case .inProgress: - self = .inProgress - case .timedOut: - self = .timedOut + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut } } } @@ -1037,14 +1042,14 @@ extension CodexAppServer.TurnInfo { extension CodexAppServer.TurnStatus { init(wireValue: CodexWireTurnStatus) { switch wireValue { - case .completed: - self = .completed - case .failed: - self = .failed - case .inProgress: - self = .inProgress - case .interrupted: - self = .interrupted + case .completed: + self = .completed + case .failed: + self = .failed + case .inProgress: + self = .inProgress + case .interrupted: + self = .interrupted } } } diff --git a/Sources/SwiftASB/Public/CodexAppServer.swift b/Sources/SwiftASB/Public/CodexAppServer.swift index e65baba..ee7897b 100644 --- a/Sources/SwiftASB/Public/CodexAppServer.swift +++ b/Sources/SwiftASB/Public/CodexAppServer.swift @@ -2,28 +2,13 @@ import Foundation import OSLog public actor CodexAppServer { - private enum ThreadTurnActivity: Sendable, Equatable { - case starting, active(turnID: String) - } - - private struct ThreadObservableActivityState: Sendable, Equatable { - var activeAutoReviewIDs: Set = [] - var activeToolLikeItemIDs: Set = [] - var activeMcpItemIDs: Set = [] - var autoReviewStatus: CodexThread.Dashboard.AutoReviewStatus = .idle - var hasToolErrorResidue = false - var hasMcpErrorResidue = false - var hookRuns: [CodexThread.Dashboard.HookRun] = [] - var isCompactingThreadContext = false - } - - internal struct RecentTurnWindowResult: Sendable { + struct RecentTurnWindowResult { let turns: [ThreadHistoryStore.ThreadSnapshot.TurnSnapshot] let nextOlderCursor: String? let nextNewerCursor: String? } - internal struct RecentFileSnapshot: Sendable, Equatable, Identifiable { + struct RecentFileSnapshot: Equatable, Identifiable { let id: String let itemID: String let latestStatusText: String? @@ -37,7 +22,7 @@ public actor CodexAppServer { let turnStartedAt: Int? } - internal struct RecentCommandSnapshot: Sendable, Equatable, Identifiable { + struct RecentCommandSnapshot: Equatable, Identifiable { let id: String let itemID: String let command: String? @@ -51,24 +36,24 @@ public actor CodexAppServer { let turnStartedAt: Int? } - internal struct RecentFileWindowResult: Sendable { + struct RecentFileWindowResult { let files: [RecentFileSnapshot] let nextOlderCursor: String? } - internal struct RecentCommandWindowResult: Sendable { + struct RecentCommandWindowResult { let commands: [RecentCommandSnapshot] let nextOlderCursor: String? } - internal struct CommandExecutionOutputDeltaEvent: Sendable, Equatable { + struct CommandExecutionOutputDeltaEvent: Equatable { let delta: String let itemID: String let threadID: String let turnID: String } - internal struct FileChangeOutputDeltaEvent: Sendable, Equatable { + struct FileChangeOutputDeltaEvent: Equatable { let delta: String let itemID: String let path: String? @@ -77,24 +62,40 @@ public actor CodexAppServer { let turnID: String } - private enum InteractiveRequestDestination: Sendable, Equatable { + private enum ThreadTurnActivity: Equatable { + case starting, active(turnID: String) + } + + private struct ThreadObservableActivityState: Equatable { + var activeAutoReviewIDs: Set = [] + var activeToolLikeItemIDs: Set = [] + var activeMcpItemIDs: Set = [] + var autoReviewStatus: CodexThread.Dashboard.AutoReviewStatus = .idle + var hasToolErrorResidue = false + var hasMcpErrorResidue = false + var hookRuns: [CodexThread.Dashboard.HookRun] = [] + var isCompactingThreadContext = false + } + + private enum InteractiveRequestDestination: Equatable { case thread(threadID: String), turn(turnID: String) } - private struct OutstandingInteractiveRequest: Sendable, Equatable { + private struct OutstandingInteractiveRequest: Equatable { let destination: InteractiveRequestDestination let kind: CodexInteractiveRequestKind let threadID: String let turnID: String? } - private let transport: any CodexAppServerTransporting - private let protocolLayer: CodexAppServerProtocol - private let featurePolicy: SwiftASBFeaturePolicy private static let logger = Logger( subsystem: "com.gaelic-ghost.SwiftASB", category: "CodexAppServer" ) + + private let transport: any CodexAppServerTransporting + private let protocolLayer: CodexAppServerProtocol + private let featurePolicy: SwiftASBFeaturePolicy private let historyStore: ThreadHistoryStore? private let historyStoreInitializationError: Error? private var serverEventTask: Task? @@ -130,8 +131,8 @@ public actor CodexAppServer { /// Omitting `configuration` uses SwiftASB's standard app-server launch /// command and local Codex executable discovery. public init(configuration: Configuration = .init()) { - self.featurePolicy = configuration.featurePolicy - self.transport = CodexAppServerTransport( + featurePolicy = configuration.featurePolicy + transport = CodexAppServerTransport( configuration: CodexAppServerTransport.Configuration( codexExecutableURL: configuration.codexExecutableURL, arguments: configuration.arguments, @@ -139,17 +140,17 @@ public actor CodexAppServer { environment: configuration.environment ) ) - self.protocolLayer = CodexAppServerProtocol() + protocolLayer = CodexAppServerProtocol() do { - self.historyStore = try ThreadHistoryStore() - self.historyStoreInitializationError = nil + historyStore = try ThreadHistoryStore() + historyStoreInitializationError = nil } catch { - self.historyStore = nil - self.historyStoreInitializationError = error + historyStore = nil + historyStoreInitializationError = error } } - internal init( + init( transport: any CodexAppServerTransporting, protocolLayer: CodexAppServerProtocol = CodexAppServerProtocol(), historyStore: ThreadHistoryStore? = nil, @@ -160,16 +161,132 @@ public actor CodexAppServer { self.featurePolicy = featurePolicy if let historyStore { self.historyStore = historyStore - self.historyStoreInitializationError = nil + historyStoreInitializationError = nil } else { do { self.historyStore = try ThreadHistoryStore(configuration: .inMemory()) - self.historyStoreInitializationError = nil + historyStoreInitializationError = nil } catch { self.historyStore = nil - self.historyStoreInitializationError = error + historyStoreInitializationError = error + } + } + } + + private static func recentFileSnapshotID(turnID: String, itemID: String) -> String { + "\(turnID):\(itemID)" + } + + private static func recentCommandSnapshotID(turnID: String, itemID: String) -> String { + "\(turnID):\(itemID)" + } + + private static func isThreadTurnsHistoryUnavailable(_ error: CodexAppServerError) -> Bool { + guard case let .protocolFailure(operation, reason) = error, + operation == "thread/turns/list" else { + return false + } + + return reason.contains("ephemeral threads do not support thread/turns/list") + || reason.contains("thread/turns/list is unavailable before first user message") + } + + private static func recentFileStatusSummary(status: String?, text: String?) -> String? { + let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedStatus = normalizedStatus?.lowercased() + + if lowercasedStatus == "completed", let payloadSummary = recentFilePayloadSummary(text: text) { + return payloadSummary + } + + if let normalizedStatus, !normalizedStatus.isEmpty { + return normalizedStatus + } + + return recentFilePayloadSummary(text: text) + } + + private static func recentFilePayloadSummary(text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + + var additions = 0 + var deletions = 0 + var hunkCount = 0 + var nonEmptyLineCount = 0 + + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let lineString = String(line) + if !lineString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + nonEmptyLineCount += 1 + } + + if lineString.hasPrefix("@@") { + hunkCount += 1 + } else if lineString.hasPrefix("+"), !lineString.hasPrefix("+++") { + additions += 1 + } else if lineString.hasPrefix("-"), !lineString.hasPrefix("---") { + deletions += 1 + } + } + + if additions > 0 || deletions > 0 || hunkCount > 0 { + var parts: [String] = [] + if additions > 0 { + parts.append("\(additions) additions") + } + if deletions > 0 { + parts.append("\(deletions) deletions") + } + if hunkCount > 1 { + parts.append("\(hunkCount) hunks") + } + if !parts.isEmpty { + return parts.joined(separator: ", ") } } + + if nonEmptyLineCount > 1 { + return "\(nonEmptyLineCount) lines changed" + } + + let firstLine = text.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? text + return String(firstLine.prefix(160)) + } + + private static func recentCommandStatusSummary(command: String?, status: String?, text: String?) -> String? { + let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedStatus = normalizedStatus?.lowercased() + + if lowercasedStatus == "completed", let payloadSummary = recentCommandOutputSummary(text: text) { + return payloadSummary + } + + if let normalizedStatus, !normalizedStatus.isEmpty { + return normalizedStatus + } + + if let payloadSummary = recentCommandOutputSummary(text: text) { + return payloadSummary + } + + return command + } + + private static func recentCommandOutputSummary(text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + if nonEmptyLines.count > 1 { + return "\(nonEmptyLines.count) output lines" + } + + guard let firstNonEmptyLine = nonEmptyLines.first else { + return nil + } + + return String(firstNonEmptyLine.prefix(160)) } /// Launches the configured local Codex app-server subprocess. @@ -251,32 +368,6 @@ public actor CodexAppServer { outstandingInteractiveRequests.removeAll() } - private func startTransport() async throws { - try await transport.start() - hasStarted = true - hasCompletedInitializeHandshake = false - isStopping = false - startServerEventLoop() - } - - private func validateStartupCompatibility( - _ diagnostics: CLIExecutableDiagnostics, - policy: StartupCompatibilityPolicy - ) throws { - switch (policy, diagnostics.compatibility) { - case (.allowOutsideReviewedSupportWindow, _), (.requireReviewedSupportWindow, .supported): - return - case (.requireReviewedSupportWindow, .outsideDocumentedWindow): - throw CodexAppServerStartupError.incompatibleCodexCLI( - diagnostics: diagnostics - ) - case (.requireReviewedSupportWindow, .unknownVersionFormat): - throw CodexAppServerStartupError.unknownCodexCLIVersion( - diagnostics: diagnostics - ) - } - } - /// Returns diagnostics for the Codex CLI executable selected at startup. /// /// The value is available after `start()` succeeds. Use it to show users @@ -288,7 +379,6 @@ public actor CodexAppServer { reason: "Codex CLI diagnostics are only available after the app-server transport has been started." ) } - guard let resolution = await transport.executableResolution() else { throw CodexAppServerError.invalidState( reason: "Codex CLI diagnostics are not available for the current transport." @@ -351,198 +441,56 @@ public actor CodexAppServer { } } - /// Runs one argv command through app-server `command/exec`. + /// Reads the app-server's current model catalog. /// - /// This intentionally omits permission-profile and sandbox overrides so - /// Codex applies the user's configured command permissions by default. - internal func executeCommand(_ request: CommandExecRequest) async throws -> CommandExecResult { - try requireInitialized(for: "command/exec") - - guard !request.command.isEmpty else { - throw CodexAppServerError.invalidState( - reason: "SwiftASB cannot run command/exec with an empty argv vector." - ) - } + /// Omitting `request` sends an empty list request, leaving pagination and + /// hidden-model behavior to the app-server defaults. + public func listModels(_ request: ModelListRequest = .init()) async throws -> ModelListPage { + try requireInitialized(for: "model/list") let requestID = CodexRPCRequestID.generated() do { - let requestPayload = try protocolLayer.makeCommandExecRequest( + let requestPayload = try protocolLayer.makeModelListRequest( id: requestID, - params: CodexProtocolCommandExecParams( - command: request.command, - cwd: request.currentDirectoryPath, - disableOutputCap: nil, - disableTimeout: nil, - env: request.environment.isEmpty ? nil : request.environment, - outputBytesCap: request.outputBytesCap, - permissionProfile: nil, - processID: nil, - sandboxPolicy: nil, - size: nil, - streamStdin: nil, - streamStdoutStderr: nil, - timeoutMS: request.timeoutMilliseconds, - tty: nil + params: .init( + cursor: request.cursor, + includeHidden: request.includeHidden, + limit: request.limit ) ) let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeCommandExecResponse( + let response = try protocolLayer.decodeModelListResponse( responsePayload, expectedID: requestID ) return .init( - exitCode: response.exitCode, - stdout: response.stdout, - stderr: response.stderr + models: response.data.map(Model.init(wireValue:)), + nextCursor: response.nextCursor ) } catch { - throw CodexAppServerError.wrap(error, operation: "command/exec") + throw CodexAppServerError.wrap(error, operation: "model/list") } } - /// Sends one user-level shell command string to a running thread. + /// Reads feature gates for the current model provider. /// - /// `thread/shellCommand` is intentionally different from `command/exec`. - /// `command/exec` runs argv-shaped SwiftASB helper commands through the - /// app-server command runner. `thread/shellCommand` sends literal shell - /// syntax to the thread's configured shell, preserving pipes, redirects, - /// quoting, and other shell behavior, and the upstream schema documents - /// that it runs unsandboxed with the user's full shell access. - internal func sendThreadShellCommand( - command: String, - threadID: String - ) async throws { - try requireInitialized(for: "thread/shellCommand") - try requireFeatureEnabled(.shellCommandExecution, for: "thread/shellCommand") - - let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedCommand.isEmpty else { - throw CodexAppServerError.invalidState( - reason: "SwiftASB cannot send thread/shellCommand with an empty shell command string." - ) - } + /// Use this app-wide snapshot to decide whether UI affordances for web + /// search, image generation, or namespace tools should be available before + /// starting a turn. + public func readModelCapabilities() async throws -> ModelCapabilities { + try requireInitialized(for: "modelProvider/capabilities/read") - let operationID = UUID().uuidString - let startedAt = Date() + let requestID = CodexRPCRequestID.generated() do { - let requestID = CodexRPCRequestID.generated() - let requestPayload = try protocolLayer.makeThreadShellCommandRequest( + let requestPayload = try protocolLayer.makeModelProviderCapabilitiesReadRequest( id: requestID, - params: .init(command: command, threadID: threadID) + params: .init() ) let responsePayload = try await transport.send(requestPayload, id: requestID) - _ = try protocolLayer.decodeThreadShellCommandResponse( - responsePayload, - expectedID: requestID - ) - - publishFeatureOperationEvent( - .init( - categoryID: .shellCommandExecution, - operationID: operationID, - title: "Send shell command", - summary: "Sent a user-level shell command to the Codex thread.", - reason: "Shell command execution is enabled by the host app.", - startedAt: startedAt, - completedAt: Date(), - appServerMethod: "thread/shellCommand", - intentKind: "threadShellCommand", - status: .succeeded - ) - ) - } catch { - publishFeatureOperationEvent( - .init( - categoryID: .shellCommandExecution, - operationID: operationID, - title: "Send shell command", - summary: "Failed to send a user-level shell command to the Codex thread.", - reason: "Shell command execution is enabled by the host app.", - startedAt: startedAt, - completedAt: Date(), - appServerMethod: "thread/shellCommand", - intentKind: "threadShellCommand", - status: .failed, - diagnosticText: String(describing: error) - ) - ) - throw CodexAppServerError.wrap(error, operation: "thread/shellCommand") - } - } - - internal func codexCommandExecutablePath() async -> String { - await transport.executableResolution()?.resolvedExecutableURL?.path ?? "codex" - } - - internal func requireFeatureEnabled( - _ categoryID: SwiftASBFeatureCategory.ID, - for operation: String - ) throws { - guard featurePolicy.mode(for: categoryID) == .enabled else { - let categoryName = SwiftASBFeatureCategory.builtInCategory(id: categoryID)?.displayName - ?? categoryID.rawValue - throw CodexAppServerError.invalidState( - reason: """ - SwiftASB cannot run \(operation) because the \(categoryName) feature category is not enabled. \ - Enable \(categoryID.rawValue) in SwiftASBFeaturePolicy before requesting this SwiftASB-owned mutation. - """ - ) - } - } - - /// Reads the app-server's current model catalog. - /// - /// Omitting `request` sends an empty list request, leaving pagination and - /// hidden-model behavior to the app-server defaults. - public func listModels(_ request: ModelListRequest = .init()) async throws -> ModelListPage { - try requireInitialized(for: "model/list") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeModelListRequest( - id: requestID, - params: .init( - cursor: request.cursor, - includeHidden: request.includeHidden, - limit: request.limit - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeModelListResponse( - responsePayload, - expectedID: requestID - ) - - return .init( - models: response.data.map(Model.init(wireValue:)), - nextCursor: response.nextCursor - ) - } catch { - throw CodexAppServerError.wrap(error, operation: "model/list") - } - } - - /// Reads feature gates for the current model provider. - /// - /// Use this app-wide snapshot to decide whether UI affordances for web - /// search, image generation, or namespace tools should be available before - /// starting a turn. - public func readModelCapabilities() async throws -> ModelCapabilities { - try requireInitialized(for: "modelProvider/capabilities/read") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeModelProviderCapabilitiesReadRequest( - id: requestID, - params: .init() - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeModelProviderCapabilitiesReadResponse( + let response = try protocolLayer.decodeModelProviderCapabilitiesReadResponse( responsePayload, expectedID: requestID ) @@ -588,169 +536,6 @@ public actor CodexAppServer { globalMcpServerStatusPage } - internal func mcpServerStatusSnapshot(threadID: String) -> McpServerStatusPage { - threadMcpServerStatusPages[threadID] ?? .init(nextCursor: nil, servers: []) - } - - /// Reads the app-server's current MCP server status snapshots. - /// - /// Omitting `request` sends an empty status-list request, leaving - /// pagination and detail level to the app-server defaults. - @available( - *, - deprecated, - message: "Use SwiftASB-owned MCP status snapshots instead of issuing raw MCP status list requests." - ) - public func listMcpServerStatuses( - _ request: McpServerStatusListRequest = .init() - ) async throws -> McpServerStatusPage { - let page = try await readMcpServerStatusPage(request) - updateMcpServerStatusCache(page, threadID: request.threadID) - return page - } - - internal func refreshGlobalMcpServerStatusSnapshot() async throws -> McpServerStatusPage { - let page = try await readMcpServerStatusPage(.init()) - globalMcpServerStatusPage = page - return page - } - - internal func globalMcpServerSummaries() -> [McpServerSummary] { - globalMcpServerStatusPage.servers.map { status in - .init(status: status, scope: .global) - } - } - - internal func hydrateMcpServerSummaries(threadID: String) async -> [McpServerSummary] { - do { - let page = try await refreshMcpServerStatusSnapshot(threadID: threadID) - return mcpServerSummaries(forThreadStatusPage: page) - } catch { - return threadMcpServerStatusPages[threadID] - .map(mcpServerSummaries(forThreadStatusPage:)) ?? [] - } - } - - internal func refreshMcpServerStatusSnapshot(threadID: String) async throws -> McpServerStatusPage { - let page = try await readMcpServerStatusPage(.init(threadID: threadID)) - threadMcpServerStatusPages[threadID] = page - return page - } - - private func readMcpServerStatusPage( - _ request: McpServerStatusListRequest - ) async throws -> McpServerStatusPage { - try requireInitialized(for: "mcpServerStatus/list") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeMcpServerStatusListRequest( - id: requestID, - params: .init( - cursor: request.cursor, - detail: request.detail?.wireValue, - limit: request.limit, - threadID: request.threadID - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeMcpServerStatusListResponse( - responsePayload, - expectedID: requestID - ) - - return .init( - nextCursor: response.nextCursor, - servers: response.data.map(McpServerStatus.init(wireValue:)) - ) - } catch { - throw CodexAppServerError.wrap(error, operation: "mcpServerStatus/list") - } - } - - private func updateMcpServerStatusCache(_ page: McpServerStatusPage, threadID: String?) { - if let threadID { - threadMcpServerStatusPages[threadID] = page - } else { - globalMcpServerStatusPage = page - } - } - - private func mcpServerSummaries(forThreadStatusPage page: McpServerStatusPage) -> [McpServerSummary] { - let globalServerNames = Set(globalMcpServerStatusPage.servers.map(\.name)) - return page.servers.map { status in - .init( - status: status, - scope: globalServerNames.contains(status.name) ? .global : .thread - ) - } - } - - func installMCPServer( - _ definition: SwiftASB.CodexExtensions.MCP.ServerDefinition - ) async throws -> SwiftASB.CodexExtensions.MCP.InstallResult { - try requireInitialized(for: "config/batchWrite") - try validateMCPServerName(definition.name) - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeConfigBatchWriteRequest( - id: requestID, - params: .init( - edits: [ - .init( - keyPath: "mcp_servers.\(definition.name)", - mergeStrategy: .replace, - value: definition.configValue.wireValue - ), - ], - expectedVersion: nil, - filePath: nil, - reloadUserConfig: true - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeConfigBatchWriteResponse( - responsePayload, - expectedID: requestID - ) - let page = try await refreshGlobalMcpServerStatusSnapshot() - - return .init( - configFilePath: response.filePath, - server: page.servers.first { $0.name == definition.name } - .map { .init(status: $0, scope: .global) }, - status: .init(protocolValue: response.status), - version: response.version - ) - } catch { - throw CodexAppServerError.wrap(error, operation: "config/batchWrite") - } - } - - private func validateMCPServerName(_ name: String) throws { - guard name.isEmpty == false else { - throw CodexAppServerError.invalidState( - reason: "SwiftASB cannot install an MCP server with an empty name because Codex stores servers under mcp_servers. in config.toml." - ) - } - - let allowedScalars = name.unicodeScalars.allSatisfy { scalar in - (65...90).contains(scalar.value) - || (97...122).contains(scalar.value) - || (48...57).contains(scalar.value) - || scalar.value == 45 - || scalar.value == 95 - } - guard allowedScalars else { - throw CodexAppServerError.invalidState( - reason: "SwiftASB cannot install MCP server '\(name)' because server names must contain only letters, numbers, underscores, or hyphens for safe config key-path writes." - ) - } - } - /// Reads one resource from a configured MCP server. public func readMcpResource(_ request: McpResourceReadRequest) async throws -> McpResourceReadResult { try requireInitialized(for: "mcpServer/resource/read") @@ -1098,98 +883,479 @@ public actor CodexAppServer { responsePayload, expectedID: requestID ) - let thread = ThreadInfo(wireValue: response.thread) - try await requireHistoryStore(for: "thread/metadata/update").recordThreadMetadataUpdated(thread) - publishLibraryEvent(.threadChanged(threadID: thread.id)) - - return thread - } catch { - throw CodexAppServerError.wrap(error, operation: "thread/metadata/update") + let thread = ThreadInfo(wireValue: response.thread) + try await requireHistoryStore(for: "thread/metadata/update").recordThreadMetadataUpdated(thread) + publishLibraryEvent(.threadChanged(threadID: thread.id)) + + return thread + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/metadata/update") + } + } + + /// Reads a page of stored Codex threads. + /// + /// Omitting `request` sends an empty thread-list request, leaving page + /// size, sort order, filters, and archive visibility to the app-server. + /// Reads a page of stored thread snapshots from the app-server. + /// + /// Omitting `request` sends an empty list request, leaving filters, sort + /// order, and page size to the app-server defaults. + public func listThreads(_ request: ThreadListRequest = .init()) async throws -> ThreadListPage { + try requireInitialized(for: "thread/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeThreadListRequest( + id: requestID, + params: .init( + archived: request.archived, + cursor: request.cursor, + cwd: request.currentDirectoryPath, + limit: request.limit, + modelProviders: request.modelProviders, + searchTerm: request.searchTerm, + sortDirection: request.sortDirection.map(CodexProtocolThreadListSortDirection.init), + sortKey: request.sortKey.map(CodexProtocolThreadListSortKey.init), + sourceKinds: request.sourceKinds?.map(CodexProtocolThreadListSourceKind.init) + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeThreadListResponse( + responsePayload, + expectedID: requestID + ) + + let threads = response.data.map(ThreadInfo.init(wireValue:)) + try await requireHistoryStore(for: "thread/list").reconcileThreadListPage( + threads, + archived: request.archived + ) + + return .init( + nextCursor: response.nextCursor, + threads: threads + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/list") + } + } + + /// Reads a page of stored Codex threads from a SwiftASB query descriptor. + /// + /// This overload compiles `query` into the app-server `thread/list` request + /// shape. Use `CodexAppServer.Library` when the same descriptor should load + /// local history snapshots first and reconcile app-server pages in the + /// background. + public func listThreads( + _ query: ThreadListQD, + cursor: String? = nil + ) async throws -> ThreadListPage { + try await listThreads(query.threadListRequest(cursor: cursor)) + } + + /// Lists thread ids currently loaded in the app-server runtime. + public func listLoadedThreads(_ request: LoadedThreadListRequest = .init()) async throws -> LoadedThreadListPage { + try requireInitialized(for: "thread/loaded/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeThreadLoadedListRequest( + id: requestID, + params: .init(cursor: request.cursor, limit: request.limit) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeThreadLoadedListResponse( + responsePayload, + expectedID: requestID + ) + + return .init(nextCursor: response.nextCursor, threadIDs: response.data) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/loaded/list") + } + } + + /// Reads a stored thread snapshot and optionally includes turns. + /// + /// Returned turns are hydrated into SwiftASB's local history store so later + /// thread-scoped history helpers can read the same completed-turn data. + public func readThread(_ request: ThreadReadRequest) async throws -> ThreadReadResult { + try requireInitialized(for: "thread/read") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeThreadReadRequest( + id: requestID, + params: .init( + includeTurns: request.includeTurns ? true : nil, + threadID: request.threadID + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeThreadReadResponse( + responsePayload, + expectedID: requestID + ) + + let thread = ThreadInfo(wireValue: response.thread) + let turns = response.thread.turns.map(TurnInfo.init(wireValue:)) + if request.includeTurns { + try await requireHistoryStore(for: "thread/read").hydrateThreadRead( + thread: thread, + turns: response.thread.turns.map { + .init( + turn: .init(wireValue: $0), + items: $0.items.map(CodexTurnItem.init(wireValue:)) + ) + } + ) + } else { + try await requireHistoryStore(for: "thread/read").recordThreadMetadataUpdated(thread) + } + + return .init(thread: thread, turns: turns) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/read") + } + } + + /// Reads a page of turns for a stored thread directly from the app-server. + /// + /// This low-level paging API surfaces app-server errors as failures. Recent + /// observable companions use a narrower startup fallback for known + /// history-unavailable responses. + public func listThreadTurns(_ request: ThreadTurnsListRequest) async throws -> ThreadTurnsPage { + try requireInitialized(for: "thread/turns/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeThreadTurnsListRequest( + id: requestID, + params: .init( + cursor: request.cursor, + itemsView: request.itemsView.map(CodexWireTurnItemsView.init), + limit: request.limit, + sortDirection: request.sortDirection.map(CodexProtocolThreadTurnsSortDirection.init), + threadID: request.threadID + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeThreadTurnsListResponse( + responsePayload, + expectedID: requestID + ) + + try await requireHistoryStore(for: "thread/turns/list").hydrateHistoricalTurns( + threadID: request.threadID, + turns: response.data.map { + .init( + turn: .init(wireValue: $0), + items: $0.items.map(CodexTurnItem.init(wireValue:)) + ) + } + ) + + return .init( + backwardsCursor: response.backwardsCursor, + nextCursor: response.nextCursor, + turns: response.data.map(TurnInfo.init(wireValue:)) + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/turns/list") + } + } + + /// Reads a page of stored items for one turn directly from the app-server. + /// + /// This low-level paging API returns app-server item snapshots without + /// assuming the caller has loaded the full containing turn. Paged item reads + /// do not mutate SwiftASB's local history store because a single item page + /// does not carry enough information to safely reconcile whole-turn item + /// ordering. + public func listThreadTurnItems(_ request: ThreadTurnsItemsListRequest) async throws -> ThreadTurnsItemsPage { + try requireInitialized(for: "thread/turns/items/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeThreadTurnsItemsListRequest( + id: requestID, + params: .init( + cursor: request.cursor, + limit: request.limit, + sortDirection: request.sortDirection.map(CodexWireRemoteControlClientsListOrder.init), + threadID: request.threadID, + turnID: request.turnID + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeThreadTurnsItemsListResponse( + responsePayload, + expectedID: requestID + ) + + return .init( + backwardsCursor: response.backwardsCursor, + items: response.data.map(CodexTurnItem.init(wireValue:)), + nextCursor: response.nextCursor + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/turns/items/list") + } + } + + /// Starts a turn from an app-server-owned request. + /// + /// Most consumers should prefer `CodexThread.startTurn(_:)` or + /// `CodexThread.startTextTurn(...)` so the thread identity and defaults are + /// supplied by the thread handle. + public func startTurn(_ request: TurnStartRequest) async throws -> CodexTurnHandle { + try requireInitialized(for: "turn/start") + try reserveThreadForTurnStart(threadID: request.threadID) + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeTurnStartRequest( + id: requestID, + params: request.wireValue + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeTurnStartResponse( + responsePayload, + expectedID: requestID + ) + + let turn = TurnInfo(wireValue: response.turn) + markThreadTurnActive(threadID: request.threadID, turnID: turn.id) + try await requireHistoryStore(for: "turn/start").recordTurnStarted(threadID: request.threadID, turn: turn) + return await makeTurnHandle(threadID: request.threadID, turn: turn) + } catch { + clearThreadTurnReservation(threadID: request.threadID) + throw CodexAppServerError.wrap(error, operation: "turn/start") + } + } + + /// Runs one argv command through app-server `command/exec`. + /// + /// This intentionally omits permission-profile and sandbox overrides so + /// Codex applies the user's configured command permissions by default. + func executeCommand(_ request: CommandExecRequest) async throws -> CommandExecResult { + try requireInitialized(for: "command/exec") + + guard !request.command.isEmpty else { + throw CodexAppServerError.invalidState( + reason: "SwiftASB cannot run command/exec with an empty argv vector." + ) + } + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeCommandExecRequest( + id: requestID, + params: CodexProtocolCommandExecParams( + command: request.command, + cwd: request.currentDirectoryPath, + disableOutputCap: nil, + disableTimeout: nil, + env: request.environment.isEmpty ? nil : request.environment, + outputBytesCap: request.outputBytesCap, + permissionProfile: nil, + processID: nil, + sandboxPolicy: nil, + size: nil, + streamStdin: nil, + streamStdoutStderr: nil, + timeoutMS: request.timeoutMilliseconds, + tty: nil + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeCommandExecResponse( + responsePayload, + expectedID: requestID + ) + + return .init( + exitCode: response.exitCode, + stdout: response.stdout, + stderr: response.stderr + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "command/exec") + } + } + + /// Sends one user-level shell command string to a running thread. + /// + /// `thread/shellCommand` is intentionally different from `command/exec`. + /// `command/exec` runs argv-shaped SwiftASB helper commands through the + /// app-server command runner. `thread/shellCommand` sends literal shell + /// syntax to the thread's configured shell, preserving pipes, redirects, + /// quoting, and other shell behavior, and the upstream schema documents + /// that it runs unsandboxed with the user's full shell access. + func sendThreadShellCommand( + command: String, + threadID: String + ) async throws { + try requireInitialized(for: "thread/shellCommand") + try requireFeatureEnabled(.shellCommandExecution, for: "thread/shellCommand") + + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCommand.isEmpty else { + throw CodexAppServerError.invalidState( + reason: "SwiftASB cannot send thread/shellCommand with an empty shell command string." + ) + } + + let operationID = UUID().uuidString + let startedAt = Date() + + do { + let requestID = CodexRPCRequestID.generated() + let requestPayload = try protocolLayer.makeThreadShellCommandRequest( + id: requestID, + params: .init(command: command, threadID: threadID) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + _ = try protocolLayer.decodeThreadShellCommandResponse( + responsePayload, + expectedID: requestID + ) + + publishFeatureOperationEvent( + .init( + categoryID: .shellCommandExecution, + operationID: operationID, + title: "Send shell command", + summary: "Sent a user-level shell command to the Codex thread.", + reason: "Shell command execution is enabled by the host app.", + startedAt: startedAt, + completedAt: Date(), + appServerMethod: "thread/shellCommand", + intentKind: "threadShellCommand", + status: .succeeded + ) + ) + } catch { + publishFeatureOperationEvent( + .init( + categoryID: .shellCommandExecution, + operationID: operationID, + title: "Send shell command", + summary: "Failed to send a user-level shell command to the Codex thread.", + reason: "Shell command execution is enabled by the host app.", + startedAt: startedAt, + completedAt: Date(), + appServerMethod: "thread/shellCommand", + intentKind: "threadShellCommand", + status: .failed, + diagnosticText: String(describing: error) + ) + ) + throw CodexAppServerError.wrap(error, operation: "thread/shellCommand") + } + } + + func codexCommandExecutablePath() async -> String { + await transport.executableResolution()?.resolvedExecutableURL?.path ?? "codex" + } + + func requireFeatureEnabled( + _ categoryID: SwiftASBFeatureCategory.ID, + for operation: String + ) throws { + guard featurePolicy.mode(for: categoryID) == .enabled else { + let categoryName = SwiftASBFeatureCategory.builtInCategory(id: categoryID)?.displayName + ?? categoryID.rawValue + throw CodexAppServerError.invalidState( + reason: """ + SwiftASB cannot run \(operation) because the \(categoryName) feature category is not enabled. \ + Enable \(categoryID.rawValue) in SwiftASBFeaturePolicy before requesting this SwiftASB-owned mutation. + """ + ) } } - /// Reads a page of stored Codex threads. - /// - /// Omitting `request` sends an empty thread-list request, leaving page - /// size, sort order, filters, and archive visibility to the app-server. - /// Reads a page of stored thread snapshots from the app-server. - /// - /// Omitting `request` sends an empty list request, leaving filters, sort - /// order, and page size to the app-server defaults. - public func listThreads(_ request: ThreadListRequest = .init()) async throws -> ThreadListPage { - try requireInitialized(for: "thread/list") - - let requestID = CodexRPCRequestID.generated() + func mcpServerStatusSnapshot(threadID: String) -> McpServerStatusPage { + threadMcpServerStatusPages[threadID] ?? .init(nextCursor: nil, servers: []) + } - do { - let requestPayload = try protocolLayer.makeThreadListRequest( - id: requestID, - params: .init( - archived: request.archived, - cursor: request.cursor, - cwd: request.currentDirectoryPath, - limit: request.limit, - modelProviders: request.modelProviders, - searchTerm: request.searchTerm, - sortDirection: request.sortDirection.map(CodexProtocolThreadListSortDirection.init), - sortKey: request.sortKey.map(CodexProtocolThreadListSortKey.init), - sourceKinds: request.sourceKinds?.map(CodexProtocolThreadListSourceKind.init) - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeThreadListResponse( - responsePayload, - expectedID: requestID - ) + func refreshGlobalMcpServerStatusSnapshot() async throws -> McpServerStatusPage { + let page = try await readMcpServerStatusPage(.init()) + globalMcpServerStatusPage = page + return page + } - let threads = response.data.map(ThreadInfo.init(wireValue:)) - try await requireHistoryStore(for: "thread/list").reconcileThreadListPage( - threads, - archived: request.archived - ) + func globalMcpServerSummaries() -> [McpServerSummary] { + globalMcpServerStatusPage.servers.map { status in + .init(status: status, scope: .global) + } + } - return .init( - nextCursor: response.nextCursor, - threads: threads - ) + func hydrateMcpServerSummaries(threadID: String) async -> [McpServerSummary] { + do { + let page = try await refreshMcpServerStatusSnapshot(threadID: threadID) + return mcpServerSummaries(forThreadStatusPage: page) } catch { - throw CodexAppServerError.wrap(error, operation: "thread/list") + return threadMcpServerStatusPages[threadID] + .map(mcpServerSummaries(forThreadStatusPage:)) ?? [] } } - /// Reads a page of stored Codex threads from a SwiftASB query descriptor. - /// - /// This overload compiles `query` into the app-server `thread/list` request - /// shape. Use `CodexAppServer.Library` when the same descriptor should load - /// local history snapshots first and reconcile app-server pages in the - /// background. - public func listThreads( - _ query: ThreadListQD, - cursor: String? = nil - ) async throws -> ThreadListPage { - try await listThreads(query.threadListRequest(cursor: cursor)) + func refreshMcpServerStatusSnapshot(threadID: String) async throws -> McpServerStatusPage { + let page = try await readMcpServerStatusPage(.init(threadID: threadID)) + threadMcpServerStatusPages[threadID] = page + return page } - /// Lists thread ids currently loaded in the app-server runtime. - public func listLoadedThreads(_ request: LoadedThreadListRequest = .init()) async throws -> LoadedThreadListPage { - try requireInitialized(for: "thread/loaded/list") + func installMCPServer( + _ definition: SwiftASB.CodexExtensions.MCP.ServerDefinition + ) async throws -> SwiftASB.CodexExtensions.MCP.InstallResult { + try requireInitialized(for: "config/batchWrite") + try validateMCPServerName(definition.name) let requestID = CodexRPCRequestID.generated() do { - let requestPayload = try protocolLayer.makeThreadLoadedListRequest( + let requestPayload = try protocolLayer.makeConfigBatchWriteRequest( id: requestID, - params: .init(cursor: request.cursor, limit: request.limit) + params: .init( + edits: [ + .init( + keyPath: "mcp_servers.\(definition.name)", + mergeStrategy: .replace, + value: definition.configValue.wireValue + ), + ], + expectedVersion: nil, + filePath: nil, + reloadUserConfig: true + ) ) let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeThreadLoadedListResponse( + let response = try protocolLayer.decodeConfigBatchWriteResponse( responsePayload, expectedID: requestID ) + let page = try await refreshGlobalMcpServerStatusSnapshot() - return .init(nextCursor: response.nextCursor, threadIDs: response.data) + return .init( + configFilePath: response.filePath, + server: page.servers + .first { $0.name == definition.name } + .map { .init(status: $0, scope: .global) }, + status: .init(protocolValue: response.status), + version: response.version + ) } catch { - throw CodexAppServerError.wrap(error, operation: "thread/loaded/list") + throw CodexAppServerError.wrap(error, operation: "config/batchWrite") } } @@ -1463,11 +1629,6 @@ public actor CodexAppServer { _ request: SwiftASB.CodexExtensions.SkillListRequest ) async throws -> SwiftASB.CodexExtensions.SkillListSnapshot { try requireInitialized(for: "skills/list") - if request.perCurrentDirectoryExtraUserRoots != nil { - throw CodexAppServerError.invalidState( - reason: "Codex CLI 0.130.0 removed per-cwd extra user roots from skills/list; pass currentDirectoryPaths and forceReload only." - ) - } let requestID = CodexRPCRequestID.generated() @@ -1496,239 +1657,76 @@ public actor CodexAppServer { ) async throws -> SwiftASB.CodexExtensions.PluginListSnapshot { try requireInitialized(for: "plugin/list") - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makePluginListRequest( - id: requestID, - params: .init(cwds: request.currentDirectoryPaths, marketplaceKinds: nil) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodePluginListResponse( - responsePayload, - expectedID: requestID - ) - - return .init(wireValue: response) - } catch { - throw CodexAppServerError.wrap(error, operation: "plugin/list") - } - } - - func readExtensionPlugin( - _ request: SwiftASB.CodexExtensions.PluginReadRequest - ) async throws -> SwiftASB.CodexExtensions.PluginDetail { - try requireInitialized(for: "plugin/read") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makePluginReadRequest( - id: requestID, - params: .init( - marketplacePath: request.marketplacePath, - pluginName: request.pluginName, - remoteMarketplaceName: request.remoteMarketplaceName - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodePluginReadResponse( - responsePayload, - expectedID: requestID - ) - - return .init(wireValue: response.plugin) - } catch { - throw CodexAppServerError.wrap(error, operation: "plugin/read") - } - } - - func listExtensionCollaborationModes() async throws -> SwiftASB.CodexExtensions.CollaborationModeList { - try requireInitialized(for: "collaborationMode/list") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeCollaborationModeListRequest( - id: requestID, - params: .init() - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeCollaborationModeListResponse( - responsePayload, - expectedID: requestID - ) - - return .init(wireValue: response) - } catch { - throw CodexAppServerError.wrap(error, operation: "collaborationMode/list") - } - } - - /// Reads a stored thread snapshot and optionally includes turns. - /// - /// Returned turns are hydrated into SwiftASB's local history store so later - /// thread-scoped history helpers can read the same completed-turn data. - public func readThread(_ request: ThreadReadRequest) async throws -> ThreadReadResult { - try requireInitialized(for: "thread/read") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeThreadReadRequest( - id: requestID, - params: .init( - includeTurns: request.includeTurns ? true : nil, - threadID: request.threadID - ) - ) - let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeThreadReadResponse( - responsePayload, - expectedID: requestID - ) - - let thread = ThreadInfo(wireValue: response.thread) - let turns = response.thread.turns.map(TurnInfo.init(wireValue:)) - if request.includeTurns { - try await requireHistoryStore(for: "thread/read").hydrateThreadRead( - thread: thread, - turns: response.thread.turns.map { - .init( - turn: .init(wireValue: $0), - items: $0.items.map(CodexTurnItem.init(wireValue:)) - ) - } - ) - } else { - try await requireHistoryStore(for: "thread/read").recordThreadMetadataUpdated(thread) - } - - return .init(thread: thread, turns: turns) - } catch { - throw CodexAppServerError.wrap(error, operation: "thread/read") - } - } - - /// Reads a page of turns for a stored thread directly from the app-server. - /// - /// This low-level paging API surfaces app-server errors as failures. Recent - /// observable companions use a narrower startup fallback for known - /// history-unavailable responses. - public func listThreadTurns(_ request: ThreadTurnsListRequest) async throws -> ThreadTurnsPage { - try requireInitialized(for: "thread/turns/list") - - let requestID = CodexRPCRequestID.generated() - - do { - let requestPayload = try protocolLayer.makeThreadTurnsListRequest( - id: requestID, - params: .init( - cursor: request.cursor, - itemsView: request.itemsView.map(CodexWireTurnItemsView.init), - limit: request.limit, - sortDirection: request.sortDirection.map(CodexProtocolThreadTurnsSortDirection.init), - threadID: request.threadID - ) + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makePluginListRequest( + id: requestID, + params: .init(cwds: request.currentDirectoryPaths, marketplaceKinds: nil) ) let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeThreadTurnsListResponse( + let response = try protocolLayer.decodePluginListResponse( responsePayload, expectedID: requestID ) - try await requireHistoryStore(for: "thread/turns/list").hydrateHistoricalTurns( - threadID: request.threadID, - turns: response.data.map { - .init( - turn: .init(wireValue: $0), - items: $0.items.map(CodexTurnItem.init(wireValue:)) - ) - } - ) - - return .init( - backwardsCursor: response.backwardsCursor, - nextCursor: response.nextCursor, - turns: response.data.map(TurnInfo.init(wireValue:)) - ) + return .init(wireValue: response) } catch { - throw CodexAppServerError.wrap(error, operation: "thread/turns/list") + throw CodexAppServerError.wrap(error, operation: "plugin/list") } } - /// Reads a page of stored items for one turn directly from the app-server. - /// - /// This low-level paging API returns app-server item snapshots without - /// assuming the caller has loaded the full containing turn. Paged item reads - /// do not mutate SwiftASB's local history store because a single item page - /// does not carry enough information to safely reconcile whole-turn item - /// ordering. - public func listThreadTurnItems(_ request: ThreadTurnsItemsListRequest) async throws -> ThreadTurnsItemsPage { - try requireInitialized(for: "thread/turns/items/list") + func readExtensionPlugin( + _ request: SwiftASB.CodexExtensions.PluginReadRequest + ) async throws -> SwiftASB.CodexExtensions.PluginDetail { + try requireInitialized(for: "plugin/read") let requestID = CodexRPCRequestID.generated() do { - let requestPayload = try protocolLayer.makeThreadTurnsItemsListRequest( + let requestPayload = try protocolLayer.makePluginReadRequest( id: requestID, params: .init( - cursor: request.cursor, - limit: request.limit, - sortDirection: request.sortDirection.map(CodexWireRemoteControlClientsListOrder.init), - threadID: request.threadID, - turnID: request.turnID + marketplacePath: request.marketplacePath, + pluginName: request.pluginName, + remoteMarketplaceName: request.remoteMarketplaceName ) ) let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeThreadTurnsItemsListResponse( + let response = try protocolLayer.decodePluginReadResponse( responsePayload, expectedID: requestID ) - return .init( - backwardsCursor: response.backwardsCursor, - items: response.data.map(CodexTurnItem.init(wireValue:)), - nextCursor: response.nextCursor - ) + return .init(wireValue: response.plugin) } catch { - throw CodexAppServerError.wrap(error, operation: "thread/turns/items/list") + throw CodexAppServerError.wrap(error, operation: "plugin/read") } } - /// Starts a turn from an app-server-owned request. - /// - /// Most consumers should prefer `CodexThread.startTurn(_:)` or - /// `CodexThread.startTextTurn(...)` so the thread identity and defaults are - /// supplied by the thread handle. - public func startTurn(_ request: TurnStartRequest) async throws -> CodexTurnHandle { - try requireInitialized(for: "turn/start") - try reserveThreadForTurnStart(threadID: request.threadID) + func listExtensionCollaborationModes() async throws -> SwiftASB.CodexExtensions.CollaborationModeList { + try requireInitialized(for: "collaborationMode/list") let requestID = CodexRPCRequestID.generated() do { - let requestPayload = try protocolLayer.makeTurnStartRequest( + let requestPayload = try protocolLayer.makeCollaborationModeListRequest( id: requestID, - params: request.wireValue + params: .init() ) let responsePayload = try await transport.send(requestPayload, id: requestID) - let response = try protocolLayer.decodeTurnStartResponse( + let response = try protocolLayer.decodeCollaborationModeListResponse( responsePayload, expectedID: requestID ) - let turn = TurnInfo(wireValue: response.turn) - markThreadTurnActive(threadID: request.threadID, turnID: turn.id) - try await requireHistoryStore(for: "turn/start").recordTurnStarted(threadID: request.threadID, turn: turn) - return await makeTurnHandle(threadID: request.threadID, turn: turn) + return .init(wireValue: response) } catch { - clearThreadTurnReservation(threadID: request.threadID) - throw CodexAppServerError.wrap(error, operation: "turn/start") + throw CodexAppServerError.wrap(error, operation: "collaborationMode/list") } } - internal func startReview( + func startReview( against subject: CodexThread.ReviewSubject, placement: CodexThread.ReviewPlacement, sourceThreadID: String @@ -1777,29 +1775,7 @@ public actor CodexAppServer { } } - private func makeTurnHandle( - threadID: String, - turn: TurnInfo - ) async -> CodexTurnHandle { - let eventStream = makeTurnEventStream(turnID: turn.id) - let minimapStream = makeTurnEventStream(turnID: turn.id) - let minimap = await MainActor.run { - CodexTurnHandle.Minimap( - threadID: threadID, - initialTurn: turn, - events: minimapStream - ) - } - return CodexTurnHandle( - appServer: self, - threadID: threadID, - turn: turn, - events: eventStream, - minimap: minimap - ) - } - - internal func interruptTurn( + func interruptTurn( threadID: String, turnID: String ) async throws { @@ -1822,7 +1798,7 @@ public actor CodexAppServer { } } - internal func steerTurn( + func steerTurn( threadID: String, turnID: String, input: [TurnInput] @@ -1856,38 +1832,39 @@ public actor CodexAppServer { } } - internal func threadEventStream( + func threadEventStream( threadID: String ) -> AsyncThrowingStream { makeThreadEventStream(threadID: threadID) } - internal func threadStatus(threadID: String) -> ThreadStatus? { + func threadStatus(threadID: String) -> ThreadStatus? { threadStatuses[threadID] } - internal func turnEventStream( + func turnEventStream( turnID: String ) -> AsyncThrowingStream { makeTurnEventStream(turnID: turnID) } - internal func threadTurnEventStream( + func threadTurnEventStream( threadID: String ) -> AsyncThrowingStream { makeThreadTurnEventStream(threadID: threadID) } - internal func unresolvedInteractiveTurnIDs(threadID: String) -> Set { + func unresolvedInteractiveTurnIDs(threadID: String) -> Set { Set( outstandingInteractiveRequests.values.compactMap { request in guard request.threadID == threadID else { return nil } + return request.turnID } ) } - internal func threadObservableActivityState(threadID: String) -> CodexThread.Dashboard.ActivityState { + func threadObservableActivityState(threadID: String) -> CodexThread.Dashboard.ActivityState { let state = threadObservableActivityStates[threadID] ?? .init() return .init( activeAutoReviewIDs: state.activeAutoReviewIDs, @@ -1901,7 +1878,7 @@ public actor CodexAppServer { ) } - internal func threadObservableActivityStream( + func threadObservableActivityStream( threadID: String ) -> AsyncStream { let streamID = UUID() @@ -1923,7 +1900,7 @@ public actor CodexAppServer { } } - internal func threadFileChangeOutputDeltaStream( + func threadFileChangeOutputDeltaStream( threadID: String ) -> AsyncStream { let streamID = UUID() @@ -1944,7 +1921,7 @@ public actor CodexAppServer { } } - internal func threadCommandExecutionOutputDeltaStream( + func threadCommandExecutionOutputDeltaStream( threadID: String ) -> AsyncStream { let streamID = UUID() @@ -1965,7 +1942,7 @@ public actor CodexAppServer { } } - internal func libraryEvents() -> AsyncStream { + func libraryEvents() -> AsyncStream { let streamID = UUID() return AsyncStream { continuation in @@ -1979,7 +1956,7 @@ public actor CodexAppServer { } } - internal func publishFeatureOperationEvent(_ event: SwiftASBFeatureOperationEvent) { + func publishFeatureOperationEvent(_ event: SwiftASBFeatureOperationEvent) { bufferedFeatureOperationEvents.append(event) if bufferedFeatureOperationEvents.count > 100 { bufferedFeatureOperationEvents.removeFirst(bufferedFeatureOperationEvents.count - 100) @@ -1994,7 +1971,7 @@ public actor CodexAppServer { } } - internal func fsChangeStream(watchID: String) -> AsyncStream { + func fsChangeStream(watchID: String) -> AsyncStream { let streamID = UUID() return AsyncStream { continuation in @@ -2010,13 +1987,13 @@ public actor CodexAppServer { } } - internal func debugThreadHistorySnapshot( + func debugThreadHistorySnapshot( threadID: String ) async throws -> ThreadHistoryStore.ThreadSnapshot? { try await requireHistoryStore(for: "thread history snapshot").snapshot(threadID: threadID) } - internal func libraryThreadSnapshots( + func libraryThreadSnapshots( query: ThreadListQD ) async throws -> [Library.ThreadSnapshot] { let historyStore = try requireHistoryStore(for: "library thread snapshots") @@ -2048,7 +2025,7 @@ public actor CodexAppServer { } } - internal func reconcileLibraryThreads( + func reconcileLibraryThreads( query: ThreadListQD, archived: Bool, maxPages: Int @@ -2083,7 +2060,7 @@ public actor CodexAppServer { } } - internal func recentClosedTurnWindow( + func recentClosedTurnWindow( threadID: String, limit: Int ) async throws -> CodexThread.HistoryWindow { @@ -2112,14 +2089,14 @@ public actor CodexAppServer { ) } - internal func recentClosedTurns( + func recentClosedTurns( threadID: String, limit: Int ) async throws -> [CodexTurnHandle.ClosedTurn] { try await recentClosedTurnWindow(threadID: threadID, limit: limit).turns } - internal func olderClosedTurnWindow( + func olderClosedTurnWindow( threadID: String, olderThan turnID: String, limit: Int @@ -2132,6 +2109,7 @@ public actor CodexAppServer { """ ) } + let orderedTurns = orderedClosedTurnSnapshots(from: threadSnapshot.turns) guard let boundaryIndex = orderedTurns.firstIndex(where: { $0.id == turnID }) else { throw CodexAppServerError.invalidState( @@ -2153,7 +2131,7 @@ public actor CodexAppServer { ) } - internal func olderClosedTurns( + func olderClosedTurns( threadID: String, olderThan turnID: String, limit: Int @@ -2161,7 +2139,7 @@ public actor CodexAppServer { try await olderClosedTurnWindow(threadID: threadID, olderThan: turnID, limit: limit).turns } - internal func newerClosedTurnWindow( + func newerClosedTurnWindow( threadID: String, newerThan turnID: String, limit: Int @@ -2174,6 +2152,7 @@ public actor CodexAppServer { """ ) } + let orderedTurns = orderedClosedTurnSnapshots(from: threadSnapshot.turns) guard let boundaryIndex = orderedTurns.firstIndex(where: { $0.id == turnID }) else { throw CodexAppServerError.invalidState( @@ -2195,7 +2174,7 @@ public actor CodexAppServer { ) } - internal func newerClosedTurns( + func newerClosedTurns( threadID: String, newerThan turnID: String, limit: Int @@ -2203,7 +2182,7 @@ public actor CodexAppServer { try await newerClosedTurnWindow(threadID: threadID, newerThan: turnID, limit: limit).turns } - internal func closedTurnWindowAroundTurn( + func closedTurnWindowAroundTurn( threadID: String, turnID: String, limit: Int @@ -2234,7 +2213,7 @@ public actor CodexAppServer { ) } - internal func closedTurnWindowAroundItem( + func closedTurnWindowAroundItem( threadID: String, itemID: String, limit: Int @@ -2267,14 +2246,14 @@ public actor CodexAppServer { ) } - internal func recentTurnSnapshots( + func recentTurnSnapshots( threadID: String, limit: Int ) async throws -> [ThreadHistoryStore.ThreadSnapshot.TurnSnapshot] { try await recentTurnWindow(threadID: threadID, limit: limit).turns } - internal func recentTurnWindow( + func recentTurnWindow( threadID: String, limit: Int ) async throws -> RecentTurnWindowResult { @@ -2308,8 +2287,8 @@ public actor CodexAppServer { return .init(turns: [], nextOlderCursor: nil, nextNewerCursor: nil) } - return .init( - turns: try await historyStore.turnSnapshots( + return try .init( + turns: await historyStore.turnSnapshots( threadID: threadID, turnIDs: page.turns.map(\.id) ), @@ -2318,7 +2297,7 @@ public actor CodexAppServer { ) } - internal func olderTurnWindow( + func olderTurnWindow( threadID: String, olderThanOrderIndex: Int, cursor: String?, @@ -2347,8 +2326,8 @@ public actor CodexAppServer { ) ) - return .init( - turns: try await historyStore.turnSnapshots( + return try .init( + turns: await historyStore.turnSnapshots( threadID: threadID, turnIDs: page.turns.map(\.id) ), @@ -2357,7 +2336,7 @@ public actor CodexAppServer { ) } - internal func newerTurnWindow( + func newerTurnWindow( threadID: String, newerThanOrderIndex: Int, cursor: String?, @@ -2390,8 +2369,8 @@ public actor CodexAppServer { ) ) - return .init( - turns: try await historyStore.turnSnapshots( + return try .init( + turns: await historyStore.turnSnapshots( threadID: threadID, turnIDs: page.turns.map(\.id) ), @@ -2400,7 +2379,7 @@ public actor CodexAppServer { ) } - internal func recentFileWindow( + func recentFileWindow( threadID: String, limit: Int ) async throws -> RecentFileWindowResult { @@ -2469,7 +2448,7 @@ public actor CodexAppServer { ) } - internal func recentCommandWindow( + func recentCommandWindow( threadID: String, limit: Int ) async throws -> RecentCommandWindowResult { @@ -2538,7 +2517,7 @@ public actor CodexAppServer { ) } - internal func olderFileWindow( + func olderFileWindow( threadID: String, olderThan oldestFile: RecentFileSnapshot, cursor: String?, @@ -2587,7 +2566,7 @@ public actor CodexAppServer { ) } - internal func olderCommandWindow( + func olderCommandWindow( threadID: String, olderThan oldestCommand: RecentCommandSnapshot, cursor: String?, @@ -2636,14 +2615,14 @@ public actor CodexAppServer { ) } - internal func turnSnapshot( + func turnSnapshot( threadID: String, turnID: String ) async throws -> ThreadHistoryStore.ThreadSnapshot.TurnSnapshot? { try await requireHistoryStore(for: "turn snapshot").turnSnapshot(threadID: threadID, turnID: turnID) } - internal func closedTurn( + func closedTurn( threadID: String, turnID: String ) async throws -> CodexTurnHandle.ClosedTurn { @@ -2655,7 +2634,6 @@ public actor CodexAppServer { """ throw CodexAppServerError.invalidState(reason: reason) } - guard snapshot.status == TurnStatus.completed.rawValue || snapshot.status == TurnStatus.failed.rawValue || snapshot.status == TurnStatus.interrupted.rawValue @@ -2671,7 +2649,7 @@ public actor CodexAppServer { return .init(threadID: threadID, snapshot: snapshot) } - internal func respond( + func respond( to request: CodexApprovalRequest, with response: CodexApprovalResponse, expectedThreadID: String, @@ -2686,49 +2664,49 @@ public actor CodexAppServer { let payload: Data switch (request, response) { - case (_, .commandExecution(let decision)) where outstandingRequest.kind == .commandExecutionApproval: - payload = try protocolLayer.makeServerResponse( - id: requestID, - result: decision.protocolValue - ) - case (_, .fileChange(let decision)) where outstandingRequest.kind == .fileChangeApproval: - payload = try protocolLayer.makeServerResponse( - id: requestID, - result: CodexProtocolFileChangeApprovalDecisionPayload(decision: decision.rawValue) - ) - case let (.guardianDeniedAction(guardianRequest), .guardianDeniedAction(.approve)) + case let (_, .commandExecution(decision)) where outstandingRequest.kind == .commandExecutionApproval: + payload = try protocolLayer.makeServerResponse( + id: requestID, + result: decision.protocolValue + ) + case let (_, .fileChange(decision)) where outstandingRequest.kind == .fileChangeApproval: + payload = try protocolLayer.makeServerResponse( + id: requestID, + result: CodexProtocolFileChangeApprovalDecisionPayload(decision: decision.rawValue) + ) + case let (.guardianDeniedAction(guardianRequest), .guardianDeniedAction(.approve)) where outstandingRequest.kind == .guardianDeniedActionApproval: - let approvalRequestID = CodexRPCRequestID.generated() - payload = try protocolLayer.makeThreadApproveGuardianDeniedActionRequest( - id: approvalRequestID, - params: .init( - event: guardianRequest.event.wireValue, - threadID: guardianRequest.threadID + let approvalRequestID = CodexRPCRequestID.generated() + payload = try protocolLayer.makeThreadApproveGuardianDeniedActionRequest( + id: approvalRequestID, + params: .init( + event: guardianRequest.event.wireValue, + threadID: guardianRequest.threadID + ) ) - ) - let responsePayload: Data - do { - responsePayload = try await transport.send(payload, id: approvalRequestID) - _ = try protocolLayer.decodeThreadApproveGuardianDeniedActionResponse( - responsePayload, - expectedID: approvalRequestID + let responsePayload: Data + do { + responsePayload = try await transport.send(payload, id: approvalRequestID) + _ = try protocolLayer.decodeThreadApproveGuardianDeniedActionResponse( + responsePayload, + expectedID: approvalRequestID + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/approveGuardianDeniedAction") + } + outstandingInteractiveRequests.removeValue(forKey: requestID) + updateThreadObservableActivityForGuardianDeniedActionApproved(threadID: guardianRequest.threadID) + publishInteractiveRequestResolution(outstandingRequest, requestID: requestID) + return + case let (_, .permissions(grantedPermissions)) where outstandingRequest.kind == .permissionsApproval: + payload = try protocolLayer.makeServerResponse( + id: requestID, + result: grantedPermissions.protocolValue + ) + default: + throw CodexAppServerError.invalidState( + reason: "Interactive approval response kind did not match the outstanding request kind for request \(requestID.description)." ) - } catch { - throw CodexAppServerError.wrap(error, operation: "thread/approveGuardianDeniedAction") - } - outstandingInteractiveRequests.removeValue(forKey: requestID) - updateThreadObservableActivityForGuardianDeniedActionApproved(threadID: guardianRequest.threadID) - publishInteractiveRequestResolution(outstandingRequest, requestID: requestID) - return - case (_, .permissions(let grantedPermissions)) where outstandingRequest.kind == .permissionsApproval: - payload = try protocolLayer.makeServerResponse( - id: requestID, - result: grantedPermissions.protocolValue - ) - default: - throw CodexAppServerError.invalidState( - reason: "Interactive approval response kind did not match the outstanding request kind for request \(requestID.description)." - ) } do { @@ -2738,7 +2716,7 @@ public actor CodexAppServer { } } - internal func respond( + func respond( to request: CodexElicitationRequest, with response: CodexElicitationResponse, expectedThreadID: String, @@ -2753,29 +2731,140 @@ public actor CodexAppServer { let payload: Data switch (request, response) { - case (_, .toolUserInput(let answers)) where outstandingRequest.kind == .toolUserInput: - payload = try protocolLayer.makeServerResponse( + case let (_, .toolUserInput(answers)) where outstandingRequest.kind == .toolUserInput: + payload = try protocolLayer.makeServerResponse( + id: requestID, + result: answers.protocolValue + ) + case let (_, .mcpServer(elicitation)) where outstandingRequest.kind == .mcpServerElicitation: + payload = try protocolLayer.makeServerResponse( + id: requestID, + result: elicitation.protocolValue + ) + default: + throw CodexAppServerError.invalidState( + reason: "Interactive elicitation response kind did not match the outstanding request kind for request \(requestID.description)." + ) + } + + do { + try await transport.sendResponse(payload, requestID: requestID) + } catch { + throw CodexAppServerError.wrap(error, operation: "server request response") + } + } + + private func startTransport() async throws { + try await transport.start() + hasStarted = true + hasCompletedInitializeHandshake = false + isStopping = false + startServerEventLoop() + } + + private func validateStartupCompatibility( + _ diagnostics: CLIExecutableDiagnostics, + policy: StartupCompatibilityPolicy + ) throws { + switch (policy, diagnostics.compatibility) { + case (.allowOutsideReviewedSupportWindow, _), (.requireReviewedSupportWindow, .supported): + return + case (.requireReviewedSupportWindow, .outsideDocumentedWindow): + throw CodexAppServerStartupError.incompatibleCodexCLI( + diagnostics: diagnostics + ) + case (.requireReviewedSupportWindow, .unknownVersionFormat): + throw CodexAppServerStartupError.unknownCodexCLIVersion( + diagnostics: diagnostics + ) + } + } + + private func readMcpServerStatusPage( + _ request: McpServerStatusListRequest + ) async throws -> McpServerStatusPage { + try requireInitialized(for: "mcpServerStatus/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeMcpServerStatusListRequest( id: requestID, - result: answers.protocolValue + params: .init( + cursor: request.cursor, + detail: request.detail?.wireValue, + limit: request.limit, + threadID: request.threadID + ) ) - case (_, .mcpServer(let elicitation)) where outstandingRequest.kind == .mcpServerElicitation: - payload = try protocolLayer.makeServerResponse( - id: requestID, - result: elicitation.protocolValue + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeMcpServerStatusListResponse( + responsePayload, + expectedID: requestID + ) + + return .init( + nextCursor: response.nextCursor, + servers: response.data.map(McpServerStatus.init(wireValue:)) + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "mcpServerStatus/list") + } + } + + private func mcpServerSummaries(forThreadStatusPage page: McpServerStatusPage) -> [McpServerSummary] { + let globalServerNames = Set(globalMcpServerStatusPage.servers.map(\.name)) + return page.servers.map { status in + .init( + status: status, + scope: globalServerNames.contains(status.name) ? .global : .thread ) - default: + } + } + + private func validateMCPServerName(_ name: String) throws { + guard name.isEmpty == false else { throw CodexAppServerError.invalidState( - reason: "Interactive elicitation response kind did not match the outstanding request kind for request \(requestID.description)." + reason: "SwiftASB cannot install an MCP server with an empty name because Codex stores servers under mcp_servers. in config.toml." ) } - do { - try await transport.sendResponse(payload, requestID: requestID) - } catch { - throw CodexAppServerError.wrap(error, operation: "server request response") + let allowedScalars = name.unicodeScalars.allSatisfy { scalar in + (65...90).contains(scalar.value) + || (97...122).contains(scalar.value) + || (48...57).contains(scalar.value) + || scalar.value == 45 + || scalar.value == 95 + } + guard allowedScalars else { + throw CodexAppServerError.invalidState( + reason: "SwiftASB cannot install MCP server '\(name)' because server names must contain only letters, numbers, underscores, or hyphens for safe config key-path writes." + ) } } + private func makeTurnHandle( + threadID: String, + turn: TurnInfo + ) async -> CodexTurnHandle { + let eventStream = makeTurnEventStream(turnID: turn.id) + let minimapStream = makeTurnEventStream(turnID: turn.id) + let minimap = await MainActor.run { + CodexTurnHandle.Minimap( + threadID: threadID, + initialTurn: turn, + events: minimapStream + ) + } + return CodexTurnHandle( + appServer: self, + threadID: threadID, + turn: turn, + events: eventStream, + minimap: minimap + ) + } + private func requireStarted(for operation: String) throws { guard hasStarted else { throw CodexAppServerError.invalidState( @@ -2801,18 +2890,18 @@ public actor CodexAppServer { let reason: String switch existingActivity { - case .starting: - reason = """ - Codex app-server already has another turn start in flight for thread \(threadID). \ - SwiftASB rejects overlapping same-thread turns because the live app-server does not \ - currently provide a reliable independent lifecycle for them. - """ - case let .active(turnID): - reason = """ - Codex app-server thread \(threadID) already has an active turn (\(turnID)). \ - SwiftASB rejects overlapping same-thread turns because the live app-server does not \ - currently provide a reliable independent lifecycle for them. - """ + case .starting: + reason = """ + Codex app-server already has another turn start in flight for thread \(threadID). \ + SwiftASB rejects overlapping same-thread turns because the live app-server does not \ + currently provide a reliable independent lifecycle for them. + """ + case let .active(turnID): + reason = """ + Codex app-server thread \(threadID) already has an active turn (\(turnID)). \ + SwiftASB rejects overlapping same-thread turns because the live app-server does not \ + currently provide a reliable independent lifecycle for them. + """ } throw CodexAppServerError.invalidState(reason: reason) @@ -2919,344 +3008,344 @@ public actor CodexAppServer { private func handleProtocolEvent(_ event: CodexAppServerProtocolEvent) async { switch event { - case .appListUpdated, .skillsChanged: - publishLibraryEvent(.appSnapshotsChanged) - case let .itemGuardianApprovalReviewStarted(notification): - updateThreadObservableActivityForAutoReviewStarted(notification) - case let .itemGuardianApprovalReviewCompleted(completion): - updateThreadObservableActivityForAutoReviewCompleted(completion.notification) - if let request = completion.publicDeniedActionApprovalRequest { - handleInteractiveApprovalRequest(request) - } - case let .mcpServerStatusUpdated(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - _ = try? await refreshGlobalMcpServerStatusSnapshot() - for threadID in Array(threadMcpServerStatusPages.keys) { - _ = await hydrateMcpServerSummaries(threadID: threadID) - } - publishLibraryEvent(.appSnapshotsChanged) - case let .configWarning(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .deprecationNotice(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .remoteControlStatusChanged(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .threadStarted(notification): - threadStatuses[notification.thread.id] = .init(wireValue: notification.thread.status) - let threadEvent = CodexThreadEvent.started( - .init(thread: .init(wireValue: notification.thread)) - ) - publishThreadEvent(threadEvent, for: notification.thread.id, isTerminal: false) - try? await historyStore?.recordThreadMetadataUpdated(.init(wireValue: notification.thread)) - publishLibraryEvent(.threadChanged(threadID: notification.thread.id)) - case let .threadStatusChanged(notification): - threadStatuses[notification.threadID] = .init(wireValue: notification.status) - let threadEvent = CodexThreadEvent.statusChanged( - .init( + case .appListUpdated, .skillsChanged: + publishLibraryEvent(.appSnapshotsChanged) + case let .itemGuardianApprovalReviewStarted(notification): + updateThreadObservableActivityForAutoReviewStarted(notification) + case let .itemGuardianApprovalReviewCompleted(completion): + updateThreadObservableActivityForAutoReviewCompleted(completion.notification) + if let request = completion.publicDeniedActionApprovalRequest { + handleInteractiveApprovalRequest(request) + } + case let .mcpServerStatusUpdated(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + _ = try? await refreshGlobalMcpServerStatusSnapshot() + for threadID in Array(threadMcpServerStatusPages.keys) { + _ = await hydrateMcpServerSummaries(threadID: threadID) + } + publishLibraryEvent(.appSnapshotsChanged) + case let .configWarning(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .deprecationNotice(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .remoteControlStatusChanged(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .threadStarted(notification): + threadStatuses[notification.thread.id] = .init(wireValue: notification.thread.status) + let threadEvent = CodexThreadEvent.started( + .init(thread: .init(wireValue: notification.thread)) + ) + publishThreadEvent(threadEvent, for: notification.thread.id, isTerminal: false) + try? await historyStore?.recordThreadMetadataUpdated(.init(wireValue: notification.thread)) + publishLibraryEvent(.threadChanged(threadID: notification.thread.id)) + case let .threadStatusChanged(notification): + threadStatuses[notification.threadID] = .init(wireValue: notification.status) + let threadEvent = CodexThreadEvent.statusChanged( + .init( + threadID: notification.threadID, + status: .init(wireValue: notification.status) + ) + ) + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) + try? await historyStore?.recordThreadStatusChanged( threadID: notification.threadID, status: .init(wireValue: notification.status) ) - ) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) - try? await historyStore?.recordThreadStatusChanged( - threadID: notification.threadID, - status: .init(wireValue: notification.status) - ) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadArchived(notification): - let threadEvent = CodexThreadEvent.archived(.init(threadID: notification.threadID)) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) - try? await historyStore?.recordThreadArchived(threadID: notification.threadID, isArchived: true) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadUnarchived(notification): - let threadEvent = CodexThreadEvent.unarchived(.init(threadID: notification.threadID)) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) - try? await historyStore?.recordThreadArchived(threadID: notification.threadID, isArchived: false) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadClosed(notification): - threadStatuses.removeValue(forKey: notification.threadID) - clearTurnActivities(threadID: notification.threadID) - settleThreadObservableActivity(threadID: notification.threadID) - finishThreadObservableActivityStreams(threadID: notification.threadID) - finishThreadCommandDeltaStreams(threadID: notification.threadID) - finishThreadFileDeltaStreams(threadID: notification.threadID) - let threadEvent = CodexThreadEvent.closed(.init(threadID: notification.threadID)) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: true) - try? await historyStore?.recordThreadClosed(threadID: notification.threadID) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadNameUpdated(notification): - let threadEvent = CodexThreadEvent.nameUpdated( - .init( - threadID: notification.threadID, - threadName: notification.threadName + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadArchived(notification): + let threadEvent = CodexThreadEvent.archived(.init(threadID: notification.threadID)) + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) + try? await historyStore?.recordThreadArchived(threadID: notification.threadID, isArchived: true) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadUnarchived(notification): + let threadEvent = CodexThreadEvent.unarchived(.init(threadID: notification.threadID)) + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) + try? await historyStore?.recordThreadArchived(threadID: notification.threadID, isArchived: false) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadClosed(notification): + threadStatuses.removeValue(forKey: notification.threadID) + clearTurnActivities(threadID: notification.threadID) + settleThreadObservableActivity(threadID: notification.threadID) + finishThreadObservableActivityStreams(threadID: notification.threadID) + finishThreadCommandDeltaStreams(threadID: notification.threadID) + finishThreadFileDeltaStreams(threadID: notification.threadID) + let threadEvent = CodexThreadEvent.closed(.init(threadID: notification.threadID)) + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: true) + try? await historyStore?.recordThreadClosed(threadID: notification.threadID) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadNameUpdated(notification): + let threadEvent = CodexThreadEvent.nameUpdated( + .init( + threadID: notification.threadID, + threadName: notification.threadName + ) ) - ) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) - try? await historyStore?.recordThreadNameUpdated( - threadID: notification.threadID, - name: notification.threadName - ) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadTokenUsageUpdated(notification): - let lastUsage = CodexThreadTokenUsageUpdated.Usage(wireValue: notification.tokenUsage.last) - let threadEvent = CodexThreadEvent.tokenUsageUpdated( - .init( + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) + try? await historyStore?.recordThreadNameUpdated( threadID: notification.threadID, - turnID: notification.turnID, - last: lastUsage, - modelContextWindow: notification.tokenUsage.modelContextWindow, - total: .init(wireValue: notification.tokenUsage.total) + name: notification.threadName ) - ) - publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) - try? await historyStore?.recordTurnTokenUsageUpdated( - threadID: notification.threadID, - turnID: notification.turnID, - usage: lastUsage, - modelContextWindow: notification.tokenUsage.modelContextWindow - ) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadGoalUpdated(notification): - publishThreadEvent( - .goalUpdated( + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadTokenUsageUpdated(notification): + let lastUsage = CodexThreadTokenUsageUpdated.Usage(wireValue: notification.tokenUsage.last) + let threadEvent = CodexThreadEvent.tokenUsageUpdated( .init( threadID: notification.threadID, turnID: notification.turnID, - goal: .init(wireValue: notification.goal) + last: lastUsage, + modelContextWindow: notification.tokenUsage.modelContextWindow, + total: .init(wireValue: notification.tokenUsage.total) ) - ), - for: notification.threadID, - isTerminal: false - ) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .threadGoalCleared(notification): - publishThreadEvent( - .goalCleared(.init(threadID: notification.threadID)), - for: notification.threadID, - isTerminal: false - ) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .fsChanged(notification): - publishFSChanged( - .init( - watchID: notification.watchID, - changedPaths: notification.changedPaths ) - ) - case let .turnStarted(notification): - markThreadTurnActive(threadID: notification.threadID, turnID: notification.turn.id) - let turn = TurnInfo(wireValue: notification.turn) - let started = CodexTurnStarted( - threadID: notification.threadID, - turn: turn - ) - publishTurnEvent(.started(started), for: notification.turn.id, isTerminal: false) - try? await historyStore?.recordTurnStarted(threadID: notification.threadID, turn: turn) - publishLibraryEvent(.threadChanged(threadID: notification.threadID)) - case let .turnDiffUpdated(notification): - let diffUpdate = CodexTurnDiffUpdate( - threadID: notification.threadID, - turnID: notification.turnID, - diff: notification.diff - ) - publishTurnEvent(.diffUpdated(diffUpdate), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordTurnDiffUpdated(turnID: notification.turnID, diff: notification.diff) - case let .itemStarted(notification): - updateThreadObservableActivityForItemStarted(notification.item, threadID: notification.threadID) - let item = CodexTurnItem(wireValue: notification.item) - let itemStarted = CodexTurnItemStarted( - threadID: notification.threadID, - turnID: notification.turnID, - item: item - ) - publishTurnEvent(.itemStarted(itemStarted), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemStarted( - threadID: notification.threadID, - turnID: notification.turnID, - item: item - ) - case let .itemCompleted(notification): - updateThreadObservableActivityForItemCompleted(notification.item, threadID: notification.threadID) - let item = CodexTurnItem(wireValue: notification.item) - let itemCompleted = CodexTurnItemCompleted( - threadID: notification.threadID, - turnID: notification.turnID, - item: item - ) - publishTurnEvent(.itemCompleted(itemCompleted), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemCompleted( - threadID: notification.threadID, - turnID: notification.turnID, - item: item - ) - case let .commandExecutionOutputDelta(notification): - let deltaEvent = CommandExecutionOutputDeltaEvent( - delta: notification.delta, - itemID: notification.itemID, - threadID: notification.threadID, - turnID: notification.turnID - ) - publishThreadCommandDelta(deltaEvent, for: notification.threadID) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case .commandExecOutputDelta: - break - case let .hookStarted(notification): - updateThreadObservableActivityForHookRun( - notification.run, - turnID: notification.turnID, - threadID: notification.threadID - ) - case let .hookCompleted(notification): - updateThreadObservableActivityForHookRun( - notification.run, - turnID: notification.turnID, - threadID: notification.threadID - ) - case let .warning(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .guardianWarning(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .modelRerouted(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - Self.logger.notice( - "Model rerouted for thread \(notification.threadID, privacy: .public) turn \(notification.turnID, privacy: .public): \(notification.fromModel, privacy: .public) -> \(notification.toModel, privacy: .public) because \(notification.reason.rawValue, privacy: .public)" - ) - case let .modelVerification(notification): - handleDiagnosticEvent(.init(wireValue: notification)) - case let .fileChangeOutputDelta(notification): - let deltaEvent = FileChangeOutputDeltaEvent( - delta: notification.delta, - itemID: notification.itemID, - path: nil, - replacesPayload: false, - threadID: notification.threadID, - turnID: notification.turnID - ) - publishThreadFileDelta(deltaEvent, for: notification.threadID) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case let .fileChangePatchUpdated(notification): - let patchText = notification.changes.map(\.diff).joined(separator: "\n") - let path = notification.changes.count == 1 ? notification.changes.first?.path : nil - let deltaEvent = FileChangeOutputDeltaEvent( - delta: patchText, - itemID: notification.itemID, - path: path, - replacesPayload: true, - threadID: notification.threadID, - turnID: notification.turnID - ) - publishThreadFileDelta(deltaEvent, for: notification.threadID) - try? await historyStore?.recordItemReplacement( - turnID: notification.turnID, - itemID: notification.itemID, - text: patchText, - path: path - ) - case let .planDelta(notification): - let planDelta = CodexTurnPlanDelta( - threadID: notification.threadID, - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - publishTurnEvent(.planDelta(planDelta), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case let .turnPlanUpdated(notification): - let planUpdate = CodexTurnPlanUpdate( - threadID: notification.threadID, - turnID: notification.turnID, - explanation: notification.explanation, - plan: notification.plan.map(CodexTurnPlanUpdate.Step.init(wireValue:)) - ) - publishTurnEvent(.planUpdated(planUpdate), for: notification.turnID, isTerminal: false) - case let .agentMessageDelta(notification): - let delta = CodexTurnAgentMessageDelta( - threadID: notification.threadID, - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - publishTurnEvent(.agentMessageDelta(delta), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case let .reasoningSummaryPartAdded(notification): - let partAdded = CodexTurnReasoningSummaryPartAdded( - threadID: notification.threadID, - turnID: notification.turnID, - itemID: notification.itemID, - summaryIndex: notification.summaryIndex - ) - publishTurnEvent(.reasoningSummaryPartAdded(partAdded), for: notification.turnID, isTerminal: false) - case let .reasoningSummaryTextDelta(notification): - let delta = CodexTurnReasoningSummaryTextDelta( - threadID: notification.threadID, - turnID: notification.turnID, - itemID: notification.itemID, - summaryIndex: notification.summaryIndex, - delta: notification.delta - ) - publishTurnEvent(.reasoningSummaryTextDelta(delta), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case let .reasoningTextDelta(notification): - let delta = CodexTurnReasoningTextDelta( - threadID: notification.threadID, - turnID: notification.turnID, - itemID: notification.itemID, - contentIndex: notification.contentIndex, - delta: notification.delta - ) - publishTurnEvent(.reasoningTextDelta(delta), for: notification.turnID, isTerminal: false) - try? await historyStore?.recordItemDelta( - turnID: notification.turnID, - itemID: notification.itemID, - delta: notification.delta - ) - case let .commandExecutionApprovalRequested(request): - handleInteractiveApprovalRequest(request.publicValue) - case let .fileChangeApprovalRequested(request): - handleInteractiveApprovalRequest(request.publicValue) - case let .permissionsApprovalRequested(request): - handleInteractiveApprovalRequest(request.publicValue) - case let .toolUserInputRequested(request): - handleInteractiveElicitationRequest(request.publicValue) - case let .mcpServerElicitationRequested(request): - handleInteractiveElicitationRequest(request.publicValue) - case let .serverRequestResolved(notification): - handleServerRequestResolved(notification) - case let .turnCompleted(notification): - clearTurnActivity(turnID: notification.turn.id) - settleThreadObservableActivity(threadID: notification.threadID) - let turn = TurnInfo(wireValue: notification.turn) - try? await historyStore?.recordTurnCompleted( - threadID: notification.threadID, - turn: turn - ) - let completion = CodexTurnCompletion( - threadID: notification.threadID, - turn: turn - ) - let turnEvent = CodexTurnEvent.completed(completion) - publishTurnEvent(turnEvent, for: notification.turn.id, isTerminal: true) - publishLibraryEvent(.turnCompleted(threadID: notification.threadID)) + publishThreadEvent(threadEvent, for: notification.threadID, isTerminal: false) + try? await historyStore?.recordTurnTokenUsageUpdated( + threadID: notification.threadID, + turnID: notification.turnID, + usage: lastUsage, + modelContextWindow: notification.tokenUsage.modelContextWindow + ) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadGoalUpdated(notification): + publishThreadEvent( + .goalUpdated( + .init( + threadID: notification.threadID, + turnID: notification.turnID, + goal: .init(wireValue: notification.goal) + ) + ), + for: notification.threadID, + isTerminal: false + ) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .threadGoalCleared(notification): + publishThreadEvent( + .goalCleared(.init(threadID: notification.threadID)), + for: notification.threadID, + isTerminal: false + ) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .fsChanged(notification): + publishFSChanged( + .init( + watchID: notification.watchID, + changedPaths: notification.changedPaths + ) + ) + case let .turnStarted(notification): + markThreadTurnActive(threadID: notification.threadID, turnID: notification.turn.id) + let turn = TurnInfo(wireValue: notification.turn) + let started = CodexTurnStarted( + threadID: notification.threadID, + turn: turn + ) + publishTurnEvent(.started(started), for: notification.turn.id, isTerminal: false) + try? await historyStore?.recordTurnStarted(threadID: notification.threadID, turn: turn) + publishLibraryEvent(.threadChanged(threadID: notification.threadID)) + case let .turnDiffUpdated(notification): + let diffUpdate = CodexTurnDiffUpdate( + threadID: notification.threadID, + turnID: notification.turnID, + diff: notification.diff + ) + publishTurnEvent(.diffUpdated(diffUpdate), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordTurnDiffUpdated(turnID: notification.turnID, diff: notification.diff) + case let .itemStarted(notification): + updateThreadObservableActivityForItemStarted(notification.item, threadID: notification.threadID) + let item = CodexTurnItem(wireValue: notification.item) + let itemStarted = CodexTurnItemStarted( + threadID: notification.threadID, + turnID: notification.turnID, + item: item + ) + publishTurnEvent(.itemStarted(itemStarted), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemStarted( + threadID: notification.threadID, + turnID: notification.turnID, + item: item + ) + case let .itemCompleted(notification): + updateThreadObservableActivityForItemCompleted(notification.item, threadID: notification.threadID) + let item = CodexTurnItem(wireValue: notification.item) + let itemCompleted = CodexTurnItemCompleted( + threadID: notification.threadID, + turnID: notification.turnID, + item: item + ) + publishTurnEvent(.itemCompleted(itemCompleted), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemCompleted( + threadID: notification.threadID, + turnID: notification.turnID, + item: item + ) + case let .commandExecutionOutputDelta(notification): + let deltaEvent = CommandExecutionOutputDeltaEvent( + delta: notification.delta, + itemID: notification.itemID, + threadID: notification.threadID, + turnID: notification.turnID + ) + publishThreadCommandDelta(deltaEvent, for: notification.threadID) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case .commandExecOutputDelta: + break + case let .hookStarted(notification): + updateThreadObservableActivityForHookRun( + notification.run, + turnID: notification.turnID, + threadID: notification.threadID + ) + case let .hookCompleted(notification): + updateThreadObservableActivityForHookRun( + notification.run, + turnID: notification.turnID, + threadID: notification.threadID + ) + case let .warning(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .guardianWarning(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .modelRerouted(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + Self.logger.notice( + "Model rerouted for thread \(notification.threadID, privacy: .public) turn \(notification.turnID, privacy: .public): \(notification.fromModel, privacy: .public) -> \(notification.toModel, privacy: .public) because \(notification.reason.rawValue, privacy: .public)" + ) + case let .modelVerification(notification): + handleDiagnosticEvent(.init(wireValue: notification)) + case let .fileChangeOutputDelta(notification): + let deltaEvent = FileChangeOutputDeltaEvent( + delta: notification.delta, + itemID: notification.itemID, + path: nil, + replacesPayload: false, + threadID: notification.threadID, + turnID: notification.turnID + ) + publishThreadFileDelta(deltaEvent, for: notification.threadID) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case let .fileChangePatchUpdated(notification): + let patchText = notification.changes.map(\.diff).joined(separator: "\n") + let path = notification.changes.count == 1 ? notification.changes.first?.path : nil + let deltaEvent = FileChangeOutputDeltaEvent( + delta: patchText, + itemID: notification.itemID, + path: path, + replacesPayload: true, + threadID: notification.threadID, + turnID: notification.turnID + ) + publishThreadFileDelta(deltaEvent, for: notification.threadID) + try? await historyStore?.recordItemReplacement( + turnID: notification.turnID, + itemID: notification.itemID, + text: patchText, + path: path + ) + case let .planDelta(notification): + let planDelta = CodexTurnPlanDelta( + threadID: notification.threadID, + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + publishTurnEvent(.planDelta(planDelta), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case let .turnPlanUpdated(notification): + let planUpdate = CodexTurnPlanUpdate( + threadID: notification.threadID, + turnID: notification.turnID, + explanation: notification.explanation, + plan: notification.plan.map(CodexTurnPlanUpdate.Step.init(wireValue:)) + ) + publishTurnEvent(.planUpdated(planUpdate), for: notification.turnID, isTerminal: false) + case let .agentMessageDelta(notification): + let delta = CodexTurnAgentMessageDelta( + threadID: notification.threadID, + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + publishTurnEvent(.agentMessageDelta(delta), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case let .reasoningSummaryPartAdded(notification): + let partAdded = CodexTurnReasoningSummaryPartAdded( + threadID: notification.threadID, + turnID: notification.turnID, + itemID: notification.itemID, + summaryIndex: notification.summaryIndex + ) + publishTurnEvent(.reasoningSummaryPartAdded(partAdded), for: notification.turnID, isTerminal: false) + case let .reasoningSummaryTextDelta(notification): + let delta = CodexTurnReasoningSummaryTextDelta( + threadID: notification.threadID, + turnID: notification.turnID, + itemID: notification.itemID, + summaryIndex: notification.summaryIndex, + delta: notification.delta + ) + publishTurnEvent(.reasoningSummaryTextDelta(delta), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case let .reasoningTextDelta(notification): + let delta = CodexTurnReasoningTextDelta( + threadID: notification.threadID, + turnID: notification.turnID, + itemID: notification.itemID, + contentIndex: notification.contentIndex, + delta: notification.delta + ) + publishTurnEvent(.reasoningTextDelta(delta), for: notification.turnID, isTerminal: false) + try? await historyStore?.recordItemDelta( + turnID: notification.turnID, + itemID: notification.itemID, + delta: notification.delta + ) + case let .commandExecutionApprovalRequested(request): + handleInteractiveApprovalRequest(request.publicValue) + case let .fileChangeApprovalRequested(request): + handleInteractiveApprovalRequest(request.publicValue) + case let .permissionsApprovalRequested(request): + handleInteractiveApprovalRequest(request.publicValue) + case let .toolUserInputRequested(request): + handleInteractiveElicitationRequest(request.publicValue) + case let .mcpServerElicitationRequested(request): + handleInteractiveElicitationRequest(request.publicValue) + case let .serverRequestResolved(notification): + handleServerRequestResolved(notification) + case let .turnCompleted(notification): + clearTurnActivity(turnID: notification.turn.id) + settleThreadObservableActivity(threadID: notification.threadID) + let turn = TurnInfo(wireValue: notification.turn) + try? await historyStore?.recordTurnCompleted( + threadID: notification.threadID, + turn: turn + ) + let completion = CodexTurnCompletion( + threadID: notification.threadID, + turn: turn + ) + let turnEvent = CodexTurnEvent.completed(completion) + publishTurnEvent(turnEvent, for: notification.turn.id, isTerminal: true) + publishLibraryEvent(.turnCompleted(threadID: notification.threadID)) } } @@ -3432,6 +3521,7 @@ public actor CodexAppServer { private func removeTurnEventContinuation(streamID: UUID, turnID: String) { guard var continuations = turnEventContinuations[turnID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { turnEventContinuations.removeValue(forKey: turnID) @@ -3471,6 +3561,7 @@ public actor CodexAppServer { private func removeFSChangeContinuation(streamID: UUID, watchID: String) { guard var continuations = fsChangeContinuations[watchID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { fsChangeContinuations.removeValue(forKey: watchID) @@ -3501,6 +3592,7 @@ public actor CodexAppServer { private func removeThreadEventContinuation(streamID: UUID, threadID: String) { guard var continuations = threadEventContinuations[threadID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { threadEventContinuations.removeValue(forKey: threadID) @@ -3511,6 +3603,7 @@ public actor CodexAppServer { private func removeThreadObservableActivityContinuation(streamID: UUID, threadID: String) { guard var continuations = threadObservableActivityContinuations[threadID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { threadObservableActivityContinuations.removeValue(forKey: threadID) @@ -3521,6 +3614,7 @@ public actor CodexAppServer { private func removeThreadFileDeltaContinuation(streamID: UUID, threadID: String) { guard var continuations = threadFileDeltaContinuations[threadID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { threadFileDeltaContinuations.removeValue(forKey: threadID) @@ -3531,6 +3625,7 @@ public actor CodexAppServer { private func removeThreadCommandDeltaContinuation(streamID: UUID, threadID: String) { guard var continuations = threadCommandDeltaContinuations[threadID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { threadCommandDeltaContinuations.removeValue(forKey: threadID) @@ -3551,6 +3646,7 @@ public actor CodexAppServer { private func removeThreadTurnEventContinuation(streamID: UUID, threadID: String) { guard var continuations = threadTurnEventContinuations[threadID] else { return } + continuations.removeValue(forKey: streamID) if continuations.isEmpty { threadTurnEventContinuations.removeValue(forKey: threadID) @@ -3777,14 +3873,14 @@ public actor CodexAppServer { ) { var state = threadObservableActivityStates[threadID] ?? .init() switch item.type { - case .commandExecution, .dynamicToolCall, .collabAgentToolCall, .fileChange: - state.activeToolLikeItemIDs.insert(item.id) - case .mcpToolCall: - state.activeMcpItemIDs.insert(item.id) - case .contextCompaction: - state.isCompactingThreadContext = true - default: - break + case .commandExecution, .dynamicToolCall, .collabAgentToolCall, .fileChange: + state.activeToolLikeItemIDs.insert(item.id) + case .mcpToolCall: + state.activeMcpItemIDs.insert(item.id) + case .contextCompaction: + state.isCompactingThreadContext = true + default: + break } threadObservableActivityStates[threadID] = state publishThreadObservableActivityState(threadID: threadID) @@ -3796,20 +3892,20 @@ public actor CodexAppServer { ) { var state = threadObservableActivityStates[threadID] ?? .init() switch item.type { - case .commandExecution, .dynamicToolCall, .collabAgentToolCall, .fileChange: - state.activeToolLikeItemIDs.remove(item.id) - if itemHasError(status: item.status) { - state.hasToolErrorResidue = true - } - case .mcpToolCall: - state.activeMcpItemIDs.remove(item.id) - if itemHasError(status: item.status) { - state.hasMcpErrorResidue = true - } - case .contextCompaction: - state.isCompactingThreadContext = false - default: - break + case .commandExecution, .dynamicToolCall, .collabAgentToolCall, .fileChange: + state.activeToolLikeItemIDs.remove(item.id) + if itemHasError(status: item.status) { + state.hasToolErrorResidue = true + } + case .mcpToolCall: + state.activeMcpItemIDs.remove(item.id) + if itemHasError(status: item.status) { + state.hasMcpErrorResidue = true + } + case .contextCompaction: + state.isCompactingThreadContext = false + default: + break } threadObservableActivityStates[threadID] = state publishThreadObservableActivityState(threadID: threadID) @@ -3940,121 +4036,6 @@ public actor CodexAppServer { } } - private static func recentFileSnapshotID(turnID: String, itemID: String) -> String { - "\(turnID):\(itemID)" - } - - private static func recentCommandSnapshotID(turnID: String, itemID: String) -> String { - "\(turnID):\(itemID)" - } - - private static func isThreadTurnsHistoryUnavailable(_ error: CodexAppServerError) -> Bool { - guard case let .protocolFailure(operation, reason) = error, - operation == "thread/turns/list" else { - return false - } - - return reason.contains("ephemeral threads do not support thread/turns/list") - || reason.contains("thread/turns/list is unavailable before first user message") - } - - private static func recentFileStatusSummary(status: String?, text: String?) -> String? { - let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedStatus = normalizedStatus?.lowercased() - - if lowercasedStatus == "completed", let payloadSummary = recentFilePayloadSummary(text: text) { - return payloadSummary - } - - if let normalizedStatus, !normalizedStatus.isEmpty { - return normalizedStatus - } - - return recentFilePayloadSummary(text: text) - } - - private static func recentFilePayloadSummary(text: String?) -> String? { - guard let text, !text.isEmpty else { return nil } - - var additions = 0 - var deletions = 0 - var hunkCount = 0 - var nonEmptyLineCount = 0 - - for line in text.split(separator: "\n", omittingEmptySubsequences: false) { - let lineString = String(line) - if !lineString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - nonEmptyLineCount += 1 - } - - if lineString.hasPrefix("@@") { - hunkCount += 1 - } else if lineString.hasPrefix("+"), !lineString.hasPrefix("+++") { - additions += 1 - } else if lineString.hasPrefix("-"), !lineString.hasPrefix("---") { - deletions += 1 - } - } - - if additions > 0 || deletions > 0 || hunkCount > 0 { - var parts: [String] = [] - if additions > 0 { - parts.append("\(additions) additions") - } - if deletions > 0 { - parts.append("\(deletions) deletions") - } - if hunkCount > 1 { - parts.append("\(hunkCount) hunks") - } - if !parts.isEmpty { - return parts.joined(separator: ", ") - } - } - - if nonEmptyLineCount > 1 { - return "\(nonEmptyLineCount) lines changed" - } - - let firstLine = text.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? text - return String(firstLine.prefix(160)) - } - - private static func recentCommandStatusSummary(command: String?, status: String?, text: String?) -> String? { - let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedStatus = normalizedStatus?.lowercased() - - if lowercasedStatus == "completed", let payloadSummary = recentCommandOutputSummary(text: text) { - return payloadSummary - } - - if let normalizedStatus, !normalizedStatus.isEmpty { - return normalizedStatus - } - - if let payloadSummary = recentCommandOutputSummary(text: text) { - return payloadSummary - } - - return command - } - - private static func recentCommandOutputSummary(text: String?) -> String? { - guard let text, !text.isEmpty else { return nil } - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - if nonEmptyLines.count > 1 { - return "\(nonEmptyLines.count) output lines" - } - - guard let firstNonEmptyLine = nonEmptyLines.first else { - return nil - } - - return String(firstNonEmptyLine.prefix(160)) - } - private func orderedClosedTurnSnapshots( from turns: [ThreadHistoryStore.ThreadSnapshot.TurnSnapshot] ) -> [ThreadHistoryStore.ThreadSnapshot.TurnSnapshot] { @@ -4139,11 +4120,12 @@ public actor CodexAppServer { private func itemHasError(status: String?) -> Bool { guard let status else { return false } + return switch status.lowercased() { - case "error", "errored", "failed", "interrupted": - true - default: - false + case "error", "errored", "failed", "interrupted": + true + default: + false } } @@ -4179,7 +4161,6 @@ public actor CodexAppServer { reason: "No outstanding interactive server request with id \(requestID.description) is currently tracked." ) } - guard outstandingRequest.threadID == expectedThreadID else { throw CodexAppServerError.invalidState( reason: "Interactive server request \(requestID.description) belongs to thread \(outstandingRequest.threadID), not thread \(expectedThreadID)." @@ -4187,22 +4168,22 @@ public actor CodexAppServer { } switch (expectedTurnID, outstandingRequest.destination) { - case let (.some(expectedTurnID), .turn(turnID)) where expectedTurnID == turnID: - return outstandingRequest - case (.none, .thread): - return outstandingRequest - case let (.some(expectedTurnID), .turn(turnID)): - throw CodexAppServerError.invalidState( - reason: "Interactive server request \(requestID.description) belongs to turn \(turnID), not turn \(expectedTurnID)." - ) - case (.some, .thread): - throw CodexAppServerError.invalidState( - reason: "Interactive server request \(requestID.description) was surfaced at the thread level and must be answered through CodexThread." - ) - case (.none, .turn): - throw CodexAppServerError.invalidState( - reason: "Interactive server request \(requestID.description) belongs to a specific turn and must be answered through CodexTurnHandle." - ) + case let (.some(expectedTurnID), .turn(turnID)) where expectedTurnID == turnID: + return outstandingRequest + case (.none, .thread): + return outstandingRequest + case let (.some(expectedTurnID), .turn(turnID)): + throw CodexAppServerError.invalidState( + reason: "Interactive server request \(requestID.description) belongs to turn \(turnID), not turn \(expectedTurnID)." + ) + case (.some, .thread): + throw CodexAppServerError.invalidState( + reason: "Interactive server request \(requestID.description) was surfaced at the thread level and must be answered through CodexThread." + ) + case (.none, .turn): + throw CodexAppServerError.invalidState( + reason: "Interactive server request \(requestID.description) belongs to a specific turn and must be answered through CodexTurnHandle." + ) } } @@ -4219,15 +4200,15 @@ public actor CodexAppServer { ) switch destination { - case let .thread(threadID): - publishThreadEvent(.approvalRequested(request), for: threadID, isTerminal: false) - case let .turn(turnID): - publishTurnEvent( - .approvalRequested(request), - for: turnID, - isTerminal: false, - bufferIfUnobserved: true - ) + case let .thread(threadID): + publishThreadEvent(.approvalRequested(request), for: threadID, isTerminal: false) + case let .turn(turnID): + publishTurnEvent( + .approvalRequested(request), + for: turnID, + isTerminal: false, + bufferIfUnobserved: true + ) } } @@ -4261,15 +4242,15 @@ public actor CodexAppServer { ) switch destination { - case let .thread(threadID): - publishThreadEvent(.elicitationRequested(request), for: threadID, isTerminal: false) - case let .turn(turnID): - publishTurnEvent( - .elicitationRequested(request), - for: turnID, - isTerminal: false, - bufferIfUnobserved: true - ) + case let .thread(threadID): + publishThreadEvent(.elicitationRequested(request), for: threadID, isTerminal: false) + case let .turn(turnID): + publishTurnEvent( + .elicitationRequested(request), + for: turnID, + isTerminal: false, + bufferIfUnobserved: true + ) } } @@ -4294,15 +4275,15 @@ public actor CodexAppServer { ) switch outstandingRequest.destination { - case let .thread(threadID): - publishThreadEvent(.serverRequestResolved(resolution), for: threadID, isTerminal: false) - case let .turn(turnID): - publishTurnEvent( - .serverRequestResolved(resolution), - for: turnID, - isTerminal: false, - bufferIfUnobserved: true - ) + case let .thread(threadID): + publishThreadEvent(.serverRequestResolved(resolution), for: threadID, isTerminal: false) + case let .turn(turnID): + publishTurnEvent( + .serverRequestResolved(resolution), + for: turnID, + isTerminal: false, + bufferIfUnobserved: true + ) } } diff --git a/Sources/SwiftASB/Public/CodexConfig.swift b/Sources/SwiftASB/Public/CodexConfig.swift index 4bd195a..7fb7864 100644 --- a/Sources/SwiftASB/Public/CodexConfig.swift +++ b/Sources/SwiftASB/Public/CodexConfig.swift @@ -5,12 +5,6 @@ import Foundation /// `CodexConfig` reads effective Codex configuration through the app-server so /// sandboxed clients do not need to inspect config files directly. public struct CodexConfig: Sendable { - private let appServer: CodexAppServer - - init(appServer: CodexAppServer) { - self.appServer = appServer - } - /// Request used to read effective app-server configuration. public struct ReadRequest: Sendable, Equatable { public var currentDirectoryPath: String? @@ -77,6 +71,12 @@ public struct CodexConfig: Sendable { public let requirements: CodexAppServer.JSONValue? } + private let appServer: CodexAppServer + + init(appServer: CodexAppServer) { + self.appServer = appServer + } + /// Reads effective app-server configuration. public func read(_ request: ReadRequest = .init()) async throws -> Snapshot { try await appServer.readConfig(request) @@ -97,11 +97,11 @@ public extension CodexAppServer { extension CodexConfig.Snapshot { init(wireValue: CodexWireConfigReadResponse) throws { - self.init( - config: try CodexConfig.jsonValue(from: wireValue.config), - layers: try wireValue.layers?.map(CodexConfig.Layer.init(wireValue:)), + try self.init( + config: CodexConfig.jsonValue(from: wireValue.config), + layers: wireValue.layers?.map(CodexConfig.Layer.init(wireValue:)), origins: Dictionary( - uniqueKeysWithValues: try wireValue.origins.map { key, value in + uniqueKeysWithValues: wireValue.origins.map { key, value in try (key, CodexConfig.LayerMetadata(wireValue: value)) } ) @@ -111,8 +111,8 @@ extension CodexConfig.Snapshot { extension CodexConfig.Layer { init(wireValue: CodexWireConfigLayer) throws { - self.init( - config: try CodexConfig.jsonValue(from: wireValue.config), + try self.init( + config: CodexConfig.jsonValue(from: wireValue.config), disabledReason: wireValue.disabledReason, name: .init(wireValue: wireValue.name), version: wireValue.version @@ -146,29 +146,29 @@ extension CodexConfig.LayerSource { extension CodexConfig.LayerSource.Kind { init(wireValue: CodexWireConfigLayerSourceType) { switch wireValue { - case .enterpriseManaged: - self = .enterpriseManaged - case .legacyManagedConfigTomlFromFile: - self = .legacyManagedConfigTomlFromFile - case .legacyManagedConfigTomlFromMdm: - self = .legacyManagedConfigTomlFromMdm - case .mdm: - self = .mdm - case .project: - self = .project - case .sessionFlags: - self = .sessionFlags - case .system: - self = .system - case .user: - self = .user + case .enterpriseManaged: + self = .enterpriseManaged + case .legacyManagedConfigTomlFromFile: + self = .legacyManagedConfigTomlFromFile + case .legacyManagedConfigTomlFromMdm: + self = .legacyManagedConfigTomlFromMdm + case .mdm: + self = .mdm + case .project: + self = .project + case .sessionFlags: + self = .sessionFlags + case .system: + self = .system + case .user: + self = .user } } } extension CodexConfig.RequirementsSnapshot { init(wireValue: CodexWireConfigRequirementsReadResponse) throws { - self.init(requirements: try wireValue.requirements.map(CodexConfig.jsonValue(from:))) + try self.init(requirements: wireValue.requirements.map(CodexConfig.jsonValue(from:))) } } @@ -183,31 +183,31 @@ extension CodexConfig { extension CodexAppServer.JSONValue { init(jsonObject: Any) throws { switch jsonObject { - case is NSNull: - self = .null - case let value as Bool: - self = .bool(value) - case let value as Int: - self = .integer(value) - case let value as NSNumber: - let doubleValue = value.doubleValue - let integerValue = value.int64Value - if doubleValue.rounded(.towardZero) == doubleValue, let exactInteger = Int(exactly: integerValue) { - self = .integer(exactInteger) - } else { - self = .double(doubleValue) - } - case let value as String: - self = .string(value) - case let value as [Any]: - self = .array(try value.map(Self.init(jsonObject:))) - case let value as [String: Any]: - self = .object(try value.mapValues(Self.init(jsonObject:))) - default: - throw CodexAppServerError.protocolFailure( - operation: "config/read", - reason: "The app-server returned a config value that SwiftASB could not represent as JSON." - ) + case is NSNull: + self = .null + case let value as Bool: + self = .bool(value) + case let value as Int: + self = .integer(value) + case let value as NSNumber: + let doubleValue = value.doubleValue + let integerValue = value.int64Value + if doubleValue.rounded(.towardZero) == doubleValue, let exactInteger = Int(exactly: integerValue) { + self = .integer(exactInteger) + } else { + self = .double(doubleValue) + } + case let value as String: + self = .string(value) + case let value as [Any]: + self = try .array(value.map(Self.init(jsonObject:))) + case let value as [String: Any]: + self = try .object(value.mapValues(Self.init(jsonObject:))) + default: + throw CodexAppServerError.protocolFailure( + operation: "config/read", + reason: "The app-server returned a config value that SwiftASB could not represent as JSON." + ) } } } diff --git a/Sources/SwiftASB/Public/CodexDiagnostics.swift b/Sources/SwiftASB/Public/CodexDiagnostics.swift index 86ad376..58ae2d7 100644 --- a/Sources/SwiftASB/Public/CodexDiagnostics.swift +++ b/Sources/SwiftASB/Public/CodexDiagnostics.swift @@ -10,29 +10,29 @@ public enum CodexDiagnosticEvent: Sendable, Equatable { public var threadID: String? { switch self { - case let .warning(warning): - warning.threadID - case let .guardianWarning(warning): - warning.threadID - case let .modelRerouted(reroute): - reroute.threadID - case let .modelVerification(verification): - verification.threadID - case .configWarning, .deprecationNotice, .mcpServerStatusChanged, .remoteControlStatusChanged: - nil + case let .warning(warning): + warning.threadID + case let .guardianWarning(warning): + warning.threadID + case let .modelRerouted(reroute): + reroute.threadID + case let .modelVerification(verification): + verification.threadID + case .configWarning, .deprecationNotice, .mcpServerStatusChanged, .remoteControlStatusChanged: + nil } } public var turnID: String? { switch self { - case .warning, .guardianWarning: - nil - case let .modelRerouted(reroute): - reroute.turnID - case let .modelVerification(verification): - verification.turnID - case .configWarning, .deprecationNotice, .mcpServerStatusChanged, .remoteControlStatusChanged: - nil + case .warning, .guardianWarning: + nil + case let .modelRerouted(reroute): + reroute.turnID + case let .modelVerification(verification): + verification.turnID + case .configWarning, .deprecationNotice, .mcpServerStatusChanged, .remoteControlStatusChanged: + nil } } } @@ -41,7 +41,7 @@ public struct CodexRuntimeWarning: Sendable, Equatable { public let message: String public let threadID: String? - init(message: String, threadID: String?) { + public init(message: String, threadID: String?) { self.message = message self.threadID = threadID } @@ -51,7 +51,7 @@ public struct CodexGuardianWarning: Sendable, Equatable { public let message: String public let threadID: String - init(message: String, threadID: String) { + public init(message: String, threadID: String) { self.message = message self.threadID = threadID } @@ -64,7 +64,11 @@ public struct CodexModelReroute: Sendable, Equatable { public let toModel: String public let turnID: String - init( + public enum Reason: Sendable, Equatable { + case highRiskCyberActivity + } + + public init( fromModel: String, reason: Reason, threadID: String, @@ -77,10 +81,6 @@ public struct CodexModelReroute: Sendable, Equatable { self.toModel = toModel self.turnID = turnID } - - public enum Reason: Sendable, Equatable { - case highRiskCyberActivity - } } public struct CodexModelVerificationDiagnostic: Sendable, Equatable { @@ -88,7 +88,7 @@ public struct CodexModelVerificationDiagnostic: Sendable, Equatable { public let turnID: String public let verifications: [CodexModelVerification] - init( + public init( threadID: String, turnID: String, verifications: [CodexModelVerification] @@ -250,14 +250,14 @@ extension CodexTextPosition { extension CodexMcpServerStatusDiagnostic.Status { init(wireValue: CodexWireMCPServerStartupState) { switch wireValue { - case .cancelled: - self = .cancelled - case .failed: - self = .failed - case .ready: - self = .ready - case .starting: - self = .starting + case .cancelled: + self = .cancelled + case .failed: + self = .failed + case .ready: + self = .ready + case .starting: + self = .starting } } } @@ -265,14 +265,14 @@ extension CodexMcpServerStatusDiagnostic.Status { extension CodexRemoteControlStatusDiagnostic.Status { init(wireValue: CodexWireRemoteControlConnectionStatus) { switch wireValue { - case .connected: - self = .connected - case .connecting: - self = .connecting - case .disabled: - self = .disabled - case .errored: - self = .errored + case .connected: + self = .connected + case .connecting: + self = .connecting + case .disabled: + self = .disabled + case .errored: + self = .errored } } } @@ -280,8 +280,8 @@ extension CodexRemoteControlStatusDiagnostic.Status { extension CodexModelReroute.Reason { init(wireValue: CodexWireModelRerouteReason) { switch wireValue { - case .highRiskCyberActivity: - self = .highRiskCyberActivity + case .highRiskCyberActivity: + self = .highRiskCyberActivity } } } @@ -289,8 +289,8 @@ extension CodexModelReroute.Reason { extension CodexModelVerification { init(wireValue: CodexWireModelVerification) { switch wireValue { - case .trustedAccessForCyber: - self = .trustedAccessForCyber + case .trustedAccessForCyber: + self = .trustedAccessForCyber } } } diff --git a/Sources/SwiftASB/Public/CodexErrors.swift b/Sources/SwiftASB/Public/CodexErrors.swift index 26dbc7a..8f882bf 100644 --- a/Sources/SwiftASB/Public/CodexErrors.swift +++ b/Sources/SwiftASB/Public/CodexErrors.swift @@ -8,12 +8,12 @@ public enum CodexAppServerError: Error, Sendable, LocalizedError, Equatable { public var errorDescription: String? { switch self { - case let .invalidState(reason): - return reason - case let .transportFailure(operation, reason): - return "Codex app-server transport failed during \(operation): \(reason)" - case let .protocolFailure(operation, reason): - return "Codex app-server protocol handling failed during \(operation): \(reason)" + case let .invalidState(reason): + return reason + case let .transportFailure(operation, reason): + return "Codex app-server transport failed during \(operation): \(reason)" + case let .protocolFailure(operation, reason): + return "Codex app-server protocol handling failed during \(operation): \(reason)" } } } @@ -28,27 +28,27 @@ public enum CodexAppServerStartupError: Error, Sendable, LocalizedError, Equatab public var errorDescription: String? { switch self { - case let .codexCLINotFound(reason): - return "SwiftASB could not find a compatible Codex CLI executable for app-server startup: \(reason)" - case let .incompatibleCodexCLI(diagnostics): - return """ - SwiftASB found Codex CLI \(diagnostics.versionString), but startup requires a version inside \ - SwiftASB's documented reviewed support window. - """ - case let .unknownCodexCLIVersion(diagnostics): - return """ - SwiftASB found Codex CLI \(diagnostics.versionString), but could not parse its version string \ - against SwiftASB's documented reviewed support window. - """ - case let .launchFailed(reason): - return "SwiftASB could not launch the Codex app-server during startup: \(reason)" - case let .initializeFailed(reason): - return "SwiftASB launched the Codex app-server but could not complete startup initialization: \(reason)" + case let .codexCLINotFound(reason): + return "SwiftASB could not find a compatible Codex CLI executable for app-server startup: \(reason)" + case let .incompatibleCodexCLI(diagnostics): + return """ + SwiftASB found Codex CLI \(diagnostics.versionString), but startup requires a version inside \ + SwiftASB's documented reviewed support window. + """ + case let .unknownCodexCLIVersion(diagnostics): + return """ + SwiftASB found Codex CLI \(diagnostics.versionString), but could not parse its version string \ + against SwiftASB's documented reviewed support window. + """ + case let .launchFailed(reason): + return "SwiftASB could not launch the Codex app-server during startup: \(reason)" + case let .initializeFailed(reason): + return "SwiftASB launched the Codex app-server but could not complete startup initialization: \(reason)" } } } -internal extension CodexAppServerError { +extension CodexAppServerError { static func wrap(_ error: Error, operation: String) -> Self { if let appServerError = error as? CodexAppServerError { return appServerError @@ -75,7 +75,7 @@ internal extension CodexAppServerError { } } -internal extension CodexAppServerStartupError { +extension CodexAppServerStartupError { static func startFailure(from error: Error) -> Self { if let startupError = error as? CodexAppServerStartupError { return startupError @@ -83,14 +83,14 @@ internal extension CodexAppServerStartupError { if let transportError = error as? CodexTransportError { switch transportError { - case let .executableDiscoveryFailed(reason): - return .codexCLINotFound(reason: reason) - case .alreadyStarted: - return .launchFailed(reason: transportError.localizedDescription) - case .failedToLaunch: - return .launchFailed(reason: transportError.localizedDescription) - default: - return .launchFailed(reason: transportError.localizedDescription) + case let .executableDiscoveryFailed(reason): + return .codexCLINotFound(reason: reason) + case .alreadyStarted: + return .launchFailed(reason: transportError.localizedDescription) + case .failedToLaunch: + return .launchFailed(reason: transportError.localizedDescription) + default: + return .launchFailed(reason: transportError.localizedDescription) } } diff --git a/Sources/SwiftASB/Public/CodexFS.swift b/Sources/SwiftASB/Public/CodexFS.swift index f834506..9e39293 100644 --- a/Sources/SwiftASB/Public/CodexFS.swift +++ b/Sources/SwiftASB/Public/CodexFS.swift @@ -6,12 +6,6 @@ import Foundation /// reading local disk from the Swift client process. That keeps sandboxed apps /// dependent on Codex-owned permissions and path handling. public struct CodexFS: Sendable { - private let appServer: CodexAppServer - - init(appServer: CodexAppServer) { - self.appServer = appServer - } - /// Request used to inspect app-server-owned filesystem metadata for an absolute path. public struct MetadataRequest: Sendable, Equatable { public var path: String @@ -180,6 +174,11 @@ public struct CodexFS: Sendable { ) } + private static func normalizedSearchTerm(_ searchTerm: String?) -> String? { + let normalized = searchTerm?.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized?.isEmpty == false ? normalized : nil + } + /// Returns the same query with a normalized result limit. public func limited(to limit: Int) -> Self { .init( @@ -227,11 +226,6 @@ public struct CodexFS: Sendable { includesHiddenEntries: includesHiddenEntries ) } - - private static func normalizedSearchTerm(_ searchTerm: String?) -> String? { - let normalized = searchTerm?.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized?.isEmpty == false ? normalized : nil - } } /// Results returned by a file-discovery query. @@ -302,8 +296,6 @@ public struct CodexFS: Sendable { public let value: Int } - public var id: String { path } - public let depth: Int public let fileName: String public let kind: Kind @@ -318,6 +310,14 @@ public struct CodexFS: Sendable { public let rankingReasons: [RankingReason] public let relativePath: String public let score: Int? + + public var id: String { path } + } + + private let appServer: CodexAppServer + + init(appServer: CodexAppServer) { + self.appServer = appServer } /// Reads app-server-owned filesystem metadata for an absolute path. @@ -362,17 +362,17 @@ public struct CodexFS: Sendable { let sortedHits = hits.sorted { lhs, rhs in switch (lhs.score, rhs.score) { - case let (left?, right?) where left != right: - return left > right - case (_?, nil): - return true - case (nil, _?): - return false - default: - if lhs.depth != rhs.depth { - return lhs.depth < rhs.depth - } - return lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending + case let (left?, right?) where left != right: + return left > right + case (_?, nil): + return true + case (nil, _?): + return false + default: + if lhs.depth != rhs.depth { + return lhs.depth < rhs.depth + } + return lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending } } @@ -451,8 +451,7 @@ private extension CodexFS { } if query.includedKinds.contains(kind), - query.searchTerm == nil || match != nil - { + query.searchTerm == nil || match != nil { hits.append( .init( depth: depth, @@ -470,8 +469,7 @@ private extension CodexFS { } if entry.kind == .directory, - depth < query.maximumDepth - { + depth < query.maximumDepth { try await collectDiscoveryHits( query: query, directoryPath: childPath, @@ -485,6 +483,7 @@ private extension CodexFS { func appendingPathComponent(_ component: String, to path: String) -> String { guard !path.isEmpty else { return component } + return path.hasSuffix("/") ? path + component : path + "/" + component } @@ -626,6 +625,7 @@ private extension CodexFS { } let initials = words.compactMap(\.first) guard !initials.isEmpty else { return false } + return String(initials).lowercased().hasPrefix(query) } @@ -634,12 +634,12 @@ private extension CodexFS { var penalty = 0 for component in components { switch component { - case ".build", "build", "deriveddata", ".swiftpm": - penalty += 80 - case "debug", "release", "checkouts", "artifacts": - penalty += 25 - default: - continue + case ".build", "build", "deriveddata", ".swiftpm": + penalty += 80 + case "debug", "release", "checkouts", "artifacts": + penalty += 25 + default: + continue } } return penalty @@ -667,12 +667,12 @@ private extension CodexFS { private extension CodexFS.FileDiscoveryHit.Kind { init(_ entryKind: CodexFS.DirectoryEntry.Kind) { switch entryKind { - case .directory: - self = .directory - case .file: - self = .file - case .other: - self = .other + case .directory: + self = .directory + case .file: + self = .file + case .other: + self = .other } } } diff --git a/Sources/SwiftASB/Public/CodexInteractiveRequests.swift b/Sources/SwiftASB/Public/CodexInteractiveRequests.swift index 40019bb..87a0810 100644 --- a/Sources/SwiftASB/Public/CodexInteractiveRequests.swift +++ b/Sources/SwiftASB/Public/CodexInteractiveRequests.swift @@ -16,9 +16,9 @@ public struct CodexInteractiveRequestResolved: Sendable, Equatable { public let turnID: String? public let kind: CodexInteractiveRequestKind - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String?, @@ -40,53 +40,53 @@ public enum CodexApprovalRequest: Sendable, Equatable { public var threadID: String { switch self { - case let .commandExecution(request): - request.threadID - case let .fileChange(request): - request.threadID - case let .guardianDeniedAction(request): - request.threadID - case let .permissions(request): - request.threadID + case let .commandExecution(request): + request.threadID + case let .fileChange(request): + request.threadID + case let .guardianDeniedAction(request): + request.threadID + case let .permissions(request): + request.threadID } } public var turnID: String { switch self { - case let .commandExecution(request): - request.turnID - case let .fileChange(request): - request.turnID - case let .guardianDeniedAction(request): - request.turnID - case let .permissions(request): - request.turnID + case let .commandExecution(request): + request.turnID + case let .fileChange(request): + request.turnID + case let .guardianDeniedAction(request): + request.turnID + case let .permissions(request): + request.turnID } } public var kind: CodexInteractiveRequestKind { switch self { - case .commandExecution: - .commandExecutionApproval - case .fileChange: - .fileChangeApproval - case .guardianDeniedAction: - .guardianDeniedActionApproval - case .permissions: - .permissionsApproval + case .commandExecution: + .commandExecutionApproval + case .fileChange: + .fileChangeApproval + case .guardianDeniedAction: + .guardianDeniedActionApproval + case .permissions: + .permissionsApproval } } - internal var requestID: CodexRPCRequestID { + var requestID: CodexRPCRequestID { switch self { - case let .commandExecution(request): - request.requestID - case let .fileChange(request): - request.requestID - case let .guardianDeniedAction(request): - request.requestID - case let .permissions(request): - request.requestID + case let .commandExecution(request): + request.requestID + case let .fileChange(request): + request.requestID + case let .guardianDeniedAction(request): + request.requestID + case let .permissions(request): + request.requestID } } } @@ -105,9 +105,9 @@ public struct CodexCommandExecutionApprovalRequest: Sendable, Equatable { public let proposedNetworkPolicyAmendments: [CodexNetworkPolicyAmendment]? public let networkApprovalContext: CodexAppServer.JSONValue? - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String, @@ -144,9 +144,9 @@ public struct CodexFileChangeApprovalRequest: Sendable, Equatable { public let grantRoot: String? public let reason: String? - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String, @@ -171,9 +171,9 @@ public struct CodexPermissionsApprovalRequest: Sendable, Equatable { public let permissions: CodexPermissionProfile public let reason: String? - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String, @@ -203,9 +203,9 @@ public struct CodexGuardianDeniedActionApprovalRequest: Sendable, Equatable { public let review: CodexGuardianApprovalReview public let event: CodexAppServer.JSONValue - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String, @@ -264,46 +264,6 @@ public struct CodexGuardianApprovalReviewAction: Sendable, Equatable { public let toolTitle: String? public let permissions: CodexPermissionProfile? public let reason: String? - - internal init( - type: ActionType, - command: String?, - currentDirectoryPath: String?, - source: CommandSource?, - argv: [String]?, - program: String?, - files: [String]?, - host: String?, - port: Int?, - networkProtocol: NetworkProtocol?, - target: String?, - connectorID: String?, - connectorName: String?, - server: String?, - toolName: String?, - toolTitle: String?, - permissions: CodexPermissionProfile?, - reason: String? - ) { - self.type = type - self.command = command - self.currentDirectoryPath = currentDirectoryPath - self.source = source - self.argv = argv - self.program = program - self.files = files - self.host = host - self.port = port - self.networkProtocol = networkProtocol - self.target = target - self.connectorID = connectorID - self.connectorName = connectorName - self.server = server - self.toolName = toolName - self.toolTitle = toolTitle - self.permissions = permissions - self.reason = reason - } } /// Guardian auto-review result attached to a reviewed action. @@ -324,18 +284,6 @@ public struct CodexGuardianApprovalReview: Sendable, Equatable { public let riskLevel: RiskLevel? public let status: Status public let userAuthorization: UserAuthorization? - - internal init( - rationale: String?, - riskLevel: RiskLevel?, - status: Status, - userAuthorization: UserAuthorization? - ) { - self.rationale = rationale - self.riskLevel = riskLevel - self.status = status - self.userAuthorization = userAuthorization - } } /// Structured command action attached to a command-execution approval request. @@ -398,23 +346,23 @@ public struct CodexNetworkPolicyAmendment: Sendable, Equatable { public init(wireValue: String) { switch wireValue { - case "allow": - self = .allow - case "deny": - self = .deny - default: - self = .unknown(wireValue) + case "allow": + self = .allow + case "deny": + self = .deny + default: + self = .unknown(wireValue) } } public var wireValue: String { switch self { - case .allow: - "allow" - case .deny: - "deny" - case let .unknown(value): - value + case .allow: + "allow" + case .deny: + "deny" + case let .unknown(value): + value } } } @@ -479,37 +427,37 @@ public enum CodexElicitationRequest: Sendable, Equatable { public var threadID: String { switch self { - case let .toolUserInput(request): - request.threadID - case let .mcpServer(request): - request.threadID + case let .toolUserInput(request): + request.threadID + case let .mcpServer(request): + request.threadID } } public var turnID: String? { switch self { - case let .toolUserInput(request): - request.turnID - case let .mcpServer(request): - request.turnID + case let .toolUserInput(request): + request.turnID + case let .mcpServer(request): + request.turnID } } public var kind: CodexInteractiveRequestKind { switch self { - case .toolUserInput: - .toolUserInput - case .mcpServer: - .mcpServerElicitation + case .toolUserInput: + .toolUserInput + case .mcpServer: + .mcpServerElicitation } } - internal var requestID: CodexRPCRequestID { + var requestID: CodexRPCRequestID { switch self { - case let .toolUserInput(request): - request.requestID - case let .mcpServer(request): - request.requestID + case let .toolUserInput(request): + request.requestID + case let .mcpServer(request): + request.requestID } } } @@ -562,9 +510,9 @@ public struct CodexToolUserInputRequest: Sendable, Equatable { public let itemID: String public let questions: [Question] - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, threadID: String, turnID: String, @@ -623,9 +571,9 @@ public struct CodexMcpServerElicitationRequest: Sendable, Equatable { public let turnID: String? public let mode: Mode - internal let requestID: CodexRPCRequestID + let requestID: CodexRPCRequestID - internal init( + init( requestID: CodexRPCRequestID, serverName: String, threadID: String, diff --git a/Sources/SwiftASB/Public/CodexMCP.swift b/Sources/SwiftASB/Public/CodexMCP.swift index ac305de..9f932c8 100644 --- a/Sources/SwiftASB/Public/CodexMCP.swift +++ b/Sources/SwiftASB/Public/CodexMCP.swift @@ -9,17 +9,20 @@ public extension CodexExtensions { /// `CodexExtensions.MCP` exposes opinionated MCP server installation without /// exposing the app-server's raw config editing API. struct MCP: Sendable { - private let appServer: CodexAppServer - - init(appServer: CodexAppServer) { - self.appServer = appServer - } - /// Transport-specific MCP server definition to install into Codex config. public enum ServerDefinition: Sendable, Equatable { case stdio(StdioServer) case http(HTTPServer) + public var name: String { + switch self { + case let .stdio(server): + server.name + case let .http(server): + server.name + } + } + /// Creates a stdio MCP server definition. public static func stdio( name: String, @@ -63,15 +66,6 @@ public extension CodexExtensions { ) ) } - - public var name: String { - switch self { - case let .stdio(server): - server.name - case let .http(server): - server.name - } - } } /// Stdio MCP server launch definition. @@ -211,6 +205,12 @@ public extension CodexExtensions { public let version: String } + private let appServer: CodexAppServer + + init(appServer: CodexAppServer) { + self.appServer = appServer + } + /// Installs an MCP server into user-level Codex configuration. @discardableResult public func install(_ definition: ServerDefinition) async throws -> InstallResult { @@ -253,10 +253,10 @@ public extension CodexAppServer { extension CodexExtensions.MCP.ServerDefinition { var configValue: CodexAppServer.JSONValue { switch self { - case let .stdio(server): - server.configValue - case let .http(server): - server.configValue + case let .stdio(server): + server.configValue + case let .http(server): + server.configValue } } } @@ -294,10 +294,10 @@ extension CodexExtensions.MCP.HTTPServer { ] switch authorization { - case let .bearerTokenEnvironmentVariable(environmentVariable)?: - object["bearer_token_env_var"] = .string(environmentVariable) - case nil: - break + case let .bearerTokenEnvironmentVariable(environmentVariable)?: + object["bearer_token_env_var"] = .string(environmentVariable) + case nil: + break } if headers.isEmpty == false { @@ -351,10 +351,10 @@ extension CodexExtensions.MCP.ToolPolicy { extension CodexExtensions.MCP.InstallResult.WriteStatus { init(protocolValue: CodexProtocolConfigWriteStatus) { switch protocolValue { - case .ok: - self = .ok - case .okOverridden: - self = .okOverridden + case .ok: + self = .ok + case .okOverridden: + self = .okOverridden } } } diff --git a/Sources/SwiftASB/Public/CodexReviewHandle.swift b/Sources/SwiftASB/Public/CodexReviewHandle.swift index 32d1665..a854322 100644 --- a/Sources/SwiftASB/Public/CodexReviewHandle.swift +++ b/Sources/SwiftASB/Public/CodexReviewHandle.swift @@ -12,18 +12,4 @@ public struct CodexReviewHandle: Sendable { public let subject: CodexThread.ReviewSubject /// The active review turn. public let turn: CodexTurnHandle - - internal init( - sourceThreadID: String, - reviewThreadID: String, - placement: CodexThread.ReviewPlacement, - subject: CodexThread.ReviewSubject, - turn: CodexTurnHandle - ) { - self.sourceThreadID = sourceThreadID - self.reviewThreadID = reviewThreadID - self.placement = placement - self.subject = subject - self.turn = turn - } } diff --git a/Sources/SwiftASB/Public/CodexThread+Agenda.swift b/Sources/SwiftASB/Public/CodexThread+Agenda.swift index a879c51..4dab805 100644 --- a/Sources/SwiftASB/Public/CodexThread+Agenda.swift +++ b/Sources/SwiftASB/Public/CodexThread+Agenda.swift @@ -1,10 +1,10 @@ import Foundation import Observation -extension CodexThread { +public extension CodexThread { @MainActor @Observable - public final class Agenda { + final class Agenda { public struct Plan: Sendable, Equatable { public struct Step: Sendable, Equatable, Identifiable { public enum Status: String, Sendable, Equatable { @@ -40,6 +40,21 @@ extension CodexThread { public private(set) var proposedPlan: ProposedPlan? public private(set) var updatedAt: Int? + @ObservationIgnored + private var threadEventTask: Task? + + @ObservationIgnored + private var turnEventTask: Task? + + @ObservationIgnored + private var proposedPlanItemsByID: [String: String] + + @ObservationIgnored + private var proposedPlanItemOrder: [String] + + @ObservationIgnored + private let appServer: CodexAppServer + public var goalTitle: String { goal?.objective ?? "" } @@ -63,22 +78,7 @@ extension CodexThread { return "" } - @ObservationIgnored - private var threadEventTask: Task? - - @ObservationIgnored - private var turnEventTask: Task? - - @ObservationIgnored - private var proposedPlanItemsByID: [String: String] - - @ObservationIgnored - private var proposedPlanItemOrder: [String] - - @ObservationIgnored - private let appServer: CodexAppServer - - internal init( + init( threadID: String, initialGoal: Goal?, appServer: CodexAppServer, @@ -87,13 +87,13 @@ extension CodexThread { ) { self.threadID = threadID self.appServer = appServer - self.currentPlan = nil - self.goal = initialGoal - self.goalStatus = initialGoal?.status - self.proposedPlan = nil - self.updatedAt = initialGoal?.updatedAt - self.proposedPlanItemsByID = [:] - self.proposedPlanItemOrder = [] + currentPlan = nil + goal = initialGoal + goalStatus = initialGoal?.status + proposedPlan = nil + updatedAt = initialGoal?.updatedAt + proposedPlanItemsByID = [:] + proposedPlanItemOrder = [] threadEventTask = Task { [weak self] in guard let self else { return } @@ -179,22 +179,22 @@ extension CodexThread { private func apply(_ event: CodexThreadEvent) { switch event { - case let .goalUpdated(update): - apply(goal: update.goal) - case .goalCleared: - applyGoalCleared() - case .started, - .statusChanged, - .diagnostic, - .approvalRequested, - .elicitationRequested, - .serverRequestResolved, - .archived, - .unarchived, - .closed, - .nameUpdated, - .tokenUsageUpdated: - return + case let .goalUpdated(update): + apply(goal: update.goal) + case .goalCleared: + applyGoalCleared() + case .started, + .statusChanged, + .diagnostic, + .approvalRequested, + .elicitationRequested, + .serverRequestResolved, + .archived, + .unarchived, + .closed, + .nameUpdated, + .tokenUsageUpdated: + return } } @@ -212,27 +212,27 @@ extension CodexThread { private func apply(_ event: CodexTurnEvent) { switch event { - case let .planUpdated(update): - currentPlan = .init(update) - if proposedPlan?.turnID == update.turnID { - clearProposedPlan() - } - case let .planDelta(delta): - applyPlanDelta(delta) - case .started, - .diffUpdated, - .diagnostic, - .approvalRequested, - .elicitationRequested, - .serverRequestResolved, - .itemStarted, - .itemCompleted, - .agentMessageDelta, - .reasoningSummaryPartAdded, - .reasoningSummaryTextDelta, - .reasoningTextDelta, - .completed: - return + case let .planUpdated(update): + currentPlan = .init(update) + if proposedPlan?.turnID == update.turnID { + clearProposedPlan() + } + case let .planDelta(delta): + applyPlanDelta(delta) + case .started, + .diffUpdated, + .diagnostic, + .approvalRequested, + .elicitationRequested, + .serverRequestResolved, + .itemStarted, + .itemCompleted, + .agentMessageDelta, + .reasoningSummaryPartAdded, + .reasoningSummaryTextDelta, + .reasoningTextDelta, + .completed: + return } } @@ -282,12 +282,12 @@ extension CodexThread.Agenda.Plan { extension CodexThread.Agenda.Plan.Step.Status { init(_ status: CodexTurnPlanUpdate.Step.Status) { switch status { - case .completed: - self = .completed - case .inProgress: - self = .inProgress - case .pending: - self = .pending + case .completed: + self = .completed + case .inProgress: + self = .inProgress + case .pending: + self = .pending } } } diff --git a/Sources/SwiftASB/Public/CodexThread+Dashboard.swift b/Sources/SwiftASB/Public/CodexThread+Dashboard.swift index 59e16cd..7bfd3fa 100644 --- a/Sources/SwiftASB/Public/CodexThread+Dashboard.swift +++ b/Sources/SwiftASB/Public/CodexThread+Dashboard.swift @@ -1,10 +1,10 @@ import Foundation import Observation -extension CodexThread { +public extension CodexThread { @MainActor @Observable - public final class Dashboard { + final class Dashboard { public struct HookRun: Sendable, Equatable, Identifiable { public struct Entry: Sendable, Equatable { public enum Kind: String, Sendable, Equatable { @@ -87,7 +87,7 @@ extension CodexThread { case timedOut } - internal struct ActivityState: Sendable, Equatable { + struct ActivityState: Equatable { var activeAutoReviewIDs: Set = [] var activeMcpItemIDs: Set = [] var activeToolLikeItemIDs: Set = [] @@ -127,7 +127,7 @@ extension CodexThread { @ObservationIgnored private var activityState: ActivityState - internal init( + init( threadID: String, initialInfo: CodexAppServer.ThreadInfo, initialMcpServers: [CodexAppServer.McpServerSummary], @@ -137,25 +137,25 @@ extension CodexThread { activityUpdates: AsyncStream ) { self.threadID = threadID - self.isArchived = false - self.isClosed = false - self.latestDiagnostic = nil - self.goalTitle = "" - self.latestTokenUsage = nil - self.mcpServers = initialMcpServers - self.name = initialInfo.name - self.planTitle = "" - self.preview = initialInfo.preview - self.status = initialInfo.status - self.activityState = initialActivityState - self.autoReviewStatus = initialActivityState.autoReviewStatus - self.hookRuns = initialActivityState.hookRuns - self.isCompactingThreadContext = initialActivityState.isCompactingThreadContext - self.mcpCallingStatus = Self.activityStatus( + isArchived = false + isClosed = false + latestDiagnostic = nil + goalTitle = "" + latestTokenUsage = nil + mcpServers = initialMcpServers + name = initialInfo.name + planTitle = "" + preview = initialInfo.preview + status = initialInfo.status + activityState = initialActivityState + autoReviewStatus = initialActivityState.autoReviewStatus + hookRuns = initialActivityState.hookRuns + isCompactingThreadContext = initialActivityState.isCompactingThreadContext + mcpCallingStatus = Self.activityStatus( activeIDs: initialActivityState.activeMcpItemIDs, hasErrorResidue: initialActivityState.hasMcpErrorResidue ) - self.toolCallingStatus = Self.activityStatus( + toolCallingStatus = Self.activityStatus( activeIDs: initialActivityState.activeToolLikeItemIDs, hasErrorResidue: initialActivityState.hasToolErrorResidue ) @@ -203,58 +203,84 @@ extension CodexThread { activityTask?.cancel() } + private static func activityStatus( + activeIDs: Set, + hasErrorResidue: Bool + ) -> ActivityStatus { + if !activeIDs.isEmpty { + return .inProgress + } + if hasErrorResidue { + return .errored + } + return .idle + } + + private static func planTitle(from update: CodexTurnPlanUpdate) -> String { + if let activeStep = update.plan.first(where: { $0.status == .inProgress }) { + return activeStep.step + } + if let pendingStep = update.plan.first(where: { $0.status == .pending }) { + return pendingStep.step + } + if let firstStep = update.plan.first { + return firstStep.step + } + return update.explanation ?? "" + } + private func apply(_ event: CodexThreadEvent) { switch event { - case let .started(started): - name = started.thread.name - preview = started.thread.preview - status = started.thread.status - case let .statusChanged(change): - status = change.status - case let .diagnostic(diagnostic): - latestDiagnostic = diagnostic - case .approvalRequested: - return - case .elicitationRequested: - return - case .serverRequestResolved: - return - case .archived: - isArchived = true - case .unarchived: - isArchived = false - case .closed: - isClosed = true - case let .nameUpdated(update): - name = update.threadName - case let .tokenUsageUpdated(update): - latestTokenUsage = update - case let .goalUpdated(update): - goalTitle = update.goal.objective - case .goalCleared: - goalTitle = "" + case let .started(started): + name = started.thread.name + preview = started.thread.preview + status = started.thread.status + case let .statusChanged(change): + status = change.status + case let .diagnostic(diagnostic): + latestDiagnostic = diagnostic + case .approvalRequested: + return + case .elicitationRequested: + return + case .serverRequestResolved: + return + case .archived: + isArchived = true + case .unarchived: + isArchived = false + case .closed: + isClosed = true + case let .nameUpdated(update): + name = update.threadName + case let .tokenUsageUpdated(update): + latestTokenUsage = update + case let .goalUpdated(update): + goalTitle = update.goal.objective + case .goalCleared: + goalTitle = "" } } private func apply(_ event: CodexTurnEvent) { switch event { - case let .planUpdated(update): - planTitle = Self.planTitle(from: update) - case .started, - .planDelta, - .diffUpdated, - .diagnostic, - .approvalRequested, - .elicitationRequested, - .serverRequestResolved, - .itemStarted, - .itemCompleted, - .agentMessageDelta, - .reasoningSummaryPartAdded, - .reasoningSummaryTextDelta, - .reasoningTextDelta, - .completed: - return + case let .planUpdated(update): + planTitle = Self.planTitle(from: update) + case .started, + .planDelta, + .diffUpdated, + .diagnostic, + .approvalRequested, + .elicitationRequested, + .serverRequestResolved, + .itemStarted, + .itemCompleted, + .agentMessageDelta, + .reasoningSummaryPartAdded, + .reasoningSummaryTextDelta, + .reasoningTextDelta, + .completed: + return } } @@ -276,32 +302,5 @@ extension CodexThread { hasErrorResidue: activityState.hasMcpErrorResidue ) } - - private static func activityStatus( - activeIDs: Set, - hasErrorResidue: Bool - ) -> ActivityStatus { - if !activeIDs.isEmpty { - return .inProgress - } - if hasErrorResidue { - return .errored - } - return .idle - } - - private static func planTitle(from update: CodexTurnPlanUpdate) -> String { - if let activeStep = update.plan.first(where: { $0.status == .inProgress }) { - return activeStep.step - } - if let pendingStep = update.plan.first(where: { $0.status == .pending }) { - return pendingStep.step - } - if let firstStep = update.plan.first { - return firstStep.step - } - return update.explanation ?? "" - } } - } diff --git a/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift b/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift index 0df5a4a..fbf5721 100644 --- a/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift +++ b/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift @@ -1,10 +1,10 @@ import Foundation import Observation -extension CodexThread { +public extension CodexThread { @MainActor @Observable - public final class RecentCommands { + final class RecentCommands { public struct CachePolicy: Sendable, Equatable { public let maxResidentCommands: Int public let minimumResidentCommands: Int @@ -66,6 +66,52 @@ extension CodexThread { public internal(set) var turnOrderIndex: Int? public internal(set) var turnStartedAt: Int? + fileprivate static func makeDisplayName(command: String?) -> String { + guard let command, !command.isEmpty else { return "Command" } + + return command + } + + fileprivate static func makeStatusSummary( + command: String?, + status: String?, + text: String? + ) -> String? { + let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedStatus = normalizedStatus?.lowercased() + + if lowercasedStatus == "completed", let outputSummary = makeOutputSummary(text: text) { + return outputSummary + } + + if let normalizedStatus, !normalizedStatus.isEmpty { + return normalizedStatus + } + + if let outputSummary = makeOutputSummary(text: text) { + return outputSummary + } + + return command + } + + fileprivate static func makeOutputSummary(text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + if nonEmptyLines.count > 1 { + return "\(nonEmptyLines.count) output lines" + } + + guard let firstNonEmptyLine = nonEmptyLines.first else { + return nil + } + + return String(firstNonEmptyLine.prefix(160)) + } + fileprivate mutating func apply(delta: String) { outputText = (outputText ?? "") + delta latestStatusText = latestStatusText ?? "Streaming command output" @@ -93,6 +139,7 @@ extension CodexThread { fileprivate mutating func slimOutput() { guard let outputText, !outputText.isEmpty else { return } + omittedOutputCharacterCount = outputText.count self.outputText = nil isOutputComplete = false @@ -126,59 +173,14 @@ extension CodexThread { self = snapshot - if (outputText == nil || outputText?.isEmpty == true), + if outputText == nil || outputText?.isEmpty == true, let existingOutputText, - !existingOutputText.isEmpty - { + !existingOutputText.isEmpty { outputText = existingOutputText isOutputComplete = existingOutputComplete omittedOutputCharacterCount = existingOmittedOutputCharacterCount } } - - fileprivate static func makeDisplayName(command: String?) -> String { - guard let command, !command.isEmpty else { return "Command" } - return command - } - - fileprivate static func makeStatusSummary( - command: String?, - status: String?, - text: String? - ) -> String? { - let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedStatus = normalizedStatus?.lowercased() - - if lowercasedStatus == "completed", let outputSummary = makeOutputSummary(text: text) { - return outputSummary - } - - if let normalizedStatus, !normalizedStatus.isEmpty { - return normalizedStatus - } - - if let outputSummary = makeOutputSummary(text: text) { - return outputSummary - } - - return command - } - - fileprivate static func makeOutputSummary(text: String?) -> String? { - guard let text, !text.isEmpty else { return nil } - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - if nonEmptyLines.count > 1 { - return "\(nonEmptyLines.count) output lines" - } - - guard let firstNonEmptyLine = nonEmptyLines.first else { - return nil - } - - return String(firstNonEmptyLine.prefix(160)) - } } public let cachePolicy: CachePolicy @@ -208,7 +210,7 @@ extension CodexThread { @ObservationIgnored private var residencyTask: Task? - internal init( + init( cachePolicy: CachePolicy, threadID: String, residentLimit: Int, @@ -222,12 +224,12 @@ extension CodexThread { self.threadID = threadID self.residentLimit = residentLimit self.nextOlderCursor = nextOlderCursor - self.commands = initialCommands - self.isLoadingOlderCommands = false - self.lastLoadErrorDescription = nil - self.residentOutputCost = initialCommands.reduce(0) { $0 + Self.commandResidentCost($1) } - self.visibleCommandIDs = [] - self.selectedCommandID = nil + commands = initialCommands + isLoadingOlderCommands = false + lastLoadErrorDescription = nil + residentOutputCost = initialCommands.reduce(0) { $0 + Self.commandResidentCost($1) } + visibleCommandIDs = [] + selectedCommandID = nil self.appServer = appServer trimResidentCommandsIfNeeded() @@ -260,6 +262,51 @@ extension CodexThread { residencyTask?.cancel() } + private static func commandSnapshotID(turnID: String, itemID: String) -> String { + "\(turnID):\(itemID)" + } + + private static func distanceToRange( + _ index: Int, + lowerBound: Int, + upperBound: Int + ) -> Int { + if index < lowerBound { + return lowerBound - index + } + if index > upperBound { + return index - upperBound + } + return 0 + } + + private static func commandResidentCost(_ command: CommandSnapshot) -> Int { + let baseCost = command.status == .inProgress ? 3 : 1 + guard let outputText = command.outputText, !outputText.isEmpty else { + return baseCost + } + + let lines = outputText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let nonEmptyLineCount = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count + let characterCost = max(1, Int(ceil(Double(outputText.count) / 480.0))) + let lineCost = max(1, Int(ceil(Double(nonEmptyLineCount) / 20.0))) + let payloadCost = min(characterCost + lineCost, command.isOutputComplete ? 18 : 9) + return baseCost + payloadCost + } + + private static func status(from persistedStatus: String?) -> CommandSnapshot.Status { + guard let persistedStatus else { return .completed } + + switch persistedStatus.lowercased() { + case "error", "errored", "failed", "interrupted": + return .errored + case "in_progress", "inprogress", "running": + return .inProgress + default: + return .completed + } + } + /// Loads commands older than the current resident window. /// /// Omitting `limit` uses this companion's resident page limit. @@ -297,20 +344,22 @@ extension CodexThread { private func apply(_ event: CodexTurnEvent) async { switch event { - case let .itemStarted(started): - guard started.item.kind == .commandExecution else { return } - upsertLiveCommand(from: started.item, turnID: started.turnID, status: .inProgress) - case let .itemCompleted(completed): - guard completed.item.kind == .commandExecution else { return } - upsertLiveCommand( - from: completed.item, - turnID: completed.turnID, - status: completed.item.isErrored ? .errored : .completed - ) - case let .completed(completion): - await refreshCommands(for: completion.turn.id) - default: - return + case let .itemStarted(started): + guard started.item.kind == .commandExecution else { return } + + upsertLiveCommand(from: started.item, turnID: started.turnID, status: .inProgress) + case let .itemCompleted(completed): + guard completed.item.kind == .commandExecution else { return } + + upsertLiveCommand( + from: completed.item, + turnID: completed.turnID, + status: completed.item.isErrored ? .errored : .completed + ) + case let .completed(completion): + await refreshCommands(for: completion.turn.id) + default: + return } trimResidentCommandsIfNeeded() @@ -320,6 +369,7 @@ extension CodexThread { private func apply(_ event: CodexAppServer.CommandExecutionOutputDeltaEvent) { let commandID = Self.commandSnapshotID(turnID: event.turnID, itemID: event.itemID) guard let index = commands.firstIndex(where: { $0.id == commandID }) else { return } + commands[index].apply(delta: event.delta) refreshResidentMetrics() trimResidentCommandsIfNeeded() @@ -418,6 +468,7 @@ extension CodexThread { residencyTask?.cancel() residencyTask = Task { [weak self] in guard let self else { return } + await self.hydrateProtectedCommandsIfNeeded() self.trimResidentCommandsIfNeeded() } @@ -427,6 +478,7 @@ extension CodexThread { let protectedIDs = protectedCommandIDSet() let slimmedProtectedIDs = commands.compactMap { command -> String? in guard protectedIDs.contains(command.id), !command.isOutputComplete else { return nil } + return command.id } @@ -435,6 +487,7 @@ extension CodexThread { for commandID in slimmedProtectedIDs { do { guard let index = commands.firstIndex(where: { $0.id == commandID }) else { continue } + let command = commands[index] guard let snapshot = try await appServer.turnSnapshot(threadID: threadID, turnID: command.turnID) else { continue @@ -442,6 +495,7 @@ extension CodexThread { guard let item = snapshot.items.first(where: { $0.id == command.itemID }) else { continue } + commands[index].hydrateOutput( command: item.command, text: item.streamedText ?? item.text, @@ -476,7 +530,7 @@ extension CodexThread { mandatoryIndices.insert(index) } for index in commands.indices.prefix(cachePolicy.protectedRecentCompletedCommands) - where commands[index].status != .inProgress { + where commands[index].status != .inProgress { mandatoryIndices.insert(index) } @@ -522,10 +576,12 @@ extension CodexThread { let protectedIDs = protectedCommandIDSet() for index in commands.indices.reversed() { guard residentOutputCost > maximumResidentOutputCost else { break } + let command = commands[index] guard command.status != .inProgress else { continue } guard !protectedIDs.contains(command.id) else { continue } guard command.isOutputComplete else { continue } + commands[index].slimOutput() refreshResidentMetrics() } @@ -585,53 +641,7 @@ extension CodexThread { private func refreshResidentMetrics() { residentOutputCost = commands.reduce(0) { $0 + Self.commandResidentCost($1) } } - - private static func commandSnapshotID(turnID: String, itemID: String) -> String { - "\(turnID):\(itemID)" - } - - private static func distanceToRange( - _ index: Int, - lowerBound: Int, - upperBound: Int - ) -> Int { - if index < lowerBound { - return lowerBound - index - } - if index > upperBound { - return index - upperBound - } - return 0 - } - - private static func commandResidentCost(_ command: CommandSnapshot) -> Int { - let baseCost = command.status == .inProgress ? 3 : 1 - guard let outputText = command.outputText, !outputText.isEmpty else { - return baseCost - } - - let lines = outputText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let nonEmptyLineCount = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count - let characterCost = max(1, Int(ceil(Double(outputText.count) / 480.0))) - let lineCost = max(1, Int(ceil(Double(nonEmptyLineCount) / 20.0))) - let payloadCost = min(characterCost + lineCost, command.isOutputComplete ? 18 : 9) - return baseCost + payloadCost - } - - private static func status(from persistedStatus: String?) -> CommandSnapshot.Status { - guard let persistedStatus else { return .completed } - switch persistedStatus.lowercased() { - case "error", "errored", "failed", "interrupted": - return .errored - case "in_progress", "inprogress", "running": - return .inProgress - default: - return .completed - } - } } - - } extension CodexThread.RecentCommands.CommandSnapshot { @@ -671,13 +681,14 @@ extension CodexThread.RecentCommands.CommandSnapshot { private static func snapshotStatus(from persistedStatus: String?) -> CodexThread.RecentCommands.CommandSnapshot.Status { guard let persistedStatus else { return .completed } + switch persistedStatus.lowercased() { - case "error", "errored", "failed", "interrupted": - return .errored - case "in_progress", "inprogress", "running": - return .inProgress - default: - return .completed + case "error", "errored", "failed", "interrupted": + return .errored + case "in_progress", "inprogress", "running": + return .inProgress + default: + return .completed } } } diff --git a/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift b/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift index 916ea65..aaa9d53 100644 --- a/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift +++ b/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift @@ -1,10 +1,10 @@ import Foundation import Observation -extension CodexThread { +public extension CodexThread { @MainActor @Observable - public final class RecentFiles { + final class RecentFiles { public struct CachePolicy: Sendable, Equatable { public let maxResidentFiles: Int public let minimumResidentFiles: Int @@ -45,7 +45,13 @@ extension CodexThread { } public struct FileSnapshot: Sendable, Equatable, Identifiable { - fileprivate struct PayloadMetrics: Sendable, Equatable { + public enum Status: String, Sendable, Equatable { + case completed + case errored + case inProgress + } + + fileprivate struct PayloadMetrics: Equatable { let additions: Int let characterCount: Int let deletions: Int @@ -57,12 +63,6 @@ extension CodexThread { } } - public enum Status: String, Sendable, Equatable { - case completed - case errored - case inProgress - } - public let id: String public let itemID: String public private(set) var displayName: String @@ -78,83 +78,9 @@ extension CodexThread { public internal(set) var turnOrderIndex: Int? public internal(set) var turnStartedAt: Int? - fileprivate mutating func apply(delta: String) { - payloadText = (payloadText ?? "") + delta - latestStatusText = latestStatusText ?? "Streaming file change" - status = .inProgress - isPayloadComplete = false - } - - fileprivate mutating func applyPatch(text: String, path: String?) { - payloadText = text - if let path, !path.isEmpty { - self.path = path - displayName = Self.makeDisplayName(path: path) - } - latestStatusText = Self.makePayloadSummary(text: text) ?? "Previewing file change" - status = .inProgress - isPayloadComplete = false - omittedPayloadCharacterCount = 0 - } - - fileprivate mutating func apply(_ item: CodexTurnItem, status: Status) { - displayName = Self.makeDisplayName(path: item.path) - latestStatusText = Self.makeStatusSummary(status: item.status, text: item.text) ?? latestStatusText - path = item.path - self.status = status - if let text = item.text, !text.isEmpty { - payloadText = text - isPayloadComplete = status != .inProgress - omittedPayloadCharacterCount = 0 - } else if status != .inProgress { - isPayloadComplete = payloadText?.isEmpty == false - } - } - - fileprivate mutating func slimPayload() { - guard let payloadText, !payloadText.isEmpty else { return } - omittedPayloadCharacterCount = payloadText.count - self.payloadText = nil - isPayloadComplete = false - } - - fileprivate mutating func hydratePayload( - text: String?, - latestStatusText: String?, - status: Status - ) { - if let text, !text.isEmpty { - payloadText = text - } - self.latestStatusText = latestStatusText ?? self.latestStatusText - self.status = status - if payloadText != nil { - isPayloadComplete = true - omittedPayloadCharacterCount = 0 - } else { - isPayloadComplete = false - } - } - - fileprivate mutating func mergeNewerSnapshot(_ snapshot: Self) { - let existingPayloadText = payloadText - let existingPayloadComplete = isPayloadComplete - let existingOmittedPayloadCharacterCount = omittedPayloadCharacterCount - - self = snapshot - - if (payloadText == nil || payloadText?.isEmpty == true), - let existingPayloadText, - !existingPayloadText.isEmpty - { - payloadText = existingPayloadText - isPayloadComplete = existingPayloadComplete - omittedPayloadCharacterCount = existingOmittedPayloadCharacterCount - } - } - fileprivate static func makeDisplayName(path: String?) -> String { guard let path, !path.isEmpty else { return "File edit" } + return URL(fileURLWithPath: path).lastPathComponent } @@ -175,6 +101,7 @@ extension CodexThread { fileprivate static func makePayloadSummary(text: String?) -> String? { guard let text, !text.isEmpty else { return nil } + let metrics = payloadMetrics(text: text) if metrics.hasStructuredDiffSignal { var parts: [String] = [] @@ -229,6 +156,81 @@ extension CodexThread { nonEmptyLineCount: nonEmptyLineCount ) } + + fileprivate mutating func apply(delta: String) { + payloadText = (payloadText ?? "") + delta + latestStatusText = latestStatusText ?? "Streaming file change" + status = .inProgress + isPayloadComplete = false + } + + fileprivate mutating func applyPatch(text: String, path: String?) { + payloadText = text + if let path, !path.isEmpty { + self.path = path + displayName = Self.makeDisplayName(path: path) + } + latestStatusText = Self.makePayloadSummary(text: text) ?? "Previewing file change" + status = .inProgress + isPayloadComplete = false + omittedPayloadCharacterCount = 0 + } + + fileprivate mutating func apply(_ item: CodexTurnItem, status: Status) { + displayName = Self.makeDisplayName(path: item.path) + latestStatusText = Self.makeStatusSummary(status: item.status, text: item.text) ?? latestStatusText + path = item.path + self.status = status + if let text = item.text, !text.isEmpty { + payloadText = text + isPayloadComplete = status != .inProgress + omittedPayloadCharacterCount = 0 + } else if status != .inProgress { + isPayloadComplete = payloadText?.isEmpty == false + } + } + + fileprivate mutating func slimPayload() { + guard let payloadText, !payloadText.isEmpty else { return } + + omittedPayloadCharacterCount = payloadText.count + self.payloadText = nil + isPayloadComplete = false + } + + fileprivate mutating func hydratePayload( + text: String?, + latestStatusText: String?, + status: Status + ) { + if let text, !text.isEmpty { + payloadText = text + } + self.latestStatusText = latestStatusText ?? self.latestStatusText + self.status = status + if payloadText != nil { + isPayloadComplete = true + omittedPayloadCharacterCount = 0 + } else { + isPayloadComplete = false + } + } + + fileprivate mutating func mergeNewerSnapshot(_ snapshot: Self) { + let existingPayloadText = payloadText + let existingPayloadComplete = isPayloadComplete + let existingOmittedPayloadCharacterCount = omittedPayloadCharacterCount + + self = snapshot + + if payloadText == nil || payloadText?.isEmpty == true, + let existingPayloadText, + !existingPayloadText.isEmpty { + payloadText = existingPayloadText + isPayloadComplete = existingPayloadComplete + omittedPayloadCharacterCount = existingOmittedPayloadCharacterCount + } + } } public let cachePolicy: CachePolicy @@ -258,7 +260,7 @@ extension CodexThread { @ObservationIgnored private var residencyTask: Task? - internal init( + init( cachePolicy: CachePolicy, threadID: String, residentLimit: Int, @@ -272,12 +274,12 @@ extension CodexThread { self.threadID = threadID self.residentLimit = residentLimit self.nextOlderCursor = nextOlderCursor - self.files = initialFiles - self.isLoadingOlderFiles = false - self.lastLoadErrorDescription = nil - self.residentPayloadCost = initialFiles.reduce(0) { $0 + Self.fileResidentCost($1) } - self.visibleFileIDs = [] - self.selectedFileID = nil + files = initialFiles + isLoadingOlderFiles = false + lastLoadErrorDescription = nil + residentPayloadCost = initialFiles.reduce(0) { $0 + Self.fileResidentCost($1) } + visibleFileIDs = [] + selectedFileID = nil self.appServer = appServer trimResidentFilesIfNeeded() @@ -310,6 +312,55 @@ extension CodexThread { residencyTask?.cancel() } + private static func fileSnapshotID(turnID: String, itemID: String) -> String { + "\(turnID):\(itemID)" + } + + private static func distanceToRange( + _ index: Int, + lowerBound: Int, + upperBound: Int + ) -> Int { + if index < lowerBound { + return lowerBound - index + } + if index > upperBound { + return index - upperBound + } + return 0 + } + + private static func fileResidentCost(_ file: FileSnapshot) -> Int { + let baseCost = file.status == .inProgress ? 3 : 1 + guard let payloadText = file.payloadText, !payloadText.isEmpty else { + return baseCost + } + + let metrics = FileSnapshot.payloadMetrics(text: payloadText) + let characterCost = max(1, Int(ceil(Double(metrics.characterCount) / 320.0))) + let lineCost = max(1, Int(ceil(Double(metrics.nonEmptyLineCount) / 12.0))) + let diffSignalCost = min(metrics.additions + metrics.deletions, 10) + let hunkCost = min(metrics.hunkCount, 4) + let payloadCost = min( + characterCost + lineCost + diffSignalCost + hunkCost, + file.isPayloadComplete ? 20 : 10 + ) + return baseCost + payloadCost + } + + private static func status(from persistedStatus: String?) -> FileSnapshot.Status { + guard let persistedStatus else { return .completed } + + switch persistedStatus.lowercased() { + case "error", "errored", "failed", "interrupted": + return .errored + case "in_progress", "inprogress", "running": + return .inProgress + default: + return .completed + } + } + /// Loads files older than the current resident window. /// /// Omitting `limit` uses this companion's resident page limit. @@ -347,20 +398,22 @@ extension CodexThread { private func apply(_ event: CodexTurnEvent) async { switch event { - case let .itemStarted(started): - guard started.item.kind == .fileChange else { return } - upsertLiveFile(from: started.item, turnID: started.turnID, status: .inProgress) - case let .itemCompleted(completed): - guard completed.item.kind == .fileChange else { return } - upsertLiveFile( - from: completed.item, - turnID: completed.turnID, - status: completed.item.isErrored ? .errored : .completed - ) - case let .completed(completion): - await refreshFiles(for: completion.turn.id) - default: - return + case let .itemStarted(started): + guard started.item.kind == .fileChange else { return } + + upsertLiveFile(from: started.item, turnID: started.turnID, status: .inProgress) + case let .itemCompleted(completed): + guard completed.item.kind == .fileChange else { return } + + upsertLiveFile( + from: completed.item, + turnID: completed.turnID, + status: completed.item.isErrored ? .errored : .completed + ) + case let .completed(completion): + await refreshFiles(for: completion.turn.id) + default: + return } trimResidentFilesIfNeeded() @@ -370,6 +423,7 @@ extension CodexThread { private func apply(_ event: CodexAppServer.FileChangeOutputDeltaEvent) { let fileID = Self.fileSnapshotID(turnID: event.turnID, itemID: event.itemID) guard let index = files.firstIndex(where: { $0.id == fileID }) else { return } + if event.replacesPayload { files[index].applyPatch(text: event.delta, path: event.path) } else { @@ -467,6 +521,7 @@ extension CodexThread { residencyTask?.cancel() residencyTask = Task { [weak self] in guard let self else { return } + await self.hydrateProtectedFilesIfNeeded() self.trimResidentFilesIfNeeded() } @@ -476,6 +531,7 @@ extension CodexThread { let protectedIDs = protectedFileIDSet() let slimmedProtectedIDs = files.compactMap { file -> String? in guard protectedIDs.contains(file.id), !file.isPayloadComplete else { return nil } + return file.id } @@ -484,6 +540,7 @@ extension CodexThread { for fileID in slimmedProtectedIDs { do { guard let index = files.firstIndex(where: { $0.id == fileID }) else { continue } + let file = files[index] guard let snapshot = try await appServer.turnSnapshot(threadID: threadID, turnID: file.turnID) else { continue @@ -491,6 +548,7 @@ extension CodexThread { guard let item = snapshot.items.first(where: { $0.id == file.itemID }) else { continue } + files[index].hydratePayload( text: item.streamedText ?? item.text, latestStatusText: FileSnapshot.makeStatusSummary( @@ -523,7 +581,7 @@ extension CodexThread { mandatoryIndices.insert(index) } for index in files.indices.prefix(cachePolicy.protectedRecentCompletedFiles) - where files[index].status != .inProgress { + where files[index].status != .inProgress { mandatoryIndices.insert(index) } @@ -569,10 +627,12 @@ extension CodexThread { let protectedIDs = protectedFileIDSet() for index in files.indices.reversed() { guard residentPayloadCost > maximumResidentPayloadCost else { break } + let file = files[index] guard file.status != .inProgress else { continue } guard !protectedIDs.contains(file.id) else { continue } guard file.isPayloadComplete else { continue } + files[index].slimPayload() refreshResidentMetrics() } @@ -632,57 +692,7 @@ extension CodexThread { private func refreshResidentMetrics() { residentPayloadCost = files.reduce(0) { $0 + Self.fileResidentCost($1) } } - - private static func fileSnapshotID(turnID: String, itemID: String) -> String { - "\(turnID):\(itemID)" - } - - private static func distanceToRange( - _ index: Int, - lowerBound: Int, - upperBound: Int - ) -> Int { - if index < lowerBound { - return lowerBound - index - } - if index > upperBound { - return index - upperBound - } - return 0 - } - - private static func fileResidentCost(_ file: FileSnapshot) -> Int { - let baseCost = file.status == .inProgress ? 3 : 1 - guard let payloadText = file.payloadText, !payloadText.isEmpty else { - return baseCost - } - - let metrics = FileSnapshot.payloadMetrics(text: payloadText) - let characterCost = max(1, Int(ceil(Double(metrics.characterCount) / 320.0))) - let lineCost = max(1, Int(ceil(Double(metrics.nonEmptyLineCount) / 12.0))) - let diffSignalCost = min(metrics.additions + metrics.deletions, 10) - let hunkCost = min(metrics.hunkCount, 4) - let payloadCost = min( - characterCost + lineCost + diffSignalCost + hunkCost, - file.isPayloadComplete ? 20 : 10 - ) - return baseCost + payloadCost - } - - private static func status(from persistedStatus: String?) -> FileSnapshot.Status { - guard let persistedStatus else { return .completed } - switch persistedStatus.lowercased() { - case "error", "errored", "failed", "interrupted": - return .errored - case "in_progress", "inprogress", "running": - return .inProgress - default: - return .completed - } - } } - - } extension CodexThread.RecentFiles.FileSnapshot { @@ -722,13 +732,14 @@ extension CodexThread.RecentFiles.FileSnapshot { private static func snapshotStatus(from persistedStatus: String?) -> CodexThread.RecentFiles.FileSnapshot.Status { guard let persistedStatus else { return .completed } + switch persistedStatus.lowercased() { - case "error", "errored", "failed", "interrupted": - return .errored - case "in_progress", "inprogress", "running": - return .inProgress - default: - return .completed + case "error", "errored", "failed", "interrupted": + return .errored + case "in_progress", "inprogress", "running": + return .inProgress + default: + return .completed } } } diff --git a/Sources/SwiftASB/Public/CodexThread+RecentTurns.swift b/Sources/SwiftASB/Public/CodexThread+RecentTurns.swift index 1ff26ac..fe783d6 100644 --- a/Sources/SwiftASB/Public/CodexThread+RecentTurns.swift +++ b/Sources/SwiftASB/Public/CodexThread+RecentTurns.swift @@ -1,10 +1,10 @@ import Foundation import Observation -extension CodexThread { +public extension CodexThread { @MainActor @Observable - public final class RecentTurns { + final class RecentTurns { public struct CachePolicy: Sendable, Equatable { public let fastScrollVelocityThreshold: Double public let jitterScrollVelocityThreshold: Double @@ -30,8 +30,8 @@ extension CodexThread { protectedRecentCompletedTurns: Int = 2, edgePrefetchThreshold: Int = 1, jitterScrollVelocityThreshold: Double = 120, - fastScrollVelocityThreshold: Double = 1_200, - veryFastScrollVelocityThreshold: Double = 2_400, + fastScrollVelocityThreshold: Double = 1200, + veryFastScrollVelocityThreshold: Double = 2400, maxPrefetchPagesPerPass: Int = 3 ) { let normalizedMaxResidentTurns = max(1, maxResidentTurns) @@ -77,8 +77,8 @@ extension CodexThread { protectedRecentCompletedTurns: 2, edgePrefetchThreshold: max(1, min(2, normalizedPageSize)), jitterScrollVelocityThreshold: 120, - fastScrollVelocityThreshold: 1_100, - veryFastScrollVelocityThreshold: 2_100, + fastScrollVelocityThreshold: 1100, + veryFastScrollVelocityThreshold: 2100, maxPrefetchPagesPerPass: 3 ) } @@ -95,7 +95,7 @@ extension CodexThread { edgePrefetchThreshold: max(2, min(4, normalizedPageSize / 2)), jitterScrollVelocityThreshold: 80, fastScrollVelocityThreshold: 900, - veryFastScrollVelocityThreshold: 1_800, + veryFastScrollVelocityThreshold: 1800, maxPrefetchPagesPerPass: 4 ) } @@ -111,8 +111,8 @@ extension CodexThread { protectedRecentCompletedTurns: 1, edgePrefetchThreshold: 1, jitterScrollVelocityThreshold: 140, - fastScrollVelocityThreshold: 1_250, - veryFastScrollVelocityThreshold: 2_500, + fastScrollVelocityThreshold: 1250, + veryFastScrollVelocityThreshold: 2500, maxPrefetchPagesPerPass: 2 ) } @@ -196,7 +196,7 @@ extension CodexThread { @ObservationIgnored private var unresolvedInteractiveTurnIDs: Set = [] - internal init( + init( cachePolicy: CachePolicy, threadID: String, residentLimit: Int, @@ -209,20 +209,20 @@ extension CodexThread { self.cachePolicy = cachePolicy self.threadID = threadID self.residentLimit = residentLimit - self.isLoadingNewerTurns = false - self.isLoadingOlderTurns = false - self.lastLoadErrorDescription = nil + isLoadingNewerTurns = false + isLoadingOlderTurns = false + lastLoadErrorDescription = nil self.nextNewerCursor = nextNewerCursor self.nextOlderCursor = nextOlderCursor - self.residentItemCount = initialTurns.reduce(0) { $0 + $1.items.count } - self.residentItemCost = initialTurns.reduce(0) { partialResult, turn in + residentItemCount = initialTurns.reduce(0) { $0 + $1.items.count } + residentItemCost = initialTurns.reduce(0) { partialResult, turn in partialResult + Self.turnResidentCost(turn) } - self.scrollActivityPhase = .idle - self.scrollVelocityPointsPerSecond = nil - self.turns = initialTurns - self.visibleTurnIDs = [] - self.scrollPositionTurnID = nil + scrollActivityPhase = .idle + scrollVelocityPointsPerSecond = nil + turns = initialTurns + visibleTurnIDs = [] + scrollPositionTurnID = nil self.appServer = appServer trimResidentTurnsIfNeeded() @@ -246,12 +246,150 @@ extension CodexThread { viewportTask?.cancel() } + private static func turnID(from event: CodexTurnEvent) -> String? { + switch event { + case let .started(started): + started.turn.id + case let .planUpdated(update): + update.turnID + case let .planDelta(delta): + delta.turnID + case let .diffUpdated(update): + update.turnID + case let .diagnostic(diagnostic): + diagnostic.turnID + case let .itemStarted(itemStarted): + itemStarted.turnID + case let .itemCompleted(itemCompleted): + itemCompleted.turnID + case let .agentMessageDelta(delta): + delta.turnID + case let .reasoningSummaryPartAdded(delta): + delta.turnID + case let .reasoningSummaryTextDelta(delta): + delta.turnID + case let .reasoningTextDelta(delta): + delta.turnID + case let .completed(completion): + completion.turn.id + case .approvalRequested, .elicitationRequested, .serverRequestResolved: + nil + } + } + + private static func distanceToRange( + _ index: Int, + lowerBound: Int, + upperBound: Int + ) -> Int { + if index < lowerBound { + return lowerBound - index + } + if index > upperBound { + return index - upperBound + } + return 0 + } + + private static func isTerminalStatus(_ status: String) -> Bool { + switch status { + case CodexAppServer.TurnStatus.completed.rawValue, + CodexAppServer.TurnStatus.failed.rawValue, + CodexAppServer.TurnStatus.interrupted.rawValue: + true + default: + false + } + } + + private static func describe(_ error: any Error) -> String { + if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { + return description + } + return String(describing: error) + } + + private static func slimmedSnapshot(from snapshot: TurnSnapshot) -> TurnSnapshot { + let retainedItems = snapshot.items.filter { !$0.isLowValueForResidency } + let omittedItemCount = snapshot.items.count - retainedItems.count + guard omittedItemCount > 0 else { return snapshot } + + return .init( + id: snapshot.id, + completedAt: snapshot.completedAt, + diff: snapshot.diff, + durationMS: snapshot.durationMS, + errorMessage: snapshot.errorMessage, + isItemPayloadComplete: false, + items: retainedItems, + omittedItemCount: omittedItemCount, + orderIndex: snapshot.orderIndex, + startedAt: snapshot.startedAt, + status: snapshot.status, + tokenUsage: snapshot.tokenUsage + ) + } + + private static func turnResidentCost(_ turn: TurnSnapshot) -> Int { + turn.items.reduce(0) { $0 + itemResidentCost($1) } + } + + private static func itemResidentCost(_ item: TurnSnapshot.Item) -> Int { + let baseCost: Int + if item.isLowValueForResidency { + baseCost = 1 + } else { + baseCost = 3 + } + + let textLength = (item.text ?? item.streamedText ?? "").count + let textWeight: Int + switch textLength { + case 0..<80: + textWeight = 0 + case 80..<240: + textWeight = 1 + case 240..<800: + textWeight = 2 + default: + textWeight = 3 + } + + return baseCost + textWeight + } + + private nonisolated static func isLowValueItemKind(_ kind: String) -> Bool { + switch kind { + case CodexTurnItem.Kind.commandExecution.rawValue, + CodexTurnItem.Kind.collabAgentToolCall.rawValue, + CodexTurnItem.Kind.contextCompaction.rawValue, + CodexTurnItem.Kind.dynamicToolCall.rawValue, + CodexTurnItem.Kind.enteredReviewMode.rawValue, + CodexTurnItem.Kind.exitedReviewMode.rawValue, + CodexTurnItem.Kind.fileChange.rawValue, + CodexTurnItem.Kind.hookPrompt.rawValue, + CodexTurnItem.Kind.imageGeneration.rawValue, + CodexTurnItem.Kind.imageView.rawValue, + CodexTurnItem.Kind.mcpToolCall.rawValue, + CodexTurnItem.Kind.webSearch.rawValue: + true + case CodexTurnItem.Kind.agentMessage.rawValue, + CodexTurnItem.Kind.plan.rawValue, + CodexTurnItem.Kind.reasoning.rawValue, + CodexTurnItem.Kind.userMessage.rawValue: + false + default: + false + } + } + /// Loads turns older than the current resident window. /// /// Omitting `limit` uses this companion's resident page limit. public func loadOlderTurns(limit: Int? = nil) async throws { guard !isLoadingOlderTurns else { return } guard let oldestOrderIndex = turns.last?.orderIndex else { return } + let pageLimit = limit ?? residentLimit isLoadingOlderTurns = true defer { isLoadingOlderTurns = false } @@ -283,6 +421,7 @@ extension CodexThread { public func loadNewerTurns(limit: Int? = nil) async throws { guard !isLoadingNewerTurns else { return } guard let newestOrderIndex = turns.first?.orderIndex else { return } + let pageLimit = limit ?? residentLimit isLoadingNewerTurns = true defer { isLoadingNewerTurns = false } @@ -334,37 +473,6 @@ extension CodexThread { scheduleViewportMaintenance() } - private static func turnID(from event: CodexTurnEvent) -> String? { - switch event { - case let .started(started): - started.turn.id - case let .planUpdated(update): - update.turnID - case let .planDelta(delta): - delta.turnID - case let .diffUpdated(update): - update.turnID - case let .diagnostic(diagnostic): - diagnostic.turnID - case let .itemStarted(itemStarted): - itemStarted.turnID - case let .itemCompleted(itemCompleted): - itemCompleted.turnID - case let .agentMessageDelta(delta): - delta.turnID - case let .reasoningSummaryPartAdded(delta): - delta.turnID - case let .reasoningSummaryTextDelta(delta): - delta.turnID - case let .reasoningTextDelta(delta): - delta.turnID - case let .completed(completion): - completion.turn.id - case .approvalRequested, .elicitationRequested, .serverRequestResolved: - nil - } - } - private func merge(_ incoming: [TurnSnapshot]) { guard !incoming.isEmpty else { return } @@ -384,6 +492,7 @@ extension CodexThread { viewportTask?.cancel() viewportTask = Task { [weak self] in guard let self else { return } + await self.refreshProtectedTurnContext() await self.hydrateProtectedTurnsIfNeeded() self.trimResidentTurnsIfNeeded() @@ -502,18 +611,23 @@ extension CodexThread { for index in turns.indices.reversed() { guard residentItemCost > maximumResidentItemCost else { break } + let turn = turns[index] guard Self.isTerminalStatus(turn.status) else { continue } guard !protectedTurnIDs.contains(turn.id) else { continue } guard turn.isItemPayloadComplete else { continue } + let slimmedTurn = Self.slimmedSnapshot(from: turn) guard slimmedTurn != turn else { continue } + turns[index] = slimmedTurn refreshResidentMetrics() } } private func protectedTurnIDSet() -> Set { + guard !turns.isEmpty else { return [] } + let focusIndices = focusIndices() let focusLowerBound = max(0, focusIndices.lowerBound - cachePolicy.protectedTurnBuffer) let focusUpperBound = min(turns.count - 1, focusIndices.upperBound + cachePolicy.protectedTurnBuffer) @@ -535,6 +649,7 @@ extension CodexThread { private func prefetchPageCount() -> Int { guard let velocity = scrollVelocityPointsPerSecond.map(abs) else { return 1 } + if velocity >= cachePolicy.veryFastScrollVelocityThreshold { return cachePolicy.maxPrefetchPagesPerPass } @@ -546,11 +661,12 @@ extension CodexThread { private func shouldSuppressPrefetchForCurrentScrollActivity() -> Bool { guard let velocity = scrollVelocityPointsPerSecond.map(abs) else { return false } + return switch scrollActivityPhase { - case .tracking, .interacting: - velocity < cachePolicy.jitterScrollVelocityThreshold - case .idle, .decelerating, .animating: - false + case .tracking, .interacting: + velocity < cachePolicy.jitterScrollVelocityThreshold + case .idle, .decelerating, .animating: + false } } @@ -562,6 +678,7 @@ extension CodexThread { let protectedTurnIDs = protectedTurnIDSet() let slimmedProtectedTurnIDs: [String] = turns.compactMap { turn in guard protectedTurnIDs.contains(turn.id), !turn.isItemPayloadComplete else { return nil } + return turn.id } @@ -572,6 +689,7 @@ extension CodexThread { guard let snapshot = try await appServer.turnSnapshot(threadID: threadID, turnID: turnID) else { continue } + merge([TurnSnapshot(snapshot)]) lastLoadErrorDescription = nil } catch { @@ -596,114 +714,7 @@ extension CodexThread { private func index(forTurnID turnID: String) -> Int? { turns.firstIndex { $0.id == turnID } } - - private static func distanceToRange( - _ index: Int, - lowerBound: Int, - upperBound: Int - ) -> Int { - if index < lowerBound { - return lowerBound - index - } - if index > upperBound { - return index - upperBound - } - return 0 - } - - private static func isTerminalStatus(_ status: String) -> Bool { - switch status { - case CodexAppServer.TurnStatus.completed.rawValue, - CodexAppServer.TurnStatus.failed.rawValue, - CodexAppServer.TurnStatus.interrupted.rawValue: - true - default: - false - } - } - - private static func describe(_ error: any Error) -> String { - if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { - return description - } - return String(describing: error) - } - - private static func slimmedSnapshot(from snapshot: TurnSnapshot) -> TurnSnapshot { - let retainedItems = snapshot.items.filter { !$0.isLowValueForResidency } - let omittedItemCount = snapshot.items.count - retainedItems.count - guard omittedItemCount > 0 else { return snapshot } - return .init( - id: snapshot.id, - completedAt: snapshot.completedAt, - diff: snapshot.diff, - durationMS: snapshot.durationMS, - errorMessage: snapshot.errorMessage, - isItemPayloadComplete: false, - items: retainedItems, - omittedItemCount: omittedItemCount, - orderIndex: snapshot.orderIndex, - startedAt: snapshot.startedAt, - status: snapshot.status, - tokenUsage: snapshot.tokenUsage - ) - } - - private static func turnResidentCost(_ turn: TurnSnapshot) -> Int { - turn.items.reduce(0) { $0 + itemResidentCost($1) } - } - - private static func itemResidentCost(_ item: TurnSnapshot.Item) -> Int { - let baseCost: Int - if item.isLowValueForResidency { - baseCost = 1 - } else { - baseCost = 3 - } - - let textLength = (item.text ?? item.streamedText ?? "").count - let textWeight: Int - switch textLength { - case 0..<80: - textWeight = 0 - case 80..<240: - textWeight = 1 - case 240..<800: - textWeight = 2 - default: - textWeight = 3 - } - - return baseCost + textWeight - } - - nonisolated private static func isLowValueItemKind(_ kind: String) -> Bool { - switch kind { - case CodexTurnItem.Kind.commandExecution.rawValue, - CodexTurnItem.Kind.collabAgentToolCall.rawValue, - CodexTurnItem.Kind.contextCompaction.rawValue, - CodexTurnItem.Kind.dynamicToolCall.rawValue, - CodexTurnItem.Kind.enteredReviewMode.rawValue, - CodexTurnItem.Kind.exitedReviewMode.rawValue, - CodexTurnItem.Kind.fileChange.rawValue, - CodexTurnItem.Kind.hookPrompt.rawValue, - CodexTurnItem.Kind.imageGeneration.rawValue, - CodexTurnItem.Kind.imageView.rawValue, - CodexTurnItem.Kind.mcpToolCall.rawValue, - CodexTurnItem.Kind.webSearch.rawValue: - true - case CodexTurnItem.Kind.agentMessage.rawValue, - CodexTurnItem.Kind.plan.rawValue, - CodexTurnItem.Kind.reasoning.rawValue, - CodexTurnItem.Kind.userMessage.rawValue: - false - default: - false - } - } } - - } extension CodexThread.RecentTurns.TurnSnapshot { diff --git a/Sources/SwiftASB/Public/CodexThread.swift b/Sources/SwiftASB/Public/CodexThread.swift index 27ed9cb..1080033 100644 --- a/Sources/SwiftASB/Public/CodexThread.swift +++ b/Sources/SwiftASB/Public/CodexThread.swift @@ -11,7 +11,7 @@ public struct CodexThread: Sendable { public let oldestTurnID: String? public let newestTurnID: String? - internal init( + init( threadID: String, turns: [CodexTurnHandle.ClosedTurn], hasOlderTurns: Bool, @@ -21,8 +21,8 @@ public struct CodexThread: Sendable { self.turns = turns self.hasOlderTurns = hasOlderTurns self.hasNewerTurns = hasNewerTurns - self.oldestTurnID = turns.last?.id - self.newestTurnID = turns.first?.id + oldestTurnID = turns.last?.id + newestTurnID = turns.first?.id } } @@ -187,7 +187,7 @@ public struct CodexThread: Sendable { private let appServer: CodexAppServer private let threadID: String - internal init(appServer: CodexAppServer, threadID: String) { + init(appServer: CodexAppServer, threadID: String) { self.appServer = appServer self.threadID = threadID } @@ -358,35 +358,35 @@ public struct CodexThread: Sendable { /// app-server event feed fails unexpectedly. public let events: AsyncThrowingStream + private let appServer: CodexAppServer + /// Thread-scoped MCP status and resource surface. public var mcp: MCP { MCP(appServer: appServer, threadID: id) } - private let appServer: CodexAppServer - - internal init( + init( appServer: CodexAppServer, session: CodexAppServer.ThreadSession, mcpServers: [CodexAppServer.McpServerSummary], events: AsyncThrowingStream ) { self.appServer = appServer - self.id = session.thread.id - self.info = session.thread - self.approvalPolicy = session.approvalPolicy - self.approvalsReviewer = session.approvalsReviewer - self.currentDirectoryPath = session.currentDirectoryPath - self.instructionSources = session.instructionSources + id = session.thread.id + info = session.thread + approvalPolicy = session.approvalPolicy + approvalsReviewer = session.approvalsReviewer + currentDirectoryPath = session.currentDirectoryPath + instructionSources = session.instructionSources self.mcpServers = mcpServers - self.model = session.model - self.modelProvider = session.modelProvider - self.activePermissionProfile = session.activePermissionProfile - self.permissionProfile = session.permissionProfile - self.reasoningEffort = session.reasoningEffort - self.sandboxPolicy = session.sandboxPolicy - self.serviceTier = session.serviceTier - self.workspace = session.workspace + model = session.model + modelProvider = session.modelProvider + activePermissionProfile = session.activePermissionProfile + permissionProfile = session.permissionProfile + reasoningEffort = session.reasoningEffort + sandboxPolicy = session.sandboxPolicy + serviceTier = session.serviceTier + workspace = session.workspace self.events = events } @@ -660,16 +660,16 @@ public struct CodexThread: Sendable { /// been refreshed or a selection changes. public func readHistoryWindow(_ query: HistoryWindowQD = .recent()) async throws -> HistoryWindow { switch query.anchor { - case .recent: - try await readRecentTurnHistoryWindow(limit: query.limit) - case let .olderThanTurn(turnID): - try await readOlderTurnHistoryWindow(olderThan: turnID, limit: query.limit) - case let .newerThanTurn(turnID): - try await readNewerTurnHistoryWindow(newerThan: turnID, limit: query.limit) - case let .aroundTurn(turnID): - try await windowAroundTurn(turnID, limit: query.limit) - case let .aroundItem(itemID): - try await windowAroundItem(itemID, limit: query.limit) + case .recent: + try await readRecentTurnHistoryWindow(limit: query.limit) + case let .olderThanTurn(turnID): + try await readOlderTurnHistoryWindow(olderThan: turnID, limit: query.limit) + case let .newerThanTurn(turnID): + try await readNewerTurnHistoryWindow(newerThan: turnID, limit: query.limit) + case let .aroundTurn(turnID): + try await windowAroundTurn(turnID, limit: query.limit) + case let .aroundItem(itemID): + try await windowAroundItem(itemID, limit: query.limit) } } @@ -881,7 +881,6 @@ public struct CodexThread: Sendable { expectedTurnID: nil ) } - } public enum CodexThreadEvent: Sendable, Equatable { @@ -902,60 +901,28 @@ public enum CodexThreadEvent: Sendable, Equatable { public struct CodexThreadStarted: Sendable, Equatable { public let thread: CodexAppServer.ThreadInfo - - internal init(thread: CodexAppServer.ThreadInfo) { - self.thread = thread - } } public struct CodexThreadStatusChanged: Sendable, Equatable { public let threadID: String public let status: CodexAppServer.ThreadStatus - - internal init( - threadID: String, - status: CodexAppServer.ThreadStatus - ) { - self.threadID = threadID - self.status = status - } } public struct CodexThreadNameUpdated: Sendable, Equatable { public let threadID: String public let threadName: String? - - internal init( - threadID: String, - threadName: String? - ) { - self.threadID = threadID - self.threadName = threadName - } } public struct CodexThreadArchived: Sendable, Equatable { public let threadID: String - - internal init(threadID: String) { - self.threadID = threadID - } } public struct CodexThreadUnarchived: Sendable, Equatable { public let threadID: String - - internal init(threadID: String) { - self.threadID = threadID - } } public struct CodexThreadClosed: Sendable, Equatable { public let threadID: String - - internal init(threadID: String) { - self.threadID = threadID - } } public struct CodexThreadTokenUsageUpdated: Sendable, Equatable { @@ -966,7 +933,7 @@ public struct CodexThreadTokenUsageUpdated: Sendable, Equatable { public let reasoningOutputTokens: Int public let totalTokens: Int - internal init( + init( cachedInputTokens: Int, inputTokens: Int, outputTokens: Int, @@ -986,44 +953,16 @@ public struct CodexThreadTokenUsageUpdated: Sendable, Equatable { public let last: Usage public let modelContextWindow: Int? public let total: Usage - - internal init( - threadID: String, - turnID: String, - last: Usage, - modelContextWindow: Int?, - total: Usage - ) { - self.threadID = threadID - self.turnID = turnID - self.last = last - self.modelContextWindow = modelContextWindow - self.total = total - } } public struct CodexThreadGoalUpdated: Sendable, Equatable { public let threadID: String public let turnID: String? public let goal: CodexThread.Goal - - internal init( - threadID: String, - turnID: String?, - goal: CodexThread.Goal - ) { - self.threadID = threadID - self.turnID = turnID - self.goal = goal - } } public struct CodexThreadGoalCleared: Sendable, Equatable { public let threadID: String - - internal init(threadID: String) { - self.threadID = threadID - } } extension CodexThread.Goal { @@ -1044,35 +983,35 @@ extension CodexThread.Goal { extension CodexThread.Goal.Status { init(wireValue: CodexWireThreadGoalStatus) { switch wireValue { - case .active: - self = .active - case .blocked: - self = .blocked - case .budgetLimited: - self = .budgetLimited - case .complete: - self = .complete - case .paused: - self = .paused - case .usageLimited: - self = .usageLimited + case .active: + self = .active + case .blocked: + self = .blocked + case .budgetLimited: + self = .budgetLimited + case .complete: + self = .complete + case .paused: + self = .paused + case .usageLimited: + self = .usageLimited } } var wireValue: CodexWireThreadGoalStatus { switch self { - case .active: - .active - case .blocked: - .blocked - case .budgetLimited: - .budgetLimited - case .complete: - .complete - case .paused: - .paused - case .usageLimited: - .usageLimited + case .active: + .active + case .blocked: + .blocked + case .budgetLimited: + .budgetLimited + case .complete: + .complete + case .paused: + .paused + case .usageLimited: + .usageLimited } } } diff --git a/Sources/SwiftASB/Public/CodexTurnHandle.swift b/Sources/SwiftASB/Public/CodexTurnHandle.swift index 62d04da..994a819 100644 --- a/Sources/SwiftASB/Public/CodexTurnHandle.swift +++ b/Sources/SwiftASB/Public/CodexTurnHandle.swift @@ -65,48 +65,48 @@ public struct CodexTurnHandle: Sendable { public private(set) var status: Status public private(set) var toolName: String? - fileprivate mutating func apply(_ item: CodexTurnItem, status: Status) { + fileprivate init(item: CodexTurnItem, kind: Kind, status: Status) { + id = item.id displayName = Self.makeDisplayName(from: item) filePath = item.path + self.kind = kind latestStatusText = item.status ?? item.text serverName = item.serverName self.status = status toolName = item.toolName } - fileprivate init(item: CodexTurnItem, kind: Kind, status: Status) { - self.id = item.id - self.displayName = Self.makeDisplayName(from: item) - self.filePath = item.path - self.kind = kind - self.latestStatusText = item.status ?? item.text - self.serverName = item.serverName - self.status = status - self.toolName = item.toolName - } - private static func makeDisplayName(from item: CodexTurnItem) -> String { switch item.kind { - case .commandExecution: - item.command ?? "Command" - case .mcpToolCall: - if let toolName = item.toolName, let serverName = item.serverName { - "\(serverName).\(toolName)" - } else if let toolName = item.toolName { - toolName - } else { - "MCP tool call" - } - case .dynamicToolCall: - item.toolName ?? "Dynamic tool call" - case .collabAgentToolCall: - item.toolName ?? "Collab tool call" - case .fileChange: - item.path ?? "File edit" - default: - item.text ?? item.status ?? item.kind.rawValue + case .commandExecution: + item.command ?? "Command" + case .mcpToolCall: + if let toolName = item.toolName, let serverName = item.serverName { + "\(serverName).\(toolName)" + } else if let toolName = item.toolName { + toolName + } else { + "MCP tool call" + } + case .dynamicToolCall: + item.toolName ?? "Dynamic tool call" + case .collabAgentToolCall: + item.toolName ?? "Collab tool call" + case .fileChange: + item.path ?? "File edit" + default: + item.text ?? item.status ?? item.kind.rawValue } } + + fileprivate mutating func apply(_ item: CodexTurnItem, status: Status) { + displayName = Self.makeDisplayName(from: item) + filePath = item.path + latestStatusText = item.status ?? item.text + serverName = item.serverName + self.status = status + toolName = item.toolName + } } public let threadID: String @@ -133,31 +133,31 @@ public struct CodexTurnHandle: Sendable { @ObservationIgnored private var eventTask: Task? - internal init( + init( threadID: String, initialTurn: CodexAppServer.TurnInfo, events: AsyncThrowingStream ) { self.threadID = threadID - self.turnID = initialTurn.id - self.callSnapshots = [] - self.currentTurn = initialTurn - self.isCompactingThreadContext = false - self.latestApprovalRequest = nil - self.latestAgentMessageDelta = nil - self.latestCompletedItem = nil - self.latestCompletion = nil - self.latestDiagnostic = nil - self.latestDiffUpdate = nil - self.latestElicitationRequest = nil - self.latestPlanDelta = nil - self.latestPlanUpdate = nil - self.latestReasoningSummaryPartAdded = nil - self.latestReasoningSummaryTextDelta = nil - self.latestReasoningTextDelta = nil - self.latestRequestResolution = nil - self.latestStartedItem = nil - self.latestStartedTurn = nil + turnID = initialTurn.id + callSnapshots = [] + currentTurn = initialTurn + isCompactingThreadContext = false + latestApprovalRequest = nil + latestAgentMessageDelta = nil + latestCompletedItem = nil + latestCompletion = nil + latestDiagnostic = nil + latestDiffUpdate = nil + latestElicitationRequest = nil + latestPlanDelta = nil + latestPlanUpdate = nil + latestReasoningSummaryPartAdded = nil + latestReasoningSummaryTextDelta = nil + latestReasoningTextDelta = nil + latestRequestResolution = nil + latestStartedItem = nil + latestStartedTurn = nil eventTask = Task { [weak self] in guard let self else { return } @@ -178,60 +178,86 @@ public struct CodexTurnHandle: Sendable { eventTask?.cancel() } + private static func callSnapshotKind(for kind: CodexTurnItem.Kind) -> CallSnapshot.Kind? { + switch kind { + case .collabAgentToolCall: + .collabTool + case .commandExecution: + .command + case .dynamicToolCall: + .dynamicTool + case .fileChange: + .fileEdit + case .mcpToolCall: + .mcp + case .subAgentActivity: + nil + default: + nil + } + } + + private static func callSnapshotStatus(for item: CodexTurnItem) -> CallSnapshot.Status { + if item.isErrored { + return .errored + } + return .completed + } + private func apply(_ event: CodexTurnEvent) { switch event { - case let .started(started): - latestStartedTurn = started - currentTurn = started.turn - case let .planUpdated(update): - latestPlanUpdate = update - case let .planDelta(delta): - latestPlanDelta = delta - case let .diffUpdated(update): - latestDiffUpdate = update - case let .diagnostic(diagnostic): - latestDiagnostic = diagnostic - case let .approvalRequested(request): - latestApprovalRequest = request - case let .elicitationRequested(request): - latestElicitationRequest = request - case let .serverRequestResolved(resolution): - latestRequestResolution = resolution - if latestApprovalRequest?.requestID == resolution.requestID { - latestApprovalRequest = nil - } - if latestElicitationRequest?.requestID == resolution.requestID { - latestElicitationRequest = nil - } - case let .itemStarted(itemStarted): - latestStartedItem = itemStarted - if itemStarted.item.kind == .contextCompaction { - isCompactingThreadContext = true - return - } - upsertCallSnapshot(from: itemStarted.item, status: .inProgress) - case let .itemCompleted(itemCompleted): - latestCompletedItem = itemCompleted - if itemCompleted.item.kind == .contextCompaction { + case let .started(started): + latestStartedTurn = started + currentTurn = started.turn + case let .planUpdated(update): + latestPlanUpdate = update + case let .planDelta(delta): + latestPlanDelta = delta + case let .diffUpdated(update): + latestDiffUpdate = update + case let .diagnostic(diagnostic): + latestDiagnostic = diagnostic + case let .approvalRequested(request): + latestApprovalRequest = request + case let .elicitationRequested(request): + latestElicitationRequest = request + case let .serverRequestResolved(resolution): + latestRequestResolution = resolution + if latestApprovalRequest?.requestID == resolution.requestID { + latestApprovalRequest = nil + } + if latestElicitationRequest?.requestID == resolution.requestID { + latestElicitationRequest = nil + } + case let .itemStarted(itemStarted): + latestStartedItem = itemStarted + if itemStarted.item.kind == .contextCompaction { + isCompactingThreadContext = true + return + } + upsertCallSnapshot(from: itemStarted.item, status: .inProgress) + case let .itemCompleted(itemCompleted): + latestCompletedItem = itemCompleted + if itemCompleted.item.kind == .contextCompaction { + isCompactingThreadContext = false + return + } + upsertCallSnapshot( + from: itemCompleted.item, + status: Self.callSnapshotStatus(for: itemCompleted.item) + ) + case let .agentMessageDelta(delta): + latestAgentMessageDelta = delta + case let .reasoningSummaryPartAdded(partAdded): + latestReasoningSummaryPartAdded = partAdded + case let .reasoningSummaryTextDelta(delta): + latestReasoningSummaryTextDelta = delta + case let .reasoningTextDelta(delta): + latestReasoningTextDelta = delta + case let .completed(completion): + latestCompletion = completion + currentTurn = completion.turn isCompactingThreadContext = false - return - } - upsertCallSnapshot( - from: itemCompleted.item, - status: Self.callSnapshotStatus(for: itemCompleted.item) - ) - case let .agentMessageDelta(delta): - latestAgentMessageDelta = delta - case let .reasoningSummaryPartAdded(partAdded): - latestReasoningSummaryPartAdded = partAdded - case let .reasoningSummaryTextDelta(delta): - latestReasoningSummaryTextDelta = delta - case let .reasoningTextDelta(delta): - latestReasoningTextDelta = delta - case let .completed(completion): - latestCompletion = completion - currentTurn = completion.turn - isCompactingThreadContext = false } } @@ -250,30 +276,6 @@ public struct CodexTurnHandle: Sendable { ) } } - - private static func callSnapshotKind(for kind: CodexTurnItem.Kind) -> CallSnapshot.Kind? { - switch kind { - case .collabAgentToolCall: - .collabTool - case .commandExecution: - .command - case .dynamicToolCall: - .dynamicTool - case .fileChange: - .fileEdit - case .mcpToolCall: - .mcp - default: - nil - } - } - - private static func callSnapshotStatus(for item: CodexTurnItem) -> CallSnapshot.Status { - if item.isErrored { - return .errored - } - return .completed - } } private final class MinimapStorage: @unchecked Sendable { @@ -284,11 +286,8 @@ public struct CodexTurnHandle: Sendable { } } - private let appServer: CodexAppServer - private let minimapStorage: MinimapStorage public let threadID: String public let turn: CodexAppServer.TurnInfo - /// Typed events for this active turn. /// /// SwiftASB buffers the earliest turn events that can arrive before the @@ -299,7 +298,20 @@ public struct CodexTurnHandle: Sendable { /// feed fails unexpectedly. public let events: AsyncThrowingStream - internal init( + private let appServer: CodexAppServer + private let minimapStorage: MinimapStorage + + /// Observable current-state mirror for this turn. + /// + /// The minimap is attached when the turn handle is created. It mirrors + /// selected latest state from the turn event stream and is not a replayable + /// event log. + @MainActor + public var minimap: Minimap { + minimapStorage.minimap + } + + init( appServer: CodexAppServer, threadID: String, turn: CodexAppServer.TurnInfo, @@ -307,22 +319,12 @@ public struct CodexTurnHandle: Sendable { minimap: Minimap ) { self.appServer = appServer - self.minimapStorage = MinimapStorage(minimap: minimap) + minimapStorage = MinimapStorage(minimap: minimap) self.threadID = threadID self.turn = turn self.events = events } - /// Observable current-state mirror for this turn. - /// - /// The minimap is attached when the turn handle is created. It mirrors - /// selected latest state from the turn event stream and is not a replayable - /// event log. - @MainActor - public var minimap: Minimap { - minimapStorage.minimap - } - /// Answers an approval request for this active turn. /// /// SwiftASB verifies that the request belongs to this turn before sending @@ -377,12 +379,12 @@ public struct CodexTurnHandle: Sendable { try await steer([.text(text)]) } - @discardableResult /// Reads this turn's completed local-history snapshot. /// /// This does not send a command to terminate the turn. Use it after terminal /// completion when the caller wants a sealed value independent of the live /// handle. + @discardableResult public func complete() async throws -> ClosedTurn { try await appServer.closedTurn(threadID: threadID, turnID: turn.id) } @@ -409,14 +411,6 @@ public enum CodexTurnEvent: Sendable, Equatable { public struct CodexTurnStarted: Sendable, Equatable { public let threadID: String public let turn: CodexAppServer.TurnInfo - - internal init( - threadID: String, - turn: CodexAppServer.TurnInfo - ) { - self.threadID = threadID - self.turn = turn - } } public struct CodexTurnPlanUpdate: Sendable, Equatable { @@ -430,7 +424,7 @@ public struct CodexTurnPlanUpdate: Sendable, Equatable { public let status: Status public let step: String - internal init( + init( status: Status, step: String ) { @@ -443,18 +437,6 @@ public struct CodexTurnPlanUpdate: Sendable, Equatable { public let turnID: String public let explanation: String? public let plan: [Step] - - internal init( - threadID: String, - turnID: String, - explanation: String?, - plan: [Step] - ) { - self.threadID = threadID - self.turnID = turnID - self.explanation = explanation - self.plan = plan - } } public struct CodexTurnPlanDelta: Sendable, Equatable { @@ -462,34 +444,12 @@ public struct CodexTurnPlanDelta: Sendable, Equatable { public let turnID: String public let itemID: String public let delta: String - - internal init( - threadID: String, - turnID: String, - itemID: String, - delta: String - ) { - self.threadID = threadID - self.turnID = turnID - self.itemID = itemID - self.delta = delta - } } public struct CodexTurnDiffUpdate: Sendable, Equatable { public let threadID: String public let turnID: String public let diff: String - - internal init( - threadID: String, - turnID: String, - diff: String - ) { - self.threadID = threadID - self.turnID = turnID - self.diff = diff - } } public struct CodexTurnItem: Sendable, Equatable { @@ -508,6 +468,7 @@ public struct CodexTurnItem: Sendable, Equatable { case mcpToolCall case plan case reasoning + case subAgentActivity case userMessage case webSearch } @@ -521,33 +482,14 @@ public struct CodexTurnItem: Sendable, Equatable { public let status: String? public let toolName: String? - internal init( - id: String, - kind: Kind, - command: String?, - path: String?, - serverName: String?, - text: String?, - status: String?, - toolName: String? - ) { - self.id = id - self.kind = kind - self.command = command - self.path = path - self.serverName = serverName - self.text = text - self.status = status - self.toolName = toolName - } - var isErrored: Bool { guard let status else { return false } + return switch status.lowercased() { - case "error", "errored", "failed", "interrupted": - true - default: - false + case "error", "errored", "failed", "interrupted": + true + default: + false } } } @@ -556,32 +498,12 @@ public struct CodexTurnItemStarted: Sendable, Equatable { public let threadID: String public let turnID: String public let item: CodexTurnItem - - internal init( - threadID: String, - turnID: String, - item: CodexTurnItem - ) { - self.threadID = threadID - self.turnID = turnID - self.item = item - } } public struct CodexTurnItemCompleted: Sendable, Equatable { public let threadID: String public let turnID: String public let item: CodexTurnItem - - internal init( - threadID: String, - turnID: String, - item: CodexTurnItem - ) { - self.threadID = threadID - self.turnID = turnID - self.item = item - } } public struct CodexTurnAgentMessageDelta: Sendable, Equatable { @@ -589,18 +511,6 @@ public struct CodexTurnAgentMessageDelta: Sendable, Equatable { public let turnID: String public let itemID: String public let delta: String - - internal init( - threadID: String, - turnID: String, - itemID: String, - delta: String - ) { - self.threadID = threadID - self.turnID = turnID - self.itemID = itemID - self.delta = delta - } } public struct CodexTurnReasoningSummaryPartAdded: Sendable, Equatable { @@ -608,18 +518,6 @@ public struct CodexTurnReasoningSummaryPartAdded: Sendable, Equatable { public let turnID: String public let itemID: String public let summaryIndex: Int - - internal init( - threadID: String, - turnID: String, - itemID: String, - summaryIndex: Int - ) { - self.threadID = threadID - self.turnID = turnID - self.itemID = itemID - self.summaryIndex = summaryIndex - } } public struct CodexTurnReasoningSummaryTextDelta: Sendable, Equatable { @@ -628,20 +526,6 @@ public struct CodexTurnReasoningSummaryTextDelta: Sendable, Equatable { public let itemID: String public let summaryIndex: Int public let delta: String - - internal init( - threadID: String, - turnID: String, - itemID: String, - summaryIndex: Int, - delta: String - ) { - self.threadID = threadID - self.turnID = turnID - self.itemID = itemID - self.summaryIndex = summaryIndex - self.delta = delta - } } public struct CodexTurnReasoningTextDelta: Sendable, Equatable { @@ -650,36 +534,14 @@ public struct CodexTurnReasoningTextDelta: Sendable, Equatable { public let itemID: String public let contentIndex: Int public let delta: String - - internal init( - threadID: String, - turnID: String, - itemID: String, - contentIndex: Int, - delta: String - ) { - self.threadID = threadID - self.turnID = turnID - self.itemID = itemID - self.contentIndex = contentIndex - self.delta = delta - } } public struct CodexTurnCompletion: Sendable, Equatable { public let threadID: String public let turn: CodexAppServer.TurnInfo - - internal init( - threadID: String, - turn: CodexAppServer.TurnInfo - ) { - self.threadID = threadID - self.turn = turn - } } -internal extension CodexTurnHandle.ClosedTurn { +extension CodexTurnHandle.ClosedTurn { init(threadID: String, snapshot: ThreadHistoryStore.ThreadSnapshot.TurnSnapshot) { self.init( id: snapshot.id, diff --git a/Sources/SwiftASB/Public/CodexWorkspace.swift b/Sources/SwiftASB/Public/CodexWorkspace.swift index 967e901..61c66f3 100644 --- a/Sources/SwiftASB/Public/CodexWorkspace.swift +++ b/Sources/SwiftASB/Public/CodexWorkspace.swift @@ -9,20 +9,10 @@ public enum CodexWorkspace { /// Named permissions profile selection for thread and turn requests. public struct PermissionSelection: Sendable, Equatable { public var id: String - public var modifications: [PermissionSelectionModification] /// Creates a named permissions profile selection. - /// - /// `modifications` is retained as source-compatible local metadata for - /// callers that still construct older selections. Codex CLI v0.135.0 - /// accepts the selected profile id on thread and turn starts, but no - /// longer accepts request-side bounded permission modifications. - public init( - id: String, - modifications: [PermissionSelectionModification] = [] - ) { + public init(id: String) { self.id = id - self.modifications = modifications } /// Built-in workspace profile selection. @@ -31,38 +21,10 @@ public enum CodexWorkspace { } } - /// Bounded modification for a selected permissions profile. - public struct PermissionSelectionModification: Sendable, Equatable { - public let path: String - - /// Adds a concrete writable root to the selected profile. - public init(additionalWritableRoot path: String) { - self.path = path - } - } - /// Named profile currently active for a session. public struct ActivePermissionProfile: Sendable, Equatable { public let id: String public let extends: String? - /// Bounded modifications reported by older app-server schemas. - /// - /// Codex CLI v0.135 no longer reports these in the active profile - /// payload, so SwiftASB surfaces an empty collection for v0.135 - /// sessions. - public let modifications: [ActivePermissionModification] - } - - /// Bounded runtime modification applied to the active profile. - public struct ActivePermissionModification: Sendable, Equatable { - /// Runtime modification kind reported by Codex. - public enum Kind: String, Sendable, Equatable { - case additionalWritableRoot - case unknown - } - - public let kind: Kind - public let path: String } /// Full runtime permissions profile reported for a thread session. @@ -161,9 +123,9 @@ public enum CodexWorkspace { currentDirectoryPath: currentDirectoryPath, repository: repository ) - self.id = worktree.id - self.identitySource = worktree.identitySource - self.displayName = worktree.displayName + id = worktree.id + identitySource = worktree.identitySource + displayName = worktree.displayName self.currentDirectoryPath = worktree.currentDirectoryPath self.repository = worktree.repository self.worktree = worktree @@ -181,6 +143,11 @@ public enum CodexWorkspace { public let currentDirectoryPath: String public let repository: RepositoryInfo? + /// True when the app-server reported any Git metadata for this worktree. + public var hasRepositoryFacts: Bool { + repository?.hasFacts == true + } + /// Creates a worktree snapshot from an app-server cwd and optional Git facts. public init( currentDirectoryPath: String, @@ -190,21 +157,16 @@ public enum CodexWorkspace { self.repository = repository?.normalized if let originURL = self.repository?.originURL, !originURL.isEmpty { - self.id = originURL - self.identitySource = .gitOrigin - self.displayName = Self.displayName(forGitOriginURL: originURL) + id = originURL + identitySource = .gitOrigin + displayName = Self.displayName(forGitOriginURL: originURL) } else { - self.id = currentDirectoryPath - self.identitySource = .currentDirectory - self.displayName = currentDirectoryPath.isEmpty ? "Unknown Project" : currentDirectoryPath + id = currentDirectoryPath + identitySource = .currentDirectory + displayName = currentDirectoryPath.isEmpty ? "Unknown Project" : currentDirectoryPath } } - /// True when the app-server reported any Git metadata for this worktree. - public var hasRepositoryFacts: Bool { - repository?.hasFacts == true - } - private static func displayName(forGitOriginURL originURL: String) -> String { guard let url = URL(string: originURL), let host = url.host, @@ -225,17 +187,6 @@ public enum CodexWorkspace { public let branch: String? public let sha: String? - /// Creates repository facts reported by Codex. - public init( - originURL: String? = nil, - branch: String? = nil, - sha: String? = nil - ) { - self.originURL = Self.normalizedFact(originURL) - self.branch = Self.normalizedFact(branch) - self.sha = Self.normalizedFact(sha) - } - /// True when Codex reported at least one Git fact for this thread. public var hasFacts: Bool { !isEmpty @@ -244,22 +195,35 @@ public enum CodexWorkspace { /// Short display form for the reported commit SHA. public var shortSHA: String? { guard let sha, !sha.isEmpty else { return nil } + return String(sha.prefix(12)) } - internal var isEmpty: Bool { + var isEmpty: Bool { originURL == nil && branch == nil && sha == nil } - internal var normalized: Self? { + var normalized: Self? { isEmpty ? nil : self } - internal static func normalizedFact(_ value: String?) -> String? { + /// Creates repository facts reported by Codex. + public init( + originURL: String? = nil, + branch: String? = nil, + sha: String? = nil + ) { + self.originURL = Self.normalizedFact(originURL) + self.branch = Self.normalizedFact(branch) + self.sha = Self.normalizedFact(sha) + } + + static func normalizedFact(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } + return trimmed } } @@ -354,7 +318,7 @@ public enum CodexWorkspace { self.remotes = remotes self.status = status self.source = source - self.id = worktreeID + id = worktreeID } public var isDirty: Bool { @@ -384,8 +348,7 @@ extension CodexWorkspace.ActivePermissionProfile { init(wireValue: CodexWireActivePermissionProfile) { self.init( id: wireValue.id, - extends: wireValue.extends, - modifications: [] + extends: wireValue.extends ) } } @@ -402,12 +365,12 @@ extension CodexWorkspace.FileSystemSandboxEntry { extension CodexWorkspace.FileSystemAccessMode { init(_ wireValue: CodexWireFileSystemAccessMode) { switch wireValue { - case .deny: - self = .none - case .read: - self = .read - case .write: - self = .write + case .deny: + self = .none + case .read: + self = .read + case .write: + self = .write } } } @@ -415,12 +378,12 @@ extension CodexWorkspace.FileSystemAccessMode { extension CodexWorkspace.FileSystemPath { init(_ wireValue: CodexWireFileSystemPath) { switch wireValue.type { - case .globPattern: - self = wireValue.pattern.map(Self.globPattern) ?? .unknown - case .path: - self = wireValue.path.map(Self.path) ?? .unknown - case .special: - self = wireValue.value.map(CodexWorkspace.FileSystemSpecialPath.init(wireValue:)).map(Self.special) ?? .unknown + case .globPattern: + self = wireValue.pattern.map(Self.globPattern) ?? .unknown + case .path: + self = wireValue.path.map(Self.path) ?? .unknown + case .special: + self = wireValue.value.map(CodexWorkspace.FileSystemSpecialPath.init(wireValue:)).map(Self.special) ?? .unknown } } } @@ -438,18 +401,18 @@ extension CodexWorkspace.FileSystemSpecialPath { extension CodexWorkspace.FileSystemSpecialPath.Kind { init(_ wireValue: CodexWireKind) { switch wireValue { - case .minimal: - self = .minimal - case .projectRoots: - self = .projectRoots - case .root: - self = .root - case .slashTmp: - self = .slashTmp - case .tmpdir: - self = .tmpdir - case .unknown: - self = .unknown + case .minimal: + self = .minimal + case .projectRoots: + self = .projectRoots + case .root: + self = .root + case .slashTmp: + self = .slashTmp + case .tmpdir: + self = .tmpdir + case .unknown: + self = .unknown } } } diff --git a/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift b/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift index f4d5a45..9a23960 100644 --- a/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift +++ b/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift @@ -37,7 +37,7 @@ public struct SwiftASBFeatureOperationEvent: Sendable, Equatable, Identifiable { rollback: Rollback = .unavailable, diagnosticText: String? = nil ) { - self.id = operationID + id = operationID self.categoryID = categoryID self.operationID = operationID self.title = title @@ -55,14 +55,14 @@ public struct SwiftASBFeatureOperationEvent: Sendable, Equatable, Identifiable { } } -extension SwiftASBFeatureOperationEvent { +public extension SwiftASBFeatureOperationEvent { /// The current result state for a feature operation. - public enum Status: String, Sendable, Equatable { + enum Status: String, Sendable, Equatable { case started, succeeded, failed, cancelled, skipped } /// One command SwiftASB ran as part of a feature operation. - public struct Command: Sendable, Equatable { + struct Command: Sendable, Equatable { public let argv: [String] public let currentDirectoryPath: String? @@ -76,7 +76,7 @@ extension SwiftASBFeatureOperationEvent { } /// Rollback metadata for a feature operation. - public struct Rollback: Sendable, Equatable { + struct Rollback: Sendable, Equatable { public let isAvailable: Bool public let handle: String? public let summary: String? diff --git a/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift b/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift index 2111c65..7a8c3fb 100644 --- a/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift +++ b/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift @@ -49,6 +49,31 @@ public struct SwiftASBFeaturePolicy: Sendable, Equatable { /// One SwiftASB feature category a consuming app can describe and configure. public struct SwiftASBFeatureCategory: Sendable, Equatable, Identifiable { + public struct Configuration: Sendable, Equatable { + public var displayName: String + public var description: String + public var permissionReason: String + public var defaultMode: SwiftASBFeatureMode + public var sensitivity: SwiftASBFeatureSensitivity + public var eventPolicy: SwiftASBFeatureEventPolicy + + public init( + displayName: String, + description: String, + permissionReason: String, + defaultMode: SwiftASBFeatureMode, + sensitivity: SwiftASBFeatureSensitivity, + eventPolicy: SwiftASBFeatureEventPolicy + ) { + self.displayName = displayName + self.description = description + self.permissionReason = permissionReason + self.defaultMode = defaultMode + self.sensitivity = sensitivity + self.eventPolicy = eventPolicy + } + } + public struct ID: RawRepresentable, Sendable, Equatable, Hashable, ExpressibleByStringLiteral { public let rawValue: String @@ -57,7 +82,7 @@ public struct SwiftASBFeatureCategory: Sendable, Equatable, Identifiable { } public init(stringLiteral value: String) { - self.rawValue = value + rawValue = value } public static let gitObservability: Self = "gitObservability" @@ -71,33 +96,6 @@ public struct SwiftASBFeatureCategory: Sendable, Equatable, Identifiable { public static let shellCommandExecution: Self = "shellCommandExecution" } - public let id: ID - public let displayName: String - public let description: String - public let permissionReason: String - public let defaultMode: SwiftASBFeatureMode - public let sensitivity: SwiftASBFeatureSensitivity - public let eventPolicy: SwiftASBFeatureEventPolicy - - /// Creates a feature category descriptor. - public init( - id: ID, - displayName: String, - description: String, - permissionReason: String, - defaultMode: SwiftASBFeatureMode, - sensitivity: SwiftASBFeatureSensitivity, - eventPolicy: SwiftASBFeatureEventPolicy - ) { - self.id = id - self.displayName = displayName - self.description = description - self.permissionReason = permissionReason - self.defaultMode = defaultMode - self.sensitivity = sensitivity - self.eventPolicy = eventPolicy - } - /// Built-in SwiftASB feature categories. public static let builtIn: [Self] = [ .init( @@ -183,6 +181,51 @@ public struct SwiftASBFeatureCategory: Sendable, Equatable, Identifiable { ), ] + public let id: ID + public let displayName: String + public let description: String + public let permissionReason: String + public let defaultMode: SwiftASBFeatureMode + public let sensitivity: SwiftASBFeatureSensitivity + public let eventPolicy: SwiftASBFeatureEventPolicy + + /// Creates a feature category descriptor. + public init( + id: ID, + configuration: Configuration + ) { + self.id = id + displayName = configuration.displayName + description = configuration.description + permissionReason = configuration.permissionReason + defaultMode = configuration.defaultMode + sensitivity = configuration.sensitivity + eventPolicy = configuration.eventPolicy + } + + /// Creates a feature category descriptor. + public init( + id: ID, + displayName: String, + description: String, + permissionReason: String, + defaultMode: SwiftASBFeatureMode, + sensitivity: SwiftASBFeatureSensitivity, + eventPolicy: SwiftASBFeatureEventPolicy + ) { + self.init( + id: id, + configuration: .init( + displayName: displayName, + description: description, + permissionReason: permissionReason, + defaultMode: defaultMode, + sensitivity: sensitivity, + eventPolicy: eventPolicy + ) + ) + } + /// Returns a built-in category by id. public static func builtInCategory(id: ID) -> Self? { builtIn.first { $0.id == id } @@ -206,6 +249,20 @@ public enum SwiftASBFeatureEventPolicy: String, Sendable, Equatable { /// Host filesystem access declared by a consuming app. public struct SwiftASBHostAccess: Sendable, Equatable { + /// Where the consuming app says its broad host access came from. + public enum AccessSource: String, Sendable, Equatable { + case declaredByHostApp + case fullDiskAccess + case securityScopedBookmark + case unknown + case unsandboxed + case userSelectedDirectory + } + + public static var unknown: Self { + .init() + } + public var homeDirectoryReadWriteGranted: Bool public var homeDirectoryURL: URL? public var accessSource: AccessSource @@ -221,10 +278,6 @@ public struct SwiftASBHostAccess: Sendable, Equatable { self.accessSource = accessSource } - public static var unknown: Self { - .init() - } - /// Declares an unsandboxed host application. public static func unsandboxed(homeDirectoryURL: URL? = nil) -> Self { .init( @@ -245,14 +298,4 @@ public struct SwiftASBHostAccess: Sendable, Equatable { accessSource: source ) } - - /// Where the consuming app says its broad host access came from. - public enum AccessSource: String, Sendable, Equatable { - case declaredByHostApp - case fullDiskAccess - case securityScopedBookmark - case unknown - case unsandboxed - case userSelectedDirectory - } } diff --git a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md index 6040b11..656c786 100644 --- a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md +++ b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md @@ -106,7 +106,6 @@ These types are public because a consumer can use them directly today. Other gen - ``CodexAppServer/readMcpResource(_:)`` - ``CodexExtensions/MCP/statusSnapshot()`` - ``CodexExtensions/MCP/readResource(server:uri:threadID:)`` -- ``CodexAppServer/McpServerStatusListRequest`` - ``CodexAppServer/McpServerStatusPage`` - ``CodexAppServer/McpServerStatus`` - ``CodexAppServer/McpServerSummary`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md index e6e013b..29370f8 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md @@ -130,7 +130,6 @@ Set ``ThreadResumeRequest/excludeTurns`` or ``ThreadForkRequest/excludeTurns`` w - ``HookError`` - ``HookMetadata`` - ``mcpServerStatusSnapshot()`` -- ``McpServerStatusListRequest`` - ``McpServerStatusPage`` - ``McpServerStatus`` - ``McpServerSummary`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md b/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md index 521f431..91053d0 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md @@ -16,7 +16,7 @@ let thread = try await appServer.startThread( ) ``` -Codex CLI v0.135 accepts the selected profile id for new thread, resumed thread, fork, and turn requests. ``PermissionSelection/modifications`` is retained as source-compatible local metadata for older callers, but SwiftASB does not send those modifications to the v0.135 app-server because the upstream schema no longer accepts them. +Codex CLI v0.140 accepts the selected profile id for new thread, resumed thread, fork, and turn requests. SwiftASB no longer carries the older request-side bounded permission modification shim because the reviewed app-server schema accepts the profile id string only. Use ``SessionSnapshot`` or the workspace values on ``CodexThread`` when a UI needs to show what Codex actually activated for the session: current directory, Git metadata, instruction sources, legacy sandbox policy, and active profile id. @@ -27,13 +27,11 @@ Use ``GitStatusSnapshot`` through ``CodexAppServer/Library/selectedGitStatus`` w ### Request Selection - ``PermissionSelection`` -- ``PermissionSelectionModification`` ### Runtime Profile - ``SessionSnapshot`` - ``ActivePermissionProfile`` -- ``ActivePermissionModification`` - ``PermissionProfile`` - ``FileSystemPermissions`` - ``FileSystemSandboxEntry`` diff --git a/Sources/SwiftASB/Transport/CodexAppServerTransport.swift b/Sources/SwiftASB/Transport/CodexAppServerTransport.swift index 616cc78..77037f7 100644 --- a/Sources/SwiftASB/Transport/CodexAppServerTransport.swift +++ b/Sources/SwiftASB/Transport/CodexAppServerTransport.swift @@ -1,13 +1,13 @@ import Foundation -internal actor CodexAppServerTransport: CodexAppServerTransporting { - internal struct Configuration: Equatable, Sendable { - internal var codexExecutableURL: URL? - internal var arguments: [String] - internal var currentDirectoryURL: URL? - internal var environment: [String: String]? - - internal init( +actor CodexAppServerTransport: CodexAppServerTransporting { + struct Configuration: Equatable { + var codexExecutableURL: URL? + var arguments: [String] + var currentDirectoryURL: URL? + var environment: [String: String]? + + init( codexExecutableURL: URL? = nil, arguments: [String] = ["app-server", "--listen", "stdio://"], currentDirectoryURL: URL? = nil, @@ -34,11 +34,11 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { private var lastExecutableResolution: CodexCLIExecutableResolver.Resolution? private var hasFinished = false - internal init(configuration: Configuration = .init()) { + init(configuration: Configuration = .init()) { self.configuration = configuration } - internal func start() throws { + func start() throws { guard process == nil else { throw CodexTransportError.alreadyStarted } @@ -92,7 +92,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { startStandardErrorLoop(fileHandle: errorPipe.fileHandleForReading) } - internal func stop() { + func stop() { guard let process else { return } if process.isRunning { @@ -108,7 +108,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { } } - internal func send(_ requestPayload: Data, id: CodexRPCRequestID) async throws -> Data { + func send(_ requestPayload: Data, id: CodexRPCRequestID) async throws -> Data { guard process != nil, let standardInputHandle else { throw CodexTransportError.notStarted } @@ -140,7 +140,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { } } - internal func sendNotification(_ notificationPayload: Data, method: String) throws { + func sendNotification(_ notificationPayload: Data, method: String) throws { guard process != nil, let standardInputHandle else { throw CodexTransportError.notStarted } @@ -155,7 +155,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { } } - internal func sendResponse(_ responsePayload: Data, requestID: CodexRPCRequestID) throws { + func sendResponse(_ responsePayload: Data, requestID: CodexRPCRequestID) throws { guard process != nil, let standardInputHandle else { throw CodexTransportError.notStarted } @@ -170,7 +170,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { } } - internal func serverEvents() -> AsyncStream { + func serverEvents() -> AsyncStream { let streamID = UUID() return AsyncStream { continuation in self.registerServerEventContinuation(continuation, id: streamID) @@ -182,7 +182,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { } } - internal func executableResolution() -> CodexCLIExecutableResolver.Resolution? { + func executableResolution() -> CodexCLIExecutableResolver.Resolution? { lastExecutableResolution } @@ -228,13 +228,14 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { do { switch try CodexRPCEnvelope.classifyInboundMessage(payload) { - case let .response(id, responsePayload): - guard let continuation = pendingResponses.removeValue(forKey: id) else { - return - } - continuation.resume(returning: responsePayload) - case let .serverEvent(event): - broadcastServerEvent(event) + case let .response(id, responsePayload): + guard let continuation = pendingResponses.removeValue(forKey: id) else { + return + } + + continuation.resume(returning: responsePayload) + case let .serverEvent(event): + broadcastServerEvent(event) } } catch let error as CodexTransportError { finishTransport(with: error) @@ -252,6 +253,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { private func handleStandardOutputEOF() { guard !hasFinished else { return } + if let partialLine = standardOutputBuffer.finishPartialLine() { handleStandardOutputDataLine(partialLine) } @@ -289,6 +291,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { for lineData in standardOutputBuffer.append(chunk) { guard !hasFinished else { return } + handleStandardOutputDataLine(lineData) } } @@ -304,6 +307,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { for lineData in standardErrorBuffer.append(chunk) { guard !hasFinished else { return } + appendStandardErrorDataLine(lineData) } } @@ -323,12 +327,12 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { private func handleProcessTermination(_ process: Process) { let reasonDescription: String switch process.terminationReason { - case .exit: - reasonDescription = "exit" - case .uncaughtSignal: - reasonDescription = "uncaught-signal" - @unknown default: - reasonDescription = "unknown" + case .exit: + reasonDescription = "exit" + case .uncaughtSignal: + reasonDescription = "uncaught-signal" + @unknown default: + reasonDescription = "unknown" } finishTransport( @@ -342,6 +346,7 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { private func finishTransport(with error: CodexTransportError) { guard !hasFinished else { return } + hasFinished = true standardOutputHandle?.readabilityHandler = nil @@ -373,27 +378,27 @@ internal actor CodexAppServerTransport: CodexAppServerTransporting { private func describe(_ source: CodexCLIExecutableResolver.Source) -> String { switch source { - case .explicit: - return "explicit executable URL" - case .path: - return "PATH lookup via /usr/bin/env codex" - case .homebrewAppleSilicon: - return "Homebrew Apple Silicon path /opt/homebrew/bin/codex" - case .homebrewIntel: - return "Homebrew Intel path /usr/local/bin/codex" - case let .npmGlobal(prefix): - return "npm global prefix \(prefix)" + case .explicit: + return "explicit executable URL" + case .path: + return "PATH lookup via /usr/bin/env codex" + case .homebrewAppleSilicon: + return "Homebrew Apple Silicon path /opt/homebrew/bin/codex" + case .homebrewIntel: + return "Homebrew Intel path /usr/local/bin/codex" + case let .npmGlobal(prefix): + return "npm global prefix \(prefix)" } } private func describe(_ compatibility: CodexCLIExecutableResolver.Compatibility) -> String { switch compatibility { - case let .supported(documentedWindow): - return "Version is within SwiftASB's documented reviewed support window (\(documentedWindow))." - case let .outsideDocumentedWindow(documentedWindow): - return "Version is outside SwiftASB's documented reviewed support window (\(documentedWindow))." - case let .unknownVersionFormat(documentedWindow): - return "Version string could not be parsed against SwiftASB's documented reviewed support window (\(documentedWindow))." + case let .supported(documentedWindow): + return "Version is within SwiftASB's documented reviewed support window (\(documentedWindow))." + case let .outsideDocumentedWindow(documentedWindow): + return "Version is outside SwiftASB's documented reviewed support window (\(documentedWindow))." + case let .unknownVersionFormat(documentedWindow): + return "Version string could not be parsed against SwiftASB's documented reviewed support window (\(documentedWindow))." } } } diff --git a/Sources/SwiftASB/Transport/CodexAppServerTransporting.swift b/Sources/SwiftASB/Transport/CodexAppServerTransporting.swift index 5885536..f7b2ec5 100644 --- a/Sources/SwiftASB/Transport/CodexAppServerTransporting.swift +++ b/Sources/SwiftASB/Transport/CodexAppServerTransporting.swift @@ -1,6 +1,6 @@ import Foundation -internal protocol CodexAppServerTransporting: Actor { +protocol CodexAppServerTransporting: Actor { func start() throws func stop() func send(_ requestPayload: Data, id: CodexRPCRequestID) async throws -> Data diff --git a/Sources/SwiftASB/Transport/CodexCLIExecutableResolver.swift b/Sources/SwiftASB/Transport/CodexCLIExecutableResolver.swift index 3761d0c..0ae1688 100644 --- a/Sources/SwiftASB/Transport/CodexCLIExecutableResolver.swift +++ b/Sources/SwiftASB/Transport/CodexCLIExecutableResolver.swift @@ -1,16 +1,16 @@ import Foundation -internal struct CodexCLIExecutableResolver { - internal struct Resolution: Sendable, Equatable { - internal let launchExecutableURL: URL - internal let launchArgumentsPrefix: [String] - internal let resolvedExecutableURL: URL? - internal let source: Source - internal let versionString: String - internal let compatibility: Compatibility +struct CodexCLIExecutableResolver { + struct Resolution: Equatable { + let launchExecutableURL: URL + let launchArgumentsPrefix: [String] + let resolvedExecutableURL: URL? + let source: Source + let versionString: String + let compatibility: Compatibility } - internal enum Source: Sendable, Equatable { + enum Source: Equatable { case explicit case path case homebrewAppleSilicon @@ -18,31 +18,36 @@ internal struct CodexCLIExecutableResolver { case npmGlobal(prefix: String) } - internal enum Compatibility: Sendable, Equatable { + enum Compatibility: Equatable { case supported(documentedWindow: String) case outsideDocumentedWindow(documentedWindow: String) case unknownVersionFormat(documentedWindow: String) } - internal struct Version: Sendable, Equatable { - internal let major: Int - internal let minor: Int - internal let patch: Int + struct Version: Equatable { + let major: Int + let minor: Int + let patch: Int private static let regex = try! NSRegularExpression(pattern: #"(\d+)\.(\d+)\.(\d+)"#) - internal static let latestSupportedPublicRelease = Version(major: 0, minor: 139, patch: 0) + static let oldestSupportedPublicRelease = Version(major: 0, minor: 139, patch: 0) + static let latestSupportedPublicRelease = Version(major: 0, minor: 140, patch: 0) - internal static var documentedWindowDescription: String { + static var documentedWindowDescription: String { + let oldest = oldestSupportedPublicRelease let latest = latestSupportedPublicRelease + if oldest == latest { + return "\(latest.major).\(latest.minor).x" + } return "\(latest.major).\(latest.minor).x" + + " plus \(oldest.major).\(oldest.minor).x when feasible" } - internal static func parse(from text: String) -> Version? { + static func parse(from text: String) -> Version? { let range = NSRange(text.startIndex.. CommandResult - internal typealias ExecutableFileChecker = @Sendable (_ path: String) -> Bool + typealias ExecutableFileChecker = @Sendable (_ path: String) -> Bool private let explicitExecutableURL: URL? private let environment: [String: String]? @@ -79,7 +84,7 @@ internal struct CodexCLIExecutableResolver { private let runCommand: CommandRunner private let isExecutableFile: ExecutableFileChecker - internal init( + init( explicitExecutableURL: URL?, environment: [String: String]?, currentDirectoryURL: URL?, @@ -93,7 +98,43 @@ internal struct CodexCLIExecutableResolver { self.isExecutableFile = isExecutableFile } - internal func resolve() throws -> Resolution { + private static func runProcess( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + currentDirectoryURL: URL? + ) throws -> CommandResult { + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() + + process.executableURL = executableURL + process.arguments = arguments + process.environment = environment + process.currentDirectoryURL = currentDirectoryURL + process.standardOutput = standardOutput + process.standardError = standardError + + try process.run() + process.waitUntilExit() + + let output = String( + decoding: standardOutput.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + let error = String( + decoding: standardError.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + + return CommandResult( + terminationStatus: process.terminationStatus, + standardOutput: output, + standardError: error + ) + } + + func resolve() throws -> Resolution { if let explicitExecutableURL { return try resolveExplicitExecutable(at: explicitExecutableURL) } @@ -178,10 +219,10 @@ internal struct CodexCLIExecutableResolver { ) } catch let error as CodexTransportError { switch error { - case .executableDiscoveryFailed: - return nil - default: - throw error + case .executableDiscoveryFailed: + return nil + default: + throw error } } } @@ -298,51 +339,16 @@ internal struct CodexCLIExecutableResolver { } let latest = Version.latestSupportedPublicRelease + let oldest = Version.oldestSupportedPublicRelease - guard parsedVersion.major == latest.major else { + guard parsedVersion.major == latest.major, parsedVersion.major == oldest.major else { return .outsideDocumentedWindow(documentedWindow: documentedWindow) } - if parsedVersion.minor == latest.minor { + if parsedVersion.minor >= oldest.minor, parsedVersion.minor <= latest.minor { return .supported(documentedWindow: documentedWindow) } return .outsideDocumentedWindow(documentedWindow: documentedWindow) } - - private static func runProcess( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - currentDirectoryURL: URL? - ) throws -> CommandResult { - let process = Process() - let standardOutput = Pipe() - let standardError = Pipe() - - process.executableURL = executableURL - process.arguments = arguments - process.environment = environment - process.currentDirectoryURL = currentDirectoryURL - process.standardOutput = standardOutput - process.standardError = standardError - - try process.run() - process.waitUntilExit() - - let output = String( - decoding: standardOutput.fileHandleForReading.readDataToEndOfFile(), - as: UTF8.self - ) - let error = String( - decoding: standardError.fileHandleForReading.readDataToEndOfFile(), - as: UTF8.self - ) - - return CommandResult( - terminationStatus: process.terminationStatus, - standardOutput: output, - standardError: error - ) - } } diff --git a/Sources/SwiftASB/Transport/CodexRPCRequestID.swift b/Sources/SwiftASB/Transport/CodexRPCRequestID.swift index 5cee1ae..efabd3f 100644 --- a/Sources/SwiftASB/Transport/CodexRPCRequestID.swift +++ b/Sources/SwiftASB/Transport/CodexRPCRequestID.swift @@ -1,23 +1,19 @@ import Foundation -internal enum CodexRPCRequestID: Hashable, Sendable, Codable, CustomStringConvertible { +enum CodexRPCRequestID: Hashable, Codable, CustomStringConvertible { case string(String) case int(Int) - internal static func generated() -> Self { - .string(UUID().uuidString) - } - - internal var description: String { + var description: String { switch self { - case let .string(value): - value - case let .int(value): - String(value) + case let .string(value): + value + case let .int(value): + String(value) } } - internal init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let string = try? container.decode(String.self) { self = .string(string) @@ -33,13 +29,17 @@ internal enum CodexRPCRequestID: Hashable, Sendable, Codable, CustomStringConver ) } - internal func encode(to encoder: Encoder) throws { + static func generated() -> Self { + .string(UUID().uuidString) + } + + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case let .string(value): - try container.encode(value) - case let .int(value): - try container.encode(value) + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) } } } diff --git a/Sources/SwiftASB/Transport/CodexTransportError.swift b/Sources/SwiftASB/Transport/CodexTransportError.swift index 8ee9688..62fa074 100644 --- a/Sources/SwiftASB/Transport/CodexTransportError.swift +++ b/Sources/SwiftASB/Transport/CodexTransportError.swift @@ -1,6 +1,6 @@ import Foundation -internal enum CodexTransportError: Error, Sendable, LocalizedError, Equatable { +enum CodexTransportError: Error, LocalizedError, Equatable { case alreadyStarted case notStarted case executableDiscoveryFailed(reason: String) @@ -19,55 +19,55 @@ internal enum CodexTransportError: Error, Sendable, LocalizedError, Equatable { case processTerminated(reason: String, status: Int32?, recentStandardError: [String]) case unexpectedEndOfStream(recentStandardError: [String]) - internal var errorDescription: String? { + var errorDescription: String? { switch self { - case .alreadyStarted: - return "Codex app-server transport is already running." - case .notStarted: - return "Codex app-server transport has not been started yet." - case let .executableDiscoveryFailed(reason): - return reason - case let .failedToLaunch(executable, reason, discoverySource, versionString, compatibilityNote): - var lines = ["Failed to launch Codex app-server transport using \(executable): \(reason)"] - if let discoverySource { - lines.append("Resolved executable source: \(discoverySource)") - } - if let versionString { - lines.append("Resolved Codex CLI version: \(versionString)") - } - if let compatibilityNote { - lines.append("Compatibility note: \(compatibilityNote)") - } - return lines.joined(separator: "\n") - case let .failedToWriteRequest(id, reason): - return "Failed to write JSON-RPC request \(id.description) to Codex app-server stdin: \(reason)" - case let .failedToWriteNotification(method, reason): - return "Failed to write JSON-RPC notification \(method) to Codex app-server stdin: \(reason)" - case let .duplicatePendingRequest(id): - return "Refused to register a duplicate in-flight JSON-RPC request with ID \(id.description)." - case let .requestCancelled(id): - return "The in-flight JSON-RPC request \(id.description) was cancelled before Codex app-server returned a response." - case let .invalidJSONRPCEnvelope(reason): - return "Received a malformed JSON-RPC envelope from Codex app-server: \(reason)" - case let .processTerminated(reason, status, recentStandardError): - let statusText = status.map(String.init) ?? "unknown" - if recentStandardError.isEmpty { - return "Codex app-server transport terminated unexpectedly (\(reason), status \(statusText))." - } - return """ - Codex app-server transport terminated unexpectedly (\(reason), status \(statusText)). - Recent stderr: - \(recentStandardError.joined(separator: "\n")) - """ - case let .unexpectedEndOfStream(recentStandardError): - if recentStandardError.isEmpty { - return "Codex app-server stdout closed before all pending requests completed." - } - return """ - Codex app-server stdout closed before all pending requests completed. - Recent stderr: - \(recentStandardError.joined(separator: "\n")) - """ + case .alreadyStarted: + return "Codex app-server transport is already running." + case .notStarted: + return "Codex app-server transport has not been started yet." + case let .executableDiscoveryFailed(reason): + return reason + case let .failedToLaunch(executable, reason, discoverySource, versionString, compatibilityNote): + var lines = ["Failed to launch Codex app-server transport using \(executable): \(reason)"] + if let discoverySource { + lines.append("Resolved executable source: \(discoverySource)") + } + if let versionString { + lines.append("Resolved Codex CLI version: \(versionString)") + } + if let compatibilityNote { + lines.append("Compatibility note: \(compatibilityNote)") + } + return lines.joined(separator: "\n") + case let .failedToWriteRequest(id, reason): + return "Failed to write JSON-RPC request \(id.description) to Codex app-server stdin: \(reason)" + case let .failedToWriteNotification(method, reason): + return "Failed to write JSON-RPC notification \(method) to Codex app-server stdin: \(reason)" + case let .duplicatePendingRequest(id): + return "Refused to register a duplicate in-flight JSON-RPC request with ID \(id.description)." + case let .requestCancelled(id): + return "The in-flight JSON-RPC request \(id.description) was cancelled before Codex app-server returned a response." + case let .invalidJSONRPCEnvelope(reason): + return "Received a malformed JSON-RPC envelope from Codex app-server: \(reason)" + case let .processTerminated(reason, status, recentStandardError): + let statusText = status.map(String.init) ?? "unknown" + if recentStandardError.isEmpty { + return "Codex app-server transport terminated unexpectedly (\(reason), status \(statusText))." + } + return """ + Codex app-server transport terminated unexpectedly (\(reason), status \(statusText)). + Recent stderr: + \(recentStandardError.joined(separator: "\n")) + """ + case let .unexpectedEndOfStream(recentStandardError): + if recentStandardError.isEmpty { + return "Codex app-server stdout closed before all pending requests completed." + } + return """ + Codex app-server stdout closed before all pending requests completed. + Recent stderr: + \(recentStandardError.joined(separator: "\n")) + """ } } } diff --git a/Sources/SwiftASB/Transport/LineDelimitedDataBuffer.swift b/Sources/SwiftASB/Transport/LineDelimitedDataBuffer.swift index f5d8eda..bd2a6ed 100644 --- a/Sources/SwiftASB/Transport/LineDelimitedDataBuffer.swift +++ b/Sources/SwiftASB/Transport/LineDelimitedDataBuffer.swift @@ -1,24 +1,24 @@ import Foundation -internal struct LineDelimitedDataBuffer: Sendable { +struct LineDelimitedDataBuffer { private var buffer = Data() - internal init() {} + init() {} - internal var isEmpty: Bool { + var isEmpty: Bool { buffer.isEmpty } - internal mutating func append(_ chunk: Data) -> [Data] { + mutating func append(_ chunk: Data) -> [Data] { buffer.append(chunk) return drainCompleteLines() } - internal mutating func removeAll(keepingCapacity: Bool = false) { + mutating func removeAll(keepingCapacity: Bool = false) { buffer.removeAll(keepingCapacity: keepingCapacity) } - internal mutating func finishPartialLine() -> Data? { + mutating func finishPartialLine() -> Data? { guard !buffer.isEmpty else { return nil } diff --git a/Tests/ASBAppKitTests/ASBThreadSidebarViewTests.swift b/Tests/ASBAppKitTests/ASBThreadSidebarViewTests.swift index c6df6bc..c5f30c3 100644 --- a/Tests/ASBAppKitTests/ASBThreadSidebarViewTests.swift +++ b/Tests/ASBAppKitTests/ASBThreadSidebarViewTests.swift @@ -2,7 +2,6 @@ import ASBPresentation import Testing -@Suite("ASBThreadSidebarView") @MainActor struct ASBThreadSidebarViewTests { @Test("adapter maps presentation sections into outline rows") diff --git a/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift b/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift index 4daee0b..41f0ef7 100644 --- a/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift +++ b/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift @@ -1,17 +1,17 @@ import Foundation import Testing -@Suite("ASBPresentation framework boundary") struct ASBPresentationBoundaryTests { @Test("presentation sources do not import renderer frameworks") func presentationSourcesAvoidRendererFrameworkImports() throws { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let sourceRoot = packageRoot.appending(path: "Sources/ASBPresentation") - let fileURLs = try FileManager.default.contentsOfDirectory( - at: sourceRoot, - includingPropertiesForKeys: nil - ) - .filter { $0.pathExtension == "swift" } + let fileURLs = try FileManager.default + .contentsOfDirectory( + at: sourceRoot, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "swift" } #expect(!fileURLs.isEmpty) diff --git a/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift b/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift index d804e56..f98bbef 100644 --- a/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift +++ b/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift @@ -1,7 +1,6 @@ import ASBPresentation import Testing -@Suite("Agenda and dashboard presentation") struct AgendaDashboardPresentationTests { @Test("agenda snapshot exposes goal, current plan, and proposed plan") func agendaSnapshotCarriesGoalAndPlan() { diff --git a/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift b/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift index e3a14c3..60f161e 100644 --- a/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift +++ b/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift @@ -1,7 +1,6 @@ import ASBPresentation import Testing -@Suite("Recent activity presentation") struct RecentActivityPresentationTests { @Test("activity snapshot keeps command and file identity distinct") func activityIDsIncludeKindPrefix() { diff --git a/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift b/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift index ef74a96..aa0f445 100644 --- a/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift +++ b/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift @@ -1,7 +1,6 @@ import ASBPresentation import Testing -@Suite("Thread sidebar presentation") struct ThreadSidebarPresentationTests { @Test("items keep stable thread identity") func itemIdentityIsStableThreadID() { diff --git a/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift b/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift index 523a69b..62e5351 100644 --- a/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift +++ b/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift @@ -1,7 +1,6 @@ import ASBPresentation import Testing -@Suite("Turn timeline presentation") struct TurnTimelinePresentationTests { @Test("snapshot flattens sections and preserves viewport hints") func snapshotFlattensSectionsAndViewport() { diff --git a/Tests/ASBSwiftUITests/ASBSwiftUIComponentTests.swift b/Tests/ASBSwiftUITests/ASBSwiftUIComponentTests.swift index 72cd17c..11f5cca 100644 --- a/Tests/ASBSwiftUITests/ASBSwiftUIComponentTests.swift +++ b/Tests/ASBSwiftUITests/ASBSwiftUIComponentTests.swift @@ -1,8 +1,7 @@ -@testable import ASBSwiftUI import ASBPresentation +@testable import ASBSwiftUI import Testing -@Suite("ASBSwiftUI components") @MainActor struct ASBSwiftUIComponentTests { @Test("thread sidebar stores snapshot and intent handler") diff --git a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift index 09a72d6..97de8ed 100644 --- a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift +++ b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("CodexAppServerProtocol", .serialized) +@Suite(.serialized) struct CodexAppServerProtocolTests { private let protocolLayer = CodexAppServerProtocol() @@ -61,14 +61,14 @@ struct CodexAppServerProtocolTests { disableOutputCap: nil, disableTimeout: nil, env: nil, - outputBytesCap: 16_384, + outputBytesCap: 16384, permissionProfile: nil, processID: nil, sandboxPolicy: nil, size: nil, streamStdin: nil, streamStdoutStderr: nil, - timeoutMS: 5_000, + timeoutMS: 5000, tty: nil ) ) @@ -81,8 +81,8 @@ struct CodexAppServerProtocolTests { let params = try #require(object["params"] as? [String: Any]) #expect(params["command"] as? [String] == ["git", "status", "--short"]) #expect(params["cwd"] as? String == "/tmp/project") - #expect(params["outputBytesCap"] as? Int == 16_384) - #expect(params["timeoutMs"] as? Int == 5_000) + #expect(params["outputBytesCap"] as? Int == 16384) + #expect(params["timeoutMs"] as? Int == 5000) #expect(params["permissionProfile"] == nil) #expect(params["sandboxPolicy"] == nil) #expect(params["processId"] == nil) @@ -217,6 +217,7 @@ struct CodexAppServerProtocolTests { personality: .friendly, runtimeWorkspaceRoots: nil, sandbox: .workspaceWrite, + selectedCapabilityRoots: nil, serviceName: "codex", serviceTier: "fast", sessionStartSource: .clear, @@ -438,7 +439,7 @@ struct CodexAppServerProtocolTests { objective: "Ship schema promotion", status: .active, threadID: "thread-123", - tokenBudget: 20_000 + tokenBudget: 20000 ) ) let setRequest = try #require(try JSONSerialization.jsonObject(with: setPayload) as? [String: Any]) @@ -926,7 +927,7 @@ struct CodexAppServerProtocolTests { url: nil, path: nil, name: nil - ) + ), ], model: "gpt-5.4", outputSchema: .object(["type": .string("object")]), @@ -1004,7 +1005,7 @@ struct CodexAppServerProtocolTests { url: nil, path: nil, name: nil - ) + ), ], threadID: "thread-123" ) @@ -1154,7 +1155,7 @@ struct CodexAppServerProtocolTests { let response = try protocolLayer.decodeTurnStartResponse(payload, expectedID: .string("turn-1")) #expect(response.turn.id == "turn-123") - #expect(response.turn.startedAt == 1713350002) + #expect(response.turn.startedAt == 1_713_350_002) #expect(response.turn.completedAt == nil) #expect(response.turn.items.isEmpty) } @@ -1196,13 +1197,13 @@ struct CodexAppServerProtocolTests { let decodedEvent = try #require(event) switch decodedEvent { - case let .turnCompleted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turn.id == "turn-123") - #expect(notification.turn.status == .completed) - #expect(notification.turn.completedAt == 1713350005) - default: - Issue.record("Expected turn/completed to decode into .turnCompleted.") + case let .turnCompleted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turn.id == "turn-123") + #expect(notification.turn.status == .completed) + #expect(notification.turn.completedAt == 1_713_350_005) + default: + Issue.record("Expected turn/completed to decode into .turnCompleted.") } } @@ -1219,12 +1220,12 @@ struct CodexAppServerProtocolTests { ) switch threadStartedEvent { - case let .threadStarted(notification): - #expect(notification.thread.id == "thread-123") - #expect(notification.thread.preview == "Hello") - #expect(notification.thread.status.type == .active) - default: - Issue.record("Expected thread/started to decode into .threadStarted.") + case let .threadStarted(notification): + #expect(notification.thread.id == "thread-123") + #expect(notification.thread.preview == "Hello") + #expect(notification.thread.status.type == .active) + default: + Issue.record("Expected thread/started to decode into .threadStarted.") } let statusChangedPayload = Data( @@ -1236,12 +1237,12 @@ struct CodexAppServerProtocolTests { ) switch statusChangedEvent { - case let .threadStatusChanged(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.status.type == .active) - #expect(notification.status.activeFlags == [.waitingOnApproval]) - default: - Issue.record("Expected thread/status/changed to decode into .threadStatusChanged.") + case let .threadStatusChanged(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.status.type == .active) + #expect(notification.status.activeFlags == [.waitingOnApproval]) + default: + Issue.record("Expected thread/status/changed to decode into .threadStatusChanged.") } let archivedPayload = Data(#"{"threadId":"thread-123"}"#.utf8) @@ -1250,10 +1251,10 @@ struct CodexAppServerProtocolTests { ) switch archivedEvent { - case let .threadArchived(notification): - #expect(notification.threadID == "thread-123") - default: - Issue.record("Expected thread/archived to decode into .threadArchived.") + case let .threadArchived(notification): + #expect(notification.threadID == "thread-123") + default: + Issue.record("Expected thread/archived to decode into .threadArchived.") } let unarchivedPayload = Data(#"{"threadId":"thread-123"}"#.utf8) @@ -1262,10 +1263,10 @@ struct CodexAppServerProtocolTests { ) switch unarchivedEvent { - case let .threadUnarchived(notification): - #expect(notification.threadID == "thread-123") - default: - Issue.record("Expected thread/unarchived to decode into .threadUnarchived.") + case let .threadUnarchived(notification): + #expect(notification.threadID == "thread-123") + default: + Issue.record("Expected thread/unarchived to decode into .threadUnarchived.") } let closedPayload = Data(#"{"threadId":"thread-123"}"#.utf8) @@ -1274,10 +1275,10 @@ struct CodexAppServerProtocolTests { ) switch closedEvent { - case let .threadClosed(notification): - #expect(notification.threadID == "thread-123") - default: - Issue.record("Expected thread/closed to decode into .threadClosed.") + case let .threadClosed(notification): + #expect(notification.threadID == "thread-123") + default: + Issue.record("Expected thread/closed to decode into .threadClosed.") } let nameUpdatedPayload = Data( @@ -1289,11 +1290,11 @@ struct CodexAppServerProtocolTests { ) switch nameUpdatedEvent { - case let .threadNameUpdated(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.threadName == "Planning Thread") - default: - Issue.record("Expected thread/name/updated to decode into .threadNameUpdated.") + case let .threadNameUpdated(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.threadName == "Planning Thread") + default: + Issue.record("Expected thread/name/updated to decode into .threadNameUpdated.") } let tokenUsageUpdatedPayload = Data( @@ -1307,13 +1308,13 @@ struct CodexAppServerProtocolTests { ) switch tokenUsageUpdatedEvent { - case let .threadTokenUsageUpdated(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.tokenUsage.last.totalTokens == 65) - #expect(notification.tokenUsage.total.totalTokens == 650) - default: - Issue.record("Expected thread/tokenUsage/updated to decode into .threadTokenUsageUpdated.") + case let .threadTokenUsageUpdated(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.tokenUsage.last.totalTokens == 65) + #expect(notification.tokenUsage.total.totalTokens == 650) + default: + Issue.record("Expected thread/tokenUsage/updated to decode into .threadTokenUsageUpdated.") } } @@ -1328,11 +1329,11 @@ struct CodexAppServerProtocolTests { ) ) switch configWarningEvent { - case let .configWarning(notification): - #expect(notification.summary == "Config key is ignored.") - #expect(notification.range?.start.line == 2) - default: - Issue.record("Expected config/warning to decode into .configWarning.") + case let .configWarning(notification): + #expect(notification.summary == "Config key is ignored.") + #expect(notification.range?.start.line == 2) + default: + Issue.record("Expected config/warning to decode into .configWarning.") } let mcpStatusEvent = try #require( @@ -1342,11 +1343,11 @@ struct CodexAppServerProtocolTests { ) ) switch mcpStatusEvent { - case let .mcpServerStatusUpdated(notification): - #expect(notification.name == "calendar") - #expect(notification.status == .ready) - default: - Issue.record("Expected mcpServer/startupStatus/updated to decode into .mcpServerStatusUpdated.") + case let .mcpServerStatusUpdated(notification): + #expect(notification.name == "calendar") + #expect(notification.status == .ready) + default: + Issue.record("Expected mcpServer/startupStatus/updated to decode into .mcpServerStatusUpdated.") } let remoteStatusEvent = try #require( @@ -1356,13 +1357,13 @@ struct CodexAppServerProtocolTests { ) ) switch remoteStatusEvent { - case let .remoteControlStatusChanged(notification): - #expect(notification.environmentID == "env-123") - #expect(notification.installationID == "install-123") - #expect(notification.serverName == "desktop") - #expect(notification.status == .connected) - default: - Issue.record("Expected remoteControl/status/changed to decode into .remoteControlStatusChanged.") + case let .remoteControlStatusChanged(notification): + #expect(notification.environmentID == "env-123") + #expect(notification.installationID == "install-123") + #expect(notification.serverName == "desktop") + #expect(notification.status == .connected) + default: + Issue.record("Expected remoteControl/status/changed to decode into .remoteControlStatusChanged.") } let deprecationEvent = try #require( @@ -1372,30 +1373,30 @@ struct CodexAppServerProtocolTests { ) ) switch deprecationEvent { - case let .deprecationNotice(notification): - #expect(notification.summary == "Old notification is deprecated.") - default: - Issue.record("Expected deprecation/notice to decode into .deprecationNotice.") + case let .deprecationNotice(notification): + #expect(notification.summary == "Old notification is deprecated.") + default: + Issue.record("Expected deprecation/notice to decode into .deprecationNotice.") } let skillsEvent = try #require( try decodeEvent(method: "skills/changed", payload: Data(#"{}"#.utf8)) ) switch skillsEvent { - case let .skillsChanged(payload): - #expect(payload.isEmpty) - default: - Issue.record("Expected skills/changed to decode into .skillsChanged.") + case let .skillsChanged(payload): + #expect(payload.isEmpty) + default: + Issue.record("Expected skills/changed to decode into .skillsChanged.") } let appListEvent = try #require( try decodeEvent(method: "app/list/updated", payload: Data(#"{"data":[]}"#.utf8)) ) switch appListEvent { - case let .appListUpdated(notification): - #expect(notification.data.isEmpty) - default: - Issue.record("Expected app/list/updated to decode into .appListUpdated.") + case let .appListUpdated(notification): + #expect(notification.data.isEmpty) + default: + Issue.record("Expected app/list/updated to decode into .appListUpdated.") } } @@ -1412,12 +1413,12 @@ struct CodexAppServerProtocolTests { ) switch turnStartedEvent { - case let .turnStarted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turn.id == "turn-123") - #expect(notification.turn.status == .inProgress) - default: - Issue.record("Expected turn/started to decode into .turnStarted.") + case let .turnStarted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turn.id == "turn-123") + #expect(notification.turn.status == .inProgress) + default: + Issue.record("Expected turn/started to decode into .turnStarted.") } let turnDiffUpdatedPayload = Data( @@ -1429,12 +1430,12 @@ struct CodexAppServerProtocolTests { ) switch turnDiffUpdatedEvent { - case let .turnDiffUpdated(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.diff == "diff --git a/file.txt b/file.txt") - default: - Issue.record("Expected turn/diff/updated to decode into .turnDiffUpdated.") + case let .turnDiffUpdated(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.diff == "diff --git a/file.txt b/file.txt") + default: + Issue.record("Expected turn/diff/updated to decode into .turnDiffUpdated.") } let turnPlanUpdatedPayload = Data( @@ -1448,15 +1449,15 @@ struct CodexAppServerProtocolTests { ) switch turnPlanUpdatedEvent { - case let .turnPlanUpdated(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.explanation == "Investigating protocol mapping.") - #expect(notification.plan.count == 2) - #expect(notification.plan.first?.status == .inProgress) - #expect(notification.plan.last?.status == .pending) - default: - Issue.record("Expected turn/plan/updated to decode into .turnPlanUpdated.") + case let .turnPlanUpdated(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.explanation == "Investigating protocol mapping.") + #expect(notification.plan.count == 2) + #expect(notification.plan.first?.status == .inProgress) + #expect(notification.plan.last?.status == .pending) + default: + Issue.record("Expected turn/plan/updated to decode into .turnPlanUpdated.") } } @@ -1471,13 +1472,13 @@ struct CodexAppServerProtocolTests { ) switch itemStartedEvent { - case let .itemStarted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.item.id == "item-123") - #expect(notification.item.type == .plan) - default: - Issue.record("Expected item/started to decode into .itemStarted.") + case let .itemStarted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.item.id == "item-123") + #expect(notification.item.type == .plan) + default: + Issue.record("Expected item/started to decode into .itemStarted.") } let itemCompletedPayload = Data( @@ -1489,13 +1490,13 @@ struct CodexAppServerProtocolTests { ) switch itemCompletedEvent { - case let .itemCompleted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.item.type == .agentMessage) - #expect(notification.item.text == "Done.") - default: - Issue.record("Expected item/completed to decode into .itemCompleted.") + case let .itemCompleted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.item.type == .agentMessage) + #expect(notification.item.text == "Done.") + default: + Issue.record("Expected item/completed to decode into .itemCompleted.") } let autoReviewStartedPayload = Data( @@ -1509,19 +1510,19 @@ struct CodexAppServerProtocolTests { ) switch autoReviewStartedEvent { - case let .itemGuardianApprovalReviewStarted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.targetItemID == "item-command-1") - #expect(notification.reviewID == "review-123") - #expect(notification.startedAtMS == 1_713_350_002_000) - #expect(notification.action.type == .command) - #expect(notification.action.command == "git status") - #expect(notification.action.source == .shell) - #expect(notification.review.status == .inProgress) - #expect(notification.review.riskLevel == .low) - default: - Issue.record("Expected item/autoApprovalReview/started to decode into .itemGuardianApprovalReviewStarted.") + case let .itemGuardianApprovalReviewStarted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.targetItemID == "item-command-1") + #expect(notification.reviewID == "review-123") + #expect(notification.startedAtMS == 1_713_350_002_000) + #expect(notification.action.type == .command) + #expect(notification.action.command == "git status") + #expect(notification.action.source == .shell) + #expect(notification.review.status == .inProgress) + #expect(notification.review.riskLevel == .low) + default: + Issue.record("Expected item/autoApprovalReview/started to decode into .itemGuardianApprovalReviewStarted.") } let autoReviewCompletedPayload = Data( @@ -1535,22 +1536,22 @@ struct CodexAppServerProtocolTests { ) switch autoReviewCompletedEvent { - case let .itemGuardianApprovalReviewCompleted(completion): - let notification = completion.notification - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.targetItemID == nil) - #expect(notification.reviewID == "review-124") - #expect(notification.startedAtMS == 1_713_350_002_000) - #expect(notification.completedAtMS == 1_713_350_003_000) - #expect(notification.decisionSource == .agent) - #expect(notification.action.type == .networkAccess) - #expect(notification.action.host == "api.example.com") - #expect(notification.action.guardianApprovalReviewActionProtocol == .https) - #expect(notification.review.status == .approved) - #expect(notification.review.userAuthorization == .high) - default: - Issue.record("Expected item/autoApprovalReview/completed to decode into .itemGuardianApprovalReviewCompleted.") + case let .itemGuardianApprovalReviewCompleted(completion): + let notification = completion.notification + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.targetItemID == nil) + #expect(notification.reviewID == "review-124") + #expect(notification.startedAtMS == 1_713_350_002_000) + #expect(notification.completedAtMS == 1_713_350_003_000) + #expect(notification.decisionSource == .agent) + #expect(notification.action.type == .networkAccess) + #expect(notification.action.host == "api.example.com") + #expect(notification.action.guardianApprovalReviewActionProtocol == .https) + #expect(notification.review.status == .approved) + #expect(notification.review.userAuthorization == .high) + default: + Issue.record("Expected item/autoApprovalReview/completed to decode into .itemGuardianApprovalReviewCompleted.") } let agentMessageDeltaPayload = Data( @@ -1562,13 +1563,13 @@ struct CodexAppServerProtocolTests { ) switch agentMessageDeltaEvent { - case let .agentMessageDelta(notification): - #expect(notification.delta == "Hello there") - #expect(notification.itemID == "item-123") - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/agentMessage/delta to decode into .agentMessageDelta.") + case let .agentMessageDelta(notification): + #expect(notification.delta == "Hello there") + #expect(notification.itemID == "item-123") + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/agentMessage/delta to decode into .agentMessageDelta.") } let planDeltaPayload = Data( @@ -1580,13 +1581,13 @@ struct CodexAppServerProtocolTests { ) switch planDeltaEvent { - case let .planDelta(notification): - #expect(notification.delta == "Decode protocol events") - #expect(notification.itemID == "item-456") - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/plan/delta to decode into .planDelta.") + case let .planDelta(notification): + #expect(notification.delta == "Decode protocol events") + #expect(notification.itemID == "item-456") + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/plan/delta to decode into .planDelta.") } let fileChangeDeltaPayload = Data( @@ -1598,13 +1599,13 @@ struct CodexAppServerProtocolTests { ) switch fileChangeDeltaEvent { - case let .fileChangeOutputDelta(notification): - #expect(notification.delta.contains("+Hello, world")) - #expect(notification.itemID == "item-file-1") - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/fileChange/outputDelta to decode into .fileChangeOutputDelta.") + case let .fileChangeOutputDelta(notification): + #expect(notification.delta.contains("+Hello, world")) + #expect(notification.itemID == "item-file-1") + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/fileChange/outputDelta to decode into .fileChangeOutputDelta.") } let fileChangePatchPayload = Data( @@ -1618,12 +1619,12 @@ struct CodexAppServerProtocolTests { ) switch fileChangePatchEvent { - case let .fileChangePatchUpdated(notification): - #expect(notification.changes[0].path == "Sources/SwiftASB/File.swift") - #expect(notification.changes[0].kind.type == .update) - #expect(notification.itemID == "item-file-1") - default: - Issue.record("Expected item/fileChange/patchUpdated to decode into .fileChangePatchUpdated.") + case let .fileChangePatchUpdated(notification): + #expect(notification.changes[0].path == "Sources/SwiftASB/File.swift") + #expect(notification.changes[0].kind.type == .update) + #expect(notification.itemID == "item-file-1") + default: + Issue.record("Expected item/fileChange/patchUpdated to decode into .fileChangePatchUpdated.") } let commandDeltaPayload = Data( @@ -1635,13 +1636,13 @@ struct CodexAppServerProtocolTests { ) switch commandDeltaEvent { - case let .commandExecutionOutputDelta(notification): - #expect(notification.delta.contains("Cloning into")) - #expect(notification.itemID == "item-command-1") - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/commandExecution/outputDelta to decode into .commandExecutionOutputDelta.") + case let .commandExecutionOutputDelta(notification): + #expect(notification.delta.contains("Cloning into")) + #expect(notification.itemID == "item-command-1") + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/commandExecution/outputDelta to decode into .commandExecutionOutputDelta.") } } @@ -1656,11 +1657,11 @@ struct CodexAppServerProtocolTests { ) switch warningEvent { - case let .warning(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.message == "Runtime configuration is using a fallback.") - default: - Issue.record("Expected warning to decode into .warning.") + case let .warning(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.message == "Runtime configuration is using a fallback.") + default: + Issue.record("Expected warning to decode into .warning.") } let guardianWarningPayload = Data( @@ -1672,11 +1673,11 @@ struct CodexAppServerProtocolTests { ) switch guardianWarningEvent { - case let .guardianWarning(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.message == "Guardian flagged this session for review.") - default: - Issue.record("Expected guardianWarning to decode into .guardianWarning.") + case let .guardianWarning(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.message == "Guardian flagged this session for review.") + default: + Issue.record("Expected guardianWarning to decode into .guardianWarning.") } let modelReroutedPayload = Data( @@ -1688,14 +1689,14 @@ struct CodexAppServerProtocolTests { ) switch modelReroutedEvent { - case let .modelRerouted(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.fromModel == "gpt-5.4") - #expect(notification.toModel == "gpt-5.4-safe") - #expect(notification.reason == .highRiskCyberActivity) - default: - Issue.record("Expected model/rerouted to decode into .modelRerouted.") + case let .modelRerouted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.fromModel == "gpt-5.4") + #expect(notification.toModel == "gpt-5.4-safe") + #expect(notification.reason == .highRiskCyberActivity) + default: + Issue.record("Expected model/rerouted to decode into .modelRerouted.") } let modelVerificationPayload = Data( @@ -1707,12 +1708,12 @@ struct CodexAppServerProtocolTests { ) switch modelVerificationEvent { - case let .modelVerification(notification): - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - #expect(notification.verifications == [.trustedAccessForCyber]) - default: - Issue.record("Expected model/verification to decode into .modelVerification.") + case let .modelVerification(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.verifications == [.trustedAccessForCyber]) + default: + Issue.record("Expected model/verification to decode into .modelVerification.") } } @@ -1735,17 +1736,17 @@ struct CodexAppServerProtocolTests { ) switch commandApprovalEvent { - case let .commandExecutionApprovalRequested(request): - #expect(request.requestID == .string("approval-1")) - #expect(request.threadID == "thread-123") - #expect(request.turnID == "turn-123") - #expect(request.itemID == "item-command-1") - #expect(request.command == "git status") - let amendment = try #require(request.proposedNetworkPolicyAmendments?.first) - #expect(amendment.publicValue.action == .unknown("audit")) - #expect(amendment.publicValue.host == "example.com") - default: - Issue.record("Expected command approval server request to decode into .commandExecutionApprovalRequested.") + case let .commandExecutionApprovalRequested(request): + #expect(request.requestID == .string("approval-1")) + #expect(request.threadID == "thread-123") + #expect(request.turnID == "turn-123") + #expect(request.itemID == "item-command-1") + #expect(request.command == "git status") + let amendment = try #require(request.proposedNetworkPolicyAmendments?.first) + #expect(amendment.publicValue.action == .unknown("audit")) + #expect(amendment.publicValue.host == "example.com") + default: + Issue.record("Expected command approval server request to decode into .commandExecutionApprovalRequested.") } let toolInputPayload = Data( @@ -1765,13 +1766,13 @@ struct CodexAppServerProtocolTests { ) switch toolInputEvent { - case let .toolUserInputRequested(request): - #expect(request.requestID == .string("input-1")) - #expect(request.questions.count == 1) - #expect(request.questions[0].isOther == false) - #expect(request.questions[0].isSecret == false) - default: - Issue.record("Expected tool user input server request to decode into .toolUserInputRequested.") + case let .toolUserInputRequested(request): + #expect(request.requestID == .string("input-1")) + #expect(request.questions.count == 1) + #expect(request.questions[0].isOther == false) + #expect(request.questions[0].isSecret == false) + default: + Issue.record("Expected tool user input server request to decode into .toolUserInputRequested.") } let mcpPayload = Data( @@ -1791,13 +1792,13 @@ struct CodexAppServerProtocolTests { ) switch mcpEvent { - case let .mcpServerElicitationRequested(request): - #expect(request.requestID == .string("mcp-1")) - #expect(request.serverName == "calendar") - #expect(request.threadID == "thread-123") - #expect(request.turnID == nil) - default: - Issue.record("Expected MCP elicitation server request to decode into .mcpServerElicitationRequested.") + case let .mcpServerElicitationRequested(request): + #expect(request.requestID == .string("mcp-1")) + #expect(request.serverName == "calendar") + #expect(request.threadID == "thread-123") + #expect(request.turnID == nil) + default: + Issue.record("Expected MCP elicitation server request to decode into .mcpServerElicitationRequested.") } } @@ -2014,13 +2015,13 @@ struct CodexAppServerProtocolTests { ) switch summaryPartAddedEvent { - case let .reasoningSummaryPartAdded(notification): - #expect(notification.itemID == "item-123") - #expect(notification.summaryIndex == 1) - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/reasoning/summaryPartAdded to decode into .reasoningSummaryPartAdded.") + case let .reasoningSummaryPartAdded(notification): + #expect(notification.itemID == "item-123") + #expect(notification.summaryIndex == 1) + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/reasoning/summaryPartAdded to decode into .reasoningSummaryPartAdded.") } let summaryTextDeltaPayload = Data( @@ -2032,12 +2033,12 @@ struct CodexAppServerProtocolTests { ) switch summaryTextDeltaEvent { - case let .reasoningSummaryTextDelta(notification): - #expect(notification.delta == "refining the plan") - #expect(notification.itemID == "item-123") - #expect(notification.summaryIndex == 1) - default: - Issue.record("Expected item/reasoning/summaryTextDelta to decode into .reasoningSummaryTextDelta.") + case let .reasoningSummaryTextDelta(notification): + #expect(notification.delta == "refining the plan") + #expect(notification.itemID == "item-123") + #expect(notification.summaryIndex == 1) + default: + Issue.record("Expected item/reasoning/summaryTextDelta to decode into .reasoningSummaryTextDelta.") } let reasoningTextDeltaPayload = Data( @@ -2049,14 +2050,14 @@ struct CodexAppServerProtocolTests { ) switch reasoningTextDeltaEvent { - case let .reasoningTextDelta(notification): - #expect(notification.contentIndex == 0) - #expect(notification.delta == "thinking...") - #expect(notification.itemID == "item-123") - #expect(notification.threadID == "thread-123") - #expect(notification.turnID == "turn-123") - default: - Issue.record("Expected item/reasoning/textDelta to decode into .reasoningTextDelta.") + case let .reasoningTextDelta(notification): + #expect(notification.contentIndex == 0) + #expect(notification.delta == "thinking...") + #expect(notification.itemID == "item-123") + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + default: + Issue.record("Expected item/reasoning/textDelta to decode into .reasoningTextDelta.") } } @@ -2066,5 +2067,4 @@ struct CodexAppServerProtocolTests { ) throws -> CodexAppServerProtocolEvent? { try protocolLayer.decodeServerEvent(.notification(method: method, payload: payload)) } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift index 4cd42e7..9129cb9 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @MainActor @@ -332,7 +332,7 @@ extension CodexAppServerTests { ) let agenda = try await thread.makeAgenda() - _ = try await agenda.setGoal("Ship plan mode", tokenBudget: 40_000) + _ = try await agenda.setGoal("Ship plan mode", tokenBudget: 40000) _ = try await agenda.pauseGoal() _ = try await agenda.resumeGoal() _ = try await agenda.clearGoal() @@ -343,7 +343,7 @@ extension CodexAppServerTests { let setGoalRequest = try companionDecodedJSONObject(from: goalSetPayloads[0]) #expect(companionValue(at: ["params", "objective"], in: setGoalRequest) as? String == "Ship plan mode") #expect(companionValue(at: ["params", "status"], in: setGoalRequest) as? String == "active") - #expect(companionValue(at: ["params", "tokenBudget"], in: setGoalRequest) as? Int == 40_000) + #expect(companionValue(at: ["params", "tokenBudget"], in: setGoalRequest) as? Int == 40000) let pauseRequest = try companionDecodedJSONObject(from: goalSetPayloads[1]) #expect(companionValue(at: ["params", "status"], in: pauseRequest) as? String == "paused") @@ -541,7 +541,6 @@ extension CodexAppServerTests { await client.stop() } - } private func companionDecodedJSONObject(from data: Data) throws -> [String: Any] { @@ -559,6 +558,7 @@ private func companionValue( else { return nil } + current = next } return current diff --git a/Tests/SwiftASBTests/Public/CodexAppServerDiagnosticsTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerDiagnosticsTests.swift index 673c223..c3ab3b7 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerDiagnosticsTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerDiagnosticsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @MainActor @@ -63,39 +63,39 @@ extension CodexAppServerTests { } switch receivedThreadEvents[0] { - case let .diagnostic(.warning(warning)): - #expect(warning.threadID == thread.id) - #expect(warning.message == "Runtime configuration is using a fallback.") - default: - Issue.record("Expected the first thread diagnostic to be a runtime warning.") + case let .diagnostic(.warning(warning)): + #expect(warning.threadID == thread.id) + #expect(warning.message == "Runtime configuration is using a fallback.") + default: + Issue.record("Expected the first thread diagnostic to be a runtime warning.") } switch receivedThreadEvents[1] { - case let .diagnostic(.guardianWarning(warning)): - #expect(warning.threadID == thread.id) - #expect(warning.message == "Guardian flagged this session for review.") - default: - Issue.record("Expected the second thread diagnostic to be a guardian warning.") + case let .diagnostic(.guardianWarning(warning)): + #expect(warning.threadID == thread.id) + #expect(warning.message == "Guardian flagged this session for review.") + default: + Issue.record("Expected the second thread diagnostic to be a guardian warning.") } switch receivedThreadEvents[2] { - case let .diagnostic(.modelRerouted(reroute)): - #expect(reroute.threadID == thread.id) - #expect(reroute.turnID == turnHandle.turn.id) - #expect(reroute.fromModel == "gpt-5.4") - #expect(reroute.toModel == "gpt-5.4-safe") - #expect(reroute.reason == CodexModelReroute.Reason.highRiskCyberActivity) - default: - Issue.record("Expected the third thread diagnostic to be a model reroute.") + case let .diagnostic(.modelRerouted(reroute)): + #expect(reroute.threadID == thread.id) + #expect(reroute.turnID == turnHandle.turn.id) + #expect(reroute.fromModel == "gpt-5.4") + #expect(reroute.toModel == "gpt-5.4-safe") + #expect(reroute.reason == CodexModelReroute.Reason.highRiskCyberActivity) + default: + Issue.record("Expected the third thread diagnostic to be a model reroute.") } switch receivedThreadEvents[3] { - case let .diagnostic(.modelVerification(verification)): - #expect(verification.threadID == thread.id) - #expect(verification.turnID == turnHandle.turn.id) - #expect(verification.verifications == [CodexModelVerification.trustedAccessForCyber]) - default: - Issue.record("Expected the fourth thread diagnostic to be a model verification.") + case let .diagnostic(.modelVerification(verification)): + #expect(verification.threadID == thread.id) + #expect(verification.turnID == turnHandle.turn.id) + #expect(verification.verifications == [CodexModelVerification.trustedAccessForCyber]) + default: + Issue.record("Expected the fourth thread diagnostic to be a model verification.") } let receivedTurnEvents = try await turnEventsTask.value @@ -106,20 +106,20 @@ extension CodexAppServerTests { } switch receivedTurnEvents[0] { - case let .diagnostic(.modelRerouted(reroute)): - #expect(reroute.threadID == thread.id) - #expect(reroute.turnID == turnHandle.turn.id) - default: - Issue.record("Expected the first turn diagnostic to be a model reroute.") + case let .diagnostic(.modelRerouted(reroute)): + #expect(reroute.threadID == thread.id) + #expect(reroute.turnID == turnHandle.turn.id) + default: + Issue.record("Expected the first turn diagnostic to be a model reroute.") } switch receivedTurnEvents[1] { - case let .diagnostic(.modelVerification(verification)): - #expect(verification.threadID == thread.id) - #expect(verification.turnID == turnHandle.turn.id) - #expect(verification.verifications == [CodexModelVerification.trustedAccessForCyber]) - default: - Issue.record("Expected the second turn diagnostic to be a model verification.") + case let .diagnostic(.modelVerification(verification)): + #expect(verification.threadID == thread.id) + #expect(verification.turnID == turnHandle.turn.id) + #expect(verification.verifications == [CodexModelVerification.trustedAccessForCyber]) + default: + Issue.record("Expected the second turn diagnostic to be a model verification.") } await waitForObservableState { @@ -132,19 +132,19 @@ extension CodexAppServerTests { } switch dashboard.latestDiagnostic { - case let .modelVerification(verification): - #expect(verification.threadID == thread.id) - #expect(verification.turnID == turnHandle.turn.id) - default: - Issue.record("Expected the thread dashboard to retain the latest model verification diagnostic.") + case let .modelVerification(verification): + #expect(verification.threadID == thread.id) + #expect(verification.turnID == turnHandle.turn.id) + default: + Issue.record("Expected the thread dashboard to retain the latest model verification diagnostic.") } switch minimap.latestDiagnostic { - case let .modelVerification(verification): - #expect(verification.threadID == thread.id) - #expect(verification.turnID == turnHandle.turn.id) - default: - Issue.record("Expected the turn minimap to retain the latest model verification diagnostic.") + case let .modelVerification(verification): + #expect(verification.threadID == thread.id) + #expect(verification.turnID == turnHandle.turn.id) + default: + Issue.record("Expected the turn minimap to retain the latest model verification diagnostic.") } await client.stop() @@ -180,11 +180,11 @@ extension CodexAppServerTests { #expect(appDiagnostics.count == 1) switch appDiagnostics.first { - case let .warning(warning): - #expect(warning.threadID == nil) - #expect(warning.message == "Global configuration warning.") - default: - Issue.record("Expected an app-wide runtime warning diagnostic.") + case let .warning(warning): + #expect(warning.threadID == nil) + #expect(warning.message == "Global configuration warning.") + default: + Issue.record("Expected an app-wide runtime warning diagnostic.") } await client.stop() @@ -220,13 +220,13 @@ extension CodexAppServerTests { #expect(appDiagnostics.count == 1) switch appDiagnostics.first { - case let .mcpServerStatusChanged(diagnostic): - #expect(diagnostic.name == "calendar") - #expect(diagnostic.status == .ready) - #expect(appDiagnostics.first?.threadID == nil) - #expect(appDiagnostics.first?.turnID == nil) - default: - Issue.record("Expected an app-wide MCP server status diagnostic.") + case let .mcpServerStatusChanged(diagnostic): + #expect(diagnostic.name == "calendar") + #expect(diagnostic.status == .ready) + #expect(appDiagnostics.first?.threadID == nil) + #expect(appDiagnostics.first?.turnID == nil) + default: + Issue.record("Expected an app-wide MCP server status diagnostic.") } await client.stop() @@ -267,5 +267,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift index d333048..58a56bd 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("CodexFS routes read-only filesystem requests through the app-server") @@ -34,13 +34,13 @@ extension CodexAppServerTests { #expect(String(data: file.data, encoding: .utf8) == "hello from CodexFS") let metadataRequest = try #require(await transport.recordedRequestPayload(for: "fs/getMetadata")) - #expect(value(at: ["params", "path"], in: try decodedJSONObject(from: metadataRequest)) as? String == "/tmp/project") + #expect(try value(at: ["params", "path"], in: decodedJSONObject(from: metadataRequest)) as? String == "/tmp/project") let directoryRequest = try #require(await transport.recordedRequestPayload(for: "fs/readDirectory")) - #expect(value(at: ["params", "path"], in: try decodedJSONObject(from: directoryRequest)) as? String == "/tmp/project") + #expect(try value(at: ["params", "path"], in: decodedJSONObject(from: directoryRequest)) as? String == "/tmp/project") let fileRequest = try #require(await transport.recordedRequestPayload(for: "fs/readFile")) - #expect(value(at: ["params", "path"], in: try decodedJSONObject(from: fileRequest)) as? String == "/tmp/project/README.md") + #expect(try value(at: ["params", "path"], in: decodedJSONObject(from: fileRequest)) as? String == "/tmp/project/README.md") await client.stop() } @@ -90,7 +90,7 @@ extension CodexAppServerTests { let directoryRequests = await transport.requestPayloads(for: "fs/readDirectory") let directoryPaths = try directoryRequests.map { - value(at: ["params", "path"], in: try decodedJSONObject(from: $0)) as? String + try value(at: ["params", "path"], in: decodedJSONObject(from: $0)) as? String } #expect(directoryPaths.contains("/tmp/project")) #expect(directoryPaths.contains("/tmp/project/Sources")) @@ -228,7 +228,7 @@ extension CodexAppServerTests { try await client.fs.unwatch(.init(watchID: "watch-123")) let unwatchRequest = try #require(await transport.recordedRequestPayload(for: "fs/unwatch")) - #expect(value(at: ["params", "watchId"], in: try decodedJSONObject(from: unwatchRequest)) as? String == "watch-123") + #expect(try value(at: ["params", "watchId"], in: decodedJSONObject(from: unwatchRequest)) as? String == "watch-123") await client.stop() } @@ -337,7 +337,7 @@ extension CodexAppServerTests { #expect(plugin.marketplaceName == "openai-curated") #expect(plugin.marketplacePath == "/tmp/marketplaces/openai-curated.json") #expect(plugin.description == "GitHub plugin detail fixture.") - #expect(plugin.apps.first?.needsAuth == true) + #expect(plugin.apps.first?.category == "developer-tools") #expect(plugin.hooks.map(\.key) == ["github-pre-tool-use", "github-post-tool-use"]) #expect(plugin.hooks.map(\.eventName) == [.preToolUse, .postToolUse]) #expect(plugin.skills.first?.displayName == "PR Review") @@ -372,7 +372,7 @@ extension CodexAppServerTests { #expect(value(at: ["params", "remoteMarketplaceName"], in: pluginReadJSON) as? String == "openai-curated") let collaborationRequest = try #require(await transport.recordedRequestPayload(for: "collaborationMode/list")) - #expect(value(at: ["params"], in: try decodedJSONObject(from: collaborationRequest)) as? [String: Any] != nil) + #expect(try value(at: ["params"], in: decodedJSONObject(from: collaborationRequest)) as? [String: Any] != nil) await client.stop() } @@ -408,7 +408,7 @@ extension CodexAppServerTests { .init( marketplaceName: "openai-curated", currentDirectoryPaths: ["/tmp/project"], - timeoutMilliseconds: 30_000 + timeoutMilliseconds: 30000 ) ) @@ -424,12 +424,12 @@ extension CodexAppServerTests { #expect(!methods.contains("thread/start")) let pluginsRequest = try #require(await transport.recordedRequestPayload(for: "plugin/list")) - #expect(value(at: ["params", "cwds"], in: try decodedJSONObject(from: pluginsRequest)) as? [String] == ["/tmp/project"]) + #expect(try value(at: ["params", "cwds"], in: decodedJSONObject(from: pluginsRequest)) as? [String] == ["/tmp/project"]) let commandRequest = try #require(await transport.recordedRequestPayload(for: "command/exec")) let commandJSON = try decodedJSONObject(from: commandRequest) #expect(value(at: ["params", "command"], in: commandJSON) as? [String] == result.command) - #expect(value(at: ["params", "timeoutMs"], in: commandJSON) as? Int == 30_000) + #expect(value(at: ["params", "timeoutMs"], in: commandJSON) as? Int == 30000) #expect(value(at: ["params", "permissionProfile"], in: commandJSON) == nil) #expect(value(at: ["params", "sandboxPolicy"], in: commandJSON) == nil) @@ -510,50 +510,6 @@ extension CodexAppServerTests { await client.stop() } - @Test("CodexExtensions rejects removed per-cwd extra skill roots option") - func codexExtensionsRejectsRemovedPerCwdExtraSkillRootsOption() async throws { - let transport = FakeCodexAppServerTransport() - let client = CodexAppServer(transport: transport) - - try await client.start() - _ = try await client.initialize( - .init( - clientInfo: .init( - name: "SwiftASBTests", - title: "SwiftASB Tests", - version: "0.1.0" - ) - ) - ) - - do { - _ = try await client.extensions.skills.list( - .init( - currentDirectoryPaths: ["/tmp/project"], - perCurrentDirectoryExtraUserRoots: [ - .init(currentDirectoryPath: "/tmp/project", extraUserRoots: ["/tmp/extra-skills"]), - ] - ) - ) - Issue.record("Expected per-cwd extra skill roots to be rejected for Codex CLI 0.130.0.") - } catch let error as CodexAppServerError { - guard case let .invalidState(reason) = error else { - Issue.record("Expected removed per-cwd extra skill roots to throw an invalidState error.") - await client.stop() - return - } - - #expect( - reason - == "Codex CLI 0.130.0 removed per-cwd extra user roots from skills/list; pass currentDirectoryPaths and forceReload only." - ) - } - - #expect(await transport.recordedRequestPayload(for: "skills/list") == nil) - - await client.stop() - } - @Test("CodexThread reads and updates app-server thread goals") func codexThreadReadsAndUpdatesAppServerThreadGoals() async throws { let transport = FakeCodexAppServerTransport() @@ -581,9 +537,9 @@ extension CodexAppServerTests { #expect(goal?.objective == "Promote schemas") #expect(goal?.status == .active) - let updated = try await thread.setGoal(.init(status: .budgetLimited, tokenBudget: 30_000)) + let updated = try await thread.setGoal(.init(status: .budgetLimited, tokenBudget: 30000)) #expect(updated.status == .budgetLimited) - #expect(updated.tokenBudget == 30_000) + #expect(updated.tokenBudget == 30000) let cleared = try await thread.clearGoal() #expect(cleared) @@ -592,7 +548,7 @@ extension CodexAppServerTests { let goalSetJSON = try decodedJSONObject(from: goalSetRequest) #expect(value(at: ["params", "threadId"], in: goalSetJSON) as? String == thread.id) #expect(value(at: ["params", "status"], in: goalSetJSON) as? String == "budgetLimited") - #expect(value(at: ["params", "tokenBudget"], in: goalSetJSON) as? Int == 30_000) + #expect(value(at: ["params", "tokenBudget"], in: goalSetJSON) as? Int == 30000) await client.stop() } @@ -612,6 +568,7 @@ private func value( let next = dictionary[key] else { return nil } + current = next } return current diff --git a/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift index 8bbd931..3c4e185 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @MainActor @@ -22,7 +22,7 @@ extension CodexAppServerTests { ) ) - try await waitForCondition(maxAttempts: 2_000) { + try await waitForCondition(maxAttempts: 2000) { await MainActor.run { inventory.appListPage != nil && inventory.skillListSnapshot != nil diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift index de508b7..3e32934 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @MainActor @@ -17,7 +17,7 @@ extension CodexAppServerTests { name: "Newest active", preview: "Fresh unarchived thread", statusType: "notLoaded", - updatedAt: 1713350030 + updatedAt: 1_713_350_030 ), ], "nextCursor": NSNull(), @@ -30,7 +30,7 @@ extension CodexAppServerTests { name: "Archived work", preview: "Stored archived thread", statusType: "notLoaded", - updatedAt: 1713350010 + updatedAt: 1_713_350_010 ), ], "nextCursor": NSNull(), @@ -111,7 +111,7 @@ extension CodexAppServerTests { name: "Deleted in GUI", preview: "Locally cached history", statusType: "notLoaded", - updatedAt: 1713350030 + updatedAt: 1_713_350_030 ), ], "nextCursor": NSNull(), @@ -186,7 +186,7 @@ extension CodexAppServerTests { name: "Package A", preview: "First repo thread", statusType: "notLoaded", - updatedAt: 1713350030 + updatedAt: 1_713_350_030 ), storedThread( id: "thread-package-b", @@ -196,7 +196,7 @@ extension CodexAppServerTests { name: "Package B", preview: "Second repo thread", statusType: "notLoaded", - updatedAt: 1713350020 + updatedAt: 1_713_350_020 ), storedThread( id: "thread-standalone", @@ -204,7 +204,7 @@ extension CodexAppServerTests { name: "Standalone", preview: "No Git origin", statusType: "notLoaded", - updatedAt: 1713350010 + updatedAt: 1_713_350_010 ), ], "nextCursor": NSNull(), @@ -270,7 +270,7 @@ extension CodexAppServerTests { name: "Active package", preview: "Active repo thread", statusType: "notLoaded", - updatedAt: 1713350030 + updatedAt: 1_713_350_030 ), storedThread( id: "thread-standalone", @@ -278,7 +278,7 @@ extension CodexAppServerTests { name: "Standalone", preview: "Standalone thread", statusType: "notLoaded", - updatedAt: 1713350020 + updatedAt: 1_713_350_020 ), ], "nextCursor": NSNull(), @@ -293,7 +293,7 @@ extension CodexAppServerTests { name: "Archived package", preview: "Archived repo thread", statusType: "notLoaded", - updatedAt: 1713350010 + updatedAt: 1_713_350_010 ), ], "nextCursor": NSNull(), @@ -324,12 +324,16 @@ extension CodexAppServerTests { "/tmp/standalone", "https://github.com/gaelic-ghost/SwiftASB.git", ]) - #expect(library.worktreeGroups.first(where: { - $0.id == "https://github.com/gaelic-ghost/SwiftASB.git" - })?.worktree?.repository?.shortSHA == "abcdef123456") - let repositoryWorktree = try #require(library.worktreeGroups.first(where: { - $0.id == "https://github.com/gaelic-ghost/SwiftASB.git" - })?.worktree) + #expect(library.worktreeGroups + .first(where: { + $0.id == "https://github.com/gaelic-ghost/SwiftASB.git" + })?.worktree? + .repository? + .shortSHA == "abcdef123456") + let repositoryWorktree = try #require(library.worktreeGroups + .first(where: { + $0.id == "https://github.com/gaelic-ghost/SwiftASB.git" + })?.worktree) #expect(library.threads(inRepositoryOriginURL: "https://github.com/gaelic-ghost/SwiftASB.git").map(\.id) == [ "thread-active", ]) @@ -342,7 +346,8 @@ extension CodexAppServerTests { #expect(library.threads( inRepositoryOriginURL: "https://github.com/gaelic-ghost/SwiftASB.git", includeArchived: true - ).map(\.id) == [ + ) + .map(\.id) == [ "thread-active", "thread-archived", ]) @@ -395,7 +400,7 @@ extension CodexAppServerTests { name: "Alpha", preview: "Alpha preview", statusType: "idle", - updatedAt: 1713350002 + updatedAt: 1_713_350_002 ), ], "nextCursor": NSNull(), @@ -498,17 +503,17 @@ extension CodexAppServerTests { await transport.emitTurnCompleted( threadID: alpha.id, turnID: "turn-alpha", - completedAt: 1713350005 + completedAt: 1_713_350_005 ) _ = try await beta.startTextTurn("Second prompt") await transport.emitTurnCompleted( threadID: beta.id, turnID: "turn-beta", - completedAt: 1713350010 + completedAt: 1_713_350_010 ) try await waitForCondition { let snapshot = try await client.debugThreadHistorySnapshot(threadID: beta.id) - return snapshot?.turns.contains { $0.id == "turn-beta" && $0.completedAt == 1713350010 } == true + return snapshot?.turns.contains { $0.id == "turn-beta" && $0.completedAt == 1_713_350_010 } == true } let library = try await client.makeLibrary( @@ -537,7 +542,7 @@ extension CodexAppServerTests { name: "Alpha", preview: "Alpha preview", statusType: "notLoaded", - updatedAt: 1713350001 + updatedAt: 1_713_350_001 ), storedThread( id: "thread-beta", @@ -545,7 +550,7 @@ extension CodexAppServerTests { name: "Beta", preview: "Beta preview", statusType: "notLoaded", - updatedAt: 1713350002 + updatedAt: 1_713_350_002 ), ], "nextCursor": NSNull(), @@ -894,7 +899,7 @@ private func storedThread( ) -> [String: Any] { var thread: [String: Any] = [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": cwd, "ephemeral": false, "id": id, @@ -948,6 +953,7 @@ private func value( let next = dictionary[component] else { return nil } + current = next } return current diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift index 65ffbc8..4747430 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift @@ -1,7 +1,7 @@ -import Foundation import CryptoKit -import Testing +import Foundation @testable import SwiftASB +import Testing extension CodexAppServerLiveIntegrationTests { @Test( @@ -22,9 +22,11 @@ extension CodexAppServerLiveIntegrationTests { .appendingPathComponent("approval-target.txt", isDirectory: false) let fixtureText = "approval-fixture-\(UUID().uuidString)\n" try Data(fixtureText.utf8).write(to: approvalFixtureURL) - let expectedDigest = Data(SHA256.hash(data: Data(fixtureText.utf8))).map { - String(format: "%02x", $0) - }.joined() + let expectedDigest = Data(SHA256.hash(data: Data(fixtureText.utf8))) + .map { + String(format: "%02x", $0) + } + .joined() let client = try await makeInitializedLiveClient(using: harness) do { @@ -61,46 +63,46 @@ extension CodexAppServerLiveIntegrationTests { ) switch approvalOutcome { - case let .approvalRequested(approvalRequest): - switch approvalRequest { - case let .commandExecution(commandRequest): - #expect(commandRequest.threadID == approvalThread.id) - #expect(commandRequest.turnID == approvalTurn.turn.id) - #expect(commandRequest.reason?.isEmpty == false) - case let .fileChange(fileRequest): - #expect(fileRequest.threadID == approvalThread.id) - #expect(fileRequest.turnID == approvalTurn.turn.id) - case let .guardianDeniedAction(guardianRequest): - #expect(guardianRequest.threadID == approvalThread.id) - #expect(guardianRequest.turnID == approvalTurn.turn.id) - case let .permissions(permissionsRequest): - #expect(permissionsRequest.threadID == approvalThread.id) - #expect(permissionsRequest.turnID == approvalTurn.turn.id) - } - - try await approvalTurn.respond( - to: approvalRequest, - with: acceptanceResponse(for: approvalRequest) - ) + case let .approvalRequested(approvalRequest): + switch approvalRequest { + case let .commandExecution(commandRequest): + #expect(commandRequest.threadID == approvalThread.id) + #expect(commandRequest.turnID == approvalTurn.turn.id) + #expect(commandRequest.reason?.isEmpty == false) + case let .fileChange(fileRequest): + #expect(fileRequest.threadID == approvalThread.id) + #expect(fileRequest.turnID == approvalTurn.turn.id) + case let .guardianDeniedAction(guardianRequest): + #expect(guardianRequest.threadID == approvalThread.id) + #expect(guardianRequest.turnID == approvalTurn.turn.id) + case let .permissions(permissionsRequest): + #expect(permissionsRequest.threadID == approvalThread.id) + #expect(permissionsRequest.turnID == approvalTurn.turn.id) + } + + try await approvalTurn.respond( + to: approvalRequest, + with: acceptanceResponse(for: approvalRequest) + ) - let resolution = try await awaitRequestResolution( - in: minimap, - expectedKind: approvalRequest.kind, - timeoutSeconds: 20, - operation: "waiting for the accepted live approval request to resolve" - ) - #expect(resolution.threadID == approvalThread.id) - #expect(resolution.turnID == approvalTurn.turn.id) + let resolution = try await awaitRequestResolution( + in: minimap, + expectedKind: approvalRequest.kind, + timeoutSeconds: 20, + operation: "waiting for the accepted live approval request to resolve" + ) + #expect(resolution.threadID == approvalThread.id) + #expect(resolution.turnID == approvalTurn.turn.id) - let completion = try await awaitCompletion( - of: approvalTurn, - timeoutSeconds: 45, - operation: "waiting for the live approval-path turn to complete" - ) - #expect(completion.turn.status == .completed) - case let .completedWithoutApproval(status, completedText): - #expect(status == .completed) - #expect(completedText == expectedDigest) + let completion = try await awaitCompletion( + of: approvalTurn, + timeoutSeconds: 45, + operation: "waiting for the live approval-path turn to complete" + ) + #expect(completion.turn.status == .completed) + case let .completedWithoutApproval(status, completedText): + #expect(status == .completed) + #expect(completedText == expectedDigest) } let latestCompletedItemText = await MainActor.run(body: { minimap.latestCompletedItem?.item.text }) @@ -130,9 +132,11 @@ extension CodexAppServerLiveIntegrationTests { .appendingPathComponent("approval-read-target.txt", isDirectory: false) let readFixtureText = "approval-probe-\(UUID().uuidString)\n" try Data(readFixtureText.utf8).write(to: readFixtureURL) - let expectedDigest = Data(SHA256.hash(data: Data(readFixtureText.utf8))).map { - String(format: "%02x", $0) - }.joined() + let expectedDigest = Data(SHA256.hash(data: Data(readFixtureText.utf8))) + .map { + String(format: "%02x", $0) + } + .joined() let editFixtureURL = harness.approvalProbeWorkspace .appendingPathComponent("approval-edit-target.txt", isDirectory: false) @@ -187,7 +191,7 @@ extension CodexAppServerLiveIntegrationTests { label: "approval-probe-\(probeCase.label)", sandboxMode: .workspaceWrite ) - results.append(try await runApprovalProbeCaseReport(probeCase, on: caseThread)) + try results.append(await runApprovalProbeCaseReport(probeCase, on: caseThread)) } catch { results.append(.init(probeCase, error: error)) } @@ -213,7 +217,7 @@ extension CodexAppServerLiveIntegrationTests { label: "approval-probe-read-only", sandboxMode: .readOnly ) - results.append(try await runApprovalProbeCaseReport(readOnlyWriteCase, on: readOnlyThread)) + try results.append(await runApprovalProbeCaseReport(readOnlyWriteCase, on: readOnlyThread)) } catch { results.append(.init(readOnlyWriteCase, error: error)) } @@ -284,8 +288,8 @@ extension CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -307,7 +311,7 @@ extension CodexAppServerLiveIntegrationTests { ) try await transport.sendNotification( - try protocolLayer.makeInitializedNotification(), + protocolLayer.makeInitializedNotification(), method: "initialized" ) @@ -332,6 +336,7 @@ extension CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .readOnly, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -370,7 +375,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: nil, outputSchema: nil, @@ -474,8 +479,8 @@ extension CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -497,7 +502,7 @@ extension CodexAppServerLiveIntegrationTests { ) try await transport.sendNotification( - try protocolLayer.makeInitializedNotification(), + protocolLayer.makeInitializedNotification(), method: "initialized" ) @@ -522,6 +527,7 @@ extension CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .readOnly, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -560,7 +566,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: nil, outputSchema: nil, @@ -609,5 +615,4 @@ extension CodexAppServerLiveIntegrationTests { throw error } } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveBehaviorScenarioTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveBehaviorScenarioTests.swift index 525ebdc..5480ca8 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveBehaviorScenarioTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveBehaviorScenarioTests.swift @@ -1,7 +1,7 @@ -import Foundation import CryptoKit -import Testing +import Foundation @testable import SwiftASB +import Testing extension CodexAppServerLiveIntegrationTests { @Test( @@ -21,9 +21,11 @@ extension CodexAppServerLiveIntegrationTests { .appendingPathComponent("behavior-matrix-read.txt", isDirectory: false) let readFixtureText = "behavior-matrix-\(UUID().uuidString)\n" try Data(readFixtureText.utf8).write(to: readFixtureURL) - let expectedDigest = Data(SHA256.hash(data: Data(readFixtureText.utf8))).map { - String(format: "%02x", $0) - }.joined() + let expectedDigest = Data(SHA256.hash(data: Data(readFixtureText.utf8))) + .map { + String(format: "%02x", $0) + } + .joined() let untrustedCreateURL = harness.approvalProbeWorkspace .appendingPathComponent("behavior-matrix-untrusted-create.txt", isDirectory: false) @@ -297,7 +299,7 @@ extension CodexAppServerLiveIntegrationTests { let commandSnapshots = await MainActor.run { recentCommands.commands } #expect(fileSnapshots.isEmpty == false || commandSnapshots.isEmpty == false) - let report = LiveFileMutationScenarioReport( + let report = try LiveFileMutationScenarioReport( threadID: thread.id, workspacePath: harness.fileScenarioWorkspace.path, turns: [ @@ -314,7 +316,7 @@ extension CodexAppServerLiveIntegrationTests { .init( path: auditURL.lastPathComponent, exists: FileManager.default.fileExists(atPath: auditURL.path), - contents: try String(contentsOf: auditURL, encoding: .utf8) + contents: String(contentsOf: auditURL, encoding: .utf8) ), ], recentFiles: fileSnapshots.map(LiveFileMutationScenarioReport.RecentFile.init), @@ -364,28 +366,28 @@ extension CodexAppServerLiveIntegrationTests { ) switch secondSameThreadOutcome { - case .started: - Issue.record( - """ - SwiftASB should reject overlapping same-thread turns before they reach the live \ - Codex app-server because the live same-thread lifecycle is not independently \ - routable today. - """ - ) - case let .failed(errorDescription): - #expect( - errorDescription.contains("overlapping same-thread turns") - || errorDescription.contains("another turn start in flight") - || errorDescription.contains("already has an active turn") - ) - #expect(errorDescription.isEmpty == false) + case .started: + Issue.record( + """ + SwiftASB should reject overlapping same-thread turns before they reach the live \ + Codex app-server because the live same-thread lifecycle is not independently \ + routable today. + """ + ) + case let .failed(errorDescription): + #expect( + errorDescription.contains("overlapping same-thread turns") + || errorDescription.contains("another turn start in flight") + || errorDescription.contains("already has an active turn") + ) + #expect(errorDescription.isEmpty == false) - let firstCompletion = try await awaitCompletion( - of: firstSameThreadTurn, - timeoutSeconds: 45, - operation: "waiting for the first same-thread turn to complete after the second start was rejected" - ) - #expect(firstCompletion.turn.status.isTerminal) + let firstCompletion = try await awaitCompletion( + of: firstSameThreadTurn, + timeoutSeconds: 45, + operation: "waiting for the first same-thread turn to complete after the second start was rejected" + ) + #expect(firstCompletion.turn.status.isTerminal) } await client.stop() diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveElicitationProbeTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveElicitationProbeTests.swift index e1b34a7..651a344 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveElicitationProbeTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveElicitationProbeTests.swift @@ -1,7 +1,7 @@ -import Foundation import CryptoKit -import Testing +import Foundation @testable import SwiftASB +import Testing extension CodexAppServerLiveIntegrationTests { @Test( @@ -52,8 +52,8 @@ extension CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -75,7 +75,7 @@ extension CodexAppServerLiveIntegrationTests { ) try await transport.sendNotification( - try protocolLayer.makeInitializedNotification(), + protocolLayer.makeInitializedNotification(), method: "initialized" ) @@ -100,6 +100,7 @@ extension CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .readOnly, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -145,7 +146,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: nil, outputSchema: nil, @@ -240,8 +241,8 @@ extension CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -263,7 +264,7 @@ extension CodexAppServerLiveIntegrationTests { ) try await transport.sendNotification( - try protocolLayer.makeInitializedNotification(), + protocolLayer.makeInitializedNotification(), method: "initialized" ) @@ -288,6 +289,7 @@ extension CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .readOnly, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -326,7 +328,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: nil, outputSchema: nil, @@ -428,8 +430,8 @@ extension CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -451,7 +453,7 @@ extension CodexAppServerLiveIntegrationTests { ) try await transport.sendNotification( - try protocolLayer.makeInitializedNotification(), + protocolLayer.makeInitializedNotification(), method: "initialized" ) @@ -476,6 +478,7 @@ extension CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .readOnly, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -514,7 +517,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: "mock-model", outputSchema: nil, @@ -568,7 +571,7 @@ extension CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: "mock-model", outputSchema: nil, @@ -603,8 +606,18 @@ extension CodexAppServerLiveIntegrationTests { ) #expect(elicitationResult.threadID == threadResponse.thread.id) #expect(elicitationResult.turnID == turnResponse.turn.id) + if !elicitationResult.sawMcpToolCall { + #expect(elicitationResult.serverName == nil) + #expect(elicitationResult.sawElicitationRequest == false) + #expect(elicitationResult.sawServerRequestResolved == false) + #expect(elicitationResult.completion.turn.status == .completed) + #expect(appsServer.toolCallRequestCount == 0) + + await transport.stop() + return + } + #expect(elicitationResult.serverName == "codex_apps") - #expect(elicitationResult.sawMcpToolCall) if !elicitationResult.sawElicitationRequest { Issue.record("app connector debug log:\n\(appsServer.debugLog)") } @@ -612,8 +625,9 @@ extension CodexAppServerLiveIntegrationTests { #expect(elicitationResult.sawServerRequestResolved) #expect(elicitationResult.completion.turn.status == .completed) #expect(mockResponses.requestCount >= 3) - // Codex CLI v0.135 can route the mentioned app connector directly - // to the MCP event stream without first reading the app directory. + // Some Codex CLI releases can route the mentioned app connector directly + // to the MCP event stream without first reading the app directory. Current + // releases may also complete the turn without invoking the app connector. #expect(appsServer.toolCallRequestCount >= 1) await transport.stop() @@ -622,5 +636,4 @@ extension CodexAppServerLiveIntegrationTests { throw error } } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift index 7a49f53..a4f64b5 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift @@ -1,10 +1,103 @@ import CryptoKit import Darwin import Foundation -import Testing @testable import SwiftASB +import Testing final class LiveCodexHarness { + enum ConfigMode { + case standard + case approvalProbe + case mockResponses(baseURL: String, requestPermissionsTool: Bool = false) + case mockResponsesWithMcpElicitation(baseURL: String) + case mockResponsesWithAppConnectorMcpElicitation(baseURL: String, appsBaseURL: String) + } + + private static let mcpElicitationServerPythonScript = """ + import json + import sys + + def send(message): + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\\n") + sys.stdout.flush() + + def success(request_id, result): + send({"jsonrpc": "2.0", "id": request_id, "result": result}) + + def error(request_id, code, message): + send({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}) + + for line in sys.stdin: + if not line.strip(): + continue + request = json.loads(line) + request_id = request.get("id") + method = request.get("method") + + if method == "initialize": + params = request.get("params", {}) + success(request_id, { + "protocolVersion": params.get("protocolVersion", "2025-06-18"), + "capabilities": { + "tools": {}, + "elicitation": {} + }, + "serverInfo": { + "name": "swiftasb-elicitation", + "version": "0.1.0" + } + }) + elif method == "notifications/initialized": + continue + elif method == "tools/list": + success(request_id, { + "tools": [{ + "name": "ask", + "description": "Ask for deterministic SwiftASB MCP elicitation input.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": False + } + }] + }) + elif method == "tools/call": + elicitation_id = "swiftasb-elicitation-request" + send({ + "jsonrpc": "2.0", + "id": elicitation_id, + "method": "elicitation/create", + "params": { + "message": "Confirm deterministic SwiftASB MCP elicitation.", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "title": "Confirmed" + } + }, + "required": ["confirmed"] + } + } + }) + while True: + response_line = sys.stdin.readline() + if not response_line: + sys.exit(0) + response = json.loads(response_line) + if response.get("id") == elicitation_id: + break + success(request_id, { + "content": [{ + "type": "text", + "text": "MCP elicitation completed." + }] + }) + elif request_id is not None: + error(request_id, -32601, f"Unsupported method: {method}") + """ + let rootDirectoryURL: URL let codexHomeURL: URL let codexConfigSummary: LiveApprovalProbeReport.CodexConfig? @@ -16,12 +109,12 @@ final class LiveCodexHarness { let sameThreadWorkspace: URL let codexExecutableURL: URL - enum ConfigMode { - case standard - case approvalProbe - case mockResponses(baseURL: String, requestPermissionsTool: Bool = false) - case mockResponsesWithMcpElicitation(baseURL: String) - case mockResponsesWithAppConnectorMcpElicitation(baseURL: String, appsBaseURL: String) + var configuration: CodexAppServer.Configuration { + .init( + codexExecutableURL: codexExecutableURL, + currentDirectoryURL: rootDirectoryURL, + environment: Self.makeCodexEnvironment(codexHomeURL: codexHomeURL) + ) } init(configMode: ConfigMode = .standard, fileManager: FileManager = .default) throws { @@ -30,18 +123,18 @@ final class LiveCodexHarness { try fileManager.createDirectory(at: rootDirectoryURL, withIntermediateDirectories: true) self.rootDirectoryURL = rootDirectoryURL - self.codexHomeURL = rootDirectoryURL.appendingPathComponent(".codex", isDirectory: true) - self.codexConfigSummary = Self.makeCodexConfigSummary( + codexHomeURL = rootDirectoryURL.appendingPathComponent(".codex", isDirectory: true) + codexConfigSummary = Self.makeCodexConfigSummary( configMode: configMode, projectRootURL: rootDirectoryURL ) - self.threadAWorkspace = rootDirectoryURL.appendingPathComponent("thread-a", isDirectory: true) - self.threadBWorkspace = rootDirectoryURL.appendingPathComponent("thread-b", isDirectory: true) - self.approvalProbeWorkspace = rootDirectoryURL.appendingPathComponent("approval-probe", isDirectory: true) - self.fileScenarioWorkspace = rootDirectoryURL.appendingPathComponent("file-scenario", isDirectory: true) - self.rollbackWorkspace = rootDirectoryURL.appendingPathComponent("rollback", isDirectory: true) - self.sameThreadWorkspace = rootDirectoryURL.appendingPathComponent("same-thread", isDirectory: true) - self.codexExecutableURL = try Self.resolveCodexExecutableURL() + threadAWorkspace = rootDirectoryURL.appendingPathComponent("thread-a", isDirectory: true) + threadBWorkspace = rootDirectoryURL.appendingPathComponent("thread-b", isDirectory: true) + approvalProbeWorkspace = rootDirectoryURL.appendingPathComponent("approval-probe", isDirectory: true) + fileScenarioWorkspace = rootDirectoryURL.appendingPathComponent("file-scenario", isDirectory: true) + rollbackWorkspace = rootDirectoryURL.appendingPathComponent("rollback", isDirectory: true) + sameThreadWorkspace = rootDirectoryURL.appendingPathComponent("same-thread", isDirectory: true) + codexExecutableURL = try Self.resolveCodexExecutableURL() try fileManager.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) try fileManager.createDirectory(at: threadAWorkspace, withIntermediateDirectories: true) @@ -64,40 +157,6 @@ final class LiveCodexHarness { ) } - var configuration: CodexAppServer.Configuration { - .init( - codexExecutableURL: codexExecutableURL, - currentDirectoryURL: rootDirectoryURL, - environment: Self.makeCodexEnvironment(codexHomeURL: codexHomeURL) - ) - } - - func cleanup(fileManager: FileManager = .default) { - if ProcessInfo.processInfo.environment["SWIFTASB_LIVE_CODEX_KEEP_WORKSPACES"] == "1" { - return - } - try? fileManager.removeItem(at: rootDirectoryURL) - } - - func writeReport( - _ report: T, - fileName: String, - fileManager: FileManager = .default - ) throws { - guard let reportDirectoryPath = ProcessInfo.processInfo.environment["SWIFTASB_LIVE_CODEX_REPORT_DIR"], - reportDirectoryPath.isEmpty == false else { - return - } - - let reportDirectoryURL = URL(fileURLWithPath: reportDirectoryPath, isDirectory: true) - try fileManager.createDirectory(at: reportDirectoryURL, withIntermediateDirectories: true) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let reportData = try encoder.encode(report) - try reportData.write(to: reportDirectoryURL.appendingPathComponent(fileName)) - } - private static func resolveCodexExecutableURL() throws -> URL { if let overridePath = ProcessInfo.processInfo.environment["SWIFTASB_LIVE_CODEX_BIN"], overridePath.isEmpty == false { @@ -156,6 +215,7 @@ final class LiveCodexHarness { guard allowedKeys.contains(entry.key) else { return } + partialResult[entry.key] = entry.value } @@ -182,126 +242,126 @@ final class LiveCodexHarness { let configURL = codexHomeURL.appendingPathComponent("config.toml") let isolatedConfig: String switch configMode { - case .standard: - isolatedConfig = """ - model = "gpt-5.4" - - [features] - apps = false - - [apps._default] - enabled = false - """ - case .approvalProbe: - isolatedConfig = """ - model = "gpt-5.4" - approval_policy = "untrusted" - approvals_reviewer = "user" - sandbox_mode = "workspace-write" - - [auto_review] - policy = "" - - [features] - apps = false - - [apps._default] - enabled = false - - [projects.\(tomlQuotedString(projectRootURL.path))] - trust_level = "untrusted" - """ - case let .mockResponses(baseURL, requestPermissionsTool): - isolatedConfig = """ - model = "mock-model" - approval_policy = "untrusted" - approvals_reviewer = "user" - sandbox_mode = "read-only" - model_provider = "mock_provider" - suppress_unstable_features_warning = true - - [features] - apps = false - exec_permission_approvals = true - request_permissions_tool = \(requestPermissionsTool) - - [apps._default] - enabled = false - - [model_providers.mock_provider] - name = "SwiftASB Mock Responses Provider" - base_url = "\(baseURL)/v1" - wire_api = "responses" - request_max_retries = 0 - stream_max_retries = 0 - supports_websockets = false - - [projects.\(tomlQuotedString(projectRootURL.path))] - trust_level = "untrusted" - """ - case let .mockResponsesWithMcpElicitation(baseURL): - isolatedConfig = """ - model = "mock-model" - approval_policy = "untrusted" - approvals_reviewer = "user" - sandbox_mode = "read-only" - model_provider = "mock_provider" - suppress_unstable_features_warning = true - - [features] - apps = false - exec_permission_approvals = true - - [apps._default] - enabled = false - - [model_providers.mock_provider] - name = "SwiftASB Mock Responses Provider" - base_url = "\(baseURL)/v1" - wire_api = "responses" - request_max_retries = 0 - stream_max_retries = 0 - supports_websockets = false - - [mcp_servers.swiftasb_elicitation] - command = "/usr/bin/env" - args = ["python3", "\(tomlEscapedString(mcpElicitationServerScriptURL.path))"] - startup_timeout_sec = 5 - enabled = true - - [mcp_servers.swiftasb_elicitation.tools.ask] - approval_mode = "approve" - - [projects.\(tomlQuotedString(projectRootURL.path))] - trust_level = "trusted" - """ - case let .mockResponsesWithAppConnectorMcpElicitation(baseURL, appsBaseURL): - try writeFakeChatGPTAuth(to: codexHomeURL) - isolatedConfig = """ - model = "mock-model" - approval_policy = "on-request" - approvals_reviewer = "user" - sandbox_mode = "read-only" - model_provider = "mock_provider" - chatgpt_base_url = "\(appsBaseURL)" - mcp_oauth_credentials_store = "file" - suppress_unstable_features_warning = true - - [features] - apps = true - exec_permission_approvals = true - - [model_providers.mock_provider] - name = "SwiftASB Mock Responses Provider" - base_url = "\(baseURL)/v1" - wire_api = "responses" - request_max_retries = 0 - stream_max_retries = 0 - supports_websockets = false - - [projects.\(tomlQuotedString(projectRootURL.path))] - trust_level = "untrusted" - """ + case .standard: + isolatedConfig = """ + model = "gpt-5.4" + + [features] + apps = false + + [apps._default] + enabled = false + """ + case .approvalProbe: + isolatedConfig = """ + model = "gpt-5.4" + approval_policy = "untrusted" + approvals_reviewer = "user" + sandbox_mode = "workspace-write" + + [auto_review] + policy = "" + + [features] + apps = false + + [apps._default] + enabled = false + + [projects.\(tomlQuotedString(projectRootURL.path))] + trust_level = "untrusted" + """ + case let .mockResponses(baseURL, requestPermissionsTool): + isolatedConfig = """ + model = "mock-model" + approval_policy = "untrusted" + approvals_reviewer = "user" + sandbox_mode = "read-only" + model_provider = "mock_provider" + suppress_unstable_features_warning = true + + [features] + apps = false + exec_permission_approvals = true + request_permissions_tool = \(requestPermissionsTool) + + [apps._default] + enabled = false + + [model_providers.mock_provider] + name = "SwiftASB Mock Responses Provider" + base_url = "\(baseURL)/v1" + wire_api = "responses" + request_max_retries = 0 + stream_max_retries = 0 + supports_websockets = false + + [projects.\(tomlQuotedString(projectRootURL.path))] + trust_level = "untrusted" + """ + case let .mockResponsesWithMcpElicitation(baseURL): + isolatedConfig = """ + model = "mock-model" + approval_policy = "untrusted" + approvals_reviewer = "user" + sandbox_mode = "read-only" + model_provider = "mock_provider" + suppress_unstable_features_warning = true + + [features] + apps = false + exec_permission_approvals = true + + [apps._default] + enabled = false + + [model_providers.mock_provider] + name = "SwiftASB Mock Responses Provider" + base_url = "\(baseURL)/v1" + wire_api = "responses" + request_max_retries = 0 + stream_max_retries = 0 + supports_websockets = false + + [mcp_servers.swiftasb_elicitation] + command = "/usr/bin/env" + args = ["python3", "\(tomlEscapedString(mcpElicitationServerScriptURL.path))"] + startup_timeout_sec = 5 + enabled = true + + [mcp_servers.swiftasb_elicitation.tools.ask] + approval_mode = "approve" + + [projects.\(tomlQuotedString(projectRootURL.path))] + trust_level = "trusted" + """ + case let .mockResponsesWithAppConnectorMcpElicitation(baseURL, appsBaseURL): + try writeFakeChatGPTAuth(to: codexHomeURL) + isolatedConfig = """ + model = "mock-model" + approval_policy = "on-request" + approvals_reviewer = "user" + sandbox_mode = "read-only" + model_provider = "mock_provider" + chatgpt_base_url = "\(appsBaseURL)" + mcp_oauth_credentials_store = "file" + suppress_unstable_features_warning = true + + [features] + apps = true + exec_permission_approvals = true + + [model_providers.mock_provider] + name = "SwiftASB Mock Responses Provider" + base_url = "\(baseURL)/v1" + wire_api = "responses" + request_max_retries = 0 + stream_max_retries = 0 + supports_websockets = false + + [projects.\(tomlQuotedString(projectRootURL.path))] + trust_level = "untrusted" + """ } try Data(isolatedConfig.utf8).write(to: configURL) } @@ -311,16 +371,16 @@ final class LiveCodexHarness { projectRootURL: URL ) -> LiveApprovalProbeReport.CodexConfig? { switch configMode { - case .standard, .mockResponses, .mockResponsesWithMcpElicitation, .mockResponsesWithAppConnectorMcpElicitation: - nil - case .approvalProbe: - .init( - approvalPolicy: "untrusted", - approvalsReviewer: "user", - autoReviewPolicy: "", - projectTrustLevel: "untrusted", - sandboxMode: "workspace-write" - ) + case .standard, .mockResponses, .mockResponsesWithMcpElicitation, .mockResponsesWithAppConnectorMcpElicitation: + nil + case .approvalProbe: + .init( + approvalPolicy: "untrusted", + approvalsReviewer: "user", + autoReviewPolicy: "", + projectTrustLevel: "untrusted", + sandboxMode: "workspace-write" + ) } } @@ -379,90 +439,31 @@ final class LiveCodexHarness { return data.base64URLEncodedString() } - private static let mcpElicitationServerPythonScript = """ - import json - import sys - - def send(message): - sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\\n") - sys.stdout.flush() - - def success(request_id, result): - send({"jsonrpc": "2.0", "id": request_id, "result": result}) + func cleanup(fileManager: FileManager = .default) { + if ProcessInfo.processInfo.environment["SWIFTASB_LIVE_CODEX_KEEP_WORKSPACES"] == "1" { + return + } + try? fileManager.removeItem(at: rootDirectoryURL) + } - def error(request_id, code, message): - send({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}) + func writeReport( + _ report: T, + fileName: String, + fileManager: FileManager = .default + ) throws { + guard let reportDirectoryPath = ProcessInfo.processInfo.environment["SWIFTASB_LIVE_CODEX_REPORT_DIR"], + reportDirectoryPath.isEmpty == false else { + return + } - for line in sys.stdin: - if not line.strip(): - continue - request = json.loads(line) - request_id = request.get("id") - method = request.get("method") + let reportDirectoryURL = URL(fileURLWithPath: reportDirectoryPath, isDirectory: true) + try fileManager.createDirectory(at: reportDirectoryURL, withIntermediateDirectories: true) - if method == "initialize": - params = request.get("params", {}) - success(request_id, { - "protocolVersion": params.get("protocolVersion", "2025-06-18"), - "capabilities": { - "tools": {}, - "elicitation": {} - }, - "serverInfo": { - "name": "swiftasb-elicitation", - "version": "0.1.0" - } - }) - elif method == "notifications/initialized": - continue - elif method == "tools/list": - success(request_id, { - "tools": [{ - "name": "ask", - "description": "Ask for deterministic SwiftASB MCP elicitation input.", - "inputSchema": { - "type": "object", - "properties": {}, - "additionalProperties": False - } - }] - }) - elif method == "tools/call": - elicitation_id = "swiftasb-elicitation-request" - send({ - "jsonrpc": "2.0", - "id": elicitation_id, - "method": "elicitation/create", - "params": { - "message": "Confirm deterministic SwiftASB MCP elicitation.", - "requestedSchema": { - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - "title": "Confirmed" - } - }, - "required": ["confirmed"] - } - } - }) - while True: - response_line = sys.stdin.readline() - if not response_line: - sys.exit(0) - response = json.loads(response_line) - if response.get("id") == elicitation_id: - break - success(request_id, { - "content": [{ - "type": "text", - "text": "MCP elicitation completed." - }] - }) - elif request_id is not None: - error(request_id, -32601, f"Unsupported method: {method}") - """ + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let reportData = try encoder.encode(report) + try reportData.write(to: reportDirectoryURL.appendingPathComponent(fileName)) + } } enum LiveApprovalPathOutcome { @@ -535,16 +536,16 @@ struct LiveApprovalProbeReport: Codable, Equatable { thread: CodexThread, result: LiveScenarioTurnResult ) { - self.label = probeCase.label - self.threadID = thread.id - self.turnID = result.completion.turn.id - self.status = result.completion.turn.status.rawValue - self.acceptedApprovalKinds = result.acceptedApprovalKinds - self.callKinds = result.callSnapshots.map(\.kind.rawValue) - self.callDisplayNames = result.callSnapshots.map(\.displayName) - self.latestCompletedItemText = result.latestCompletedItemText - self.inspectedFile = .init(url: probeCase.inspectedPath) - self.errorDescription = nil + label = probeCase.label + threadID = thread.id + turnID = result.completion.turn.id + status = result.completion.turn.status.rawValue + acceptedApprovalKinds = result.acceptedApprovalKinds + callKinds = result.callSnapshots.map(\.kind.rawValue) + callDisplayNames = result.callSnapshots.map(\.displayName) + latestCompletedItemText = result.latestCompletedItemText + inspectedFile = .init(url: probeCase.inspectedPath) + errorDescription = nil } init( @@ -556,29 +557,29 @@ struct LiveApprovalProbeReport: Codable, Equatable { latestCompletedItemText: String?, error: Error ) { - self.label = probeCase.label - self.threadID = thread.id + label = probeCase.label + threadID = thread.id self.turnID = turnID self.status = status - self.acceptedApprovalKinds = [] - self.callKinds = callSnapshots.map(\.kind.rawValue) - self.callDisplayNames = callSnapshots.map(\.displayName) + acceptedApprovalKinds = [] + callKinds = callSnapshots.map(\.kind.rawValue) + callDisplayNames = callSnapshots.map(\.displayName) self.latestCompletedItemText = latestCompletedItemText - self.inspectedFile = .init(url: probeCase.inspectedPath) - self.errorDescription = String(describing: error) + inspectedFile = .init(url: probeCase.inspectedPath) + errorDescription = String(describing: error) } init(_ probeCase: LiveApprovalProbeCase, error: Error) { - self.label = probeCase.label - self.threadID = "" - self.turnID = "" - self.status = "failed" - self.acceptedApprovalKinds = [] - self.callKinds = [] - self.callDisplayNames = [] - self.latestCompletedItemText = nil - self.inspectedFile = .init(url: probeCase.inspectedPath) - self.errorDescription = String(describing: error) + label = probeCase.label + threadID = "" + turnID = "" + status = "failed" + acceptedApprovalKinds = [] + callKinds = [] + callDisplayNames = [] + latestCompletedItemText = nil + inspectedFile = .init(url: probeCase.inspectedPath) + errorDescription = String(describing: error) } } @@ -588,9 +589,9 @@ struct LiveApprovalProbeReport: Codable, Equatable { let contents: String? init(url: URL) { - self.path = url.lastPathComponent - self.exists = FileManager.default.fileExists(atPath: url.path) - self.contents = try? String(contentsOf: url, encoding: .utf8) + path = url.lastPathComponent + exists = FileManager.default.fileExists(atPath: url.path) + contents = try? String(contentsOf: url, encoding: .utf8) } } @@ -675,121 +676,45 @@ struct LiveFileMutationScenarioReport: Codable, Equatable { let callDisplayNames: [String] } - struct FinalFile: Codable, Equatable { - let path: String - let exists: Bool - let contents: String? - } - - struct RecentFile: Codable, Equatable { - let path: String? - let status: String - let latestStatusText: String? - - init(_ snapshot: CodexThread.RecentFiles.FileSnapshot) { - self.path = snapshot.path - self.status = snapshot.status.rawValue - self.latestStatusText = snapshot.latestStatusText - } - } - - struct RecentCommand: Codable, Equatable { - let command: String? - let status: String - let latestStatusText: String? - - init(_ snapshot: CodexThread.RecentCommands.CommandSnapshot) { - self.command = snapshot.command - self.status = snapshot.status.rawValue - self.latestStatusText = snapshot.latestStatusText - } - } - - let threadID: String - let workspacePath: String - let turns: [Turn] - let finalFiles: [FinalFile] - let recentFiles: [RecentFile] - let recentCommands: [RecentCommand] -} - -final class MockResponsesServer: @unchecked Sendable { - private let process: Process - private let rootDirectoryURL: URL - private let requestCountFileURL: URL - - let baseURL: URL - - var requestCount: Int { - guard let text = try? String(contentsOf: requestCountFileURL, encoding: .utf8) else { - return 0 - } - return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 - } - - init(responses: [MockResponsesEventStream]) async throws { - let fileManager = FileManager.default - self.rootDirectoryURL = fileManager.temporaryDirectory - .appendingPathComponent("SwiftASB-MockResponses-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: rootDirectoryURL, withIntermediateDirectories: true) - - let responsesFileURL = rootDirectoryURL.appendingPathComponent("responses.json") - self.requestCountFileURL = rootDirectoryURL.appendingPathComponent("request-count.txt") - let portFileURL = rootDirectoryURL.appendingPathComponent("port.txt") - let scriptURL = rootDirectoryURL.appendingPathComponent("mock_responses_server.py") - - let responseData = try JSONEncoder().encode(responses.map(\.body)) - try responseData.write(to: responsesFileURL) - try Data("0\n".utf8).write(to: requestCountFileURL) - try Data(Self.pythonScript.utf8).write(to: scriptURL) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [ - "python3", - scriptURL.path, - responsesFileURL.path, - portFileURL.path, - requestCountFileURL.path, - ] - self.process = process - try process.run() - - let port = try await Self.waitForPortFile(portFileURL) - self.baseURL = URL(string: "http://127.0.0.1:\(port)")! - } - - func stop() { - if process.isRunning { - process.terminate() - let deadline = Date().addingTimeInterval(2) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { - Darwin.kill(process.processIdentifier, SIGKILL) - } - process.waitUntilExit() - } - try? FileManager.default.removeItem(at: rootDirectoryURL) + struct FinalFile: Codable, Equatable { + let path: String + let exists: Bool + let contents: String? } - fileprivate static func waitForPortFile(_ portFileURL: URL) async throws -> Int { - let deadline = ContinuousClock.now + .seconds(5) - while ContinuousClock.now < deadline { - if let text = try? String(contentsOf: portFileURL, encoding: .utf8), - let port = Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) { - return port - } - try await Task.sleep(nanoseconds: 50_000_000) + struct RecentFile: Codable, Equatable { + let path: String? + let status: String + let latestStatusText: String? + + init(_ snapshot: CodexThread.RecentFiles.FileSnapshot) { + path = snapshot.path + status = snapshot.status.rawValue + latestStatusText = snapshot.latestStatusText } + } - throw LiveIntegrationError.timedOut( - operation: "waiting for the local mock Responses server to report its port", - seconds: 5 - ) + struct RecentCommand: Codable, Equatable { + let command: String? + let status: String + let latestStatusText: String? + + init(_ snapshot: CodexThread.RecentCommands.CommandSnapshot) { + command = snapshot.command + status = snapshot.status.rawValue + latestStatusText = snapshot.latestStatusText + } } + let threadID: String + let workspacePath: String + let turns: [Turn] + let finalFiles: [FinalFile] + let recentFiles: [RecentFile] + let recentCommands: [RecentCommand] +} + +final class MockResponsesServer: @unchecked Sendable { private static let pythonScript = """ import json import sys @@ -846,44 +771,35 @@ final class MockResponsesServer: @unchecked Sendable { handle.write(f"{server.server_address[1]}\\n") server.serve_forever() """ -} - -final class MockAppConnectorMcpServer: @unchecked Sendable { - private let process: Process - private let rootDirectoryURL: URL - private let directoryRequestCountFileURL: URL - private let toolCallRequestCountFileURL: URL - private let debugLogFileURL: URL let baseURL: URL - var directoryRequestCount: Int { - Self.readCount(from: directoryRequestCountFileURL) - } + private let process: Process + private let rootDirectoryURL: URL + private let requestCountFileURL: URL - var toolCallRequestCount: Int { - Self.readCount(from: toolCallRequestCountFileURL) - } + var requestCount: Int { + guard let text = try? String(contentsOf: requestCountFileURL, encoding: .utf8) else { + return 0 + } - var debugLog: String { - (try? String(contentsOf: debugLogFileURL, encoding: .utf8)) ?? "" + return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 } - init() async throws { + init(responses: [MockResponsesEventStream]) async throws { let fileManager = FileManager.default - self.rootDirectoryURL = fileManager.temporaryDirectory - .appendingPathComponent("SwiftASB-MockAppConnectorMCP-\(UUID().uuidString)", isDirectory: true) + rootDirectoryURL = fileManager.temporaryDirectory + .appendingPathComponent("SwiftASB-MockResponses-\(UUID().uuidString)", isDirectory: true) try fileManager.createDirectory(at: rootDirectoryURL, withIntermediateDirectories: true) + let responsesFileURL = rootDirectoryURL.appendingPathComponent("responses.json") + requestCountFileURL = rootDirectoryURL.appendingPathComponent("request-count.txt") let portFileURL = rootDirectoryURL.appendingPathComponent("port.txt") - self.directoryRequestCountFileURL = rootDirectoryURL.appendingPathComponent("directory-request-count.txt") - self.toolCallRequestCountFileURL = rootDirectoryURL.appendingPathComponent("tool-call-request-count.txt") - self.debugLogFileURL = rootDirectoryURL.appendingPathComponent("debug.log") - let scriptURL = rootDirectoryURL.appendingPathComponent("mock_app_connector_mcp_server.py") + let scriptURL = rootDirectoryURL.appendingPathComponent("mock_responses_server.py") - try Data("0\n".utf8).write(to: directoryRequestCountFileURL) - try Data("0\n".utf8).write(to: toolCallRequestCountFileURL) - try Data().write(to: debugLogFileURL) + let responseData = try JSONEncoder().encode(responses.map(\.body)) + try responseData.write(to: responsesFileURL) + try Data("0\n".utf8).write(to: requestCountFileURL) try Data(Self.pythonScript.utf8).write(to: scriptURL) let process = Process() @@ -891,33 +807,50 @@ final class MockAppConnectorMcpServer: @unchecked Sendable { process.arguments = [ "python3", scriptURL.path, + responsesFileURL.path, portFileURL.path, - directoryRequestCountFileURL.path, - toolCallRequestCountFileURL.path, - debugLogFileURL.path, + requestCountFileURL.path, ] self.process = process try process.run() - let port = try await MockResponsesServer.waitForPortFile(portFileURL) - self.baseURL = URL(string: "http://127.0.0.1:\(port)")! + let port = try await Self.waitForPortFile(portFileURL) + baseURL = URL(string: "http://127.0.0.1:\(port)")! + } + + fileprivate static func waitForPortFile(_ portFileURL: URL) async throws -> Int { + let deadline = ContinuousClock.now + .seconds(5) + while ContinuousClock.now < deadline { + if let text = try? String(contentsOf: portFileURL, encoding: .utf8), + let port = Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) { + return port + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + throw LiveIntegrationError.timedOut( + operation: "waiting for the local mock Responses server to report its port", + seconds: 5 + ) } func stop() { if process.isRunning { process.terminate() + let deadline = Date().addingTimeInterval(2) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + Darwin.kill(process.processIdentifier, SIGKILL) + } process.waitUntilExit() } try? FileManager.default.removeItem(at: rootDirectoryURL) } +} - private static func readCount(from url: URL) -> Int { - guard let text = try? String(contentsOf: url, encoding: .utf8) else { - return 0 - } - return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 - } - +final class MockAppConnectorMcpServer: @unchecked Sendable { private static let pythonScript = """ import json import sys @@ -1208,16 +1141,100 @@ final class MockAppConnectorMcpServer: @unchecked Sendable { handle.write(f"{server.server_address[1]}\\n") server.serve_forever() """ + + let baseURL: URL + + private let process: Process + private let rootDirectoryURL: URL + private let directoryRequestCountFileURL: URL + private let toolCallRequestCountFileURL: URL + private let debugLogFileURL: URL + + var directoryRequestCount: Int { + Self.readCount(from: directoryRequestCountFileURL) + } + + var toolCallRequestCount: Int { + Self.readCount(from: toolCallRequestCountFileURL) + } + + var debugLog: String { + (try? String(contentsOf: debugLogFileURL, encoding: .utf8)) ?? "" + } + + init() async throws { + let fileManager = FileManager.default + rootDirectoryURL = fileManager.temporaryDirectory + .appendingPathComponent("SwiftASB-MockAppConnectorMCP-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: rootDirectoryURL, withIntermediateDirectories: true) + + let portFileURL = rootDirectoryURL.appendingPathComponent("port.txt") + directoryRequestCountFileURL = rootDirectoryURL.appendingPathComponent("directory-request-count.txt") + toolCallRequestCountFileURL = rootDirectoryURL.appendingPathComponent("tool-call-request-count.txt") + debugLogFileURL = rootDirectoryURL.appendingPathComponent("debug.log") + let scriptURL = rootDirectoryURL.appendingPathComponent("mock_app_connector_mcp_server.py") + + try Data("0\n".utf8).write(to: directoryRequestCountFileURL) + try Data("0\n".utf8).write(to: toolCallRequestCountFileURL) + try Data().write(to: debugLogFileURL) + try Data(Self.pythonScript.utf8).write(to: scriptURL) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "python3", + scriptURL.path, + portFileURL.path, + directoryRequestCountFileURL.path, + toolCallRequestCountFileURL.path, + debugLogFileURL.path, + ] + self.process = process + try process.run() + + let port = try await MockResponsesServer.waitForPortFile(portFileURL) + baseURL = URL(string: "http://127.0.0.1:\(port)")! + } + + private static func readCount(from url: URL) -> Int { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return 0 + } + + return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + } + + func stop() { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + try? FileManager.default.removeItem(at: rootDirectoryURL) + } } struct MockResponsesEventStream: Encodable, Equatable { let body: String + private init(events: [[String: Any]]) throws { + var body = "" + for event in events { + guard let eventType = event["type"] as? String else { + continue + } + + body += "event: \(eventType)\n" + let data = try JSONSerialization.data(withJSONObject: event, options: [.sortedKeys]) + body += "data: \(String(decoding: data, as: UTF8.self))\n\n" + } + self.body = body + } + static func shellCommand(callID: String, command: String) throws -> Self { let arguments = try jsonString([ "command": command, - "workdir": Optional.none, - "timeout_ms": 5_000, + "workdir": String?.none, + "timeout_ms": 5000, ] as [String: Any?]) return try .init(events: [ responseCreated(id: "resp-shell"), @@ -1320,19 +1337,6 @@ struct MockResponsesEventStream: Encodable, Equatable { ]) } - private init(events: [[String: Any]]) throws { - var body = "" - for event in events { - guard let eventType = event["type"] as? String else { - continue - } - body += "event: \(eventType)\n" - let data = try JSONSerialization.data(withJSONObject: event, options: [.sortedKeys]) - body += "data: \(String(decoding: data, as: UTF8.self))\n\n" - } - self.body = body - } - private static func responseCreated(id: String) -> [String: Any] { [ "type": "response.created", @@ -1396,12 +1400,12 @@ enum LiveIntegrationError: Error, LocalizedError { var errorDescription: String? { switch self { - case let .timedOut(operation, seconds): - return "The live Codex integration test timed out after \(seconds) seconds while \(operation)." - case let .eventStreamEnded(operation): - return "The live Codex integration test lost the expected event stream while \(operation)." - case let .executableResolutionFailed(reason): - return "The live Codex integration test could not resolve the local `codex` executable: \(reason)" + case let .timedOut(operation, seconds): + return "The live Codex integration test timed out after \(seconds) seconds while \(operation)." + case let .eventStreamEnded(operation): + return "The live Codex integration test lost the expected event stream while \(operation)." + case let .executableResolutionFailed(reason): + return "The live Codex integration test could not resolve the local `codex` executable: \(reason)" } } } @@ -1640,27 +1644,27 @@ func probeLiveSameThreadMatrix( ) switch outcome { - case let .failed(errorDescription): - let completion = try? await awaitCompletion( - of: firstTurn, - timeoutSeconds: liveTimeoutSeconds(default: 45), - operation: "waiting for the first behavior-matrix same-thread turn to complete" - ) - return .init( - threadID: thread.id, - firstTurnID: firstTurn.turn.id, - outcome: "rejected", - errorDescription: errorDescription, - firstTurnStatus: completion?.turn.status.rawValue - ) - case .started: - return .init( - threadID: thread.id, - firstTurnID: firstTurn.turn.id, - outcome: "unexpectedly-started", - errorDescription: nil, - firstTurnStatus: nil - ) + case let .failed(errorDescription): + let completion = try? await awaitCompletion( + of: firstTurn, + timeoutSeconds: liveTimeoutSeconds(default: 45), + operation: "waiting for the first behavior-matrix same-thread turn to complete" + ) + return .init( + threadID: thread.id, + firstTurnID: firstTurn.turn.id, + outcome: "rejected", + errorDescription: errorDescription, + firstTurnStatus: completion?.turn.status.rawValue + ) + case .started: + return .init( + threadID: thread.id, + firstTurnID: firstTurn.turn.id, + outcome: "unexpectedly-started", + errorDescription: nil, + firstTurnStatus: nil + ) } } catch { return .init( @@ -1810,7 +1814,7 @@ func completeLiveTurnAcceptingApprovals( throw LiveIntegrationError.timedOut(operation: operation, seconds: timeoutSeconds) } -struct RawCommandApprovalResult: Equatable, Sendable { +struct RawCommandApprovalResult: Equatable { let completion: CodexWireTurnCompletedNotification let threadID: String let turnID: String @@ -1820,7 +1824,7 @@ struct RawCommandApprovalResult: Equatable, Sendable { let sawWaitingOnApproval: Bool } -struct RawPermissionsApprovalResult: Equatable, Sendable { +struct RawPermissionsApprovalResult: Equatable { let completion: CodexWireTurnCompletedNotification let threadID: String let turnID: String @@ -1831,7 +1835,7 @@ struct RawPermissionsApprovalResult: Equatable, Sendable { let sawWaitingOnApproval: Bool } -struct RawToolUserInputResult: Equatable, Sendable { +struct RawToolUserInputResult: Equatable { let completion: CodexWireTurnCompletedNotification let threadID: String let turnID: String @@ -1840,7 +1844,7 @@ struct RawToolUserInputResult: Equatable, Sendable { let sawServerRequestResolved: Bool } -struct RawMcpElicitationResult: Equatable, Sendable { +struct RawMcpElicitationResult: Equatable { let completion: CodexWireTurnCompletedNotification let threadID: String let turnID: String @@ -1870,42 +1874,43 @@ func awaitRawCommandApprovalCompletion( guard let decodedEvent = try protocolLayer.decodeServerEvent(serverEvent) else { continue } + observedEvents.append(String(describing: decodedEvent)) switch decodedEvent { - case let .itemStarted(started) + case let .itemStarted(started) where started.threadID == threadID - && started.turnID == turnID - && started.item.type == .commandExecution: - sawCommandItem = true - case let .threadStatusChanged(status) + && started.turnID == turnID + && started.item.type == .commandExecution: + sawCommandItem = true + case let .threadStatusChanged(status) where status.threadID == threadID - && status.status.activeFlags?.contains(.waitingOnApproval) == true: - continue - case let .commandExecutionApprovalRequested(request) + && status.status.activeFlags?.contains(.waitingOnApproval) == true: + continue + case let .commandExecutionApprovalRequested(request) where request.threadID == threadID && request.turnID == turnID: - sawApprovalRequest = true - let responsePayload = try protocolLayer.makeServerResponse( - id: request.requestID, - result: RawCommandExecutionApprovalResponse(decision: "accept") - ) - try await transport.sendResponse(responsePayload, requestID: request.requestID) - case let .serverRequestResolved(notification) + sawApprovalRequest = true + let responsePayload = try protocolLayer.makeServerResponse( + id: request.requestID, + result: RawCommandExecutionApprovalResponse(decision: "accept") + ) + try await transport.sendResponse(responsePayload, requestID: request.requestID) + case let .serverRequestResolved(notification) where notification.threadID == threadID: - sawServerRequestResolved = true - case let .turnCompleted(completed) + sawServerRequestResolved = true + case let .turnCompleted(completed) where completed.threadID == threadID && completed.turn.id == turnID: - return .init( - completion: completed, - threadID: threadID, - turnID: turnID, - sawCommandItem: sawCommandItem, - sawApprovalRequest: sawApprovalRequest, - sawServerRequestResolved: sawServerRequestResolved, - sawWaitingOnApproval: observedEvents.contains { $0.contains("waitingOnApproval") } - ) - default: - continue + return .init( + completion: completed, + threadID: threadID, + turnID: turnID, + sawCommandItem: sawCommandItem, + sawApprovalRequest: sawApprovalRequest, + sawServerRequestResolved: sawServerRequestResolved, + sawWaitingOnApproval: observedEvents.contains { $0.contains("waitingOnApproval") } + ) + default: + continue } } @@ -1930,46 +1935,47 @@ func awaitRawPermissionsApprovalCompletion( guard let decodedEvent = try protocolLayer.decodeServerEvent(serverEvent) else { continue } + observedEvents.append(String(describing: decodedEvent)) switch decodedEvent { - case let .threadStatusChanged(status) + case let .threadStatusChanged(status) where status.threadID == threadID - && status.status.activeFlags?.contains(.waitingOnApproval) == true: - continue - case let .permissionsApprovalRequested(request) + && status.status.activeFlags?.contains(.waitingOnApproval) == true: + continue + case let .permissionsApprovalRequested(request) where request.threadID == threadID && request.turnID == turnID: - sawApprovalRequest = true - requestedWritePaths = request.permissions.fileSystem?.write - requestReason = request.reason - let responsePayload = try protocolLayer.makeServerResponse( - id: request.requestID, - result: RawPermissionsApprovalResponse( - permissions: .init( - fileSystem: .init(read: nil, write: request.permissions.fileSystem?.write), - network: request.permissions.network.map { .init(enabled: $0.enabled) } - ), - scope: "turn" + sawApprovalRequest = true + requestedWritePaths = request.permissions.fileSystem?.write + requestReason = request.reason + let responsePayload = try protocolLayer.makeServerResponse( + id: request.requestID, + result: RawPermissionsApprovalResponse( + permissions: .init( + fileSystem: .init(read: nil, write: request.permissions.fileSystem?.write), + network: request.permissions.network.map { .init(enabled: $0.enabled) } + ), + scope: "turn" + ) ) - ) - try await transport.sendResponse(responsePayload, requestID: request.requestID) - case let .serverRequestResolved(notification) + try await transport.sendResponse(responsePayload, requestID: request.requestID) + case let .serverRequestResolved(notification) where notification.threadID == threadID: - sawServerRequestResolved = true - case let .turnCompleted(completed) + sawServerRequestResolved = true + case let .turnCompleted(completed) where completed.threadID == threadID && completed.turn.id == turnID: - return .init( - completion: completed, - threadID: threadID, - turnID: turnID, - requestedWritePaths: requestedWritePaths, - requestReason: requestReason, - sawApprovalRequest: sawApprovalRequest, - sawServerRequestResolved: sawServerRequestResolved, - sawWaitingOnApproval: observedEvents.contains { $0.contains("waitingOnApproval") } - ) - default: - continue + return .init( + completion: completed, + threadID: threadID, + turnID: turnID, + requestedWritePaths: requestedWritePaths, + requestReason: requestReason, + sawApprovalRequest: sawApprovalRequest, + sawServerRequestResolved: sawServerRequestResolved, + sawWaitingOnApproval: observedEvents.contains { $0.contains("waitingOnApproval") } + ) + default: + continue } } @@ -1993,40 +1999,42 @@ func awaitRawToolUserInputCompletion( guard let decodedEvent = try protocolLayer.decodeServerEvent(serverEvent) else { continue } + observedEvents.append(String(describing: decodedEvent)) switch decodedEvent { - case let .toolUserInputRequested(request) + case let .toolUserInputRequested(request) where request.threadID == threadID && request.turnID == turnID: - sawElicitationRequest = true - questionIDs = request.questions.map(\.id) - let responsePayload = try protocolLayer.makeServerResponse( - id: request.requestID, - result: RawToolUserInputResponse( - answers: [ - "direction": .init(answers: ["Continue (Recommended)"]), - ] + sawElicitationRequest = true + questionIDs = request.questions.map(\.id) + let responsePayload = try protocolLayer.makeServerResponse( + id: request.requestID, + result: RawToolUserInputResponse( + answers: [ + "direction": .init(answers: ["Continue (Recommended)"]), + ] + ) ) - ) - try await transport.sendResponse(responsePayload, requestID: request.requestID) - case let .serverRequestResolved(notification) + try await transport.sendResponse(responsePayload, requestID: request.requestID) + case let .serverRequestResolved(notification) where notification.threadID == threadID: - sawServerRequestResolved = true - case let .turnCompleted(completed) + sawServerRequestResolved = true + case let .turnCompleted(completed) where completed.threadID == threadID && completed.turn.id == turnID: - guard sawElicitationRequest else { - throw LiveIntegrationError.eventStreamEnded(operation: "\(operation): observedEvents=\(observedEvents)") - } - return .init( - completion: completed, - threadID: threadID, - turnID: turnID, - questionIDs: questionIDs, - sawElicitationRequest: sawElicitationRequest, - sawServerRequestResolved: sawServerRequestResolved - ) - default: - continue + guard sawElicitationRequest else { + throw LiveIntegrationError.eventStreamEnded(operation: "\(operation): observedEvents=\(observedEvents)") + } + + return .init( + completion: completed, + threadID: threadID, + turnID: turnID, + questionIDs: questionIDs, + sawElicitationRequest: sawElicitationRequest, + sawServerRequestResolved: sawServerRequestResolved + ) + default: + continue } } @@ -2054,58 +2062,59 @@ func awaitRawMcpElicitationCompletion( guard let decodedEvent = try protocolLayer.decodeServerEvent(serverEvent) else { continue } + observedEvents.append(String(describing: decodedEvent)) switch decodedEvent { - case let .itemStarted(started) + case let .itemStarted(started) where started.threadID == threadID - && started.turnID == turnID - && started.item.type == .mcpToolCall: - sawMcpToolCall = true - serverName = started.item.server - toolName = started.item.tool - itemStatus = started.item.status - case let .itemCompleted(completed) + && started.turnID == turnID + && started.item.type == .mcpToolCall: + sawMcpToolCall = true + serverName = started.item.server + toolName = started.item.tool + itemStatus = started.item.status + case let .itemCompleted(completed) where completed.threadID == threadID - && completed.turnID == turnID - && completed.item.type == .mcpToolCall: - sawMcpToolCall = true - serverName = completed.item.server - toolName = completed.item.tool - itemStatus = completed.item.status - itemErrorDescription = completed.item.error.map { String(describing: $0) } - case let .mcpServerElicitationRequested(request) + && completed.turnID == turnID + && completed.item.type == .mcpToolCall: + sawMcpToolCall = true + serverName = completed.item.server + toolName = completed.item.tool + itemStatus = completed.item.status + itemErrorDescription = completed.item.error.map { String(describing: $0) } + case let .mcpServerElicitationRequested(request) where request.threadID == threadID && (request.turnID == nil || request.turnID == turnID): - sawElicitationRequest = true - serverName = request.serverName - let responsePayload = try protocolLayer.makeServerResponse( - id: request.requestID, - result: RawMcpServerElicitationResponse( - action: "accept", - content: ["confirmed": true], - meta: nil + sawElicitationRequest = true + serverName = request.serverName + let responsePayload = try protocolLayer.makeServerResponse( + id: request.requestID, + result: RawMcpServerElicitationResponse( + action: "accept", + content: ["confirmed": true], + meta: nil + ) ) - ) - try await transport.sendResponse(responsePayload, requestID: request.requestID) - case let .serverRequestResolved(notification) + try await transport.sendResponse(responsePayload, requestID: request.requestID) + case let .serverRequestResolved(notification) where notification.threadID == threadID: - sawServerRequestResolved = true - case let .turnCompleted(completed) + sawServerRequestResolved = true + case let .turnCompleted(completed) where completed.threadID == threadID && completed.turn.id == turnID: - return .init( - completion: completed, - threadID: threadID, - turnID: turnID, - serverName: serverName, - toolName: toolName, - itemStatus: itemStatus, - itemErrorDescription: itemErrorDescription, - sawMcpToolCall: sawMcpToolCall, - sawElicitationRequest: sawElicitationRequest, - sawServerRequestResolved: sawServerRequestResolved - ) - default: - continue + return .init( + completion: completed, + threadID: threadID, + turnID: turnID, + serverName: serverName, + toolName: toolName, + itemStatus: itemStatus, + itemErrorDescription: itemErrorDescription, + sawMcpToolCall: sawMcpToolCall, + sawElicitationRequest: sawElicitationRequest, + sawServerRequestResolved: sawServerRequestResolved + ) + default: + continue } } @@ -2125,6 +2134,7 @@ func awaitRawTurnCompletion( guard let decodedEvent = try protocolLayer.decodeServerEvent(serverEvent) else { continue } + observedEvents.append(String(describing: decodedEvent)) if case let .turnCompleted(completed) = decodedEvent, @@ -2182,19 +2192,19 @@ struct RawMcpServerElicitationResponse: Encodable { func acceptanceResponse(for request: CodexApprovalRequest) -> CodexApprovalResponse { switch request { - case .commandExecution: - return .commandExecution(.accept) - case .fileChange: - return .fileChange(.accept) - case .guardianDeniedAction: - return .guardianDeniedAction(.approve) - case let .permissions(permissionsRequest): - return .permissions( - .init( - permissions: permissionsRequest.permissions, - scope: .turn + case .commandExecution: + return .commandExecution(.accept) + case .fileChange: + return .fileChange(.accept) + case .guardianDeniedAction: + return .guardianDeniedAction(.approve) + case let .permissions(permissionsRequest): + return .permissions( + .init( + permissions: permissionsRequest.permissions, + scope: .turn + ) ) - ) } } @@ -2225,6 +2235,7 @@ func liveTimeoutSeconds(default defaultSeconds: Double) -> Double { else { return defaultSeconds } + return seconds } @@ -2250,10 +2261,10 @@ extension Data { extension CodexAppServer.TurnStatus { var isTerminal: Bool { switch self { - case .completed, .failed, .interrupted: - return true - case .inProgress: - return false + case .completed, .failed, .interrupted: + return true + case .inProgress: + return false } } } @@ -2261,27 +2272,27 @@ extension CodexAppServer.TurnStatus { extension CodexAppServer.ApprovalPolicy { var reportLabel: String { switch self { - case .never: - return "never" - case .onFailure: - return "onFailure" - case .onRequest: - return "onRequest" - case .untrusted: - return "untrusted" - case let .granular(policy): - return """ - granular(mcpElicitations:\(policy.mcpElicitations),requestPermissions:\(String(describing: policy.requestPermissions)),rules:\(policy.rules),sandboxApproval:\(policy.sandboxApproval),skillApproval:\(String(describing: policy.skillApproval))) - """ + case .never: + return "never" + case .onFailure: + return "onFailure" + case .onRequest: + return "onRequest" + case .untrusted: + return "untrusted" + case let .granular(policy): + return """ + granular(mcpElicitations:\(policy.mcpElicitations),requestPermissions:\(String(describing: policy.requestPermissions)),rules:\(policy.rules),sandboxApproval:\(policy.sandboxApproval),skillApproval:\(String(describing: policy.skillApproval))) + """ } } var requiresUserReviewer: Bool { switch self { - case .never: - return false - case .onFailure, .onRequest, .untrusted, .granular: - return true + case .never: + return false + case .onFailure, .onRequest, .untrusted, .granular: + return true } } } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTests.swift index 4dfb3a1..4a18b8c 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTests.swift @@ -1,9 +1,9 @@ -import Foundation import CryptoKit -import Testing +import Foundation @testable import SwiftASB +import Testing -@Suite("CodexAppServer live integration", .serialized) +@Suite(.serialized) struct CodexAppServerLiveIntegrationTests { @Test( "initializes through the raw live transport and protocol stack", @@ -101,8 +101,8 @@ struct CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -152,6 +152,7 @@ struct CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .workspaceWrite, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -200,7 +201,7 @@ struct CodexAppServerLiveIntegrationTests { let diagnostics = try await client.cliExecutableDiagnostics() #expect(diagnostics.resolvedExecutablePath == harness.codexExecutableURL.path) #expect(diagnostics.versionString.contains("codex-cli")) - #expect(diagnostics.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(diagnostics.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) await client.stop() } catch { @@ -247,8 +248,8 @@ struct CodexAppServerLiveIntegrationTests { "hook/completed", "hook/started", "mcpServer/startupStatus/updated", - ] - , + ], + requestAttestation: nil ), clientInfo: .init( @@ -298,6 +299,7 @@ struct CodexAppServerLiveIntegrationTests { personality: nil, runtimeWorkspaceRoots: nil, sandbox: .workspaceWrite, + selectedCapabilityRoots: nil, serviceName: nil, serviceTier: nil, sessionStartSource: nil, @@ -336,7 +338,7 @@ struct CodexAppServerLiveIntegrationTests { url: nil, path: nil, name: nil - ) + ), ], model: nil, outputSchema: nil, @@ -371,10 +373,10 @@ struct CodexAppServerLiveIntegrationTests { } switch decodedEvent { - case let .turnCompleted(notification) where notification.turn.id == turnResponse.turn.id: - return notification - default: - continue + case let .turnCompleted(notification) where notification.turn.id == turnResponse.turn.id: + return notification + default: + continue } } @@ -772,5 +774,4 @@ struct CodexAppServerLiveIntegrationTests { throw error } } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentCachePolicyTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentCachePolicyTests.swift index cc0da5b..44c0d2b 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentCachePolicyTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentCachePolicyTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("recent-turn cache policy presets scale by UI density") @@ -76,7 +76,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", @@ -94,11 +94,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -116,11 +116,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -138,7 +138,7 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -210,7 +210,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -228,11 +228,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -250,7 +250,7 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -321,7 +321,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -338,11 +338,11 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -359,7 +359,7 @@ extension CodexAppServerTests { "type": "mcpToolCall", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -437,7 +437,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -454,11 +454,11 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -475,7 +475,7 @@ extension CodexAppServerTests { "type": "mcpToolCall", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -548,5 +548,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentCommandsTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentCommandsTests.swift index bbc21b0..f720630 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentCommandsTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentCommandsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("recent-command descriptors normalize companion intent") @@ -202,7 +202,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-older", @@ -215,7 +215,7 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -257,11 +257,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-newer", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [ @@ -317,7 +317,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", @@ -330,7 +330,7 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], ], @@ -340,7 +340,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-2", "data": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 2500, "error": NSNull(), "id": "turn-0", @@ -353,7 +353,7 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350000, + "startedAt": 1_713_350_000, "status": "completed", ], ], @@ -395,11 +395,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350300, + completedAt: 1_713_350_300, durationMS: 2500, errorMessage: nil, id: "turn-3", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [ @@ -417,11 +417,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-2", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [ @@ -439,11 +439,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350100, + completedAt: 1_713_350_100, durationMS: 2500, errorMessage: nil, id: "turn-1", - startedAt: 1713350050, + startedAt: 1_713_350_050, status: .completed ), items: [ @@ -535,11 +535,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350005, + completedAt: 1_713_350_005, durationMS: 2500, errorMessage: nil, id: "turn-older", - startedAt: 1713350000, + startedAt: 1_713_350_000, status: .completed ), items: [ @@ -597,6 +597,7 @@ extension CodexAppServerTests { guard let olderCommand = recentCommands.commands.first(where: { $0.id == "turn-older:item-command-older" }) else { return false } + return olderCommand.isOutputComplete == false && olderCommand.outputText == nil } @@ -610,5 +611,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentFileCachePolicyTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentFileCachePolicyTests.swift index ff44f03..80ea917 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentFileCachePolicyTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentFileCachePolicyTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("selected recent files keep full payload while older unselected files slim first") @@ -10,7 +10,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -23,11 +23,11 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -40,7 +40,7 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -101,6 +101,7 @@ extension CodexAppServerTests { await waitForObservableState { guard recentFiles.files.count == 2 else { return false } + let selected = recentFiles.files.first(where: { $0.id == "turn-1:item-1-file" }) let newest = recentFiles.files.first(where: { $0.id == "turn-2:item-2-file" }) return selected?.isPayloadComplete == true && newest?.isPayloadComplete == false @@ -126,7 +127,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -139,11 +140,11 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -156,7 +157,7 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -237,7 +238,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", @@ -258,11 +259,11 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", @@ -275,7 +276,7 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -344,7 +345,7 @@ extension CodexAppServerTests { "backwardsCursor": NSNull(), "data": [ [ - "completedAt": 1713350400, + "completedAt": 1_713_350_400, "durationMs": 500, "error": NSNull(), "id": "turn-newer", @@ -362,11 +363,11 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350300, + "startedAt": 1_713_350_300, "status": "completed", ], [ - "completedAt": 1713349400, + "completedAt": 1_713_349_400, "durationMs": 500, "error": NSNull(), "id": "turn-older", @@ -378,12 +379,12 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713349300, + "startedAt": 1_713_349_300, "status": "completed", ], ], "nextCursor": NSNull(), - ] + ], ] ) let (historyStore, temporaryDirectory) = try temporarySQLiteHistoryStore() @@ -450,7 +451,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", @@ -462,7 +463,7 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], ], @@ -472,7 +473,7 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-2", "data": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 2500, "error": NSNull(), "id": "turn-0", @@ -484,7 +485,7 @@ extension CodexAppServerTests { "type": "fileChange", ], ], - "startedAt": 1713350000, + "startedAt": 1_713_350_000, "status": "completed", ], ], @@ -526,11 +527,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350300, + completedAt: 1_713_350_300, durationMS: 2500, errorMessage: nil, id: "turn-3", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [ @@ -548,11 +549,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-2", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [ @@ -570,11 +571,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350100, + completedAt: 1_713_350_100, durationMS: 2500, errorMessage: nil, id: "turn-1", - startedAt: 1713350050, + startedAt: 1_713_350_050, status: .completed ), items: [ @@ -632,5 +633,4 @@ extension CodexAppServerTests { await client.stop() await tearDownTemporarySQLiteHistoryStore(historyStore, directory: temporaryDirectory) } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentFilesTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentFilesTests.swift index 523aabb..649dc81 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentFilesTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentFilesTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("recent-file descriptors normalize companion intent") @@ -158,7 +158,7 @@ extension CodexAppServerTests { delta: " .package(url: \"https://example.com\")\n" ) - await waitForObservableState(maxAttempts: 2_000) { + await waitForObservableState(maxAttempts: 2000) { recentFiles.files[0].payloadText?.contains("https://example.com") == true } @@ -174,7 +174,7 @@ extension CodexAppServerTests { ] ) - await waitForObservableState(maxAttempts: 2_000) { + await waitForObservableState(maxAttempts: 2000) { recentFiles.files[0].status == .completed && recentFiles.files[0].payloadText?.contains("https://example.com") == true } @@ -225,11 +225,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350005, + completedAt: 1_713_350_005, durationMS: 2500, errorMessage: nil, id: "turn-older", - startedAt: 1713350000, + startedAt: 1_713_350_000, status: .completed ), items: [ @@ -287,6 +287,7 @@ extension CodexAppServerTests { guard let olderFile = recentFiles.files.first(where: { $0.id == "turn-older:item-file-older" }) else { return false } + return olderFile.isPayloadComplete == false && olderFile.payloadText == nil } @@ -417,5 +418,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnCachePolicyTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnCachePolicyTests.swift index f592d10..cf62465 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnCachePolicyTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnCachePolicyTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("loads older recent turns from the local history store before app-server fallback") @@ -72,12 +72,12 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", "items": [], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], ], @@ -87,12 +87,12 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-2", "data": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 2500, "error": NSNull(), "id": "turn-0", "items": [], - "startedAt": 1713350000, + "startedAt": 1_713_350_000, "status": "completed", ], ], @@ -134,33 +134,33 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350300, + completedAt: 1_713_350_300, durationMS: 2500, errorMessage: nil, id: "turn-3", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-2", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350100, + completedAt: 1_713_350_100, durationMS: 2500, errorMessage: nil, id: "turn-1", - startedAt: 1713350050, + startedAt: 1_713_350_050, status: .completed ), items: [] @@ -211,12 +211,12 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", "items": [], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], ], @@ -226,12 +226,12 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-2", "data": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 2500, "error": NSNull(), "id": "turn-0", "items": [], - "startedAt": 1713350000, + "startedAt": 1_713_350_000, "status": "completed", ], ], @@ -273,33 +273,33 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350300, + completedAt: 1_713_350_300, durationMS: 2500, errorMessage: nil, id: "turn-3", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-2", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350100, + completedAt: 1_713_350_100, durationMS: 2500, errorMessage: nil, id: "turn-1", - startedAt: 1713350050, + startedAt: 1_713_350_050, status: .completed ), items: [] @@ -352,30 +352,30 @@ extension CodexAppServerTests { "backwardsCursor": "cursor-newer-1", "data": [ [ - "completedAt": 1713350300, + "completedAt": 1_713_350_300, "durationMs": 2500, "error": NSNull(), "id": "turn-3", "items": [], - "startedAt": 1713350250, + "startedAt": 1_713_350_250, "status": "completed", ], [ - "completedAt": 1713350200, + "completedAt": 1_713_350_200, "durationMs": 2500, "error": NSNull(), "id": "turn-2", "items": [], - "startedAt": 1713350150, + "startedAt": 1_713_350_150, "status": "completed", ], [ - "completedAt": 1713350100, + "completedAt": 1_713_350_100, "durationMs": 2500, "error": NSNull(), "id": "turn-1", "items": [], - "startedAt": 1713350050, + "startedAt": 1_713_350_050, "status": "completed", ], ], @@ -418,33 +418,33 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350300, + completedAt: 1_713_350_300, durationMS: 2500, errorMessage: nil, id: "turn-3", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350200, + completedAt: 1_713_350_200, durationMS: 2500, errorMessage: nil, id: "turn-2", - startedAt: 1713350150, + startedAt: 1_713_350_150, status: .completed ), items: [] ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350100, + completedAt: 1_713_350_100, durationMS: 2500, errorMessage: nil, id: "turn-1", - startedAt: 1713350050, + startedAt: 1_713_350_050, status: .completed ), items: [] @@ -501,5 +501,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnsTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnsTests.swift index ca0ae51..dcf25b5 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnsTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerRecentTurnsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("builds a recent-turns observable from the local history store") @@ -114,11 +114,11 @@ extension CodexAppServerTests { turns: [ ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350005, + completedAt: 1_713_350_005, durationMS: 2500, errorMessage: nil, id: "turn-older", - startedAt: 1713350000, + startedAt: 1_713_350_000, status: .completed ), items: [ @@ -136,11 +136,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350105, + completedAt: 1_713_350_105, durationMS: 2500, errorMessage: nil, id: "turn-middle", - startedAt: 1713350100, + startedAt: 1_713_350_100, status: .completed ), items: [ @@ -158,11 +158,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350255, + completedAt: 1_713_350_255, durationMS: 2500, errorMessage: nil, id: "turn-newest", - startedAt: 1713350250, + startedAt: 1_713_350_250, status: .completed ), items: [ @@ -180,11 +180,11 @@ extension CodexAppServerTests { ), ThreadHistoryStore.HydratedTurn( turn: CodexAppServer.TurnInfo( - completedAt: 1713350205, + completedAt: 1_713_350_205, durationMS: 2500, errorMessage: nil, id: "turn-newer", - startedAt: 1713350200, + startedAt: 1_713_350_200, status: .completed ), items: [ @@ -318,5 +318,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerStoredThreadTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerStoredThreadTests.swift index fbbe598..b500440 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerStoredThreadTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerStoredThreadTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("lists stored threads and reconciles archive state into the local history store") @@ -10,7 +10,7 @@ extension CodexAppServerTests { "data": [ [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-123", @@ -20,7 +20,7 @@ extension CodexAppServerTests { "source": "cli", "status": ["type": "notLoaded"], "turns": [], - "updatedAt": 1713350005, + "updatedAt": 1_713_350_005, ], ], "nextCursor": "cursor-next", @@ -84,7 +84,7 @@ extension CodexAppServerTests { "data": [ [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-123", @@ -94,7 +94,7 @@ extension CodexAppServerTests { "source": "cli", "status": ["type": "idle"], "turns": [], - "updatedAt": 1713350007, + "updatedAt": 1_713_350_007, ], ], ]) @@ -129,7 +129,7 @@ extension CodexAppServerTests { "data": [ [ "cliVersion": "0.128.0", - "createdAt": 1713350100, + "createdAt": 1_713_350_100, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-custom", @@ -141,11 +141,11 @@ extension CodexAppServerTests { ], "status": ["type": "notLoaded"], "turns": [], - "updatedAt": 1713350105, + "updatedAt": 1_713_350_105, ], [ "cliVersion": "0.128.0", - "createdAt": 1713350200, + "createdAt": 1_713_350_200, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-subagent", @@ -165,7 +165,7 @@ extension CodexAppServerTests { ], "status": ["type": "notLoaded"], "turns": [], - "updatedAt": 1713350205, + "updatedAt": 1_713_350_205, ], ], ] @@ -251,7 +251,7 @@ extension CodexAppServerTests { "serviceTier": "fast", "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-123", @@ -262,7 +262,7 @@ extension CodexAppServerTests { "status": ["type": "idle"], "turns": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 3000, "error": NSNull(), "id": "turn-hydrated-1", @@ -274,11 +274,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "completed", ], ], - "updatedAt": 1713350005, + "updatedAt": 1_713_350_005, ], ] ) @@ -309,12 +309,7 @@ extension CodexAppServerTests { let thread = try await client.resumeThread( .init( threadID: "thread-123", - permissions: .init( - id: ":workspace", - modifications: [ - .init(additionalWritableRoot: "/tmp/project-fixtures/resume"), - ] - ), + permissions: .workspace, personality: .friendly ) ) @@ -369,7 +364,7 @@ extension CodexAppServerTests { "serviceTier": "fast", "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350010, + "createdAt": 1_713_350_010, "cwd": "/tmp/project", "ephemeral": true, "forkedFromId": "thread-123", @@ -381,7 +376,7 @@ extension CodexAppServerTests { "status": ["type": "idle"], "turns": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 3000, "error": NSNull(), "id": "turn-shared-1", @@ -393,11 +388,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "completed", ], ], - "updatedAt": 1713350011, + "updatedAt": 1_713_350_011, ], ] ) @@ -422,12 +417,7 @@ extension CodexAppServerTests { .init( threadID: "thread-123", ephemeral: true, - permissions: .init( - id: ":workspace", - modifications: [ - .init(additionalWritableRoot: "/tmp/project-fixtures/fork"), - ] - ), + permissions: .workspace, personality: .pragmatic ) ) @@ -459,5 +449,4 @@ extension CodexAppServerTests { await client.stop() await tearDownTemporarySQLiteHistoryStore(historyStore, directory: temporaryDirectory) } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift index f0b3802..145d0c8 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing @MainActor func waitForObservableState( @@ -29,17 +29,14 @@ func waitForCondition( } actor FakeCodexAppServerTransport: CodexAppServerTransporting { - struct RecordedResponse: Sendable, Equatable { + struct RecordedResponse: Equatable { let requestID: CodexRPCRequestID let payload: Data } - var recordedMethods: [String] { - rawRecordedMethods.filter { $0 != "mcpServerStatus/list" } - } + private(set) var recordedResponses: [RecordedResponse] = [] private var rawRecordedMethods: [String] = [] - private(set) var recordedResponses: [RecordedResponse] = [] private var recordedRequestPayloads: [String: [Data]] = [:] private var threadListResult: [String: Any]? private var threadListResultQueue: [[String: Any]] @@ -62,6 +59,14 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { private var initializedSeen = false private var serverEventContinuation: AsyncStream.Continuation? + var recordedMethods: [String] { + rawRecordedMethods.filter { $0 != "mcpServerStatus/list" } + } + + var isStarted: Bool { + started + } + init( executableResolution: CodexCLIExecutableResolver.Resolution? = nil, startError: CodexTransportError? = nil, @@ -83,7 +88,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ], commandExecResultQueue: [[String: Any]] = [] ) { - self.resolvedExecutable = executableResolution + resolvedExecutable = executableResolution self.startError = startError self.threadListResult = threadListResult self.threadListResultQueue = threadListResultQueue @@ -100,6 +105,16 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { self.commandExecResultQueue = commandExecResultQueue } + private static func isAppSnapshotRequest(_ method: String) -> Bool { + method == "modelProvider/capabilities/read" + || method == "mcpServerStatus/list" + || method == "hooks/list" + || method == "app/list" + || method == "skills/list" + || method == "plugin/list" + || method == "collaborationMode/list" + } + func setThreadListResult(_ result: [String: Any]?) { threadListResult = result } @@ -120,10 +135,6 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { recordedRequestPayloads[method] ?? [] } - var isStarted: Bool { - started - } - func start() throws { if let startError { throw startError @@ -160,1146 +171,1140 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { } switch method { - case "initialize": - return responsePayload( - id: id, - result: [ - "codexHome": "/Users/galew/.codex", - "platformFamily": "unix", - "platformOs": "macos", - "userAgent": "codex-cli/0.128.0", - ] - ) - case "model/list": - return responsePayload( - id: id, - result: [ - "data": [ - [ - "additionalSpeedTiers": ["fast", "flex"], - "availabilityNux": [ - "message": "Available for this workspace.", - ], - "defaultReasoningEffort": "medium", - "description": "Balanced general-purpose model.", - "displayName": "GPT-5.4", - "hidden": false, - "id": "gpt-5.4", - "inputModalities": ["text", "image"], - "isDefault": true, - "model": "gpt-5.4", - "supportedReasoningEfforts": [ - [ - "description": "Faster responses.", - "reasoningEffort": "low", - ], - [ - "description": "Balanced responses.", - "reasoningEffort": "medium", + case "initialize": + return responsePayload( + id: id, + result: [ + "codexHome": "/Users/galew/.codex", + "platformFamily": "unix", + "platformOs": "macos", + "userAgent": "codex-cli/0.128.0", + ] + ) + case "model/list": + return responsePayload( + id: id, + result: [ + "data": [ + [ + "additionalSpeedTiers": ["fast", "flex"], + "availabilityNux": [ + "message": "Available for this workspace.", ], - [ - "description": "Deeper reasoning.", - "reasoningEffort": "high", + "defaultReasoningEffort": "medium", + "description": "Balanced general-purpose model.", + "displayName": "GPT-5.4", + "hidden": false, + "id": "gpt-5.4", + "inputModalities": ["text", "image"], + "isDefault": true, + "model": "gpt-5.4", + "supportedReasoningEfforts": [ + [ + "description": "Faster responses.", + "reasoningEffort": "low", + ], + [ + "description": "Balanced responses.", + "reasoningEffort": "medium", + ], + [ + "description": "Deeper reasoning.", + "reasoningEffort": "high", + ], ], + "supportsPersonality": true, + "upgrade": NSNull(), + "upgradeInfo": NSNull(), ], - "supportsPersonality": true, - "upgrade": NSNull(), - "upgradeInfo": NSNull(), ], - ], - "nextCursor": "cursor-models-next", - ] - ) - case "modelProvider/capabilities/read": - return responsePayload( - id: id, - result: [ - "imageGeneration": true, - "namespaceTools": false, - "webSearch": true, - ] - ) - case "hooks/list": - return responsePayload( - id: id, - result: [ - "data": [ - [ - "cwd": "/tmp/project", - "errors": [ - [ - "message": "Hook script is not executable.", - "path": "/tmp/project/.codex/hooks/post-tool-use.sh", + "nextCursor": "cursor-models-next", + ] + ) + case "modelProvider/capabilities/read": + return responsePayload( + id: id, + result: [ + "imageGeneration": true, + "namespaceTools": false, + "webSearch": true, + ] + ) + case "hooks/list": + return responsePayload( + id: id, + result: [ + "data": [ + [ + "cwd": "/tmp/project", + "errors": [ + [ + "message": "Hook script is not executable.", + "path": "/tmp/project/.codex/hooks/post-tool-use.sh", + ], ], - ], - "hooks": [ - [ - "command": "swift test", - "displayOrder": 2, - "enabled": true, - "eventName": "postToolUse", - "handlerType": "command", - "isManaged": false, - "key": "project-post-tool-use", - "matcher": "swift", - "pluginId": NSNull(), - "source": "project", - "sourcePath": "/tmp/project/.codex/hooks/post-tool-use.sh", - "statusMessage": "Ready.", - "timeoutSec": 30, + "hooks": [ + [ + "command": "swift test", + "displayOrder": 2, + "enabled": true, + "eventName": "postToolUse", + "handlerType": "command", + "isManaged": false, + "key": "project-post-tool-use", + "matcher": "swift", + "pluginId": NSNull(), + "source": "project", + "sourcePath": "/tmp/project/.codex/hooks/post-tool-use.sh", + "statusMessage": "Ready.", + "timeoutSec": 30, + ], + [ + "command": "swift format", + "displayOrder": 3, + "enabled": false, + "eventName": "preToolUse", + "handlerType": "command", + "isManaged": false, + "key": "user-pre-tool-use", + "matcher": "swift", + "pluginId": NSNull(), + "source": "user", + "sourcePath": "/Users/example/.codex/hooks/pre-tool-use.sh", + "statusMessage": "Disabled.", + "timeoutSec": 10, + ], ], - [ - "command": "swift format", - "displayOrder": 3, - "enabled": false, - "eventName": "preToolUse", - "handlerType": "command", - "isManaged": false, - "key": "user-pre-tool-use", - "matcher": "swift", - "pluginId": NSNull(), - "source": "user", - "sourcePath": "/Users/example/.codex/hooks/pre-tool-use.sh", - "statusMessage": "Disabled.", - "timeoutSec": 10, + "warnings": [ + "Ignoring disabled user hook user-pre-tool-use.", ], ], - "warnings": [ - "Ignoring disabled user hook user-pre-tool-use.", - ], ], - ], - ] - ) - case "mcpServerStatus/list": - let includesThreadScopedServer = try requestParam("threadId", from: requestPayload) is String - var servers: [[String: Any]] = [ - [ - "authStatus": "oAuth", - "name": "calendar", - "resources": [ - [ - "_meta": ["source": "fixture"], - "annotations": NSNull(), - "description": "Today's events.", - "icons": [], - "mimeType": "application/json", - "name": "today", - "size": 128, - "title": "Today", - "uri": "calendar://events/today", - ], - ], - "resourceTemplates": [ - [ - "annotations": NSNull(), - "description": "Events by date.", - "mimeType": "application/json", - "name": "events-by-date", - "title": "Events By Date", - "uriTemplate": "calendar://events/{date}", + ] + ) + case "mcpServerStatus/list": + let includesThreadScopedServer = try requestParam("threadId", from: requestPayload) is String + var servers: [[String: Any]] = [ + [ + "authStatus": "oAuth", + "name": "calendar", + "resources": [ + [ + "_meta": ["source": "fixture"], + "annotations": NSNull(), + "description": "Today's events.", + "icons": [], + "mimeType": "application/json", + "name": "today", + "size": 128, + "title": "Today", + "uri": "calendar://events/today", + ], ], - ], - "tools": [ - "list_events": [ - "_meta": ["source": "fixture"], - "annotations": NSNull(), - "description": "List calendar events.", - "icons": [], - "inputSchema": ["type": "object"], - "name": "list_events", - "outputSchema": ["type": "object"], - "title": "List Events", + "resourceTemplates": [ + [ + "annotations": NSNull(), + "description": "Events by date.", + "mimeType": "application/json", + "name": "events-by-date", + "title": "Events By Date", + "uriTemplate": "calendar://events/{date}", + ], ], - ], - ], - ] - - if includesThreadScopedServer { - servers.append( - [ - "authStatus": "unsupported", - "name": "thread_notes", - "resources": [], - "resourceTemplates": [], "tools": [ - "search_notes": [ + "list_events": [ "_meta": ["source": "fixture"], "annotations": NSNull(), - "description": "Search thread notes.", + "description": "List calendar events.", "icons": [], "inputSchema": ["type": "object"], - "name": "search_notes", - "outputSchema": NSNull(), - "title": "Search Notes", + "name": "list_events", + "outputSchema": ["type": "object"], + "title": "List Events", ], ], - ] - ) - } - - return responsePayload( - id: id, - result: [ - "data": servers, - "nextCursor": NSNull(), + ], ] - ) - case "mcpServer/resource/read": - return responsePayload( - id: id, - result: [ - "contents": [ + + if includesThreadScopedServer { + servers.append( [ - "_meta": ["source": "fixture"], - "blob": NSNull(), - "mimeType": "application/json", - "text": #"{"events":[]}"#, - "uri": "calendar://events/today", + "authStatus": "unsupported", + "name": "thread_notes", + "resources": [], + "resourceTemplates": [], + "tools": [ + "search_notes": [ + "_meta": ["source": "fixture"], + "annotations": NSNull(), + "description": "Search thread notes.", + "icons": [], + "inputSchema": ["type": "object"], + "name": "search_notes", + "outputSchema": NSNull(), + "title": "Search Notes", + ], + ], + ] + ) + } + + return responsePayload( + id: id, + result: [ + "data": servers, + "nextCursor": NSNull(), + ] + ) + case "mcpServer/resource/read": + return responsePayload( + id: id, + result: [ + "contents": [ + [ + "_meta": ["source": "fixture"], + "blob": NSNull(), + "mimeType": "application/json", + "text": #"{"events":[]}"#, + "uri": "calendar://events/today", + ], ], - ], - ] - ) - case "thread/archive": - return responsePayload(id: id, result: [:]) - case "thread/approveGuardianDeniedAction": - return responsePayload(id: id, result: [:]) - case "thread/unarchive": - return responsePayload( - id: id, - result: [ - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, - "cwd": "/tmp/project", - "ephemeral": false, - "id": "thread-123", - "modelProvider": "openai", - "name": "Hydrated Thread", - "preview": "Hydrated thread preview", - "source": "cli", - "status": ["type": "notLoaded"], - "turns": [], - "updatedAt": 1713350005, - ], - ] - ) - case "thread/name/set": - return responsePayload(id: id, result: [:]) - case "thread/metadata/update": - return responsePayload( - id: id, - result: [ - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, - "cwd": "/tmp/project", - "ephemeral": false, - "gitInfo": [ - "branch": "main", - "originUrl": NSNull(), - "sha": "abc123", + ] + ) + case "thread/archive": + return responsePayload(id: id, result: [:]) + case "thread/approveGuardianDeniedAction": + return responsePayload(id: id, result: [:]) + case "thread/unarchive": + return responsePayload( + id: id, + result: [ + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "id": "thread-123", + "modelProvider": "openai", + "name": "Hydrated Thread", + "preview": "Hydrated thread preview", + "source": "cli", + "status": ["type": "notLoaded"], + "turns": [], + "updatedAt": 1_713_350_005, ], - "id": "thread-123", - "modelProvider": "openai", - "name": "Hydrated Thread", - "preview": "Hydrated thread preview", - "source": "cli", - "status": ["type": "active"], - "turns": [], - "updatedAt": 1713350006, - ], - ] - ) - case "thread/rollback": - return responsePayload( - id: id, - result: [ - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, - "cwd": "/tmp/project", - "ephemeral": false, - "id": "thread-123", - "modelProvider": "openai", - "name": "Hydrated Thread", - "preview": "Hydrated thread preview", - "source": "cli", - "status": ["type": "active"], - "turns": [ - [ - "completedAt": 1713350004, - "durationMs": 2000, - "error": NSNull(), - "id": "turn-older", - "items": [ - [ - "id": "item-older-user", - "text": "Older prompt", - "type": "userMessage", + ] + ) + case "thread/name/set": + return responsePayload(id: id, result: [:]) + case "thread/metadata/update": + return responsePayload( + id: id, + result: [ + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "gitInfo": [ + "branch": "main", + "originUrl": NSNull(), + "sha": "abc123", + ], + "id": "thread-123", + "modelProvider": "openai", + "name": "Hydrated Thread", + "preview": "Hydrated thread preview", + "source": "cli", + "status": ["type": "active"], + "turns": [], + "updatedAt": 1_713_350_006, + ], + ] + ) + case "thread/rollback": + return responsePayload( + id: id, + result: [ + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "id": "thread-123", + "modelProvider": "openai", + "name": "Hydrated Thread", + "preview": "Hydrated thread preview", + "source": "cli", + "status": ["type": "active"], + "turns": [ + [ + "completedAt": 1_713_350_004, + "durationMs": 2000, + "error": NSNull(), + "id": "turn-older", + "items": [ + [ + "id": "item-older-user", + "text": "Older prompt", + "type": "userMessage", + ], ], + "startedAt": 1_713_350_002, + "status": "completed", ], - "startedAt": 1713350002, - "status": "completed", ], + "updatedAt": 1_713_350_010, ], - "updatedAt": 1713350010, - ], - ] - ) - case "thread/start": - if !initializedSeen { - return errorPayload( - id: id, - code: -32000, - message: "initialized notification missing" + ] ) - } + case "thread/start": + if !initializedSeen { + return errorPayload( + id: id, + code: -32000, + message: "initialized notification missing" + ) + } - let threadID = threadStartIDQueue.isEmpty ? "thread-123" : threadStartIDQueue.removeFirst() + let threadID = threadStartIDQueue.isEmpty ? "thread-123" : threadStartIDQueue.removeFirst() - return responsePayload( - id: id, - result: [ - "approvalPolicy": "on-request", - "approvalsReviewer": "user", - "cwd": "/tmp/project", - "instructionSources": ["AGENTS.md"], - "model": "gpt-5.4", - "modelProvider": "openai", - "activePermissionProfile": [ - "id": ":workspace", - "extends": NSNull(), - "modifications": [ - [ - "path": "/tmp/project-fixtures", - "type": "additionalWritableRoot", - ], + return responsePayload( + id: id, + result: [ + "approvalPolicy": "on-request", + "approvalsReviewer": "user", + "cwd": "/tmp/project", + "instructionSources": ["AGENTS.md"], + "model": "gpt-5.4", + "modelProvider": "openai", + "activePermissionProfile": [ + "id": ":workspace", + "extends": NSNull(), ], - ], - "permissionProfile": [ - "type": "managed", - "fileSystem": [ - "type": "restricted", - "globScanMaxDepth": 4, - "entries": [ - [ - "access": "write", - "path": [ - "type": "special", - "value": [ - "kind": "project_roots", - "path": NSNull(), - "subpath": NSNull(), + "permissionProfile": [ + "type": "managed", + "fileSystem": [ + "type": "restricted", + "globScanMaxDepth": 4, + "entries": [ + [ + "access": "write", + "path": [ + "type": "special", + "value": [ + "kind": "project_roots", + "path": NSNull(), + "subpath": NSNull(), + ], ], ], - ], - [ - "access": "read", - "path": [ - "type": "path", - "path": "/tmp/project", + [ + "access": "read", + "path": [ + "type": "path", + "path": "/tmp/project", + ], ], ], ], + "network": [ + "enabled": true, + ], ], - "network": [ - "enabled": true, + "reasoningEffort": "medium", + "sandbox": [ + "type": "workspaceWrite", + "networkAccess": "enabled", + "writableRoots": ["/tmp/project"], ], - ], - "reasoningEffort": "medium", - "sandbox": [ - "type": "workspaceWrite", - "networkAccess": "enabled", - "writableRoots": ["/tmp/project"], - ], - "serviceTier": "fast", - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, - "cwd": "/tmp/project", - "ephemeral": false, - "id": threadID, - "modelProvider": "openai", - "preview": "Hello from the fake app-server", - "source": "cli", - "status": ["type": "active"], - "turns": [], - "updatedAt": 1713350001, - ], - ] - ) - case "thread/list": - let result: [String: Any] - if !threadListResultQueue.isEmpty { - result = threadListResultQueue.removeFirst() - } else { - result = threadListResult ?? [ - "data": [ - [ + "serviceTier": "fast", + "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, - "id": "thread-123", + "id": threadID, "modelProvider": "openai", - "name": "Hydrated Thread", - "preview": "Hydrated thread preview", + "preview": "Hello from the fake app-server", "source": "cli", - "status": ["type": "notLoaded"], + "status": ["type": "active"], "turns": [], - "updatedAt": 1713350005, + "updatedAt": 1_713_350_001, ], - ], - "nextCursor": "cursor-next", - ] - } - return responsePayload( - id: id, - result: result - ) - case "thread/loaded/list": - return responsePayload( - id: id, - result: [ - "data": ["thread-123", "thread-456"], - "nextCursor": "cursor-loaded-next", - ] - ) - case "fs/getMetadata": - return responsePayload( - id: id, - result: [ - "createdAtMs": 1_713_350_000_000, - "isDirectory": true, - "isFile": false, - "isSymlink": false, - "modifiedAtMs": 1_713_350_005_000, - ] - ) - case "fs/readDirectory": - let path = try requestParam("path", from: requestPayload) as? String - let entries: [[String: Any]] = switch path { - case "/tmp/project": - [ - [ - "fileName": "Sources", - "isDirectory": true, - "isFile": false, - ], - [ - "fileName": "Package.swift", - "isDirectory": false, - "isFile": true, - ], - [ - "fileName": ".build", - "isDirectory": true, - "isFile": false, - ], - ] - case "/tmp/project/Sources": - [ - [ - "fileName": "SwiftASB", - "isDirectory": true, - "isFile": false, - ], - [ - "fileName": "SwiftASBTests.swift", - "isDirectory": false, - "isFile": true, - ], - ] - case "/tmp/project/Sources/SwiftASB": - [ - [ - "fileName": "CodexFS.swift", - "isDirectory": false, - "isFile": true, - ], - [ - "fileName": "CodexAppServer.swift", - "isDirectory": false, - "isFile": true, - ], - ] - case "/tmp/project/.build": - [ - [ - "fileName": "debug", + ] + ) + case "thread/list": + let result: [String: Any] + if !threadListResultQueue.isEmpty { + result = threadListResultQueue.removeFirst() + } else { + result = threadListResult ?? [ + "data": [ + [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "id": "thread-123", + "modelProvider": "openai", + "name": "Hydrated Thread", + "preview": "Hydrated thread preview", + "source": "cli", + "status": ["type": "notLoaded"], + "turns": [], + "updatedAt": 1_713_350_005, + ], + ], + "nextCursor": "cursor-next", + ] + } + return responsePayload( + id: id, + result: result + ) + case "thread/loaded/list": + return responsePayload( + id: id, + result: [ + "data": ["thread-123", "thread-456"], + "nextCursor": "cursor-loaded-next", + ] + ) + case "fs/getMetadata": + return responsePayload( + id: id, + result: [ + "createdAtMs": 1_713_350_000_000, "isDirectory": true, "isFile": false, - ], - [ - "fileName": "cache.log", - "isDirectory": false, - "isFile": true, - ], - ] - case "/tmp/project/.build/debug": - [ - [ - "fileName": "CodexFS.o", - "isDirectory": false, - "isFile": true, - ], - ] - default: - [] - } - return responsePayload( - id: id, - result: [ - "entries": entries, - ] - ) - case "fs/readFile": - return responsePayload( - id: id, - result: [ - "dataBase64": Data("hello from CodexFS".utf8).base64EncodedString(), - ] - ) - case "fs/watch": - return responsePayload( - id: id, - result: [ - "path": "/tmp/project", - ] - ) - case "fs/unwatch": - return responsePayload( - id: id, - result: [:] - ) - case "config/read": - return responsePayload( - id: id, - result: [ - "config": [ - "model": "gpt-5.2", - "sandbox_mode": "workspace-write", - ], - "layers": [ + "isSymlink": false, + "modifiedAtMs": 1_713_350_005_000, + ] + ) + case "fs/readDirectory": + let path = try requestParam("path", from: requestPayload) as? String + let entries: [[String: Any]] = switch path { + case "/tmp/project": [ - "config": [ - "model": "gpt-5.2", + [ + "fileName": "Sources", + "isDirectory": true, + "isFile": false, ], - "name": [ - "type": "user", - "file": "/Users/galew/.codex/config.toml", + [ + "fileName": "Package.swift", + "isDirectory": false, + "isFile": true, ], - "version": "1", - ], + [ + "fileName": ".build", + "isDirectory": true, + "isFile": false, + ], + ] + case "/tmp/project/Sources": [ - "config": [ - "sandbox_mode": "workspace-write", + [ + "fileName": "SwiftASB", + "isDirectory": true, + "isFile": false, ], - "disabledReason": "Project config is disabled for this fixture.", - "name": [ - "type": "project", - "dotCodexFolder": "/tmp/project/.codex", + [ + "fileName": "SwiftASBTests.swift", + "isDirectory": false, + "isFile": true, ], - "version": "2", - ], + ] + case "/tmp/project/Sources/SwiftASB": [ - "config": [ - "review_model": "gpt-5.5", + [ + "fileName": "CodexFS.swift", + "isDirectory": false, + "isFile": true, + ], + [ + "fileName": "CodexAppServer.swift", + "isDirectory": false, + "isFile": true, + ], + ] + case "/tmp/project/.build": + [ + [ + "fileName": "debug", + "isDirectory": true, + "isFile": false, + ], + [ + "fileName": "cache.log", + "isDirectory": false, + "isFile": true, + ], + ] + case "/tmp/project/.build/debug": + [ + [ + "fileName": "CodexFS.o", + "isDirectory": false, + "isFile": true, + ], + ] + default: + [] + } + return responsePayload( + id: id, + result: [ + "entries": entries, + ] + ) + case "fs/readFile": + return responsePayload( + id: id, + result: [ + "dataBase64": Data("hello from CodexFS".utf8).base64EncodedString(), + ] + ) + case "fs/watch": + return responsePayload( + id: id, + result: [ + "path": "/tmp/project", + ] + ) + case "fs/unwatch": + return responsePayload( + id: id, + result: [:] + ) + case "config/read": + return responsePayload( + id: id, + result: [ + "config": [ + "model": "gpt-5.2", + "sandbox_mode": "workspace-write", + ], + "layers": [ + [ + "config": [ + "model": "gpt-5.2", + ], + "name": [ + "type": "user", + "file": "/Users/galew/.codex/config.toml", + ], + "version": "1", ], - "name": [ - "type": "enterpriseManaged", - "id": "enterprise-layer-1", - "name": "Admin Defaults", + [ + "config": [ + "sandbox_mode": "workspace-write", + ], + "disabledReason": "Project config is disabled for this fixture.", + "name": [ + "type": "project", + "dotCodexFolder": "/tmp/project/.codex", + ], + "version": "2", + ], + [ + "config": [ + "review_model": "gpt-5.5", + ], + "name": [ + "type": "enterpriseManaged", + "id": "enterprise-layer-1", + "name": "Admin Defaults", + ], + "version": "3", ], - "version": "3", ], - ], - "origins": [ - "model": [ - "name": [ - "type": "user", - "file": "/Users/galew/.codex/config.toml", + "origins": [ + "model": [ + "name": [ + "type": "user", + "file": "/Users/galew/.codex/config.toml", + ], + "version": "1", + ], + "sandbox_mode": [ + "name": [ + "type": "project", + "dotCodexFolder": "/tmp/project/.codex", + ], + "version": "2", + ], + "review_model": [ + "name": [ + "type": "enterpriseManaged", + "id": "enterprise-layer-1", + "name": "Admin Defaults", + ], + "version": "3", ], - "version": "1", ], - "sandbox_mode": [ - "name": [ - "type": "project", - "dotCodexFolder": "/tmp/project/.codex", + ] + ) + case "config/batchWrite": + return responsePayload( + id: id, + result: [ + "filePath": "/Users/example/.codex/config.toml", + "overriddenMetadata": NSNull(), + "status": "ok", + "version": "sha256:swiftasb-config-write", + ] + ) + case "configRequirements/read": + return responsePayload( + id: id, + result: [ + "requirements": [ + "featureRequirements": [ + "network_access": true, ], - "version": "2", ], - "review_model": [ - "name": [ - "type": "enterpriseManaged", - "id": "enterprise-layer-1", - "name": "Admin Defaults", + ] + ) + case "app/list": + return responsePayload( + id: id, + result: [ + "data": [ + [ + "branding": [ + "isDiscoverableApp": true, + "developer": "OpenAI", + "category": "developer-tools", + "privacyPolicy": "https://example.com/privacy", + "termsOfService": "https://example.com/terms", + "website": "https://example.com/github", + ], + "appMetadata": [ + "categories": ["Developer Tools"], + "developer": "OpenAI", + "screenshots": [ + [ + "fileId": "screenshot-1", + "url": "https://example.com/screenshot.png", + "userPrompt": "Show repository issues.", + ], + ], + "version": "1.2.3", + "versionId": "version-123", + "versionNotes": "Fixture metadata.", + ], + "description": "GitHub app fixture", + "distributionChannel": "curated", + "id": "github", + "installUrl": "https://example.com/install", + "isAccessible": true, + "isEnabled": true, + "labels": ["kind": "connector"], + "logoUrl": "https://example.com/logo-light.png", + "logoUrlDark": "https://example.com/logo-dark.png", + "name": "GitHub", + "pluginDisplayNames": ["GitHub"], ], - "version": "3", ], - ], - ] - ) - case "config/batchWrite": - return responsePayload( - id: id, - result: [ - "filePath": "/Users/example/.codex/config.toml", - "overriddenMetadata": NSNull(), - "status": "ok", - "version": "sha256:swiftasb-config-write", - ] - ) - case "configRequirements/read": - return responsePayload( - id: id, - result: [ - "requirements": [ - "featureRequirements": [ - "network_access": true, + "nextCursor": "apps-next", + ] + ) + case "skills/list": + return responsePayload( + id: id, + result: [ + "data": [ + [ + "cwd": "/tmp/project", + "errors": [ + [ + "message": "Skipped duplicate skill.", + "path": "/tmp/skills/duplicate/SKILL.md", + ], + ], + "skills": [ + [ + "description": "Build Swift packages.", + "enabled": true, + "interface": [ + "displayName": "Swift Package Workflow", + "shortDescription": "SwiftPM workflow from interface", + ], + "name": "swift-package-build-run-workflow", + "path": "/tmp/skills/swift-package-build-run-workflow/SKILL.md", + "scope": "user", + "shortDescription": "Legacy SwiftPM workflow", + ], + ], + ], ], - ], - ] - ) - case "app/list": - return responsePayload( - id: id, - result: [ - "data": [ - [ - "branding": [ - "isDiscoverableApp": true, - "developer": "OpenAI", - "category": "developer-tools", - "privacyPolicy": "https://example.com/privacy", - "termsOfService": "https://example.com/terms", - "website": "https://example.com/github", + ] + ) + case "plugin/list": + return responsePayload( + id: id, + result: [ + "featuredPluginIds": ["github"], + "marketplaceLoadErrors": [ + [ + "marketplacePath": "/tmp/bad-marketplace.json", + "message": "Fixture marketplace failed to load.", ], - "appMetadata": [ - "categories": ["Developer Tools"], - "developer": "OpenAI", - "screenshots": [ + ], + "marketplaces": [ + [ + "interface": [ + "displayName": "Curated", + ], + "name": "openai-curated", + "plugins": [ + [ + "authPolicy": "ON_USE", + "enabled": true, + "id": "github", + "installed": true, + "installPolicy": "AVAILABLE", + "interface": [ + "brandColor": "#111111", + "capabilities": ["issues", "pull-requests"], + "category": "developer-tools", + "defaultPrompt": ["Review my PR."], + "developerName": "OpenAI", + "displayName": "GitHub", + "longDescription": "GitHub plugin fixture.", + "screenshots": [], + "screenshotUrls": [], + "shortDescription": "GitHub workflows.", + ], + "name": "GitHub", + "source": [ + "type": "remote", + ], + ], [ - "fileId": "screenshot-1", - "url": "https://example.com/screenshot.png", - "userPrompt": "Show repository issues.", + "authPolicy": "ON_INSTALL", + "enabled": false, + "id": "local-plugin", + "installed": false, + "installPolicy": "NOT_AVAILABLE", + "name": "Local Plugin", + "source": [ + "path": "/tmp/plugins/local-plugin", + "type": "local", + ], ], ], - "version": "1.2.3", - "versionId": "version-123", - "versionNotes": "Fixture metadata.", ], - "description": "GitHub app fixture", - "distributionChannel": "curated", - "id": "github", - "installUrl": "https://example.com/install", - "isAccessible": true, - "isEnabled": true, - "labels": ["kind": "connector"], - "logoUrl": "https://example.com/logo-light.png", - "logoUrlDark": "https://example.com/logo-dark.png", - "name": "GitHub", - "pluginDisplayNames": ["GitHub"], ], - ], - "nextCursor": "apps-next", - ] - ) - case "skills/list": - return responsePayload( - id: id, - result: [ - "data": [ - [ - "cwd": "/tmp/project", - "errors": [ + ] + ) + case "plugin/read": + return responsePayload( + id: id, + result: [ + "plugin": [ + "apps": [ + [ + "category": "developer-tools", + "description": "GitHub app summary", + "id": "github", + "installUrl": "https://example.com/install", + "name": "GitHub", + ], + ], + "appTemplates": [], + "description": "GitHub plugin detail fixture.", + "hooks": [ + [ + "eventName": "preToolUse", + "key": "github-pre-tool-use", + ], [ - "message": "Skipped duplicate skill.", - "path": "/tmp/skills/duplicate/SKILL.md", + "eventName": "postToolUse", + "key": "github-post-tool-use", ], ], + "marketplaceName": "openai-curated", + "marketplacePath": "/tmp/marketplaces/openai-curated.json", + "mcpServers": [], "skills": [ [ - "description": "Build Swift packages.", + "description": "Review pull requests.", "enabled": true, "interface": [ - "displayName": "Swift Package Workflow", - "shortDescription": "SwiftPM workflow from interface", + "displayName": "PR Review", + "shortDescription": "Review PRs.", ], - "name": "swift-package-build-run-workflow", - "path": "/tmp/skills/swift-package-build-run-workflow/SKILL.md", - "scope": "user", - "shortDescription": "Legacy SwiftPM workflow", + "name": "review-pr", + "path": "/tmp/plugins/github/skills/review-pr/SKILL.md", + "shortDescription": "Legacy review PRs.", ], ], - ], - ], - ] - ) - case "plugin/list": - return responsePayload( - id: id, - result: [ - "featuredPluginIds": ["github"], - "marketplaceLoadErrors": [ - [ - "marketplacePath": "/tmp/bad-marketplace.json", - "message": "Fixture marketplace failed to load.", - ], - ], - "marketplaces": [ - [ - "interface": [ - "displayName": "Curated", - ], - "name": "openai-curated", - "plugins": [ - [ - "authPolicy": "ON_USE", - "enabled": true, - "id": "github", - "installed": true, - "installPolicy": "AVAILABLE", - "interface": [ - "brandColor": "#111111", - "capabilities": ["issues", "pull-requests"], - "category": "developer-tools", - "defaultPrompt": ["Review my PR."], - "developerName": "OpenAI", - "displayName": "GitHub", - "longDescription": "GitHub plugin fixture.", - "screenshots": [], - "screenshotUrls": [], - "shortDescription": "GitHub workflows.", - ], - "name": "GitHub", - "source": [ - "type": "remote", - ], + "summary": [ + "authPolicy": "ON_USE", + "enabled": true, + "id": "github", + "installed": true, + "installPolicy": "AVAILABLE", + "interface": [ + "brandColor": "#111111", + "capabilities": ["issues", "pull-requests"], + "category": "developer-tools", + "defaultPrompt": ["Review my PR."], + "developerName": "OpenAI", + "displayName": "GitHub", + "longDescription": "GitHub plugin fixture.", + "screenshots": [], + "screenshotUrls": [], + "shortDescription": "GitHub workflows.", ], - [ - "authPolicy": "ON_INSTALL", - "enabled": false, - "id": "local-plugin", - "installed": false, - "installPolicy": "NOT_AVAILABLE", - "name": "Local Plugin", - "source": [ - "path": "/tmp/plugins/local-plugin", - "type": "local", - ], + "name": "GitHub", + "source": [ + "refName": "main", + "sha": "abc123", + "type": "git", + "url": "https://github.com/openai/github-plugin", ], ], ], - ], - ] - ) - case "plugin/read": - return responsePayload( - id: id, - result: [ - "plugin": [ - "apps": [ + ] + ) + case "collaborationMode/list": + return responsePayload( + id: id, + result: [ + "data": [ [ - "description": "GitHub app summary", - "id": "github", - "installUrl": "https://example.com/install", - "name": "GitHub", - "needsAuth": true, + "mode": "plan", + "model": "gpt-5.2", + "name": "Plan", + "reasoning_effort": "medium", ], ], - "appTemplates": [], - "description": "GitHub plugin detail fixture.", - "hooks": [ - [ - "eventName": "preToolUse", - "key": "github-pre-tool-use", - ], - [ - "eventName": "postToolUse", - "key": "github-post-tool-use", - ], + ] + ) + case "thread/goal/get": + return responsePayload( + id: id, + result: [ + "goal": [ + "createdAt": 1_713_350_000, + "objective": "Promote schemas", + "status": "active", + "threadId": "thread-123", + "timeUsedSeconds": 12, + "tokenBudget": 20000, + "tokensUsed": 400, + "updatedAt": 1_713_350_010, ], - "marketplaceName": "openai-curated", - "marketplacePath": "/tmp/marketplaces/openai-curated.json", - "mcpServers": [], - "skills": [ - [ - "description": "Review pull requests.", - "enabled": true, - "interface": [ - "displayName": "PR Review", - "shortDescription": "Review PRs.", - ], - "name": "review-pr", - "path": "/tmp/plugins/github/skills/review-pr/SKILL.md", - "shortDescription": "Legacy review PRs.", - ], + ] + ) + case "thread/goal/set": + return responsePayload( + id: id, + result: [ + "goal": [ + "createdAt": 1_713_350_000, + "objective": "Promote schemas", + "status": "budgetLimited", + "threadId": "thread-123", + "timeUsedSeconds": 12, + "tokenBudget": 30000, + "tokensUsed": 400, + "updatedAt": 1_713_350_020, ], - "summary": [ - "authPolicy": "ON_USE", - "enabled": true, - "id": "github", - "installed": true, - "installPolicy": "AVAILABLE", - "interface": [ - "brandColor": "#111111", - "capabilities": ["issues", "pull-requests"], - "category": "developer-tools", - "defaultPrompt": ["Review my PR."], - "developerName": "OpenAI", - "displayName": "GitHub", - "longDescription": "GitHub plugin fixture.", - "screenshots": [], - "screenshotUrls": [], - "shortDescription": "GitHub workflows.", - ], - "name": "GitHub", - "source": [ - "refName": "main", - "sha": "abc123", - "type": "git", - "url": "https://github.com/openai/github-plugin", + ] + ) + case "thread/goal/clear": + return responsePayload( + id: id, + result: [ + "cleared": true, + ] + ) + case "thread/read": + return responsePayload( + id: id, + result: threadReadResult ?? [ + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "id": "thread-123", + "modelProvider": "openai", + "name": "Hydrated Thread", + "preview": "Hydrated thread preview", + "source": "cli", + "status": ["type": "notLoaded"], + "turns": [ + [ + "completedAt": 1_713_350_005, + "durationMs": 3000, + "error": NSNull(), + "id": "turn-hydrated-1", + "items": [ + [ + "id": "item-user-1", + "text": "Hydrated user prompt.", + "type": "userMessage", + ], + [ + "id": "item-agent-1", + "status": "completed", + "text": "Hydrated reply from thread/read.", + "type": "agentMessage", + ], + ], + "startedAt": 1_713_350_002, + "status": "completed", + ], ], + "updatedAt": 1_713_350_005, ], - ], - ] - ) - case "collaborationMode/list": - return responsePayload( - id: id, - result: [ - "data": [ - [ - "mode": "plan", - "model": "gpt-5.2", - "name": "Plan", - "reasoning_effort": "medium", - ], - ], - ] - ) - case "thread/goal/get": - return responsePayload( - id: id, - result: [ - "goal": [ - "createdAt": 1_713_350_000, - "objective": "Promote schemas", - "status": "active", - "threadId": "thread-123", - "timeUsedSeconds": 12, - "tokenBudget": 20_000, - "tokensUsed": 400, - "updatedAt": 1_713_350_010, - ], - ] - ) - case "thread/goal/set": - return responsePayload( - id: id, - result: [ - "goal": [ - "createdAt": 1_713_350_000, - "objective": "Promote schemas", - "status": "budgetLimited", - "threadId": "thread-123", - "timeUsedSeconds": 12, - "tokenBudget": 30_000, - "tokensUsed": 400, - "updatedAt": 1_713_350_020, - ], - ] - ) - case "thread/goal/clear": - return responsePayload( - id: id, - result: [ - "cleared": true, - ] - ) - case "thread/read": - return responsePayload( - id: id, - result: threadReadResult ?? [ - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, + ] + ) + case "thread/compact/start": + return responsePayload( + id: id, + result: [:] + ) + case "thread/fork": + return responsePayload( + id: id, + result: threadForkResult ?? [ + "approvalPolicy": "on-request", + "approvalsReviewer": "user", "cwd": "/tmp/project", - "ephemeral": false, - "id": "thread-123", + "instructionSources": ["AGENTS.md"], + "model": "gpt-5.4", "modelProvider": "openai", - "name": "Hydrated Thread", - "preview": "Hydrated thread preview", - "source": "cli", - "status": ["type": "notLoaded"], - "turns": [ - [ - "completedAt": 1713350005, - "durationMs": 3000, - "error": NSNull(), - "id": "turn-hydrated-1", - "items": [ - [ - "id": "item-user-1", - "text": "Hydrated user prompt.", - "type": "userMessage", - ], - [ - "id": "item-agent-1", - "status": "completed", - "text": "Hydrated reply from thread/read.", - "type": "agentMessage", + "reasoningEffort": "medium", + "sandbox": [ + "type": "workspaceWrite", + "networkAccess": "enabled", + "writableRoots": ["/tmp/project"], + ], + "serviceTier": "fast", + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_010, + "cwd": "/tmp/project", + "ephemeral": false, + "forkedFromId": "thread-123", + "id": "thread-456", + "modelProvider": "openai", + "name": "Forked Thread", + "preview": "Hydrated fork preview", + "source": "cli", + "status": ["type": "idle"], + "turns": [ + [ + "completedAt": 1_713_350_005, + "durationMs": 3000, + "error": NSNull(), + "id": "turn-hydrated-1", + "items": [ + [ + "id": "item-agent-1", + "status": "completed", + "text": "Forked reply from thread/fork.", + "type": "agentMessage", + ], ], + "startedAt": 1_713_350_002, + "status": "completed", ], - "startedAt": 1713350002, - "status": "completed", ], + "updatedAt": 1_713_350_011, ], - "updatedAt": 1713350005, - ], - ] - ) - case "thread/compact/start": - return responsePayload( - id: id, - result: [:] - ) - case "thread/fork": - return responsePayload( - id: id, - result: threadForkResult ?? [ - "approvalPolicy": "on-request", - "approvalsReviewer": "user", - "cwd": "/tmp/project", - "instructionSources": ["AGENTS.md"], - "model": "gpt-5.4", - "modelProvider": "openai", - "reasoningEffort": "medium", - "sandbox": [ - "type": "workspaceWrite", - "networkAccess": "enabled", - "writableRoots": ["/tmp/project"], - ], - "serviceTier": "fast", - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350010, + ] + ) + case "thread/resume": + return responsePayload( + id: id, + result: threadResumeResult ?? [ + "approvalPolicy": "on-request", + "approvalsReviewer": "user", "cwd": "/tmp/project", - "ephemeral": false, - "forkedFromId": "thread-123", - "id": "thread-456", + "instructionSources": ["AGENTS.md"], + "model": "gpt-5.4", "modelProvider": "openai", - "name": "Forked Thread", - "preview": "Hydrated fork preview", - "source": "cli", - "status": ["type": "idle"], - "turns": [ - [ - "completedAt": 1713350005, - "durationMs": 3000, - "error": NSNull(), - "id": "turn-hydrated-1", - "items": [ - [ - "id": "item-agent-1", - "status": "completed", - "text": "Forked reply from thread/fork.", - "type": "agentMessage", + "reasoningEffort": "medium", + "sandbox": [ + "type": "workspaceWrite", + "networkAccess": "enabled", + "writableRoots": ["/tmp/project"], + ], + "serviceTier": "fast", + "thread": [ + "cliVersion": "0.128.0", + "createdAt": 1_713_350_000, + "cwd": "/tmp/project", + "ephemeral": false, + "id": "thread-123", + "modelProvider": "openai", + "name": "Resumed Thread", + "preview": "Hydrated resume preview", + "source": "cli", + "status": ["type": "idle"], + "turns": [ + [ + "completedAt": 1_713_350_005, + "durationMs": 3000, + "error": NSNull(), + "id": "turn-hydrated-1", + "items": [ + [ + "id": "item-agent-1", + "status": "completed", + "text": "Resumed reply from thread/resume.", + "type": "agentMessage", + ], ], + "startedAt": 1_713_350_002, + "status": "completed", ], - "startedAt": 1713350002, - "status": "completed", ], + "updatedAt": 1_713_350_005, ], - "updatedAt": 1713350011, - ], - ] - ) - case "thread/resume": - return responsePayload( - id: id, - result: threadResumeResult ?? [ - "approvalPolicy": "on-request", - "approvalsReviewer": "user", - "cwd": "/tmp/project", - "instructionSources": ["AGENTS.md"], - "model": "gpt-5.4", - "modelProvider": "openai", - "reasoningEffort": "medium", - "sandbox": [ - "type": "workspaceWrite", - "networkAccess": "enabled", - "writableRoots": ["/tmp/project"], - ], - "serviceTier": "fast", - "thread": [ - "cliVersion": "0.128.0", - "createdAt": 1713350000, - "cwd": "/tmp/project", - "ephemeral": false, - "id": "thread-123", - "modelProvider": "openai", - "name": "Resumed Thread", - "preview": "Hydrated resume preview", - "source": "cli", - "status": ["type": "idle"], - "turns": [ + ] + ) + case "thread/turns/list": + if let threadTurnsListErrorMessage { + return errorPayload( + id: id, + code: -32600, + message: threadTurnsListErrorMessage + ) + } + if !threadTurnsListResultQueue.isEmpty { + return responsePayload( + id: id, + result: threadTurnsListResultQueue.removeFirst() + ) + } + return responsePayload( + id: id, + result: threadTurnsListResult ?? [ + "backwardsCursor": "cursor-newer", + "data": [ + [ + "completedAt": 1_713_350_100, + "durationMs": 2500, + "error": NSNull(), + "id": "turn-newer", + "items": [], + "startedAt": 1_713_350_050, + "status": "completed", + ], [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 3000, "error": NSNull(), - "id": "turn-hydrated-1", - "items": [ - [ - "id": "item-agent-1", - "status": "completed", - "text": "Resumed reply from thread/resume.", - "type": "agentMessage", - ], - ], - "startedAt": 1713350002, + "id": "turn-older", + "items": [], + "startedAt": 1_713_350_002, "status": "completed", ], ], - "updatedAt": 1713350005, - ], - ] - ) - case "thread/turns/list": - if let threadTurnsListErrorMessage { - return errorPayload( + "nextCursor": "cursor-older", + ] + ) + case "thread/turns/items/list": + return responsePayload( + id: id, + result: threadTurnsItemsListResult ?? [ + "backwardsCursor": "cursor-newer-items", + "data": [ + [ + "id": "item-command-1", + "command": "swift test", + "status": "completed", + "type": "commandExecution", + ], + [ + "id": "item-agent-1", + "status": "completed", + "text": "Done.", + "type": "agentMessage", + ], + ], + "nextCursor": "cursor-older-items", + ] + ) + case "command/exec": + if !commandExecResultQueue.isEmpty { + return responsePayload( + id: id, + result: commandExecResultQueue.removeFirst() + ) + } + return responsePayload( id: id, - code: -32600, - message: threadTurnsListErrorMessage + result: commandExecResult ) - } - if !threadTurnsListResultQueue.isEmpty { + case "thread/shellCommand": return responsePayload( id: id, - result: threadTurnsListResultQueue.removeFirst() + result: [:] ) - } - return responsePayload( - id: id, - result: threadTurnsListResult ?? [ - "backwardsCursor": "cursor-newer", - "data": [ - [ - "completedAt": 1713350100, - "durationMs": 2500, + case "review/start": + let delivery = try requestParam("delivery", from: requestPayload) as? String + let sourceThreadID = try #require(requestParam("threadId", from: requestPayload) as? String) + let reviewThreadID = delivery == "detached" ? "review-thread-123" : sourceThreadID + let turnID = turnStartIDQueue.isEmpty ? "review-turn-123" : turnStartIDQueue.removeFirst() + return responsePayload( + id: id, + result: [ + "reviewThreadId": reviewThreadID, + "turn": [ + "completedAt": NSNull(), + "durationMs": NSNull(), "error": NSNull(), - "id": "turn-newer", + "id": turnID, "items": [], - "startedAt": 1713350050, - "status": "completed", + "startedAt": 1_713_350_003, + "status": "inProgress", ], - [ - "completedAt": 1713350005, - "durationMs": 3000, + ] + ) + case "turn/start": + let turnID = turnStartIDQueue.isEmpty ? "turn-123" : turnStartIDQueue.removeFirst() + return responsePayload( + id: id, + result: [ + "turn": [ + "completedAt": NSNull(), + "durationMs": NSNull(), "error": NSNull(), - "id": "turn-older", + "id": turnID, "items": [], - "startedAt": 1713350002, - "status": "completed", - ], - ], - "nextCursor": "cursor-older", - ] - ) - case "thread/turns/items/list": - return responsePayload( - id: id, - result: threadTurnsItemsListResult ?? [ - "backwardsCursor": "cursor-newer-items", - "data": [ - [ - "id": "item-command-1", - "command": "swift test", - "status": "completed", - "type": "commandExecution", - ], - [ - "id": "item-agent-1", - "status": "completed", - "text": "Done.", - "type": "agentMessage", + "startedAt": 1_713_350_002, + "status": "inProgress", ], - ], - "nextCursor": "cursor-older-items", - ] - ) - case "command/exec": - if !commandExecResultQueue.isEmpty { + ] + ) + case "turn/steer": return responsePayload( id: id, - result: commandExecResultQueue.removeFirst() + result: [ + "turnId": "turn-123", + ] + ) + case "turn/interrupt": + return responsePayload( + id: id, + result: [:] + ) + default: + return errorPayload( + id: id, + code: -32601, + message: "unsupported method in fake transport" ) - } - return responsePayload( - id: id, - result: commandExecResult - ) - case "thread/shellCommand": - return responsePayload( - id: id, - result: [:] - ) - case "review/start": - let delivery = try requestParam("delivery", from: requestPayload) as? String - let sourceThreadID = try #require(requestParam("threadId", from: requestPayload) as? String) - let reviewThreadID = delivery == "detached" ? "review-thread-123" : sourceThreadID - let turnID = turnStartIDQueue.isEmpty ? "review-turn-123" : turnStartIDQueue.removeFirst() - return responsePayload( - id: id, - result: [ - "reviewThreadId": reviewThreadID, - "turn": [ - "completedAt": NSNull(), - "durationMs": NSNull(), - "error": NSNull(), - "id": turnID, - "items": [], - "startedAt": 1713350003, - "status": "inProgress", - ], - ] - ) - case "turn/start": - let turnID = turnStartIDQueue.isEmpty ? "turn-123" : turnStartIDQueue.removeFirst() - return responsePayload( - id: id, - result: [ - "turn": [ - "completedAt": NSNull(), - "durationMs": NSNull(), - "error": NSNull(), - "id": turnID, - "items": [], - "startedAt": 1713350002, - "status": "inProgress", - ], - ] - ) - case "turn/steer": - return responsePayload( - id: id, - result: [ - "turnId": "turn-123", - ] - ) - case "turn/interrupt": - return responsePayload( - id: id, - result: [:] - ) - default: - return errorPayload( - id: id, - code: -32601, - message: "unsupported method in fake transport" - ) } } @@ -1345,7 +1350,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { func emitTurnCompleted( threadID: String, turnID: String, - completedAt: Int = 1713350005 + completedAt: Int = 1_713_350_005 ) { let payload = payloadObject([ "threadId": threadID, @@ -1355,7 +1360,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "error": NSNull(), "id": turnID, "items": [], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "completed", ], ]) @@ -1377,7 +1382,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { [ "command": "git status", "type": "unknown", - ] + ], ], "cwd": "/tmp/project", "itemId": itemID, @@ -1484,7 +1489,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ], ], "question": "Which direction should we take?", - ] + ], ], "threadId": threadID, "turnId": turnID, @@ -1529,10 +1534,10 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ) { let jsonRequestID: Any switch requestID { - case let .string(value): - jsonRequestID = value - case let .int(value): - jsonRequestID = value + case let .string(value): + jsonRequestID = value + case let .int(value): + jsonRequestID = value } let payload = payloadObject([ @@ -1549,7 +1554,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { let payload = payloadObject([ "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": threadID, @@ -1558,7 +1563,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "source": "cli", "status": ["type": "active"], "turns": [], - "updatedAt": 1713350001, + "updatedAt": 1_713_350_001, ], ]) @@ -1624,7 +1629,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "reasoningOutputTokens": 5, "totalTokens": 65, ], - "modelContextWindow": 200000, + "modelContextWindow": 200_000, "total": [ "cachedInputTokens": 100, "inputTokens": 200, @@ -1707,7 +1712,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "id": hookID, "scope": "turn", "sourcePath": "/tmp/project/.codex/hooks/pre-tool-use.sh", - "startedAt": 1713350003, + "startedAt": 1_713_350_003, "status": status, "statusMessage": NSNull(), ], @@ -1730,14 +1735,14 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { let jsonStatusMessage: Any = statusMessage ?? NSNull() let payload = payloadObject([ "run": [ - "completedAt": 1713350004, + "completedAt": 1_713_350_004, "displayOrder": 1, "durationMs": 150, "entries": [ [ "kind": "feedback", "text": "Hook finished.", - ] + ], ], "eventName": "preToolUse", "executionMode": "sync", @@ -1745,7 +1750,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "id": hookID, "scope": "turn", "sourcePath": "/tmp/project/.codex/hooks/pre-tool-use.sh", - "startedAt": 1713350003, + "startedAt": 1_713_350_003, "status": status, "statusMessage": jsonStatusMessage, ], @@ -1843,7 +1848,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "error": NSNull(), "id": turnID, "items": [], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "inProgress", ], ]) @@ -2090,16 +2095,6 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { return params[name] } - private static func isAppSnapshotRequest(_ method: String) -> Bool { - method == "modelProvider/capabilities/read" - || method == "mcpServerStatus/list" - || method == "hooks/list" - || method == "app/list" - || method == "skills/list" - || method == "plugin/list" - || method == "collaborationMode/list" - } - private func responsePayload(id: CodexRPCRequestID, result: [String: Any]) -> Data { payloadObject([ "id": id.jsonObjectValue, @@ -2236,7 +2231,8 @@ func threadEvents( } func temporarySQLiteHistoryStore() throws -> (ThreadHistoryStore, URL) { - let temporaryDirectory = FileManager.default.temporaryDirectory + let temporaryDirectory = FileManager.default + .temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: temporaryDirectory, @@ -2267,10 +2263,10 @@ func settleObservableTeardown() async { extension CodexRPCRequestID { var jsonObjectValue: Any { switch self { - case let .string(value): - value - case let .int(value): - value + case let .string(value): + value + case let .int(value): + value } } } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift index 00d75aa..0d490b8 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("CodexAppServer", .serialized) +@Suite(.serialized) struct CodexAppServerTests { @Test("preserves unknown reasoning effort wire values") func preservesUnknownReasoningEffortWireValues() { @@ -93,8 +93,8 @@ struct CodexAppServerTests { launchArgumentsPrefix: [], resolvedExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/codex"), source: .homebrewAppleSilicon, - versionString: "codex-cli 0.139.0", - compatibility: .supported(documentedWindow: "0.139.x") + versionString: "codex-cli 0.140.0", + compatibility: .supported(documentedWindow: "0.140.x plus 0.139.x when feasible") ) ) let client = CodexAppServer(transport: transport) @@ -104,8 +104,8 @@ struct CodexAppServerTests { let diagnostics = try await client.cliExecutableDiagnostics() #expect(diagnostics.source == .homebrewAppleSilicon) #expect(diagnostics.resolvedExecutablePath == "/opt/homebrew/bin/codex") - #expect(diagnostics.versionString == "codex-cli 0.139.0") - #expect(diagnostics.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(diagnostics.versionString == "codex-cli 0.140.0") + #expect(diagnostics.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) await client.stop() } @@ -118,8 +118,8 @@ struct CodexAppServerTests { launchArgumentsPrefix: [], resolvedExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/codex"), source: .homebrewAppleSilicon, - versionString: "codex-cli 0.139.0", - compatibility: .supported(documentedWindow: "0.139.x") + versionString: "codex-cli 0.140.0", + compatibility: .supported(documentedWindow: "0.140.x plus 0.139.x when feasible") ) ) let client = CodexAppServer(transport: transport) @@ -134,7 +134,7 @@ struct CodexAppServerTests { ) ) - #expect(startup.cliExecutableDiagnostics.versionString == "codex-cli 0.139.0") + #expect(startup.cliExecutableDiagnostics.versionString == "codex-cli 0.140.0") #expect(startup.initializeSession.codexHome == "/Users/galew/.codex") #expect(await transport.recordedMethods == ["initialize", "initialized"]) @@ -150,7 +150,7 @@ struct CodexAppServerTests { resolvedExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/codex"), source: .homebrewAppleSilicon, versionString: "codex-cli 0.128.0", - compatibility: .outsideDocumentedWindow(documentedWindow: "0.139.x") + compatibility: .outsideDocumentedWindow(documentedWindow: "0.140.x plus 0.139.x when feasible") ) ) let client = CodexAppServer(transport: transport) @@ -160,7 +160,7 @@ struct CodexAppServerTests { source: .homebrewAppleSilicon, resolvedExecutablePath: "/opt/homebrew/bin/codex", versionString: "codex-cli 0.128.0", - compatibility: .outsideDocumentedWindow(documentedWindow: "0.139.x") + compatibility: .outsideDocumentedWindow(documentedWindow: "0.140.x plus 0.139.x when feasible") ) )) { try await client.start( @@ -187,7 +187,7 @@ struct CodexAppServerTests { resolvedExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/codex"), source: .homebrewAppleSilicon, versionString: "codex-cli 0.128.0", - compatibility: .outsideDocumentedWindow(documentedWindow: "0.139.x") + compatibility: .outsideDocumentedWindow(documentedWindow: "0.140.x plus 0.139.x when feasible") ) ) let client = CodexAppServer(transport: transport) @@ -285,7 +285,7 @@ struct CodexAppServerTests { } @Test("replays buffered feature operation events to later subscribers") - func replaysBufferedFeatureOperationEventsToLaterSubscribers() async throws { + func replaysBufferedFeatureOperationEventsToLaterSubscribers() async { let client = CodexAppServer(transport: FakeCodexAppServerTransport()) let event = SwiftASBFeatureOperationEvent( categoryID: .gitActions, @@ -296,7 +296,7 @@ struct CodexAppServerTests { startedAt: Date(timeIntervalSince1970: 1_700_000_000), completedAt: Date(timeIntervalSince1970: 1_700_000_001), commands: [ - .init(argv: ["git", "switch", "-c", "docs/example"]) + .init(argv: ["git", "switch", "-c", "docs/example"]), ], appServerMethod: "command/exec", intentKind: "gitBranchCreate", @@ -341,7 +341,7 @@ struct CodexAppServerTests { command: ["git", "status", "--short", "--branch"], currentDirectoryPath: "/tmp/project", outputBytesCap: 4096, - timeoutMilliseconds: 5_000 + timeoutMilliseconds: 5000 ) ) @@ -623,45 +623,6 @@ struct CodexAppServerTests { await client.stop() } - @Test("lists app-wide MCP server status through the compatibility request") - func listsAppWideMcpServerStatusThroughCompatibilityRequest() async throws { - let transport = FakeCodexAppServerTransport() - let client = CodexAppServer(transport: transport) - - try await client.start() - _ = try await client.initialize( - .init( - clientInfo: .init( - name: "SwiftASBTests", - title: "SwiftASB Tests", - version: "0.1.0" - ) - ) - ) - - let page = try await client.listMcpServerStatuses( - .init(cursor: "cursor-start", limit: 4, detail: .toolsAndAuthOnly) - ) - - #expect(page.nextCursor == nil) - #expect(page.servers.count == 1) - #expect(page.servers[0].name == "calendar") - #expect(page.servers[0].authStatus == .oAuth) - #expect(page.servers[0].resources[0].uri == "calendar://events/today") - #expect(page.servers[0].resourceTemplates[0].uriTemplate == "calendar://events/{date}") - #expect(page.servers[0].tools["list_events"]?.title == "List Events") - #expect(page.servers[0].tools["list_events"]?.inputSchema == .object(["type": .string("object")])) - - let requestPayload = try #require(await transport.recordedRequestPayload(for: "mcpServerStatus/list")) - let request = try #require(try JSONSerialization.jsonObject(with: requestPayload) as? [String: Any]) - let params = try #require(request["params"] as? [String: Any]) - #expect(params["cursor"] as? String == "cursor-start") - #expect(params["limit"] as? Int == 4) - #expect(params["detail"] as? String == "toolsAndAuthOnly") - - await client.stop() - } - @Test("reads MCP resources through the public client") func readsMcpResource() async throws { let transport = FakeCodexAppServerTransport() @@ -754,5 +715,4 @@ struct CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerThreadHydrationTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerThreadHydrationTests.swift index 6d7d63c..9ea9560 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerThreadHydrationTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerThreadHydrationTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("reads a stored thread and hydrates returned turns into the local history store") @@ -66,7 +66,7 @@ extension CodexAppServerTests { threadReadResult: [ "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-123", @@ -76,7 +76,7 @@ extension CodexAppServerTests { "status": ["type": "notLoaded"], "turns": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 3000, "error": NSNull(), "id": "turn-123", @@ -87,11 +87,11 @@ extension CodexAppServerTests { "type": "agentMessage", ], ], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "completed", ], ], - "updatedAt": 1713350005, + "updatedAt": 1_713_350_005, ], ] ) @@ -181,7 +181,7 @@ extension CodexAppServerTests { threadReadResult: [ "thread": [ "cliVersion": "0.128.0", - "createdAt": 1713350000, + "createdAt": 1_713_350_000, "cwd": "/tmp/project", "ephemeral": false, "id": "thread-123", @@ -191,7 +191,7 @@ extension CodexAppServerTests { "status": ["type": "notLoaded"], "turns": [ [ - "completedAt": 1713350005, + "completedAt": 1_713_350_005, "durationMs": 3000, "error": NSNull(), "id": "turn-123", @@ -202,11 +202,11 @@ extension CodexAppServerTests { "type": "commandExecution", ], ], - "startedAt": 1713350002, + "startedAt": 1_713_350_002, "status": "completed", ], ], - "updatedAt": 1713350005, + "updatedAt": 1_713_350_005, ], ] ) @@ -473,63 +473,62 @@ extension CodexAppServerTests { } switch receivedEvents[0] { - case let .started(started): - #expect(started.thread.id == thread.id) - #expect(started.thread.preview == "Hello from thread/started") - default: - Issue.record("Expected the first thread event to be .started.") + case let .started(started): + #expect(started.thread.id == thread.id) + #expect(started.thread.preview == "Hello from thread/started") + default: + Issue.record("Expected the first thread event to be .started.") } switch receivedEvents[1] { - case let .statusChanged(change): - #expect(change.threadID == thread.id) - #expect(change.status.type == .active) - #expect(change.status.activeFlags == [.waitingOnApproval]) - default: - Issue.record("Expected the second thread event to be .statusChanged.") + case let .statusChanged(change): + #expect(change.threadID == thread.id) + #expect(change.status.type == .active) + #expect(change.status.activeFlags == [.waitingOnApproval]) + default: + Issue.record("Expected the second thread event to be .statusChanged.") } switch receivedEvents[2] { - case let .archived(event): - #expect(event.threadID == thread.id) - default: - Issue.record("Expected the third thread event to be .archived.") + case let .archived(event): + #expect(event.threadID == thread.id) + default: + Issue.record("Expected the third thread event to be .archived.") } switch receivedEvents[3] { - case let .unarchived(event): - #expect(event.threadID == thread.id) - default: - Issue.record("Expected the fourth thread event to be .unarchived.") + case let .unarchived(event): + #expect(event.threadID == thread.id) + default: + Issue.record("Expected the fourth thread event to be .unarchived.") } switch receivedEvents[4] { - case let .nameUpdated(update): - #expect(update.threadID == thread.id) - #expect(update.threadName == "Planning Thread") - default: - Issue.record("Expected the fifth thread event to be .nameUpdated.") + case let .nameUpdated(update): + #expect(update.threadID == thread.id) + #expect(update.threadName == "Planning Thread") + default: + Issue.record("Expected the fifth thread event to be .nameUpdated.") } switch receivedEvents[5] { - case let .tokenUsageUpdated(update): - #expect(update.threadID == thread.id) - #expect(update.turnID == "turn-123") - #expect(update.last.totalTokens == 65) - #expect(update.total.totalTokens == 650) - #expect(update.modelContextWindow == 200000) - default: - Issue.record("Expected the sixth thread event to be .tokenUsageUpdated.") + case let .tokenUsageUpdated(update): + #expect(update.threadID == thread.id) + #expect(update.turnID == "turn-123") + #expect(update.last.totalTokens == 65) + #expect(update.total.totalTokens == 650) + #expect(update.modelContextWindow == 200_000) + default: + Issue.record("Expected the sixth thread event to be .tokenUsageUpdated.") } switch receivedEvents[6] { - case let .closed(event): - #expect(event.threadID == thread.id) - default: - Issue.record("Expected the seventh thread event to be .closed.") + case let .closed(event): + #expect(event.threadID == thread.id) + default: + Issue.record("Expected the seventh thread event to be .closed.") } await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift index 61ec26f..0fd2d2e 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @MainActor @@ -284,17 +284,11 @@ extension CodexAppServerTests { ) let thread = try await client.startThread( .init( - permissions: .init( - id: ":workspace", - modifications: [ - .init(additionalWritableRoot: "/tmp/project-fixtures"), - ] - ) + permissions: .workspace ) ) #expect(thread.activePermissionProfile?.id == ":workspace") - #expect(thread.activePermissionProfile?.modifications.isEmpty == true) #expect(thread.permissionProfile == nil) #expect(thread.workspace.currentDirectoryPath == "/tmp/project") #expect(thread.workspace.projectInfo == thread.info.projectInfo) @@ -343,7 +337,7 @@ extension CodexAppServerTests { let rolledBackThread = try await thread.rollbackLastTurns(1) #expect(rolledBackThread.id == thread.id) - #expect(rolledBackThread.info.updatedAt == 1713350010) + #expect(rolledBackThread.info.updatedAt == 1_713_350_010) let requestPayload = try #require(await transport.recordedRequestPayload(for: "thread/rollback")) let request = try #require(try JSONSerialization.jsonObject(with: requestPayload) as? [String: Any]) @@ -359,10 +353,9 @@ extension CodexAppServerTests { #expect(afterRollback.rollbacks[0].previousNewestTurnID == "turn-newer") #expect(afterRollback.rollbacks[0].resultingNewestTurnID == "turn-older") #expect(afterRollback.rollbacks[0].removedTurnIDs == ["turn-newer"]) - #expect(afterRollback.rollbacks[0].serverUpdatedAt == 1713350010) + #expect(afterRollback.rollbacks[0].serverUpdatedAt == 1_713_350_010) await client.stop() await tearDownTemporarySQLiteHistoryStore(historyStore, directory: temporaryDirectory) } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift index 0e6cec8..a028ac6 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("interrupts a turn through CodexTurnHandle") @@ -178,10 +178,10 @@ extension CodexAppServerTests { Issue.record("Expected overlapping same-thread turn start to be rejected.") } catch let error as CodexAppServerError { switch error { - case let .invalidState(reason): - #expect(reason.contains("overlapping same-thread turns") || reason.contains("already has an active turn")) - default: - Issue.record("Expected overlapping same-thread turn start to throw an invalidState error.") + case let .invalidState(reason): + #expect(reason.contains("overlapping same-thread turns") || reason.contains("already has an active turn")) + default: + Issue.record("Expected overlapping same-thread turn start to throw an invalidState error.") } } @@ -259,7 +259,6 @@ extension CodexAppServerTests { await client.stop() return } - guard case let .commandExecution(commandRequest) = approvalRequest else { Issue.record("Expected the buffered approval request to be a command execution approval.") await client.stop() @@ -348,7 +347,6 @@ extension CodexAppServerTests { await client.stop() return } - guard case let .guardianDeniedAction(guardianRequest) = approvalRequest else { Issue.record("Expected a guardian denied-action approval request.") await client.stop() @@ -446,6 +444,7 @@ extension CodexAppServerTests { await client.stop() return } + #expect(reason.contains("belongs to a specific turn")) #expect(reason.contains("CodexTurnHandle")) } @@ -506,6 +505,7 @@ extension CodexAppServerTests { await client.stop() return } + #expect(reason.contains("response kind did not match")) } @@ -538,6 +538,7 @@ extension CodexAppServerTests { await client.stop() return } + #expect(reason.contains("No outstanding interactive server request")) } @@ -606,6 +607,7 @@ extension CodexAppServerTests { await client.stop() return } + #expect(reason.contains("belongs to thread \(firstThread.id)")) #expect(reason.contains("not thread \(secondThread.id)")) } @@ -653,7 +655,6 @@ extension CodexAppServerTests { await client.stop() return } - guard case let .mcpServer(mcpRequest) = request else { Issue.record("Expected the thread elicitation event to contain an MCP server request.") await client.stop() @@ -739,7 +740,6 @@ extension CodexAppServerTests { await client.stop() return } - guard case let .toolUserInput(inputRequest) = request else { Issue.record("Expected the elicitation event to contain a tool user input request.") await client.stop() @@ -755,7 +755,7 @@ extension CodexAppServerTests { with: .toolUserInput( .init( answers: [ - "goal": .init(answers: ["Ship it"]) + "goal": .init(answers: ["Ship it"]), ] ) ) @@ -774,5 +774,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTurnLifecycleTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTurnLifecycleTests.swift index c28b972..f6d5229 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTurnLifecycleTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTurnLifecycleTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("runs initialize, thread/start, and turn/start through the public client") @@ -54,19 +54,14 @@ extension CodexAppServerTests { "Hello from SwiftASB", effort: .medium, model: "gpt-5.4", - permissions: .init( - id: ":workspace", - modifications: [ - .init(additionalWritableRoot: "/tmp/turn-fixtures"), - ] - ), + permissions: .workspace, summary: .concise ) #expect(turnHandle.threadID == thread.id) #expect(turnHandle.turn.id == "turn-123") #expect(turnHandle.turn.status == .inProgress) - #expect(turnHandle.turn.startedAt == 1713350002) + #expect(turnHandle.turn.startedAt == 1_713_350_002) let firstEventTask = Task { try await turnEvents(from: turnHandle.events, count: 9) @@ -119,99 +114,99 @@ extension CodexAppServerTests { #expect(receivedEvents.count == 9) switch receivedEvents[0] { - case let .started(started): - #expect(started.threadID == thread.id) - #expect(started.turn.id == turnHandle.turn.id) - #expect(started.turn.status == .inProgress) - default: - Issue.record("Expected the first streamed event to be .started.") + case let .started(started): + #expect(started.threadID == thread.id) + #expect(started.turn.id == turnHandle.turn.id) + #expect(started.turn.status == .inProgress) + default: + Issue.record("Expected the first streamed event to be .started.") } switch receivedEvents[1] { - case let .itemStarted(itemStarted): - #expect(itemStarted.threadID == thread.id) - #expect(itemStarted.turnID == turnHandle.turn.id) - #expect(itemStarted.item.id == "item-plan-1") - #expect(itemStarted.item.kind == .plan) - #expect(itemStarted.item.text == nil) - default: - Issue.record("Expected the second streamed event to be .itemStarted.") + case let .itemStarted(itemStarted): + #expect(itemStarted.threadID == thread.id) + #expect(itemStarted.turnID == turnHandle.turn.id) + #expect(itemStarted.item.id == "item-plan-1") + #expect(itemStarted.item.kind == .plan) + #expect(itemStarted.item.text == nil) + default: + Issue.record("Expected the second streamed event to be .itemStarted.") } switch receivedEvents[2] { - case let .planUpdated(update): - #expect(update.threadID == thread.id) - #expect(update.turnID == turnHandle.turn.id) - #expect(update.explanation == "Map richer progress notifications.") - #expect(update.plan.count == 2) - #expect(update.plan.first?.status == .inProgress) - #expect(update.plan.last?.status == .pending) - default: - Issue.record("Expected the third streamed event to be .planUpdated.") + case let .planUpdated(update): + #expect(update.threadID == thread.id) + #expect(update.turnID == turnHandle.turn.id) + #expect(update.explanation == "Map richer progress notifications.") + #expect(update.plan.count == 2) + #expect(update.plan.first?.status == .inProgress) + #expect(update.plan.last?.status == .pending) + default: + Issue.record("Expected the third streamed event to be .planUpdated.") } switch receivedEvents[3] { - case let .planDelta(delta): - #expect(delta.threadID == thread.id) - #expect(delta.turnID == turnHandle.turn.id) - #expect(delta.itemID == "item-plan-1") - #expect(delta.delta == "Stream partial plan text") - default: - Issue.record("Expected the fourth streamed event to be .planDelta.") + case let .planDelta(delta): + #expect(delta.threadID == thread.id) + #expect(delta.turnID == turnHandle.turn.id) + #expect(delta.itemID == "item-plan-1") + #expect(delta.delta == "Stream partial plan text") + default: + Issue.record("Expected the fourth streamed event to be .planDelta.") } switch receivedEvents[4] { - case let .agentMessageDelta(delta): - #expect(delta.threadID == thread.id) - #expect(delta.turnID == turnHandle.turn.id) - #expect(delta.itemID == "item-agent-1") - #expect(delta.delta == "Working on it") - default: - Issue.record("Expected the fifth streamed event to be .agentMessageDelta.") + case let .agentMessageDelta(delta): + #expect(delta.threadID == thread.id) + #expect(delta.turnID == turnHandle.turn.id) + #expect(delta.itemID == "item-agent-1") + #expect(delta.delta == "Working on it") + default: + Issue.record("Expected the fifth streamed event to be .agentMessageDelta.") } switch receivedEvents[5] { - case let .reasoningTextDelta(delta): - #expect(delta.threadID == thread.id) - #expect(delta.turnID == turnHandle.turn.id) - #expect(delta.itemID == "item-reasoning-1") - #expect(delta.contentIndex == 0) - #expect(delta.delta == "thinking...") - default: - Issue.record("Expected the sixth streamed event to be .reasoningTextDelta.") + case let .reasoningTextDelta(delta): + #expect(delta.threadID == thread.id) + #expect(delta.turnID == turnHandle.turn.id) + #expect(delta.itemID == "item-reasoning-1") + #expect(delta.contentIndex == 0) + #expect(delta.delta == "thinking...") + default: + Issue.record("Expected the sixth streamed event to be .reasoningTextDelta.") } switch receivedEvents[6] { - case let .reasoningSummaryTextDelta(delta): - #expect(delta.threadID == thread.id) - #expect(delta.turnID == turnHandle.turn.id) - #expect(delta.itemID == "item-reasoning-1") - #expect(delta.summaryIndex == 0) - #expect(delta.delta == "Summarizing the approach.") - default: - Issue.record("Expected the seventh streamed event to be .reasoningSummaryTextDelta.") + case let .reasoningSummaryTextDelta(delta): + #expect(delta.threadID == thread.id) + #expect(delta.turnID == turnHandle.turn.id) + #expect(delta.itemID == "item-reasoning-1") + #expect(delta.summaryIndex == 0) + #expect(delta.delta == "Summarizing the approach.") + default: + Issue.record("Expected the seventh streamed event to be .reasoningSummaryTextDelta.") } switch receivedEvents[7] { - case let .itemCompleted(itemCompleted): - #expect(itemCompleted.threadID == thread.id) - #expect(itemCompleted.turnID == turnHandle.turn.id) - #expect(itemCompleted.item.id == "item-agent-1") - #expect(itemCompleted.item.kind == .agentMessage) - #expect(itemCompleted.item.text == "Done.") - #expect(itemCompleted.item.status == "completed") - default: - Issue.record("Expected the eighth streamed event to be .itemCompleted.") + case let .itemCompleted(itemCompleted): + #expect(itemCompleted.threadID == thread.id) + #expect(itemCompleted.turnID == turnHandle.turn.id) + #expect(itemCompleted.item.id == "item-agent-1") + #expect(itemCompleted.item.kind == .agentMessage) + #expect(itemCompleted.item.text == "Done.") + #expect(itemCompleted.item.status == "completed") + default: + Issue.record("Expected the eighth streamed event to be .itemCompleted.") } switch receivedEvents[8] { - case let .completed(completion): - #expect(completion.threadID == thread.id) - #expect(completion.turn.id == turnHandle.turn.id) - #expect(completion.turn.status == CodexAppServer.TurnStatus.completed) - #expect(completion.turn.completedAt == 1713350005) - default: - Issue.record("Expected the ninth streamed event to be .completed.") + case let .completed(completion): + #expect(completion.threadID == thread.id) + #expect(completion.turn.id == turnHandle.turn.id) + #expect(completion.turn.status == CodexAppServer.TurnStatus.completed) + #expect(completion.turn.completedAt == 1_713_350_005) + default: + Issue.record("Expected the ninth streamed event to be .completed.") } let recordedMethods = await transport.recordedMethods @@ -504,5 +499,4 @@ extension CodexAppServerTests { await client.stop() } - } diff --git a/Tests/SwiftASBTests/Public/CodexMCPTests.swift b/Tests/SwiftASBTests/Public/CodexMCPTests.swift index 87c4e5f..b03d69d 100644 --- a/Tests/SwiftASBTests/Public/CodexMCPTests.swift +++ b/Tests/SwiftASBTests/Public/CodexMCPTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import SwiftASB +import Testing extension CodexAppServerTests { @Test("MCP install writes a stdio server through config batch write") @@ -44,8 +44,8 @@ extension CodexAppServerTests { ) let result: CodexExtensions.MCP.InstallResult switch installResult { - case let .mcp(mcpResult): - result = mcpResult + case let .mcp(mcpResult): + result = mcpResult } #expect(result.configFilePath == "/Users/example/.codex/config.toml") @@ -101,7 +101,7 @@ extension CodexAppServerTests { .mcp( .http( name: "search", - url: try #require(URL(string: "https://example.com/mcp")), + url: #require(URL(string: "https://example.com/mcp")), authorization: .bearerTokenEnvironmentVariable("SEARCH_MCP_TOKEN"), headers: ["X-Static": "yes"], environmentHeaders: ["Authorization": "SEARCH_MCP_AUTH_HEADER"], diff --git a/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift b/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift index 7a83897..4ceca8f 100644 --- a/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift +++ b/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift @@ -1,7 +1,6 @@ -import Testing @testable import SwiftASB +import Testing -@Suite("Codex workspace facts") struct CodexWorkspaceTests { @Test("worktree snapshot uses Codex-reported Git origin when available") func worktreeSnapshotUsesGitOriginWhenAvailable() { diff --git a/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift b/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift index e53de38..0ccb646 100644 --- a/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift +++ b/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift @@ -1,8 +1,7 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("SwiftASB feature operation events") struct SwiftASBFeatureOperationEventTests { @Test("feature operation event carries mutation metadata") func featureOperationEventCarriesMutationMetadata() { diff --git a/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift b/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift index 66f270b..5c10197 100644 --- a/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift +++ b/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift @@ -1,8 +1,7 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("SwiftASB feature policy") struct SwiftASBFeaturePolicyTests { @Test("built-in feature categories have stable ids and defaults") func builtInFeatureCategoriesHaveStableIDsAndDefaults() { diff --git a/Tests/SwiftASBTests/Transport/CodexAppServerTransportTests.swift b/Tests/SwiftASBTests/Transport/CodexAppServerTransportTests.swift index 418db1c..fa465de 100644 --- a/Tests/SwiftASBTests/Transport/CodexAppServerTransportTests.swift +++ b/Tests/SwiftASBTests/Transport/CodexAppServerTransportTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("CodexAppServerTransport subprocess edges", .serialized) +@Suite(.serialized) struct CodexAppServerTransportTests { @Test("rejects duplicate in-flight request IDs before writing a second request") func rejectsDuplicatePendingRequestIDs() async throws { @@ -60,16 +60,16 @@ struct CodexAppServerTransportTests { } catch let error as CodexTransportError { let recentStandardError: [String] switch error { - case let .processTerminated(reason, status, standardError): - #expect(reason == "exit") - #expect(status == 42) - recentStandardError = standardError - case let .unexpectedEndOfStream(standardError): - recentStandardError = standardError - default: - Issue.record("Expected process termination or stdout EOF, got \(String(describing: error)).") - await transport.stop() - return + case let .processTerminated(reason, status, standardError): + #expect(reason == "exit") + #expect(status == 42) + recentStandardError = standardError + case let .unexpectedEndOfStream(standardError): + recentStandardError = standardError + default: + Issue.record("Expected process termination or stdout EOF, got \(String(describing: error)).") + await transport.stop() + return } let recentStandardErrorIndexes = recentStandardError.compactMap { line in @@ -150,7 +150,8 @@ struct CodexAppServerTransportTests { } private func makeFakeCodexExecutable() throws -> URL { - let directoryURL = FileManager.default.temporaryDirectory + let directoryURL = FileManager.default + .temporaryDirectory .appendingPathComponent("SwiftASB-FakeCodex-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) diff --git a/Tests/SwiftASBTests/Transport/CodexCLIExecutableResolverTests.swift b/Tests/SwiftASBTests/Transport/CodexCLIExecutableResolverTests.swift index 2e1bf80..a17da6f 100644 --- a/Tests/SwiftASBTests/Transport/CodexCLIExecutableResolverTests.swift +++ b/Tests/SwiftASBTests/Transport/CodexCLIExecutableResolverTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("CodexCLIExecutableResolver", .serialized) +@Suite(.serialized) struct CodexCLIExecutableResolverTests { @Test("prefers an explicit executable URL when configured") func prefersExplicitExecutableURL() throws { @@ -23,10 +23,10 @@ struct CodexCLIExecutableResolverTests { #expect(resolution.launchExecutableURL == explicitURL) #expect(resolution.launchArgumentsPrefix.isEmpty) #expect(resolution.resolvedExecutableURL == explicitURL) - #expect(resolution.versionString == "codex-cli 0.139.0") - #expect(resolution.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(resolution.versionString == "codex-cli 0.140.0") + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) #expect(recorder.recordedInvocations == [ - .init(executablePath: explicitURL.path, arguments: ["--version"]) + .init(executablePath: explicitURL.path, arguments: ["--version"]), ]) } @@ -48,9 +48,9 @@ struct CodexCLIExecutableResolverTests { #expect(resolution.launchExecutableURL.path == "/usr/bin/env") #expect(resolution.launchArgumentsPrefix == ["codex"]) #expect(resolution.resolvedExecutableURL == nil) - #expect(resolution.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) #expect(recorder.recordedInvocations == [ - .init(executablePath: "/usr/bin/env", arguments: ["codex", "--version"]) + .init(executablePath: "/usr/bin/env", arguments: ["codex", "--version"]), ]) } @@ -76,10 +76,10 @@ struct CodexCLIExecutableResolverTests { #expect(resolution.launchExecutableURL.path == homebrewPath) #expect(resolution.launchArgumentsPrefix.isEmpty) #expect(resolution.resolvedExecutableURL?.path == homebrewPath) - #expect(resolution.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) #expect(recorder.recordedInvocations == [ .init(executablePath: "/usr/bin/env", arguments: ["codex", "--version"]), - .init(executablePath: homebrewPath, arguments: ["--version"]) + .init(executablePath: homebrewPath, arguments: ["--version"]), ]) } @@ -106,11 +106,11 @@ struct CodexCLIExecutableResolverTests { #expect(resolution.launchExecutableURL.path == npmCodexPath) #expect(resolution.launchArgumentsPrefix.isEmpty) #expect(resolution.resolvedExecutableURL?.path == npmCodexPath) - #expect(resolution.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) #expect(recorder.recordedInvocations == [ .init(executablePath: "/usr/bin/env", arguments: ["codex", "--version"]), .init(executablePath: "/usr/bin/env", arguments: ["npm", "prefix", "-g"]), - .init(executablePath: npmCodexPath, arguments: ["--version"]) + .init(executablePath: npmCodexPath, arguments: ["--version"]), ]) } @@ -139,7 +139,7 @@ struct CodexCLIExecutableResolverTests { @Test("marks supported versions inside the documented support window") func marksSupportedVersionsInsideSupportWindow() throws { let explicitURL = URL(fileURLWithPath: "/tmp/codex-explicit") - let recorder = CommandRecorder(pathVersionStandardOutput: "codex-cli 0.139.3") + let recorder = CommandRecorder(pathVersionStandardOutput: "codex-cli 0.140.3") let resolver = CodexCLIExecutableResolver( explicitExecutableURL: explicitURL, @@ -150,13 +150,30 @@ struct CodexCLIExecutableResolverTests { ) let resolution = try resolver.resolve() - #expect(resolution.compatibility == .supported(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) + } + + @Test("marks latest prior minor versions as supported when feasible") + func marksLatestPriorMinorVersionsAsSupportedWhenFeasible() throws { + let explicitURL = URL(fileURLWithPath: "/tmp/codex-explicit") + let recorder = CommandRecorder(pathVersionStandardOutput: "codex-cli 0.139.9") + + let resolver = CodexCLIExecutableResolver( + explicitExecutableURL: explicitURL, + environment: nil, + currentDirectoryURL: nil, + runCommand: recorder.run, + isExecutableFile: { $0 == explicitURL.path } + ) + + let resolution = try resolver.resolve() + #expect(resolution.compatibility == .supported(documentedWindow: "0.140.x plus 0.139.x when feasible")) } @Test("marks older minor versions outside the documented support window") func marksOlderMinorVersionsOutsideSupportWindow() throws { let explicitURL = URL(fileURLWithPath: "/tmp/codex-explicit") - let recorder = CommandRecorder(pathVersionStandardOutput: "codex-cli 0.128.9") + let recorder = CommandRecorder(pathVersionStandardOutput: "codex-cli 0.138.9") let resolver = CodexCLIExecutableResolver( explicitExecutableURL: explicitURL, @@ -167,7 +184,7 @@ struct CodexCLIExecutableResolverTests { ) let resolution = try resolver.resolve() - #expect(resolution.compatibility == .outsideDocumentedWindow(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .outsideDocumentedWindow(documentedWindow: "0.140.x plus 0.139.x when feasible")) } @Test("marks unparseable version strings as unknown format") @@ -184,7 +201,7 @@ struct CodexCLIExecutableResolverTests { ) let resolution = try resolver.resolve() - #expect(resolution.compatibility == .unknownVersionFormat(documentedWindow: "0.139.x")) + #expect(resolution.compatibility == .unknownVersionFormat(documentedWindow: "0.140.x plus 0.139.x when feasible")) } } @@ -195,6 +212,7 @@ private final class CommandRecorder: @unchecked Sendable { } private(set) var recordedInvocations: [Invocation] = [] + private let pathVersionTerminationStatus: Int32 private let pathVersionStandardOutput: String private let pathVersionStandardError: String @@ -204,7 +222,7 @@ private final class CommandRecorder: @unchecked Sendable { init( pathVersionTerminationStatus: Int32 = 0, - pathVersionStandardOutput: String = "codex-cli 0.139.0", + pathVersionStandardOutput: String = "codex-cli 0.140.0", pathVersionStandardError: String = "", npmPrefixTerminationStatus: Int32 = 0, npmPrefixOutput: String = "/Users/galew/.npm-global", diff --git a/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift b/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift index 7e53ea2..424c455 100644 --- a/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift +++ b/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("CodexRPCEnvelope", .serialized) +@Suite(.serialized) struct CodexRPCEnvelopeTests { @Test("classifies JSON-RPC responses using string request IDs") func classifiesResponse() throws { diff --git a/Tests/SwiftASBTests/Transport/LineDelimitedDataBufferTests.swift b/Tests/SwiftASBTests/Transport/LineDelimitedDataBufferTests.swift index 602d09d..a6fd173 100644 --- a/Tests/SwiftASBTests/Transport/LineDelimitedDataBufferTests.swift +++ b/Tests/SwiftASBTests/Transport/LineDelimitedDataBufferTests.swift @@ -1,8 +1,8 @@ import Foundation -import Testing @testable import SwiftASB +import Testing -@Suite("LineDelimitedDataBuffer", .serialized) +@Suite(.serialized) struct LineDelimitedDataBufferTests { @Test("drains complete lines and preserves partial trailing data") func drainsCompleteLines() { diff --git a/Tools/AgentSB/agentsb/maintain.py b/Tools/AgentSB/agentsb/maintain.py index 516c044..6f02f53 100644 --- a/Tools/AgentSB/agentsb/maintain.py +++ b/Tools/AgentSB/agentsb/maintain.py @@ -248,6 +248,9 @@ def _compatibility_alignment_patch(facts: dict[str, Any], schema_diff: dict[str, if not current_window or not target_window or target_window == current_window or target_minor is None: return None + prior_window = f"0.{target_minor - 1}.x" if target_minor > 0 else "none" + supported_window = f"{target_window} plus {prior_window} when feasible" + return "\n".join( [ "diff --git a/scripts/generate-wire-types.sh b/scripts/generate-wire-types.sh", @@ -266,8 +269,8 @@ def _compatibility_alignment_patch(facts: dict[str, Any], schema_diff: dict[str, "--- a/README.md", "+++ b/README.md", "@@", - f"-*Note: SwiftASB currently supports the latest reviewed Codex CLI minor release, `{current_window}`.*", - f"+*Note: SwiftASB currently supports the latest reviewed Codex CLI minor release, `{target_window}`.*", + f"-*Note: SwiftASB currently supports the reviewed current Codex CLI minor release, `{current_window}`.*", + f"+*Note: SwiftASB currently supports the reviewed current Codex CLI minor release, `{target_window}`, and the latest prior minor, `{prior_window}`, when that prior runtime remains compatible.*", "diff --git a/ROADMAP.md b/ROADMAP.md", "--- a/ROADMAP.md", "+++ b/ROADMAP.md", @@ -284,18 +287,19 @@ def _compatibility_alignment_patch(facts: dict[str, Any], schema_diff: dict[str, "@@", f"-- current reviewed minor release: `{current_window}`", f"+- current reviewed minor release: `{target_window}`", + f"+- latest prior minor supported when feasible: `{prior_window}`", "diff --git a/Tools/AgentSB/tests/test_cli.py b/Tools/AgentSB/tests/test_cli.py", "--- a/Tools/AgentSB/tests/test_cli.py", "+++ b/Tools/AgentSB/tests/test_cli.py", "@@", f'- assert facts["reviewed_codex_cli_window"]["window"] == "{current_window}"', - f'+ assert facts["reviewed_codex_cli_window"]["window"] == "{target_window}"', + f'+ assert facts["reviewed_codex_cli_window"]["window"] == "{supported_window}"', "diff --git a/Tools/AgentSB/tests/test_tools.py b/Tools/AgentSB/tests/test_tools.py", "--- a/Tools/AgentSB/tests/test_tools.py", "+++ b/Tools/AgentSB/tests/test_tools.py", "@@", f'- assert facts["reviewed_codex_cli_window"]["window"] == "{current_window}"', - f'+ assert facts["reviewed_codex_cli_window"]["window"] == "{target_window}"', + f'+ assert facts["reviewed_codex_cli_window"]["window"] == "{supported_window}"', ] ) diff --git a/Tools/AgentSB/tests/test_cli.py b/Tools/AgentSB/tests/test_cli.py index d9fe5a1..9cd8d1c 100644 --- a/Tools/AgentSB/tests/test_cli.py +++ b/Tools/AgentSB/tests/test_cli.py @@ -19,7 +19,7 @@ def test_cli_inspect_outputs_json(repo_root, capsys): assert exit_code == 0 facts = json.loads(captured.out) - assert facts["reviewed_codex_cli_window"]["window"] == "0.139.x" + assert facts["reviewed_codex_cli_window"]["window"] == "0.140.x plus 0.139.x when feasible" def test_cli_schema_review_writes_report(fake_repo, capsys): diff --git a/Tools/AgentSB/tests/test_tools.py b/Tools/AgentSB/tests/test_tools.py index 9b88319..533b7e4 100644 --- a/Tools/AgentSB/tests/test_tools.py +++ b/Tools/AgentSB/tests/test_tools.py @@ -6,7 +6,7 @@ def test_inspect_repo_reads_swiftasb_facts(repo_root): facts = inspect_repo(repo_root) - assert facts["reviewed_codex_cli_window"]["window"] == "0.139.x" + assert facts["reviewed_codex_cli_window"]["window"] == "0.140.x plus 0.139.x when feasible" assert any( item["name"] == "CodexLifecycleV2Batch+JSONValue.swift" for item in facts["promoted_wire_files"] diff --git a/docs/maintainers/interactive-lifecycle-release-boundary.md b/docs/maintainers/interactive-lifecycle-release-boundary.md index 37b5c7c..9df2088 100644 --- a/docs/maintainers/interactive-lifecycle-release-boundary.md +++ b/docs/maintainers/interactive-lifecycle-release-boundary.md @@ -49,15 +49,21 @@ runtime while the app-server schema is moving quickly before v1. Current policy: -- support the latest reviewed public Codex CLI minor release -- current reviewed minor release: `0.139.x` -- widen back to a rolling window only after the latest generated-wire and public - API boundaries have caught up with the current app-server shape +- support the reviewed current public Codex CLI minor release +- also support the latest prior public Codex CLI minor release when it remains + compatible with SwiftASB's shipped public API and validation surface +- current reviewed minor release: `0.140.x` +- latest prior minor supported for this release: `0.139.x` +- widen beyond current-plus-prior only after the latest generated-wire and + public API boundaries have caught up with the current app-server shape - reassess this policy when Codex reaches a future major-version release Practical implications: - do not require exact CLI-version pinning as the normal package contract +- when a Codex CLI refresh removes or changes fields, prefer breaking cleanup in + SwiftASB over carrying old public shims, then keep the prior minor supported + only if the resulting public API still runs against that prior app-server - expect many upstream releases to be additive rather than immediately breaking - when a newer CLI exposes extra app-server behavior, treat that as a possible late additive promotion or gated capability rather than as proof that the @@ -109,6 +115,7 @@ belongs in the release boundary: | Core item lifecycle notifications | `CodexTurnEvent.itemStarted`, `.itemCompleted` | These are the supported item-level lifecycle events today. | | Agent-message and plan deltas | `CodexTurnEvent.agentMessageDelta`, `.planDelta` | These are part of the intended interactive turn stream. | | Reasoning deltas | `CodexTurnEvent.reasoningSummaryPartAdded`, `.reasoningSummaryTextDelta`, `.reasoningTextDelta` | These are public because they are already part of the meaningful supported turn stream. | +| Sub-agent activity turn items | `CodexTurnItem.Kind.subAgentActivity` | v0.140 adds this item type to the thread-item union. SwiftASB exposes it as an item kind so existing item-start, item-complete, and stored-turn surfaces can preserve the app-server classification without a separate sub-agent activity API. | | Server-request resolution notifications | `CodexTurnEvent.serverRequestResolved`, `CodexThreadEvent.serverRequestResolved` | These are public because request cleanup is part of the supported interactive lifecycle. | | Approval request families | `CodexTurnEvent.approvalRequested`, `CodexThreadEvent.approvalRequested` | These are protocol-level server requests rather than generated notifications, but they are part of the supported public lifecycle. | | Elicitation request families | `CodexTurnEvent.elicitationRequested`, `CodexThreadEvent.elicitationRequested` | These are also protocol-level server requests and part of the supported public lifecycle. | @@ -120,7 +127,7 @@ belongs in the release boundary: | Thread shell command execution | `CodexThread.sendShellCommand(_:)` | `thread/shellCommand` is public as an explicitly gated, high-impact thread action. It stays separate from internal `command/exec` helper usage because it sends literal shell syntax to the thread shell and upstream documents it as unsandboxed full-user shell access. | | Code review start | `CodexThread.startReview(against:placement:)` | `review/start` is public as a thread-scoped review action. SwiftASB exposes hand-owned review subjects and placement names instead of the upstream `target` and `delivery` field names, and returns `CodexReviewHandle` so detached reviews can surface the returned review thread id. | | App-wide model listing | `CodexAppServer.listModels(...)` | `model/list` describes shared runtime capabilities rather than one conversation thread, so the public API belongs on the connection-owning app-server actor. | -| App-wide MCP-server status snapshots | `CodexAppServer.mcp.statusSnapshot()`, `CodexAppServer.Inventory.mcpServers`, `CodexAppServer.Library.mcpServers` | SwiftASB owns refresh for `mcpServerStatus/list`. `CodexMCP.statusSnapshot()` keeps the full connection-wide catalog for inspectors; observable Inventory and Library state expose lightweight `McpServerSummary` values for common UI surfaces. The lower-level list request remains compatibility-only while consumers move to owned snapshots. | +| App-wide MCP-server status snapshots | `CodexAppServer.mcp.statusSnapshot()`, `CodexAppServer.Inventory.mcpServers`, `CodexAppServer.Library.mcpServers` | SwiftASB owns refresh for `mcpServerStatus/list`. `CodexMCP.statusSnapshot()` keeps the full connection-wide catalog for inspectors; observable Inventory and Library state expose lightweight `McpServerSummary` values for common UI surfaces. The lower-level list request remains internal snapshot-refresh support instead of a public compatibility surface. | | App-wide MCP resource reads | `CodexAppServer.mcp.readResource(...)` | `mcpServer/resource/read` is public as a read-only capability/resource inspection action. It stays app-server-owned because the resource may be connection-wide, with optional thread context only when the app-server needs it. | | App-wide hook diagnostics listing | `CodexAppServer.Inventory`, `CodexAppServer.listHooks(...)` | `hooks/list` reports configured hooks, warnings, and load errors for working directories. Routine UI should consume Inventory while direct reads remain available when a caller intentionally owns timing or cwd selection. | | Thread-visible MCP-server summaries | `CodexThread.mcpServers`, `CodexThread.Dashboard.mcpServers` | Thread handles and dashboards expose the effective MCP service list for that thread as `McpServerSummary` values. SwiftASB classifies summaries as global when the server name is present in the global cache and thread-scoped otherwise, because the upstream status response does not currently carry an explicit scope field. | @@ -202,6 +209,7 @@ Remaining gap inside the observable-only slice: | Error notifications | The current public contract keeps lifecycle failures unified under `CodexAppServerError` instead of streaming raw protocol error-notification payloads. | | Guardian approval review started / completed notifications | The generated wire comments already mark these payloads as unstable, so they should stay internal until upstream stabilizes them and the package decides on a real public model. | | Thread-targeted MCP status update metadata | `v0.139.0` adds an optional `threadId` on `mcpServer/startupStatus/updated`, but SwiftASB keeps the public MCP status surface centered on app-wide snapshots and thread-visible summaries until a dedicated thread-scoped MCP diagnostic model is clearly earned. | +| Capability roots, plugin sharing metadata, and app auth-state drift | `v0.140.0` adds selected capability roots and additional plugin/app metadata while removing the plugin app-summary `needsAuth` field. SwiftASB keeps capability roots and sharing metadata internal for now, and removes the old public `needsAuth` compatibility field instead of inventing a fallback auth value. | ## Family Inventory diff --git a/docs/maintainers/mcp-configuration-writing.md b/docs/maintainers/mcp-configuration-writing.md index 6ead393..f237a32 100644 --- a/docs/maintainers/mcp-configuration-writing.md +++ b/docs/maintainers/mcp-configuration-writing.md @@ -28,8 +28,6 @@ MCP service reads are already owned by SwiftASB: - `CodexAppServer.mcp.install(_:)` writes user-level MCP server definitions through app-server `config/batchWrite`, reloads user config, and refreshes SwiftASB's global MCP status snapshot. -- `CodexAppServer.listMcpServerStatuses(_:)` remains a deprecated - compatibility method for callers that still need a direct list request. Thread summaries classify a service as `global` when its name appears in SwiftASB's global status cache and as `thread` otherwise. That is an inference, diff --git a/docs/maintainers/quicktype-codegen-notes.md b/docs/maintainers/quicktype-codegen-notes.md index 9e12fd6..c01c818 100644 --- a/docs/maintainers/quicktype-codegen-notes.md +++ b/docs/maintainers/quicktype-codegen-notes.md @@ -71,21 +71,20 @@ inspecting upstream schema changes or quicktype regressions. ## Current generated batch The default generated batch currently stages against the local experimental -`v0.139.0` schema dump: +`v0.140.0` schema dump: -- `SCHEMA_VERSION=v0.139.0` +- `SCHEMA_VERSION=v0.140.0` - promoted output: `Sources/SwiftASB/Generated/CodexWire/Latest/CodexLifecycleV2Batch+JSONValue.swift` The promoted `Latest` snapshot is intentionally not swapped blindly just -because staging generation succeeds. The current `v0.139.0` staging pass is a -good example of that boundary discipline: the promoted-wire delta from the -previous reviewed snapshot is only an optional `threadId` on -`CodexWireMCPServerStatusUpdatedNotification` plus wording-only generated -comment drift on turn environments, and SwiftASB keeps that new MCP status -target internal-only until a real thread-scoped diagnostic surface is clearly -earned. Promote generated changes only after classifying public, -observable-only, and internal effects. +because staging generation succeeds. The current `v0.140.0` staging pass is a +good example of that boundary discipline: SwiftASB promoted the generated +0.140 wire delta internally, widened the public turn-item kind enum for the new +`subAgentActivity` item type, and deliberately removed old public +compatibility shims for `needsAuth`, permission-profile modifications, +per-cwd skill roots, and raw MCP status-list reads. Promote generated changes +only after classifying public, observable-only, internal, and removed effects. ## Compatibility Shim Policy diff --git a/docs/maintainers/v1-public-api-audit.md b/docs/maintainers/v1-public-api-audit.md index 0fdd215..53f40dd 100644 --- a/docs/maintainers/v1-public-api-audit.md +++ b/docs/maintainers/v1-public-api-audit.md @@ -249,9 +249,9 @@ Use these decisions for every public symbol: - [x] Review `listModels(_:)` and MCP status as app-wide capability surfaces. Decision: keep model listing public, make routine app-wide state observable through `CodexAppServer.Inventory`, keep MCP summaries in observable - companions, and expose MCP detail reads through `CodexAppServer.mcp`. - Keep `listMcpServerStatuses(_:)` compatibility-only while consumers move - away from raw MCP list requests. + companions, and expose MCP detail reads through `CodexAppServer.mcp`. The + later 0.140 compatibility cleanup removed the raw MCP status-list public + convenience in favor of SwiftASB-owned snapshots. - [x] Review whether `CodexAppServer.swift` should keep all nested app-server request/result/domain values, or split more values into dedicated files. Decision: split by responsibility before v1; no new owners were introduced. @@ -514,7 +514,7 @@ Use these decisions for every public symbol: Decision: covered by the startup, progress/approval, diagnostics/history, and SwiftUI observable companion walkthroughs in `Sources/SwiftASB/SwiftASB.docc/`. - [x] Update stale README release references before the next release. - Decision: README now names `v1.7.4` as the current released baseline. + Decision: README now names `v1.7.5` as the current released baseline. - [x] Confirm README, DocC, and this audit use the same v1 release boundary. Decision: README, DocC, and this audit now describe the same narrow v1 promise: app-server lifecycle, app-wide capability reads, stored-thread diff --git a/docs/maintainers/v1-public-api-symbol-inventory.md b/docs/maintainers/v1-public-api-symbol-inventory.md index 25abf13..1a9ed1d 100644 --- a/docs/maintainers/v1-public-api-symbol-inventory.md +++ b/docs/maintainers/v1-public-api-symbol-inventory.md @@ -34,8 +34,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.McpResourceTemplate` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpServerStatus` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpServerStatus.AuthStatus` (`enum`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest.Detail` (`enum`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpServerStatusPage` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpTool` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.Model` (`struct`) - Sources/SwiftASB/Public/CodexAppServer+Models.swift @@ -219,8 +217,6 @@ additions are recorded in the ledger sections below. - `CodexTurnReasoningTextDelta` (`struct`) - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnStarted` (`struct`) - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexWorkspace` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift -- `CodexWorkspace.ActivePermissionModification` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift -- `CodexWorkspace.ActivePermissionModification.Kind` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.ActivePermissionProfile` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.FileSystemAccessMode` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.FileSystemPath` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift @@ -233,7 +229,6 @@ additions are recorded in the ledger sections below. - `CodexWorkspace.PermissionProfile` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.PermissionProfile.Kind` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.PermissionSelection` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift -- `CodexWorkspace.PermissionSelectionModification` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.ProjectInfo` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.ProjectInfo.IdentitySource` (`enum`) - Sources/SwiftASB/Public/CodexWorkspace.swift - `CodexWorkspace.RepositoryInfo` (`struct`) - Sources/SwiftASB/Public/CodexWorkspace.swift @@ -250,8 +245,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.InitializeRequest.init(capabilities:clientInfo:)` - `init(capabilities: CodexAppServer.InitializeCapabilities = .init(), clientInfo: CodexAppServer.ClientInfo)` - Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift - `CodexAppServer.InputModality.init(rawValue:)` - `init?(rawValue: String)` - Sources/SwiftASB/Public/CodexAppServer+Models.swift - `CodexAppServer.McpServerStatus.AuthStatus.init(rawValue:)` - `init?(rawValue: String)` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest.Detail.init(rawValue:)` - `init?(rawValue: String)` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest.init(cursor:limit:detail:threadID:)` - `init(cursor: String? = nil, limit: Int? = nil, detail: CodexAppServer.McpServerStatusListRequest.Detail? = nil, threadID: String? = nil)` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.ModelListRequest.init(cursor:limit:includeHidden:)` - `init(cursor: String? = nil, limit: Int? = nil, includeHidden: Bool? = nil)` - Sources/SwiftASB/Public/CodexAppServer+Models.swift - `CodexAppServer.Personality.init(rawValue:)` - `init?(rawValue: String)` - Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift - `CodexAppServer.ReasoningEffort.init(rawValue:)` - `init?(rawValue: String)` - Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift @@ -342,7 +335,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.init(configuration:)` - `init(configuration: CodexAppServer.Configuration = .init())` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.initialize(_:)` - `func initialize(_ request: CodexAppServer.InitializeRequest) async throws -> CodexAppServer.InitializeSession` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.mcpServerStatusSnapshot()` - `func mcpServerStatusSnapshot() -> CodexAppServer.McpServerStatusPage` - Sources/SwiftASB/Public/CodexAppServer.swift -- `CodexAppServer.listMcpServerStatuses(_:)` - `@available(*, deprecated) func listMcpServerStatuses(_ request: CodexAppServer.McpServerStatusListRequest = .init()) async throws -> CodexAppServer.McpServerStatusPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listModels(_:)` - `func listModels(_ request: CodexAppServer.ModelListRequest = .init()) async throws -> CodexAppServer.ModelListPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listThreadTurns(_:)` - `func listThreadTurns(_ request: CodexAppServer.ThreadTurnsListRequest) async throws -> CodexAppServer.ThreadTurnsPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listThreads(_:)` - `func listThreads(_ request: CodexAppServer.ThreadListRequest = .init()) async throws -> CodexAppServer.ThreadListPage` - Sources/SwiftASB/Public/CodexAppServer.swift @@ -444,7 +436,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.InitializeCapabilities.init(experimentalAPI:optOutNotificationMethods:)` - `init(experimentalAPI: Bool? = nil, optOutNotificationMethods: [String]? = nil)` - Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift - `CodexAppServer.InitializeRequest.init(capabilities:clientInfo:)` - `init(capabilities: CodexAppServer.InitializeCapabilities = .init(), clientInfo: CodexAppServer.ClientInfo)` - Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift - `CodexAppServer.LoadedThreadListRequest.init(cursor:limit:)` - `init(cursor: String? = nil, limit: Int? = nil)` - Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift -- `CodexAppServer.McpServerStatusListRequest.init(cursor:limit:detail:threadID:)` - `init(cursor: String? = nil, limit: Int? = nil, detail: CodexAppServer.McpServerStatusListRequest.Detail? = nil, threadID: String? = nil)` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.ModelListRequest.init(cursor:limit:includeHidden:)` - `init(cursor: String? = nil, limit: Int? = nil, includeHidden: Bool? = nil)` - Sources/SwiftASB/Public/CodexAppServer+Models.swift - `CodexAppServer.ThreadForkRequest.init(threadID:approvalPolicy:approvalsReviewer:baseInstructions:config:currentDirectoryPath:developerInstructions:ephemeral:excludeTurns:model:modelProvider:personality:sandboxMode:serviceName:serviceTier:)` - `init(threadID: String, approvalPolicy: CodexAppServer.ApprovalPolicy? = nil, approvalsReviewer: CodexAppServer.ApprovalsReviewer? = nil, baseInstructions: String? = nil, config: [String : CodexAppServer.JSONValue]? = nil, currentDirectoryPath: String? = nil, developerInstructions: String? = nil, ephemeral: Bool? = nil, excludeTurns: Bool? = nil, model: String? = nil, modelProvider: String? = nil, personality: CodexAppServer.Personality? = nil, sandboxMode: CodexAppServer.SandboxMode? = nil, serviceName: String? = nil, serviceTier: CodexAppServer.ServiceTier? = nil)` - Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift - `CodexAppServer.ThreadListRequest.init(cursor:limit:sortKey:sortDirection:modelProviders:sourceKinds:archived:currentDirectoryPath:searchTerm:)` - `init(cursor: String? = nil, limit: Int? = nil, sortKey: CodexAppServer.ThreadListSortKey? = nil, sortDirection: CodexAppServer.ThreadListSortDirection? = nil, modelProviders: [String]? = nil, sourceKinds: [CodexAppServer.ThreadListSourceKind]? = nil, archived: Bool? = nil, currentDirectoryPath: String? = nil, searchTerm: String? = nil)` - Sources/SwiftASB/Public/CodexAppServer+ThreadLifecycle.swift @@ -461,7 +452,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.init(configuration:)` - `init(configuration: CodexAppServer.Configuration = .init())` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listLoadedThreads(_:)` - `func listLoadedThreads(_ request: CodexAppServer.LoadedThreadListRequest = .init()) async throws -> CodexAppServer.LoadedThreadListPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.mcpServerStatusSnapshot()` - `func mcpServerStatusSnapshot() -> CodexAppServer.McpServerStatusPage` - Sources/SwiftASB/Public/CodexAppServer.swift -- `CodexAppServer.listMcpServerStatuses(_:)` - `@available(*, deprecated) func listMcpServerStatuses(_ request: CodexAppServer.McpServerStatusListRequest = .init()) async throws -> CodexAppServer.McpServerStatusPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listModels(_:)` - `func listModels(_ request: CodexAppServer.ModelListRequest = .init()) async throws -> CodexAppServer.ModelListPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.listThreads(_:)` - `func listThreads(_ request: CodexAppServer.ThreadListRequest = .init()) async throws -> CodexAppServer.ThreadListPage` - Sources/SwiftASB/Public/CodexAppServer.swift - `CodexAppServer.startThread(_:)` - `func startThread(_ request: CodexAppServer.ThreadStartRequest = .init()) async throws -> CodexThread` - Sources/SwiftASB/Public/CodexAppServer.swift @@ -543,8 +533,6 @@ additions are recorded in the ledger sections below. - `CodexAppServer.McpServerStatus.AuthStatus.notLoggedIn` - `case notLoggedIn` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpServerStatus.AuthStatus.oAuth` - `case oAuth` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.McpServerStatus.AuthStatus.unsupported` - `case unsupported` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest.Detail.full` - `case full` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift -- `CodexAppServer.McpServerStatusListRequest.Detail.toolsAndAuthOnly` - `case toolsAndAuthOnly` - Sources/SwiftASB/Public/CodexAppServer+MCP.swift - `CodexAppServer.NetworkAccess.enabled` - `case enabled` - Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift - `CodexAppServer.NetworkAccess.explicit(_:)` - `case explicit(Bool)` - Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift - `CodexAppServer.NetworkAccess.restricted` - `case restricted` - Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift @@ -765,6 +753,7 @@ additions are recorded in the ledger sections below. - `CodexTurnItem.Kind.mcpToolCall` - `case mcpToolCall` - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnItem.Kind.plan` - `case plan` - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnItem.Kind.reasoning` - `case reasoning` - Sources/SwiftASB/Public/CodexTurnHandle.swift +- `CodexTurnItem.Kind.subAgentActivity` - `case subAgentActivity` - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnItem.Kind.userMessage` - `case userMessage` - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnItem.Kind.webSearch` - `case webSearch` - Sources/SwiftASB/Public/CodexTurnHandle.swift - `CodexTurnPlanUpdate.Step.Status.completed` - `case completed` - Sources/SwiftASB/Public/CodexTurnHandle.swift @@ -791,7 +780,7 @@ The 2026-05-06 app-server schema promotion added several hand-owned public names - `CodexThread.RecentFilesQD` and `CodexThread.RecentCommandsQD` describe repeatable recent-activity companion startup intent. - `CodexAppServer.Library.GroupedBy.repository` groups app-wide library snapshots by `CodexWorkspace.ProjectInfo` identity: app-server Git origin metadata with cwd fallback. - `CodexAppServer.Library` exposes stable worktree groups, selected worktree/repository context, and sorted repository/worktree thread filters for sidebar and project-browser UIs independent of the caller-selected visible grouping mode. -- `CodexWorkspace` owns app-server-owned permission selections, runtime workspace permission facts, project identity, worktree snapshots, and selected-worktree Git observability values: `PermissionSelection`, `PermissionSelectionModification`, `ActivePermissionProfile`, `ActivePermissionModification`, `PermissionProfile`, `FileSystemPermissions`, `FileSystemSandboxEntry`, `FileSystemAccessMode`, `FileSystemPath`, `FileSystemSpecialPath`, `NetworkPermissions`, `ProjectInfo`, `RepositoryInfo`, `SessionSnapshot`, `WorktreeSnapshot`, `GitStatusSnapshot`, `GitStatusSummary`, `GitRemoteInfo`, and `GitFactSource`. +- `CodexWorkspace` owns app-server-owned permission selections, runtime workspace permission facts, project identity, worktree snapshots, and selected-worktree Git observability values: `PermissionSelection`, `ActivePermissionProfile`, `PermissionProfile`, `FileSystemPermissions`, `FileSystemSandboxEntry`, `FileSystemAccessMode`, `FileSystemPath`, `FileSystemSpecialPath`, `NetworkPermissions`, `ProjectInfo`, `RepositoryInfo`, `SessionSnapshot`, `WorktreeSnapshot`, `GitStatusSnapshot`, `GitStatusSummary`, `GitRemoteInfo`, and `GitFactSource`. - `CodexAppServer.ThreadStartRequest`, `ThreadResumeRequest`, `ThreadForkRequest`, `TurnStartRequest`, `CodexThread.TurnStartRequest`, and `CodexThread.startTextTurn(...)` now accept optional `CodexWorkspace.PermissionSelection` values. - `CodexAppServer.ThreadSession` and `CodexThread` now expose active permission-profile provenance, runtime permission facts, app-server-owned project identity, app-server-owned worktree snapshots, and a `CodexWorkspace.SessionSnapshot`. - `CodexAppServer.ThreadInfo` and `CodexAppServer.Library.ThreadSnapshot` now expose `CodexAppServer.ThreadSource` so launcher UIs can badge CLI, app-server, editor, custom, and sub-agent threads without reading generated wire values. diff --git a/scripts/generate-wire-types.sh b/scripts/generate-wire-types.sh index acc33ce..2326b68 100755 --- a/scripts/generate-wire-types.sh +++ b/scripts/generate-wire-types.sh @@ -2,7 +2,7 @@ set -eu ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -SCHEMA_VERSION=${SCHEMA_VERSION:-v0.139.0} +SCHEMA_VERSION=${SCHEMA_VERSION:-v0.140.0} SCHEMA_ROOT="$ROOT_DIR/codex-schemas/$SCHEMA_VERSION" DERIVED_DIR="$ROOT_DIR/tmp/derived-schemas/${SCHEMA_VERSION//./_}" OUT_DIR="$ROOT_DIR/tmp/quicktype-wire/${SCHEMA_VERSION//./_}" diff --git a/scripts/patch_quicktype_swift_any.py b/scripts/patch_quicktype_swift_any.py index 61355c5..5c14f03 100644 --- a/scripts/patch_quicktype_swift_any.py +++ b/scripts/patch_quicktype_swift_any.py @@ -14,6 +14,7 @@ JSON_VALUE_DECLARATION = """ +// swiftformat:disable redundantSendable indirect enum CodexWireJSONValue: Codable, Equatable, Sendable { case null case bool(Bool) @@ -69,6 +70,7 @@ } } } +// swiftformat:enable redundantSendable """.strip() diff --git a/scripts/run-live-codex-server-request-probes.sh b/scripts/run-live-codex-server-request-probes.sh index 61b9d20..9510074 100755 --- a/scripts/run-live-codex-server-request-probes.sh +++ b/scripts/run-live-codex-server-request-probes.sh @@ -13,7 +13,7 @@ printf '%s\n' 'Running SwiftASB live Codex server-request probes.' printf '%s\n' 'Step 1/3: deterministic command and permissions approval probes' sh "$REPO_ROOT/scripts/run-live-codex-approval-probe.sh" -printf '%s\n' 'Step 2/3: deterministic tool-user-input and app-connector MCP elicitation probes' +printf '%s\n' 'Step 2/3: deterministic tool-user-input and app-connector MCP observation probes' env SWIFTASB_ENABLE_LIVE_CODEX_SERVER_REQUEST_TESTS=1 \ SWIFTASB_LIVE_CODEX_REPORT_DIR="$SWIFTASB_LIVE_CODEX_REPORT_DIR" \ swift test --filter 'CodexAppServerLiveIntegrationTests/(completesDeterministicToolUserInputThroughRawRealAppServer|completesDeterministicAppConnectorMcpElicitationThroughRawRealAppServer)'