import Foundation /// A `{version, value}` wrapper carried by structured `session-updated` /// patches for `metadata` / `agentState` / `todos` / `teamState`. /// /// The version is the only safe way to reject stale patches: the dual SSE /// connections have no shared ordering, so `value` must be applied — and the /// matching watermark stored — only when `version` is strictly greater than /// the cached one (see `isNewerVersionedPatch(patchVersion:cachedVersion:)`). /// Never assign the wrapper itself into a `Session`. public struct VersionedValue: Codable, Equatable, Sendable { public var version: Int /// `nil` covers wire-`null` (explicit clear, e.g. team deletion). public var value: Value? public init(version: Int, value: Value?) { self.version = version self.value = value } } /// A patch field whose wire type is `T | null`, where "key absent" and /// "key present with null" mean different things. /// /// `SessionPatch.model = nil` means the patch does not touch `model`; /// `.null` means the patch explicitly clears it. Swift's synthesized Codable /// (`decodeIfPresent`) conflates the two, so `SessionPatch` decodes these by /// hand via `contains` + `decodeNil`. public enum PatchField: Equatable, Sendable { case null case value(Value) /// The value as it would appear after applying: `nil` for `.null`. public var wireValue: Value? { if case .value(let value) = self { return value } return nil } } /// A structured `session-updated` patch. /// /// Mirrors `SessionPatchSchema` (`shared/src/schemas.ts:371-404`), which is /// zod `.strict()`: unknown keys make decoding throw. That strictness is /// load-bearing — `SessionUpdatedData` discriminates the /// `Session | SessionPatch` union by attempting `Session` first and falling /// back to the strict patch, so a malformed full session must not silently /// half-decode as a lenient patch. public struct SessionPatch: Equatable, Sendable { public var active: Bool? public var thinking: Bool? /// Carried on the wire but NOT applied by `applySessionDetailPatch` /// (mirroring `web/src/lib/sessionPatch.ts`). public var activeTurnStartedAt: PatchField? public var activeAt: Int? public var updatedAt: Int? public var metadata: VersionedValue? public var agentState: VersionedValue? public var todos: VersionedValue<[TodoItem]>? public var teamState: VersionedValue? public var model: PatchField? public var modelReasoningEffort: PatchField? public var effort: PatchField? public var serviceTier: PatchField? public var permissionMode: PermissionMode? public var collaborationMode: CodexCollaborationMode? public var copilotAgentMode: CopilotAgentMode? public var backgroundTaskCount: Int? /// Bare refetch trigger for `GET /api/sessions/:id/scratchlist`; never /// applied onto the `Session` (which carries no such field). public var scratchlistUpdatedAt: Int? public init( active: Bool? = nil, thinking: Bool? = nil, activeTurnStartedAt: PatchField? = nil, activeAt: Int? = nil, updatedAt: Int? = nil, metadata: VersionedValue? = nil, agentState: VersionedValue? = nil, todos: VersionedValue<[TodoItem]>? = nil, teamState: VersionedValue? = nil, model: PatchField? = nil, modelReasoningEffort: PatchField? = nil, effort: PatchField? = nil, serviceTier: PatchField? = nil, permissionMode: PermissionMode? = nil, collaborationMode: CodexCollaborationMode? = nil, copilotAgentMode: CopilotAgentMode? = nil, backgroundTaskCount: Int? = nil, scratchlistUpdatedAt: Int? = nil ) { self.active = active self.thinking = thinking self.activeTurnStartedAt = activeTurnStartedAt self.activeAt = activeAt self.updatedAt = updatedAt self.metadata = metadata self.agentState = agentState self.todos = todos self.teamState = teamState self.model = model self.modelReasoningEffort = modelReasoningEffort self.effort = effort self.serviceTier = serviceTier self.permissionMode = permissionMode self.collaborationMode = collaborationMode self.copilotAgentMode = copilotAgentMode self.backgroundTaskCount = backgroundTaskCount self.scratchlistUpdatedAt = scratchlistUpdatedAt } } extension SessionPatch: Codable { private enum CodingKeys: String, CodingKey, CaseIterable { case active case thinking case activeTurnStartedAt case activeAt case updatedAt case metadata case agentState case todos case teamState case model case modelReasoningEffort case effort case serviceTier case permissionMode case collaborationMode case copilotAgentMode case backgroundTaskCount case scratchlistUpdatedAt } public init(from decoder: Decoder) throws { // Mirror zod `.strict()`: any unknown key rejects the whole patch. try rejectUnknownKeys( in: decoder, known: Set(CodingKeys.allCases.map(\.stringValue)), payloadName: "SessionPatch" ) let container = try decoder.container(keyedBy: CodingKeys.self) active = try container.decodeIfPresent(Bool.self, forKey: .active) thinking = try container.decodeIfPresent(Bool.self, forKey: .thinking) activeTurnStartedAt = try Self.decodePatchField(Int.self, in: container, forKey: .activeTurnStartedAt) activeAt = try container.decodeIfPresent(Int.self, forKey: .activeAt) updatedAt = try container.decodeIfPresent(Int.self, forKey: .updatedAt) metadata = try container.decodeIfPresent(VersionedValue.self, forKey: .metadata) agentState = try container.decodeIfPresent(VersionedValue.self, forKey: .agentState) todos = try container.decodeIfPresent(VersionedValue<[TodoItem]>.self, forKey: .todos) teamState = try container.decodeIfPresent(VersionedValue.self, forKey: .teamState) model = try Self.decodePatchField(String.self, in: container, forKey: .model) modelReasoningEffort = try Self.decodePatchField(String.self, in: container, forKey: .modelReasoningEffort) effort = try Self.decodePatchField(String.self, in: container, forKey: .effort) serviceTier = try Self.decodePatchField(String.self, in: container, forKey: .serviceTier) permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode) collaborationMode = try container.decodeIfPresent(CodexCollaborationMode.self, forKey: .collaborationMode) copilotAgentMode = try container.decodeIfPresent(CopilotAgentMode.self, forKey: .copilotAgentMode) backgroundTaskCount = try container.decodeIfPresent(Int.self, forKey: .backgroundTaskCount) scratchlistUpdatedAt = try container.decodeIfPresent(Int.self, forKey: .scratchlistUpdatedAt) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(active, forKey: .active) try container.encodeIfPresent(thinking, forKey: .thinking) try Self.encodePatchField(activeTurnStartedAt, in: &container, forKey: .activeTurnStartedAt) try container.encodeIfPresent(activeAt, forKey: .activeAt) try container.encodeIfPresent(updatedAt, forKey: .updatedAt) try container.encodeIfPresent(metadata, forKey: .metadata) try container.encodeIfPresent(agentState, forKey: .agentState) try container.encodeIfPresent(todos, forKey: .todos) try container.encodeIfPresent(teamState, forKey: .teamState) try Self.encodePatchField(model, in: &container, forKey: .model) try Self.encodePatchField(modelReasoningEffort, in: &container, forKey: .modelReasoningEffort) try Self.encodePatchField(effort, in: &container, forKey: .effort) try Self.encodePatchField(serviceTier, in: &container, forKey: .serviceTier) try container.encodeIfPresent(permissionMode, forKey: .permissionMode) try container.encodeIfPresent(collaborationMode, forKey: .collaborationMode) try container.encodeIfPresent(copilotAgentMode, forKey: .copilotAgentMode) try container.encodeIfPresent(backgroundTaskCount, forKey: .backgroundTaskCount) try container.encodeIfPresent(scratchlistUpdatedAt, forKey: .scratchlistUpdatedAt) } private static func decodePatchField( _ type: Value.Type, in container: KeyedDecodingContainer, forKey key: CodingKeys ) throws -> PatchField? { guard container.contains(key) else { return nil } if try container.decodeNil(forKey: key) { return .null } return .value(try container.decode(Value.self, forKey: key)) } private static func encodePatchField( _ field: PatchField?, in container: inout KeyedEncodingContainer, forKey key: CodingKeys ) throws { guard let field else { return } switch field { case .null: try container.encodeNil(forKey: key) case .value(let value): try container.encode(value, forKey: key) } } }