diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/AgentFlavor.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/AgentFlavor.swift new file mode 100644 index 00000000..2a483b93 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/AgentFlavor.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Agent flavor id. +/// +/// Ports `AGENT_FLAVORS` (`shared/src/modes.ts:10`) plus the capability / +/// label tables from `shared/src/flavors.ts`. Unknown wire values are +/// preserved in `.other` so a newer hub never crashes decoding — they behave +/// like TS's failed `isKnownFlavor` check (no capabilities, "Unknown" label, +/// Claude permission-mode fallback). +public enum AgentFlavor: Equatable, Hashable, Sendable { + case agy + case claude + case codex + case copilot + case cursor + case gemini + case grok + case kimi + case opencode + case pi + case other(String) + + /// Order matches `AGENT_FLAVORS`. + public static let knownFlavors: [AgentFlavor] = [ + .agy, .claude, .codex, .copilot, .cursor, .gemini, .grok, .kimi, .opencode, .pi, + ] + + /// Flavors offered when creating a new session (`CREATABLE_AGENT_FLAVORS`). + /// Gemini is excluded: the consumer Gemini CLI was sunset, but stored + /// Gemini sessions remain viewable. + public static let creatableFlavors: [AgentFlavor] = knownFlavors.filter { $0 != .gemini } + + public var isKnown: Bool { + if case .other = self { return false } + return true + } +} + +extension AgentFlavor: RawRepresentable { + public init(rawValue: String) { + switch rawValue { + case "agy": self = .agy + case "claude": self = .claude + case "codex": self = .codex + case "copilot": self = .copilot + case "cursor": self = .cursor + case "gemini": self = .gemini + case "grok": self = .grok + case "kimi": self = .kimi + case "opencode": self = .opencode + case "pi": self = .pi + default: self = .other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .agy: return "agy" + case .claude: return "claude" + case .codex: return "codex" + case .copilot: return "copilot" + case .cursor: return "cursor" + case .gemini: return "gemini" + case .grok: return "grok" + case .kimi: return "kimi" + case .opencode: return "opencode" + case .pi: return "pi" + case .other(let raw): return raw + } + } +} + +extension AgentFlavor: Codable { + public init(from decoder: Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +// MARK: - Labels and capabilities (shared/src/flavors.ts) + +extension AgentFlavor { + /// Display name (`FLAVOR_LABELS`); `"Unknown"` for `.other`, mirroring + /// `getFlavorLabel`. + public var displayLabel: String { + switch self { + case .agy: return "Antigravity" + case .claude: return "Claude" + case .codex: return "Codex" + case .copilot: return "Copilot" + case .cursor: return "Cursor" + case .gemini: return "Gemini" + case .grok: return "Grok Build" + case .kimi: return "Kimi" + case .opencode: return "OpenCode" + case .pi: return "Pi" + case .other: return "Unknown" + } + } + + /// `hasCapability(flavor, 'model-change')` — true for every known flavor. + public var supportsModelChange: Bool { + isKnown + } + + /// `hasCapability(flavor, 'effort')` — claude, grok, and pi only. + public var supportsEffort: Bool { + switch self { + case .claude, .grok, .pi: return true + default: return false + } + } + + /// `isCodexFamilyFlavor` — flavors speaking the generic codex envelope + /// protocol variants. + public var isCodexFamily: Bool { + switch self { + case .codex, .gemini, .grok, .kimi, .copilot, .opencode: return true + default: return false + } + } +} + +/// String-based mirror of `getFlavorLabel(flavor)` for raw +/// `metadata.flavor` values (`nil`/unknown → `"Unknown"`). +public func flavorLabel(forFlavor flavor: String?) -> String { + guard let flavor else { return "Unknown" } + return AgentFlavor(rawValue: flavor).displayLabel +} + +/// String-based mirror of `supportsModelChange(flavor)` (`nil`/unknown → false). +public func supportsModelChange(forFlavor flavor: String?) -> Bool { + guard let flavor else { return false } + return AgentFlavor(rawValue: flavor).supportsModelChange +} + +/// String-based mirror of `supportsEffort(flavor)` (`nil`/unknown → false). +public func supportsEffort(forFlavor flavor: String?) -> Bool { + guard let flavor else { return false } + return AgentFlavor(rawValue: flavor).supportsEffort +} + +/// String-based mirror of `isCodexFamilyFlavor(flavor)`. +public func isCodexFamilyFlavor(_ flavor: String?) -> Bool { + guard let flavor else { return false } + return AgentFlavor(rawValue: flavor).isCodexFamily +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift new file mode 100644 index 00000000..2145f338 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Visual weight of a permission mode in pickers. +/// +/// Ports `PermissionModeTone` (`shared/src/modes.ts`). +public enum PermissionModeTone: String, Codable, Sendable { + case neutral + case info + case warning + case danger +} + +/// Union of every flavor's permission mode ids. +/// +/// Ports `PERMISSION_MODES` (`shared/src/modes.ts:52-66`); declaration order +/// matches the TS array. Labels and tones port +/// `PERMISSION_MODE_LABELS` / `PERMISSION_MODE_TONES` verbatim (English — +/// localization is an M5 concern layered above the catalog). +public enum PermissionMode: String, Codable, CaseIterable, Sendable { + case `default` + case acceptEdits + case auto + case bypassPermissions + case plan + case ask + case debug + case autoReview + case readOnly = "read-only" + case safeYolo = "safe-yolo" + case yolo + case requestReview = "request-review" + case alwaysProceed = "always-proceed" + + public var label: String { + switch self { + case .default: return "Default" + case .acceptEdits: return "Accept Edits" + case .auto: return "Auto" + case .bypassPermissions: return "Yolo" + case .plan: return "Plan Mode" + case .ask: return "Ask Mode" + case .debug: return "Debug Mode" + case .autoReview: return "Auto-review" + case .readOnly: return "Read Only" + case .safeYolo: return "Safe Yolo" + case .yolo: return "Yolo" + case .requestReview: return "Request Review" + case .alwaysProceed: return "Always Proceed" + } + } + + public var tone: PermissionModeTone { + switch self { + case .default: return .neutral + case .acceptEdits: return .warning + case .auto: return .warning + case .bypassPermissions: return .danger + case .plan: return .info + case .ask: return .info + case .debug: return .info + case .autoReview: return .warning + case .readOnly: return .warning + case .safeYolo: return .warning + case .yolo: return .danger + case .requestReview: return .neutral + case .alwaysProceed: return .danger + } + } +} + +/// A picker-ready permission mode entry (`PermissionModeOption`). +public struct PermissionModeOption: Equatable, Sendable { + public let mode: PermissionMode + public let label: String + public let tone: PermissionModeTone + + public init(mode: PermissionMode) { + self.mode = mode + self.label = mode.label + self.tone = mode.tone + } +} + +extension AgentFlavor { + /// Permission modes offered for this flavor, in picker order. + /// + /// Ports the per-flavor arrays in `shared/src/modes.ts` + /// (`getPermissionModesForFlavor`): unknown flavors fall back to the + /// Claude set, and Pi offers none (its RPC mode always auto-approves). + public var permissionModes: [PermissionMode] { + switch self { + case .codex, .gemini, .kimi, .copilot: + return [.default, .readOnly, .safeYolo, .yolo] + case .grok: + return [.default, .auto, .plan, .bypassPermissions] + case .opencode: + return [.default, .plan, .yolo] + case .agy: + return [.requestReview, .alwaysProceed] + case .cursor: + return [.default, .plan, .ask, .debug, .autoReview, .yolo] + case .pi: + return [] + case .claude, .other: + return [.default, .acceptEdits, .auto, .bypassPermissions, .plan] + } + } +} + +/// String-based mirror of `getPermissionModesForFlavor(flavor)`: +/// `nil` and unknown flavors yield the Claude set. +public func permissionModes(forFlavor flavor: String?) -> [PermissionMode] { + AgentFlavor(rawValue: flavor ?? "claude").permissionModes +} + +/// Ports `getPermissionModeOptionsForFlavor(flavor)`. +public func permissionModeOptions(forFlavor flavor: String?) -> [PermissionModeOption] { + permissionModes(forFlavor: flavor).map(PermissionModeOption.init(mode:)) +} + +/// Ports `isPermissionModeAllowedForFlavor(mode, flavor)`. +public func isPermissionModeAllowed(_ mode: PermissionMode, forFlavor flavor: String?) -> Bool { + permissionModes(forFlavor: flavor).contains(mode) +} + +// MARK: - Codex collaboration modes + +/// Ports `CODEX_COLLABORATION_MODES` + labels (`shared/src/modes.ts`). +public enum CodexCollaborationMode: String, Codable, CaseIterable, Sendable { + case `default` + case plan + + public var label: String { + switch self { + case .default: return "Default" + case .plan: return "Plan" + } + } +} + +// MARK: - Copilot agent modes + +/// Ports `COPILOT_AGENT_MODES` + labels (`shared/src/copilotModes.ts`). +/// +/// Decoding accepts the legacy `"fleet"` value and coerces it to +/// `.interactive`, mirroring `CopilotAgentModeSchema` (`shared/src/schemas.ts:9-12`). +public enum CopilotAgentMode: String, CaseIterable, Sendable { + case interactive + case plan + case autopilot + + public var label: String { + switch self { + case .interactive: return "Interactive" + case .plan: return "Plan" + case .autopilot: return "Autopilot" + } + } + + /// Ports `normalizeCopilotAgentMode`: `"fleet"` and anything invalid + /// coerce to `.interactive`. + public static func normalize(_ value: String?) -> CopilotAgentMode { + guard let value, value != "fleet" else { return .interactive } + return CopilotAgentMode(rawValue: value) ?? .interactive + } +} + +extension CopilotAgentMode: Codable { + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + if raw == "fleet" { + self = .interactive + return + } + guard let mode = CopilotAgentMode(rawValue: raw) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown CopilotAgentMode '\(raw)'" + )) + } + self = mode + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift new file mode 100644 index 00000000..f765d244 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift @@ -0,0 +1,106 @@ +import Foundation + +/// A pending tool-approval / user-input request raised by the agent. +/// +/// Mirrors `AgentStateRequestSchema` (`shared/src/schemas.ts`). Permission +/// requests are NOT messages: they live on `Session.agentState.requests` +/// keyed by request id and are resolved over +/// `POST /api/sessions/:id/permissions/:requestId/approve|deny`. +public struct AgentStateRequest: Codable, Equatable, Sendable { + public var tool: String + /// Free-form tool arguments (`z.unknown()` on the wire). + public var arguments: JSONValue? + /// Epoch ms when the request was raised; older CLIs omit it. + public var createdAt: Int? + + public init(tool: String, arguments: JSONValue? = nil, createdAt: Int? = nil) { + self.tool = tool + self.arguments = arguments + self.createdAt = createdAt + } +} + +/// A resolved permission request, moved from `requests` to +/// `completedRequests` once decided. +/// +/// Mirrors `AgentStateCompletedRequestSchema` (`shared/src/schemas.ts`). +public struct AgentStateCompletedRequest: Codable, Equatable, Sendable { + public enum Status: String, Codable, Sendable { + case canceled + case denied + case approved + } + + public enum Decision: String, Codable, Sendable { + case approved + case approvedForSession = "approved_for_session" + case denied + case abort + } + + public var tool: String + public var arguments: JSONValue? + public var createdAt: Int? + public var completedAt: Int? + public var status: Status + public var reason: String? + /// Permission mode switched to while approving, when any. + public var mode: String? + public var decision: Decision? + public var allowTools: [String]? + /// Two wire formats, kept raw: + /// flat `{question: [answers]}` (AskUserQuestion) or + /// nested `{question: {answers: [...]}}` (request_user_input). + public var answers: JSONValue? + + public init( + tool: String, + arguments: JSONValue? = nil, + createdAt: Int? = nil, + completedAt: Int? = nil, + status: Status, + reason: String? = nil, + mode: String? = nil, + decision: Decision? = nil, + allowTools: [String]? = nil, + answers: JSONValue? = nil + ) { + self.tool = tool + self.arguments = arguments + self.createdAt = createdAt + self.completedAt = completedAt + self.status = status + self.reason = reason + self.mode = mode + self.decision = decision + self.allowTools = allowTools + self.answers = answers + } +} + +/// Agent-owned session state carried on `Session.agentState` and patched over +/// SSE under the `agentStateVersion` gate. +/// +/// Mirrors `AgentStateSchema` (`shared/src/schemas.ts:195-203`). +public struct AgentState: Codable, Equatable, Sendable { + /// `true` while the terminal owns the session (remote control paused). + public var controlledByUser: Bool? + /// Launch mode the session was started in (persisted for reopen/resume). + public var startingMode: SessionStartingMode? + /// Pending requests keyed by request id. + public var requests: [String: AgentStateRequest]? + /// Resolved requests keyed by request id. + public var completedRequests: [String: AgentStateCompletedRequest]? + + public init( + controlledByUser: Bool? = nil, + startingMode: SessionStartingMode? = nil, + requests: [String: AgentStateRequest]? = nil, + completedRequests: [String: AgentStateCompletedRequest]? = nil + ) { + self.controlledByUser = controlledByUser + self.startingMode = startingMode + self.requests = requests + self.completedRequests = completedRequests + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift new file mode 100644 index 00000000..63349a00 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift @@ -0,0 +1,91 @@ +import Foundation + +/// One chat message as returned by `GET /api/sessions/:id/messages` and the +/// `message-received` SSE event. +/// +/// Mirrors `DecryptedMessageSchema` (`shared/src/schemas.ts`). `content` is +/// deliberately left as raw ``JSONValue``: it is the role-wrapped envelope +/// whose decode tree (`docs/api/client-contract/messages.md`) is ported in the +/// M2 chat milestones. Decoding a message is total — an absent or `null` +/// `content` degrades to `.null` instead of throwing. +public struct DecryptedMessage: Codable, Equatable, Sendable { + /// Server uuid. Optimistic rows use the client `localId` until echoed. + public var id: String + /// Per-session insert counter; `null` on optimistic rows. + public var seq: Int? + /// Client-generated id for optimistic reconciliation. + public var localId: String? + /// Role-wrapped envelope, kept wire-verbatim (decoded by the chat pipeline). + public var content: JSONValue + /// Hub receive time (epoch ms). + public var createdAt: Int + /// When the agent consumed the message (epoch ms). `nil` covers both + /// wire-`null` (still queued) and an absent key (pre-V8 hubs stamped + /// rows as already invoked without the field). Current hubs always send + /// the key, so treating `nil` as "queued" is correct against them; if + /// pre-V8 hub support is ever needed, the M2 window store must + /// distinguish explicit null (see pagination.md "Queued semantics"). + public var invokedAt: Int? + /// Future-scheduled send time (epoch ms), when set. + public var scheduledAt: Int? + + public init( + id: String, + seq: Int? = nil, + localId: String? = nil, + content: JSONValue = .null, + createdAt: Int, + invokedAt: Int? = nil, + scheduledAt: Int? = nil + ) { + self.id = id + self.seq = seq + self.localId = localId + self.content = content + self.createdAt = createdAt + self.invokedAt = invokedAt + self.scheduledAt = scheduledAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + seq = try container.decodeIfPresent(Int.self, forKey: .seq) + localId = try container.decodeIfPresent(String.self, forKey: .localId) + content = try container.decodeIfPresent(JSONValue.self, forKey: .content) ?? .null + createdAt = try container.decode(Int.self, forKey: .createdAt) + invokedAt = try container.decodeIfPresent(Int.self, forKey: .invokedAt) + scheduledAt = try container.decodeIfPresent(Int.self, forKey: .scheduledAt) + } +} + +/// Metadata describing an uploaded message attachment. +/// +/// Mirrors `AttachmentMetadataSchema` (`shared/src/schemas.ts`). Used inside +/// user-message payloads and in the send-message request body. +public struct AttachmentMetadata: Codable, Equatable, Sendable { + public var id: String + public var filename: String + public var mimeType: String + /// Size in bytes. + public var size: Int + public var path: String + /// Web-serving detail; present on wire payloads, absent from fixtures. + public var previewUrl: String? + + public init( + id: String, + filename: String, + mimeType: String, + size: Int, + path: String, + previewUrl: String? = nil + ) { + self.id = id + self.filename = filename + self.mimeType = mimeType + self.size = size + self.path = path + self.previewUrl = previewUrl + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Machine.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Machine.swift new file mode 100644 index 00000000..dd824dab --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Machine.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Machine (runner host) metadata written by the CLI. +/// +/// Mirrors `MachineMetadataSchema` (`shared/src/schemas.ts`). +public struct MachineMetadata: Codable, Equatable, Sendable { + public var host: String + public var platform: String + public var happyCliVersion: String + public var displayName: String? + public var homeDir: String? + public var happyHomeDir: String? + public var happyLibDir: String? + public var workspaceRoots: [String]? + /// Machine-scoped RPC capability ids this runner registers. + public var capabilities: [String]? + public var startedCliMtimeMs: Int? + public var installedCliMtimeMs: Int? + public var supervisedRestart: Bool? + + public init( + host: String, + platform: String, + happyCliVersion: String, + displayName: String? = nil, + homeDir: String? = nil, + happyHomeDir: String? = nil, + happyLibDir: String? = nil, + workspaceRoots: [String]? = nil, + capabilities: [String]? = nil, + startedCliMtimeMs: Int? = nil, + installedCliMtimeMs: Int? = nil, + supervisedRestart: Bool? = nil + ) { + self.host = host + self.platform = platform + self.happyCliVersion = happyCliVersion + self.displayName = displayName + self.homeDir = homeDir + self.happyHomeDir = happyHomeDir + self.happyLibDir = happyLibDir + self.workspaceRoots = workspaceRoots + self.capabilities = capabilities + self.startedCliMtimeMs = startedCliMtimeMs + self.installedCliMtimeMs = installedCliMtimeMs + self.supervisedRestart = supervisedRestart + } +} + +/// Runner capability flags inside `RunnerState`. +/// +/// `agentConfigs` (unified agent configuration descriptors) is kept +/// wire-verbatim; nothing in v1 consumes its internals. +public struct RunnerCapabilities: Codable, Equatable, Sendable { + public var piExistingSessionResume: Bool? + public var agentConfigs: [JSONValue]? + + public init(piExistingSessionResume: Bool? = nil, agentConfigs: [JSONValue]? = nil) { + self.piExistingSessionResume = piExistingSessionResume + self.agentConfigs = agentConfigs + } +} + +/// Runner daemon state reported per machine. +/// +/// Mirrors `RunnerStateSchema` (`shared/src/schemas.ts`). `status` and +/// `shutdownSource` are open string unions on the wire (`enum | string`), so +/// they stay `String` with known-value helpers; `lastSpawnError` is kept +/// wire-verbatim. +public struct RunnerState: Codable, Equatable, Sendable { + public var status: String + public var pid: Int? + public var httpPort: Int? + public var startedAt: Int? + public var capabilities: RunnerCapabilities? + public var shutdownRequestedAt: Int? + public var shutdownSource: String? + public var lastSpawnError: JSONValue? + + public var isRunning: Bool { status == "running" } + public var isShuttingDown: Bool { status == "shutting-down" } + + public init( + status: String, + pid: Int? = nil, + httpPort: Int? = nil, + startedAt: Int? = nil, + capabilities: RunnerCapabilities? = nil, + shutdownRequestedAt: Int? = nil, + shutdownSource: String? = nil, + lastSpawnError: JSONValue? = nil + ) { + self.status = status + self.pid = pid + self.httpPort = httpPort + self.startedAt = startedAt + self.capabilities = capabilities + self.shutdownRequestedAt = shutdownRequestedAt + self.shutdownSource = shutdownSource + self.lastSpawnError = lastSpawnError + } +} + +/// Point-in-time host health sample. +/// +/// Mirrors `MachineHealthSchema` (`shared/src/schemas.ts`). zod marks it +/// `.strict()`, but this decoder stays lenient on purpose: health is +/// advisory, and a new hub-side field must not break machine decoding. +public struct MachineHealth: Codable, Equatable, Sendable { + public var collectedAt: Int + public var cpuCount: Int? + public var load1m: Double? + public var cpuPercent: Double? + public var memoryPercent: Double? + public var uptimeSeconds: Double? + + public init( + collectedAt: Int, + cpuCount: Int? = nil, + load1m: Double? = nil, + cpuPercent: Double? = nil, + memoryPercent: Double? = nil, + uptimeSeconds: Double? = nil + ) { + self.collectedAt = collectedAt + self.cpuCount = cpuCount + self.load1m = load1m + self.cpuPercent = cpuPercent + self.memoryPercent = memoryPercent + self.uptimeSeconds = uptimeSeconds + } +} + +/// One machine row of `GET /api/machines` and full-payload `machine-updated` +/// events. +/// +/// Mirrors `MachineSchema` (`shared/src/schemas.ts:492-507`). +public struct Machine: Codable, Equatable, Sendable { + public var id: String + public var namespace: String + public var seq: Int + public var createdAt: Int + public var updatedAt: Int + public var active: Bool + public var activeAt: Int + public var metadata: MachineMetadata? + public var metadataVersion: Int + public var runnerState: RunnerState? + public var runnerStateVersion: Int + public var health: MachineHealth? + + public init( + id: String, + namespace: String, + seq: Int, + createdAt: Int, + updatedAt: Int, + active: Bool, + activeAt: Int, + metadata: MachineMetadata? = nil, + metadataVersion: Int, + runnerState: RunnerState? = nil, + runnerStateVersion: Int, + health: MachineHealth? = nil + ) { + self.id = id + self.namespace = namespace + self.seq = seq + self.createdAt = createdAt + self.updatedAt = updatedAt + self.active = active + self.activeAt = activeAt + self.metadata = metadata + self.metadataVersion = metadataVersion + self.runnerState = runnerState + self.runnerStateVersion = runnerStateVersion + self.health = health + } +} + +/// A flat `machine-updated` patch. +/// +/// Mirrors `MachinePatchSchema` (`shared/src/schemas.ts:509-513`), zod +/// `.strict()` — strictness discriminates the +/// `Machine | MachinePatch | null` union (see `MachineUpdatedData`). +public struct MachinePatch: Equatable, Sendable { + public var active: Bool? + public var activeAt: Int? + public var updatedAt: Int? + + public init(active: Bool? = nil, activeAt: Int? = nil, updatedAt: Int? = nil) { + self.active = active + self.activeAt = activeAt + self.updatedAt = updatedAt + } +} + +extension MachinePatch: Codable { + private enum CodingKeys: String, CodingKey, CaseIterable { + case active + case activeAt + case updatedAt + } + + public init(from decoder: Decoder) throws { + try rejectUnknownKeys( + in: decoder, + known: Set(CodingKeys.allCases.map(\.stringValue)), + payloadName: "MachinePatch" + ) + let container = try decoder.container(keyedBy: CodingKeys.self) + active = try container.decodeIfPresent(Bool.self, forKey: .active) + activeAt = try container.decodeIfPresent(Int.self, forKey: .activeAt) + updatedAt = try container.decodeIfPresent(Int.self, forKey: .updatedAt) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(active, forKey: .active) + try container.encodeIfPresent(activeAt, forKey: .activeAt) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift new file mode 100644 index 00000000..cb582a9a --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Cursor block of a `GET /api/sessions/:id/messages` response. +/// +/// Mirrors `MessagesResponse['page']` (`shared/src/apiTypes.ts`); full cursor +/// semantics in `docs/api/client-contract/pagination.md`. Every cursor is a +/// `(seq, at)` pair — both halves travel together. +public struct MessagesPage: Codable, Equatable, Sendable { + public enum Direction: String, Codable, Sendable { + case latest + case before + case after + } + + public var direction: Direction + public var limit: Int + /// Server's current epoch for this session; a mismatch on your next + /// `after` request yields a `reset: true` latest page. + public var epoch: Int + /// `true` ⇒ discard the local window, this page replaces it. + public var reset: Bool + public var nextBeforeSeq: Int? + public var nextBeforeAt: Int? + public var nextAfterSeq: Int? + public var nextAfterAt: Int? + /// Newest position at snapshot time (inclusive catch-up bound). + public var snapshotHeadSeq: Int? + public var snapshotHeadAt: Int? + /// More rows exist in the requested direction. + public var hasMore: Bool + + public init( + direction: Direction, + limit: Int, + epoch: Int, + reset: Bool, + nextBeforeSeq: Int? = nil, + nextBeforeAt: Int? = nil, + nextAfterSeq: Int? = nil, + nextAfterAt: Int? = nil, + snapshotHeadSeq: Int? = nil, + snapshotHeadAt: Int? = nil, + hasMore: Bool + ) { + self.direction = direction + self.limit = limit + self.epoch = epoch + self.reset = reset + self.nextBeforeSeq = nextBeforeSeq + self.nextBeforeAt = nextBeforeAt + self.nextAfterSeq = nextAfterSeq + self.nextAfterAt = nextAfterAt + self.snapshotHeadSeq = snapshotHeadSeq + self.snapshotHeadAt = snapshotHeadAt + self.hasMore = hasMore + } +} + +/// Envelope of `GET /api/sessions/:id/messages`. +public struct MessagesResponse: Codable, Equatable, Sendable { + /// Ascending display order. + public var messages: [DecryptedMessage] + public var page: MessagesPage + + public init(messages: [DecryptedMessage], page: MessagesPage) { + self.messages = messages + self.page = page + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift deleted file mode 100644 index 1a1a17ed..00000000 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// The hub protocol generation this client understands. -/// -/// Real wire models (sessions, messages, patches, catalogs) land in M1a. -/// This type exists from M0 so the target, its tests, and the app's package -/// linkage have a stable anchor. -public struct ProtocolVersion: Sendable { - /// Highest protocol generation supported by this build. - public static let supported = 1 -} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift new file mode 100644 index 00000000..40c79623 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift @@ -0,0 +1,137 @@ +import Foundation + +/// The full session record returned by `GET /api/sessions/:id` and carried by +/// full-payload `session-updated` SSE events. +/// +/// Mirrors `SessionSchema` (`shared/src/schemas.ts:304-338`) field by field. +/// `metadataVersion` / `agentStateVersion` / `todosUpdatedAt` / +/// `teamStateUpdatedAt` are the watermarks the versioned-patch gate in +/// `applySessionDetailPatch(session:patch:)` checks against. +public struct Session: Codable, Equatable, Sendable { + public var id: String + public var namespace: String + public var seq: Int + public var createdAt: Int + public var updatedAt: Int + public var pinned: Bool? + public var globalPinned: Bool? + public var active: Bool + /// Last keep-alive stamp (epoch ms). The wire may carry `null` for legacy + /// rows; zod coerces to `0` and this decoder replicates that. + public var activeAt: Int + public var metadata: SessionMetadata? + public var metadataVersion: Int + public var agentState: AgentState? + public var agentStateVersion: Int + public var thinking: Bool + public var thinkingAt: Int + public var activeTurnStartedAt: Int? + public var backgroundTaskCount: Int? + public var todos: [TodoItem]? + /// Team-mode state, kept wire-verbatim (no v1 UI consumes its internals). + public var teamState: JSONValue? + /// Version watermark for `todos` patches (absent = 0 when gating). + public var todosUpdatedAt: Int? + /// Version watermark for `teamState` patches (absent = 0 when gating). + public var teamStateUpdatedAt: Int? + public var model: String? + public var modelReasoningEffort: String? + public var effort: String? + public var serviceTier: String? + public var permissionMode: PermissionMode? + public var collaborationMode: CodexCollaborationMode? + public var copilotAgentMode: CopilotAgentMode? + + public init( + id: String, + namespace: String, + seq: Int, + createdAt: Int, + updatedAt: Int, + pinned: Bool? = nil, + globalPinned: Bool? = nil, + active: Bool, + activeAt: Int, + metadata: SessionMetadata? = nil, + metadataVersion: Int, + agentState: AgentState? = nil, + agentStateVersion: Int, + thinking: Bool, + thinkingAt: Int, + activeTurnStartedAt: Int? = nil, + backgroundTaskCount: Int? = nil, + todos: [TodoItem]? = nil, + teamState: JSONValue? = nil, + todosUpdatedAt: Int? = nil, + teamStateUpdatedAt: Int? = nil, + model: String? = nil, + modelReasoningEffort: String? = nil, + effort: String? = nil, + serviceTier: String? = nil, + permissionMode: PermissionMode? = nil, + collaborationMode: CodexCollaborationMode? = nil, + copilotAgentMode: CopilotAgentMode? = nil + ) { + self.id = id + self.namespace = namespace + self.seq = seq + self.createdAt = createdAt + self.updatedAt = updatedAt + self.pinned = pinned + self.globalPinned = globalPinned + self.active = active + self.activeAt = activeAt + self.metadata = metadata + self.metadataVersion = metadataVersion + self.agentState = agentState + self.agentStateVersion = agentStateVersion + self.thinking = thinking + self.thinkingAt = thinkingAt + self.activeTurnStartedAt = activeTurnStartedAt + self.backgroundTaskCount = backgroundTaskCount + self.todos = todos + self.teamState = teamState + self.todosUpdatedAt = todosUpdatedAt + self.teamStateUpdatedAt = teamStateUpdatedAt + self.model = model + self.modelReasoningEffort = modelReasoningEffort + self.effort = effort + self.serviceTier = serviceTier + self.permissionMode = permissionMode + self.collaborationMode = collaborationMode + self.copilotAgentMode = copilotAgentMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + namespace = try container.decode(String.self, forKey: .namespace) + seq = try container.decode(Int.self, forKey: .seq) + createdAt = try container.decode(Int.self, forKey: .createdAt) + updatedAt = try container.decode(Int.self, forKey: .updatedAt) + pinned = try container.decodeIfPresent(Bool.self, forKey: .pinned) + globalPinned = try container.decodeIfPresent(Bool.self, forKey: .globalPinned) + active = try container.decode(Bool.self, forKey: .active) + // zod: `z.number().nullish().transform((value) => value ?? 0)`. + activeAt = try container.decodeIfPresent(Int.self, forKey: .activeAt) ?? 0 + metadata = try container.decodeIfPresent(SessionMetadata.self, forKey: .metadata) + metadataVersion = try container.decode(Int.self, forKey: .metadataVersion) + agentState = try container.decodeIfPresent(AgentState.self, forKey: .agentState) + agentStateVersion = try container.decode(Int.self, forKey: .agentStateVersion) + thinking = try container.decode(Bool.self, forKey: .thinking) + thinkingAt = try container.decode(Int.self, forKey: .thinkingAt) + activeTurnStartedAt = try container.decodeIfPresent(Int.self, forKey: .activeTurnStartedAt) + backgroundTaskCount = try container.decodeIfPresent(Int.self, forKey: .backgroundTaskCount) + todos = try container.decodeIfPresent([TodoItem].self, forKey: .todos) + teamState = try container.decodeIfPresent(JSONValue.self, forKey: .teamState) + todosUpdatedAt = try container.decodeIfPresent(Int.self, forKey: .todosUpdatedAt) + teamStateUpdatedAt = try container.decodeIfPresent(Int.self, forKey: .teamStateUpdatedAt) + model = try container.decodeIfPresent(String.self, forKey: .model) + modelReasoningEffort = try container.decodeIfPresent(String.self, forKey: .modelReasoningEffort) + effort = try container.decodeIfPresent(String.self, forKey: .effort) + serviceTier = try container.decodeIfPresent(String.self, 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) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift new file mode 100644 index 00000000..1bb87081 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift @@ -0,0 +1,145 @@ +import Foundation + +/// How a session was launched. Shared by `SessionMetadata.startingMode` and +/// `AgentState.startingMode` (`z.enum(['local', 'remote', 'pty'])`). +public enum SessionStartingMode: String, Codable, Sendable { + case local + case remote + case pty +} + +/// The `metadata.summary` blob (`{text, updatedAt}`). +public struct SessionMetadataSummary: Codable, Equatable, Sendable { + public var text: String + public var updatedAt: Int + + public init(text: String, updatedAt: Int) { + self.text = text + self.updatedAt = updatedAt + } +} + +/// Worktree info for sessions spawned into a git worktree. +/// +/// Mirrors `WorktreeMetadataSchema` (`shared/src/schemas.ts`). +public struct WorktreeMetadata: Codable, Equatable, Sendable { + public var basePath: String + public var branch: String + public var name: String + public var worktreePath: String? + public var createdAt: Int? + + public init( + basePath: String, + branch: String, + name: String, + worktreePath: String? = nil, + createdAt: Int? = nil + ) { + self.basePath = basePath + self.branch = branch + self.name = name + self.worktreePath = worktreePath + self.createdAt = createdAt + } +} + +/// Conversation-history feature toggles inside `SessionCapabilities`. +public struct ConversationHistoryCapabilities: Codable, Equatable, Sendable { + public var forkCurrent: Bool? + public var forkAtMessage: Bool? + public var rewindToMessage: Bool? + + public init( + forkCurrent: Bool? = nil, + forkAtMessage: Bool? = nil, + rewindToMessage: Bool? = nil + ) { + self.forkCurrent = forkCurrent + self.forkAtMessage = forkAtMessage + self.rewindToMessage = rewindToMessage + } +} + +/// Per-session capability flags advertised by the CLI. +public struct SessionCapabilities: Codable, Equatable, Sendable { + public var terminal: Bool? + public var conversationHistory: ConversationHistoryCapabilities? + + public init( + terminal: Bool? = nil, + conversationHistory: ConversationHistoryCapabilities? = nil + ) { + self.terminal = terminal + self.conversationHistory = conversationHistory + } +} + +/// Typed subset of the session `metadata` blob (`MetadataSchema`, +/// `shared/src/schemas.ts:54-163`) covering what the app renders. +/// +/// Decoding is lenient by construction: unknown wire fields (the per-flavor +/// native-session-id bookkeeping, fork/rewind maps, import state, ...) are +/// ignored by `JSONDecoder`, matching zod's default strip behavior. The CLI is +/// the only writer of metadata — natives never round-trip it to the hub, so +/// dropping fields we do not render is safe. +public struct SessionMetadata: Codable, Equatable, Sendable { + /// Working directory of the session (required on the wire). + public var path: String + /// Hostname of the machine running the CLI (required on the wire). + public var host: String + /// User-assigned session name. + public var name: String? + public var os: String? + public var summary: SessionMetadataSummary? + public var machineId: String? + /// Agent flavor id (`z.string().nullish()`); resolve with + /// ``AgentFlavor/init(rawValue:)`` — unknown values map to `.other`. + public var flavor: String? + public var startingMode: SessionStartingMode? + /// Free-form lifecycle marker (e.g. `running`, `archived`). + public var lifecycleState: String? + /// Durable link to the session that replaced this one after a + /// clear/reopen; used to follow superseding sessions. + public var supersededBySessionId: String? + public var worktree: WorktreeMetadata? + public var capabilities: SessionCapabilities? + /// Loopback MCP URL when the session's CLI happy server is running. + public var hapiMcpUrl: String? + public var slashCommands: [String]? + public var tools: [String]? + + public init( + path: String, + host: String, + name: String? = nil, + os: String? = nil, + summary: SessionMetadataSummary? = nil, + machineId: String? = nil, + flavor: String? = nil, + startingMode: SessionStartingMode? = nil, + lifecycleState: String? = nil, + supersededBySessionId: String? = nil, + worktree: WorktreeMetadata? = nil, + capabilities: SessionCapabilities? = nil, + hapiMcpUrl: String? = nil, + slashCommands: [String]? = nil, + tools: [String]? = nil + ) { + self.path = path + self.host = host + self.name = name + self.os = os + self.summary = summary + self.machineId = machineId + self.flavor = flavor + self.startingMode = startingMode + self.lifecycleState = lifecycleState + self.supersededBySessionId = supersededBySessionId + self.worktree = worktree + self.capabilities = capabilities + self.hapiMcpUrl = hapiMcpUrl + self.slashCommands = slashCommands + self.tools = tools + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift new file mode 100644 index 00000000..7ba26ef5 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift @@ -0,0 +1,208 @@ +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) + } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift new file mode 100644 index 00000000..0412bb60 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Classification of a pending request for list badges. +public enum PendingRequestKind: String, Codable, Sendable { + case permission + case input +} + +/// A capped, list-friendly view of one pending tool request. +/// +/// Mirrors `PendingRequest` (`shared/src/sessionSummary.ts`). +public struct PendingRequest: Codable, Equatable, Sendable { + public var id: String + public var kind: PendingRequestKind + public var tool: String + /// Epoch ms when the request was raised (falls back server-side to the + /// session's `updatedAt` for requests stored without `createdAt`). + public var since: Int + + public init(id: String, kind: PendingRequestKind, tool: String, since: Int) { + self.id = id + self.kind = kind + self.tool = tool + self.since = since + } +} + +/// Todo completion counters shown on the session row. +public struct TodoProgress: Codable, Equatable, Sendable { + public var completed: Int + public var total: Int + + public init(completed: Int, total: Int) { + self.completed = completed + self.total = total + } +} + +/// Metadata subset embedded in a session-list row. +/// +/// Mirrors `SessionSummaryMetadata` (`shared/src/sessionSummary.ts`). +public struct SessionSummaryMetadata: Codable, Equatable, Sendable { + /// The `summary` field carries only the text in list rows. + public struct Summary: Codable, Equatable, Sendable { + public var text: String + + public init(text: String) { + self.text = text + } + } + + public var name: String? + public var path: String + public var machineId: String? + public var summary: Summary? + public var flavor: String? + public var worktree: WorktreeMetadata? + /// Native agent session id resolved per flavor by the hub. + public var agentSessionId: String? + public var lifecycleState: String? + public var hapiMcpUrl: String? + + public init( + name: String? = nil, + path: String, + machineId: String? = nil, + summary: Summary? = nil, + flavor: String? = nil, + worktree: WorktreeMetadata? = nil, + agentSessionId: String? = nil, + lifecycleState: String? = nil, + hapiMcpUrl: String? = nil + ) { + self.name = name + self.path = path + self.machineId = machineId + self.summary = summary + self.flavor = flavor + self.worktree = worktree + self.agentSessionId = agentSessionId + self.lifecycleState = lifecycleState + self.hapiMcpUrl = hapiMcpUrl + } +} + +/// One row of `GET /api/sessions`. +/// +/// Mirrors `SessionSummary` (`shared/src/sessionSummary.ts`). The summary is +/// hub-derived: badges must use `pendingRequestsCount` (authoritative total), +/// never `pendingRequests.count` (capped at 5, oldest first). +public struct SessionSummary: Codable, Equatable, Sendable { + /// Server-side cap on `pendingRequests` entries + /// (`PENDING_REQUEST_SUMMARY_CAP`, `shared/src/sessionSummary.ts`). + public static let pendingRequestSummaryCap = 5 + + public var id: String + public var active: Bool + public var thinking: Bool + public var activeAt: Int + public var updatedAt: Int + public var pinned: Bool? + public var globalPinned: Bool? + public var metadata: SessionSummaryMetadata? + /// Watermark mirrors of the detail record so structured SSE patches can + /// be gated without a detail fetch. + public var metadataVersion: Int + public var agentStateVersion: Int + public var todosUpdatedAt: Int + public var todoProgress: TodoProgress? + public var pendingRequestsCount: Int + public var pendingRequestKinds: [PendingRequestKind] + public var pendingRequests: [PendingRequest] + public var backgroundTaskCount: Int + public var futureScheduledMessageCount: Int + /// Epoch ms of the soonest uninvoked scheduled message, or `nil`. + public var nextScheduledAt: Int? + public var model: String? + public var modelReasoningEffort: String? + public var effort: String? + + public init( + id: String, + active: Bool, + thinking: Bool, + activeAt: Int, + updatedAt: Int, + pinned: Bool? = nil, + globalPinned: Bool? = nil, + metadata: SessionSummaryMetadata? = nil, + metadataVersion: Int, + agentStateVersion: Int, + todosUpdatedAt: Int, + todoProgress: TodoProgress? = nil, + pendingRequestsCount: Int, + pendingRequestKinds: [PendingRequestKind] = [], + pendingRequests: [PendingRequest] = [], + backgroundTaskCount: Int = 0, + futureScheduledMessageCount: Int = 0, + nextScheduledAt: Int? = nil, + model: String? = nil, + modelReasoningEffort: String? = nil, + effort: String? = nil + ) { + self.id = id + self.active = active + self.thinking = thinking + self.activeAt = activeAt + self.updatedAt = updatedAt + self.pinned = pinned + self.globalPinned = globalPinned + self.metadata = metadata + self.metadataVersion = metadataVersion + self.agentStateVersion = agentStateVersion + self.todosUpdatedAt = todosUpdatedAt + self.todoProgress = todoProgress + self.pendingRequestsCount = pendingRequestsCount + self.pendingRequestKinds = pendingRequestKinds + self.pendingRequests = pendingRequests + self.backgroundTaskCount = backgroundTaskCount + self.futureScheduledMessageCount = futureScheduledMessageCount + self.nextScheduledAt = nextScheduledAt + self.model = model + self.modelReasoningEffort = modelReasoningEffort + self.effort = effort + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/StrictDecoding.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/StrictDecoding.swift new file mode 100644 index 00000000..c49b85e9 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/StrictDecoding.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A pass-through coding key used to enumerate whatever keys a JSON object +/// actually carries (the synthesized `CodingKeys` enums cannot represent +/// unknown keys). +struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} + +/// Mirror of zod's `.strict()`: throw when the payload carries a key outside +/// `known`. Used by the patch types whose strictness discriminates the +/// `Session | SessionPatch` and `Machine | MachinePatch | null` unions. +func rejectUnknownKeys(in decoder: Decoder, known: Set, payloadName: String) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + for key in container.allKeys where !known.contains(key.stringValue) { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key '\(key.stringValue)' in strict \(payloadName) payload" + )) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SyncEvent.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SyncEvent.swift new file mode 100644 index 00000000..0aece0e3 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SyncEvent.swift @@ -0,0 +1,265 @@ +import Foundation + +/// Why a session ended (`session-ended` event). +public enum SessionEndReason: String, Codable, Sendable { + case completed + case terminated + case error + case handoff + case cleared +} + +/// Payload of a `toast` event (visibility-targeted in-app notification). +public struct ToastPayload: Codable, Equatable, Sendable { + public var title: String + public var body: String + public var sessionId: String + public var url: String + + public init(title: String, body: String, sessionId: String, url: String) { + self.title = title + self.body = body + self.sessionId = sessionId + self.url = url + } +} + +/// Reconnect verdict inside `connection-changed`. +/// +/// `ok` = the hub replays every missed event right after the handshake, so +/// the client may skip its REST resync. `gap` — or an absent/unknown verdict +/// — means a full refetch is required. +public enum ResumeVerdict: String, Codable, Sendable { + case ok + case gap +} + +/// Payload of the `connection-changed` handshake frame. +public struct ConnectionChangedPayload: Codable, Equatable, Sendable { + public var status: String + /// Needed for `POST /api/visibility`; new on every reconnect. + public var subscriptionId: String? + /// Decoded leniently: an unknown wire verdict becomes `nil`, which + /// clients must treat as `gap` (the contract's "absence" rule). + public var resume: ResumeVerdict? + + public init(status: String, subscriptionId: String? = nil, resume: ResumeVerdict? = nil) { + self.status = status + self.subscriptionId = subscriptionId + self.resume = resume + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + status = try container.decode(String.self, forKey: .status) + subscriptionId = try container.decodeIfPresent(String.self, forKey: .subscriptionId) + if let raw = try container.decodeIfPresent(String.self, forKey: .resume) { + resume = ResumeVerdict(rawValue: raw) + } else { + resume = nil + } + } +} + +/// The `session-updated` (and `session-added`) data union: +/// a full `Session`, a strict `SessionPatch`, or something this client cannot +/// interpret (the caller must fall back to a REST refetch, mirroring the web +/// client's behavior when `SessionUpdatedDataSchema` fails to parse). +public enum SessionUpdatedData: Equatable, Sendable { + case session(Session) + case patch(SessionPatch) + case unrecognized(JSONValue) +} + +extension SessionUpdatedData: Decodable { + public init(from decoder: Decoder) throws { + // Same order as `z.union([SessionSchema, SessionPatchSchema])`. + if let session = try? Session(from: decoder) { + self = .session(session) + return + } + if let patch = try? SessionPatch(from: decoder) { + self = .patch(patch) + return + } + self = .unrecognized(try JSONValue(from: decoder)) + } +} + +/// The `machine-updated` data union: full `Machine`, strict `MachinePatch`, +/// explicit `null` (machine removed), or unrecognized (refetch machines). +public enum MachineUpdatedData: Equatable, Sendable { + case machine(Machine) + case patch(MachinePatch) + case removed + case unrecognized(JSONValue) +} + +extension MachineUpdatedData: Decodable { + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), single.decodeNil() { + self = .removed + return + } + if let machine = try? Machine(from: decoder) { + self = .machine(machine) + return + } + if let patch = try? MachinePatch(from: decoder) { + self = .patch(patch) + return + } + self = .unrecognized(try JSONValue(from: decoder)) + } +} + +/// One frame of the `GET /api/events` SSE stream. +/// +/// Mirrors `SyncEventSchema` (`shared/src/schemas.ts:535-605`) and +/// `docs/api/client-contract/sse.md`. Every case carries the optional +/// `namespace`. Unknown event types decode to `.unknown` — never throw — +/// so a hub newer than this client degrades gracefully; known types with +/// malformed payloads throw, mirroring a zod parse failure. +public enum SyncEvent: Equatable, Sendable { + /// Handle exactly like `session-updated` (the web client shares the + /// branch): `.session` upserts, anything else falls back to a refetch. + case sessionAdded(namespace: String?, sessionId: String, data: SessionUpdatedData?) + case sessionUpdated(namespace: String?, sessionId: String, data: SessionUpdatedData?) + case sessionRemoved(namespace: String?, sessionId: String) + case messageReceived(namespace: String?, sessionId: String, message: DecryptedMessage) + /// History changed structurally (rewind/fork/import/clear): discard the + /// window and tail-sync. + case messagesInvalidated(namespace: String?, sessionId: String) + /// A scheduled message became due and was handed to the agent. + case scheduledMatured(namespace: String?, sessionId: String) + /// `reason` is `nil` when absent or unknown on the wire. + case sessionEnded(namespace: String?, sessionId: String, reason: SessionEndReason?) + /// `data` is `nil` when the wire field is absent (refetch machines). + case machineUpdated(namespace: String?, machineId: String, data: MachineUpdatedData?) + case toast(namespace: String?, data: ToastPayload) + /// Queued user messages were consumed: stamp `invokedAt` on the rows. + case messagesConsumed(namespace: String?, sessionId: String, localIds: [String], invokedAt: Int) + case messageCancelled(namespace: String?, sessionId: String, messageId: String, localId: String?) + /// Feeds the staleness watchdog only. Carries no SSE `id`. + case heartbeat(namespace: String?, timestamp: Int?) + /// Subscription handshake. Carries no SSE `id`. + case connectionChanged(namespace: String?, data: ConnectionChangedPayload?) + /// Any `type` this client does not know. Ignore. + case unknown(type: String, namespace: String?) +} + +extension SyncEvent: Decodable { + private enum CodingKeys: String, CodingKey { + case type + case namespace + case sessionId + case machineId + case data + case message + case reason + case localIds + case invokedAt + case messageId + case localId + } + + private struct HeartbeatData: Decodable { + let timestamp: Int + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + let namespace = try container.decodeIfPresent(String.self, forKey: .namespace) + switch type { + case "session-added": + self = .sessionAdded( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + data: try container.decodeIfPresent(SessionUpdatedData.self, forKey: .data) + ) + case "session-updated": + self = .sessionUpdated( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + data: try container.decodeIfPresent(SessionUpdatedData.self, forKey: .data) + ) + case "session-removed": + self = .sessionRemoved( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId) + ) + case "message-received": + self = .messageReceived( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + message: try container.decode(DecryptedMessage.self, forKey: .message) + ) + case "messages-invalidated": + self = .messagesInvalidated( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId) + ) + case "scheduled-matured": + self = .scheduledMatured( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId) + ) + case "session-ended": + let reason = try container.decodeIfPresent(String.self, forKey: .reason) + .flatMap(SessionEndReason.init(rawValue:)) + self = .sessionEnded( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + reason: reason + ) + case "machine-updated": + let machineId = try container.decode(String.self, forKey: .machineId) + // `data: null` means "machine removed" and must stay distinct + // from an absent `data` (refetch); `decodeIfPresent` would + // conflate the two, so probe presence and null by hand. + let data: MachineUpdatedData? + if container.contains(.data) { + if try container.decodeNil(forKey: .data) { + data = .removed + } else { + data = try container.decode(MachineUpdatedData.self, forKey: .data) + } + } else { + data = nil + } + self = .machineUpdated(namespace: namespace, machineId: machineId, data: data) + case "toast": + self = .toast( + namespace: namespace, + data: try container.decode(ToastPayload.self, forKey: .data) + ) + case "messages-consumed": + self = .messagesConsumed( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + localIds: try container.decode([String].self, forKey: .localIds), + invokedAt: try container.decode(Int.self, forKey: .invokedAt) + ) + case "message-cancelled": + self = .messageCancelled( + namespace: namespace, + sessionId: try container.decode(String.self, forKey: .sessionId), + messageId: try container.decode(String.self, forKey: .messageId), + localId: try container.decodeIfPresent(String.self, forKey: .localId) + ) + case "heartbeat": + self = .heartbeat( + namespace: namespace, + timestamp: try container.decodeIfPresent(HeartbeatData.self, forKey: .data)?.timestamp + ) + case "connection-changed": + self = .connectionChanged( + namespace: namespace, + data: try container.decodeIfPresent(ConnectionChangedPayload.self, forKey: .data) + ) + default: + self = .unknown(type: type, namespace: namespace) + } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/TodoItem.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/TodoItem.swift new file mode 100644 index 00000000..9f5c6e3b --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/TodoItem.swift @@ -0,0 +1,50 @@ +import Foundation + +/// One entry of the agent's TodoWrite list, carried on `Session.todos`. +/// +/// Mirrors `TodoItemSchema` (`shared/src/schemas.ts:207-215`). The zod schema +/// defaults `priority` to `medium` and `id` to `""` when absent; decoding +/// applies the same defaults so the parsed shape matches the web client's. +public struct TodoItem: Codable, Equatable, Sendable { + public enum Status: String, Codable, Sendable { + case pending + case inProgress = "in_progress" + case completed + } + + public enum Priority: String, Codable, Sendable { + case high + case medium + case low + } + + public var content: String + public var status: Status + public var priority: Priority + public var id: String + /// Present-tense label shown while the item is in progress. + public var activeForm: String? + + public init( + content: String, + status: Status, + priority: Priority = .medium, + id: String = "", + activeForm: String? = nil + ) { + self.content = content + self.status = status + self.priority = priority + self.id = id + self.activeForm = activeForm + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decode(String.self, forKey: .content) + status = try container.decode(Status.self, forKey: .status) + priority = try container.decodeIfPresent(Priority.self, forKey: .priority) ?? .medium + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + activeForm = try container.decodeIfPresent(String.self, forKey: .activeForm) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift new file mode 100644 index 00000000..0e71488c --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift @@ -0,0 +1,139 @@ +import Foundation + +// Pure session-patch application rules, ported line-for-line from +// `web/src/lib/sessionPatch.ts` (the normative reference — see +// docs/api/client-contract/sse.md "Versioned patch algorithm"). +// +// Portability notes against the TS source: +// - `activeTurnStartedAt` and `scratchlistUpdatedAt` are carried by +// `SessionPatch` but deliberately NEVER applied to the `Session`, exactly +// like the reference implementation (`scratchlistUpdatedAt` is a bare +// refetch trigger; `activeTurnStartedAt` is simply not assigned there). +// - JS distinguishes a `null` and an *absent* `activeTurnStartedAt` on the +// cached session; Swift folds both into `nil`. The only observable +// difference is which side of the render-irrelevance gate a +// `activeTurnStartedAt: null` patch lands on — and since the field is never +// assigned, `applySessionDetailPatch` returns the same result either way +// unless the patch also carries other changed fields (then TS may +// additionally refresh a sub-minute `activeAt`; harmless keep-alive data). + +/// Version-monotonicity gate for structured patches carrying +/// metadata / agentState / todos / teamState. +/// +/// SSE reconnects and the dual-connection model can replay an OLDER patch +/// after the cache already holds newer state; applying it would regress +/// resume / session-id / pending-request state. Strictly newer only — +/// the same version arriving twice must also be dropped. +public func isNewerVersionedPatch(patchVersion: Int, cachedVersion: Int) -> Bool { + patchVersion > cachedVersion +} + +/// True when applying `patch` to `session` would change nothing that renders. +/// +/// Keep-alive patches re-send fields about every ~10 s in which typically +/// only `activeAt` moves. Relative-time labels only change at minute +/// boundaries, so a sub-minute `activeAt` delta is treated as invisible; +/// an `activeAt` move of ≥ 60 s stays render-relevant. +public func isRenderIrrelevantSessionPatch(session: Session, patch: SessionPatch) -> Bool { + if let value = patch.active, value != session.active { return false } + if let value = patch.thinking, value != session.thinking { return false } + if let field = patch.activeTurnStartedAt, field.wireValue != session.activeTurnStartedAt { return false } + if let value = patch.activeAt { + if abs(value - session.activeAt) < 60_000 { + // Sub-minute keep-alive move: skipped, contributes nothing. + } else if value != session.activeAt { + return false + } + } + if let value = patch.updatedAt, value != session.updatedAt { return false } + // Versioned wrappers never equal the session's plain fields in the TS + // reference (object identity), so their presence is always relevant. + if patch.metadata != nil { return false } + if patch.agentState != nil { return false } + if patch.todos != nil { return false } + if patch.teamState != nil { return false } + if let field = patch.model, field.wireValue != session.model { return false } + if let field = patch.modelReasoningEffort, field.wireValue != session.modelReasoningEffort { return false } + if let field = patch.effort, field.wireValue != session.effort { return false } + if let field = patch.serviceTier, field.wireValue != session.serviceTier { return false } + if let value = patch.permissionMode, value != session.permissionMode { return false } + if let value = patch.collaborationMode, value != session.collaborationMode { return false } + if let value = patch.copilotAgentMode, value != session.copilotAgentMode { return false } + if let value = patch.backgroundTaskCount, value != session.backgroundTaskCount { return false } + // `Session` carries no scratchlistUpdatedAt, so its presence is always + // "new information" in the TS reference (undefined !== number). + if patch.scratchlistUpdatedAt != nil { return false } + return true +} + +/// Apply a validated `SessionPatch` onto a detail `Session`. +/// +/// Returns `nil` when nothing render-relevant changed so callers can keep +/// the prior value (and object identity semantics of the reference client). +/// Field-by-field only — versioned `{version, value}` wrappers are unwrapped +/// under the strict `>` gate and never assigned wholesale. +public func applySessionDetailPatch(session: Session, patch: SessionPatch) -> Session? { + if isRenderIrrelevantSessionPatch(session: session, patch: patch) { + return nil + } + var next = session + var changed = false + + func assign(_ keyPath: WritableKeyPath, _ value: Value) { + if next[keyPath: keyPath] != value { + next[keyPath: keyPath] = value + changed = true + } + } + + if let value = patch.active { assign(\.active, value) } + if let value = patch.thinking { assign(\.thinking, value) } + if let value = patch.activeAt { assign(\.activeAt, value) } + // Monotonic with the hub's applySessionPatch: a rejected stale + // metadata/agentState replay must not rewind updatedAt. + if let value = patch.updatedAt { assign(\.updatedAt, max(next.updatedAt, value)) } + if let field = patch.model { assign(\.model, field.wireValue) } + if let field = patch.modelReasoningEffort { assign(\.modelReasoningEffort, field.wireValue) } + if let field = patch.effort { assign(\.effort, field.wireValue) } + // TS uses hasOwnProperty + `?? null` here; PatchField already encodes + // presence, so explicit-null clears exactly like the reference. + if let field = patch.serviceTier { assign(\.serviceTier, field.wireValue) } + if let value = patch.permissionMode { assign(\.permissionMode, value as PermissionMode?) } + if let value = patch.collaborationMode { assign(\.collaborationMode, value as CodexCollaborationMode?) } + if let value = patch.copilotAgentMode { assign(\.copilotAgentMode, value as CopilotAgentMode?) } + if let value = patch.backgroundTaskCount { assign(\.backgroundTaskCount, value as Int?) } + + // Version gates: dual SSE can deliver duplicates out of order. Only mark + // changed when a strictly newer version lands. + if let wrapper = patch.todos, + isNewerVersionedPatch(patchVersion: wrapper.version, cachedVersion: next.todosUpdatedAt ?? 0) { + next.todos = wrapper.value + next.todosUpdatedAt = wrapper.version + changed = true + } + if let wrapper = patch.teamState, + isNewerVersionedPatch(patchVersion: wrapper.version, cachedVersion: next.teamStateUpdatedAt ?? 0) { + // `nil` value = TeamDelete clear. + next.teamState = wrapper.value + next.teamStateUpdatedAt = wrapper.version + changed = true + } + if let wrapper = patch.metadata, + isNewerVersionedPatch(patchVersion: wrapper.version, cachedVersion: next.metadataVersion) { + next.metadata = wrapper.value + next.metadataVersion = wrapper.version + changed = true + } + if let wrapper = patch.agentState, + isNewerVersionedPatch(patchVersion: wrapper.version, cachedVersion: next.agentStateVersion) { + next.agentState = wrapper.value + next.agentStateVersion = wrapper.version + changed = true + } + + // Deliberately NOT applied (mirrors web/src/lib/sessionPatch.ts): + // - patch.activeTurnStartedAt — the reference never assigns it. + // - patch.scratchlistUpdatedAt — refetch trigger only. + + return changed ? next : nil +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/ProtocolVersion.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/ProtocolVersion.swift new file mode 100644 index 00000000..def3fae0 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/ProtocolVersion.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The hub protocol generation this client understands. +/// +/// Wire models live under `Models/`, mode/flavor catalogs under `Catalog/`, +/// and the session-patch rules under `Patch/`. This anchor predates them +/// (M0) and stays the single place the supported generation is declared. +public struct ProtocolVersion: Sendable { + /// Highest protocol generation supported by this build. + public static let supported = 1 +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift new file mode 100644 index 00000000..addb4ab2 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift @@ -0,0 +1,240 @@ +import Foundation +import HapiProtocol +import Testing + +/// Checks of the mode/flavor catalogs: exhaustively against the generated +/// `shared/fixtures/catalogs/modes.json` (track K, from `shared/src/modes.ts`), +/// plus inline spot checks transcribed from `shared/src/modes.ts`, +/// `shared/src/flavors.ts`, and `shared/src/copilotModes.ts` for fast +/// diagnosis when the generated catalog and the port disagree. +@Suite("Mode and flavor catalogs") +struct CatalogTests { + // MARK: - Generated catalog (shared/fixtures/catalogs/modes.json) + + private struct GeneratedModesCatalog: Decodable { + struct PermissionEntry: Decodable { + let label: String + let mode: PermissionMode + let tone: PermissionModeTone + } + + struct CollaborationEntry: Decodable { + let label: String + let mode: CodexCollaborationMode + } + + let codexCollaborationModes: [CollaborationEntry] + let permissionModesByFlavor: [String: [PermissionEntry]] + } + + /// Same resolution as FixtureDecodingTests: this file lives in + /// `ios/Packages/HapiKit/Tests/HapiProtocolTests/`, so the package root is + /// three levels up and the repo fixtures sit at `../../../shared/fixtures`. + private static func generatedCatalogURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/HapiProtocolTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root: ios/Packages/HapiKit + .appendingPathComponent("../../../shared/fixtures/catalogs/modes.json") + .standardizedFileURL + } + + @Test func portMatchesGeneratedModesCatalogExactly() throws { + let data = try Data(contentsOf: Self.generatedCatalogURL()) + let catalog = try JSONDecoder().decode(GeneratedModesCatalog.self, from: data) + + // Every known flavor is present in the generated catalog and vice + // versa (the catalog only carries known flavors). + #expect( + catalog.permissionModesByFlavor.keys.sorted() + == AgentFlavor.knownFlavors.map(\.rawValue).sorted() + ) + + for (flavor, expected) in catalog.permissionModesByFlavor.sorted(by: { $0.key < $1.key }) { + let actual = permissionModeOptions(forFlavor: flavor) + #expect( + actual.map(\.mode) == expected.map(\.mode), + "mode list mismatch for flavor \(flavor)" + ) + for (actualOption, expectedEntry) in zip(actual, expected) { + #expect( + actualOption.label == expectedEntry.label, + "label mismatch for \(flavor)/\(expectedEntry.mode.rawValue)" + ) + #expect( + actualOption.tone == expectedEntry.tone, + "tone mismatch for \(flavor)/\(expectedEntry.mode.rawValue)" + ) + } + } + + #expect(catalog.codexCollaborationModes.map(\.mode) == CodexCollaborationMode.allCases) + for entry in catalog.codexCollaborationModes { + #expect(entry.mode.label == entry.label) + } + } + + // MARK: - Permission modes per flavor (modes.ts getPermissionModesForFlavor) + + @Test func codexFamilyFlavorsShareTheCodexModeSet() { + let expected: [PermissionMode] = [.default, .readOnly, .safeYolo, .yolo] + #expect(permissionModes(forFlavor: "codex") == expected) + #expect(permissionModes(forFlavor: "gemini") == expected) + #expect(permissionModes(forFlavor: "kimi") == expected) + #expect(permissionModes(forFlavor: "copilot") == expected) + } + + @Test func claudeSetIsTheFallbackForNilAndUnknownFlavors() { + let expected: [PermissionMode] = [.default, .acceptEdits, .auto, .bypassPermissions, .plan] + #expect(permissionModes(forFlavor: "claude") == expected) + #expect(permissionModes(forFlavor: nil) == expected) + #expect(permissionModes(forFlavor: "some-future-agent") == expected) + } + + @Test func remainingFlavorsMatchTheirDeclaredSets() { + #expect(permissionModes(forFlavor: "grok") == [.default, .auto, .plan, .bypassPermissions]) + #expect(permissionModes(forFlavor: "opencode") == [.default, .plan, .yolo]) + #expect(permissionModes(forFlavor: "agy") == [.requestReview, .alwaysProceed]) + #expect(permissionModes(forFlavor: "cursor") == [.default, .plan, .ask, .debug, .autoReview, .yolo]) + // Pi has no runtime permission switching (always auto-approve). + #expect(permissionModes(forFlavor: "pi") == []) + } + + @Test func modeAllowanceFollowsTheFlavorSets() { + #expect(isPermissionModeAllowed(.acceptEdits, forFlavor: "claude")) + #expect(!isPermissionModeAllowed(.acceptEdits, forFlavor: "codex")) + #expect(isPermissionModeAllowed(.yolo, forFlavor: "opencode")) + #expect(!isPermissionModeAllowed(.yolo, forFlavor: "claude")) + } + + // MARK: - Labels, tones, raw values (modes.ts data tables) + + @Test func permissionModeCatalogHasThirteenEntries() { + #expect(PermissionMode.allCases.count == 13) + } + + @Test func labelsMatchModesTs() { + #expect(PermissionMode.default.label == "Default") + #expect(PermissionMode.acceptEdits.label == "Accept Edits") + #expect(PermissionMode.bypassPermissions.label == "Yolo") + #expect(PermissionMode.autoReview.label == "Auto-review") + #expect(PermissionMode.readOnly.label == "Read Only") + #expect(PermissionMode.plan.label == "Plan Mode") + #expect(PermissionMode.alwaysProceed.label == "Always Proceed") + } + + @Test func tonesMatchModesTs() { + #expect(PermissionMode.default.tone == .neutral) + #expect(PermissionMode.plan.tone == .info) + #expect(PermissionMode.safeYolo.tone == .warning) + #expect(PermissionMode.yolo.tone == .danger) + #expect(PermissionMode.bypassPermissions.tone == .danger) + #expect(PermissionMode.alwaysProceed.tone == .danger) + #expect(PermissionMode.requestReview.tone == .neutral) + } + + @Test func kebabCaseWireValuesRoundTrip() throws { + #expect(PermissionMode.readOnly.rawValue == "read-only") + #expect(PermissionMode.safeYolo.rawValue == "safe-yolo") + #expect(PermissionMode.requestReview.rawValue == "request-review") + #expect(PermissionMode.alwaysProceed.rawValue == "always-proceed") + let decoded = try JSONDecoder().decode(PermissionMode.self, from: Data("\"read-only\"".utf8)) + #expect(decoded == .readOnly) + } + + @Test func optionListsCarryLabelAndTone() { + let options = permissionModeOptions(forFlavor: "codex") + #expect(options.count == 4) + #expect(options.first == PermissionModeOption(mode: .default)) + #expect(options.first?.label == "Default") + #expect(options.first?.tone == .neutral) + } + + // MARK: - Agent flavors (modes.ts + flavors.ts) + + @Test func knownFlavorsMatchAgentFlavorsOrder() { + #expect(AgentFlavor.knownFlavors.map(\.rawValue) == [ + "agy", "claude", "codex", "copilot", "cursor", + "gemini", "grok", "kimi", "opencode", "pi", + ]) + } + + @Test func creatableFlavorsExcludeOnlyGemini() { + #expect(AgentFlavor.creatableFlavors.count == 9) + #expect(!AgentFlavor.creatableFlavors.contains(.gemini)) + #expect(AgentFlavor.creatableFlavors.contains(.claude)) + } + + @Test func unknownFlavorsAreCapturedNotCrashed() throws { + let flavor = AgentFlavor(rawValue: "mystery") + #expect(flavor == .other("mystery")) + #expect(flavor.rawValue == "mystery") + #expect(!flavor.isKnown) + let decoded = try JSONDecoder().decode(AgentFlavor.self, from: Data("\"newagent\"".utf8)) + #expect(decoded == .other("newagent")) + let known = try JSONDecoder().decode(AgentFlavor.self, from: Data("\"grok\"".utf8)) + #expect(known == .grok) + } + + @Test func flavorLabelsMatchFlavorsTs() { + #expect(AgentFlavor.agy.displayLabel == "Antigravity") + #expect(AgentFlavor.grok.displayLabel == "Grok Build") + #expect(AgentFlavor.opencode.displayLabel == "OpenCode") + #expect(AgentFlavor.other("x").displayLabel == "Unknown") + #expect(flavorLabel(forFlavor: "pi") == "Pi") + #expect(flavorLabel(forFlavor: nil) == "Unknown") + } + + @Test func effortSupportMatchesFlavorsTs() { + #expect(AgentFlavor.claude.supportsEffort) + #expect(AgentFlavor.grok.supportsEffort) + #expect(AgentFlavor.pi.supportsEffort) + #expect(!AgentFlavor.codex.supportsEffort) + #expect(!AgentFlavor.cursor.supportsEffort) + #expect(supportsEffort(forFlavor: "claude")) + #expect(!supportsEffort(forFlavor: "unknown")) + #expect(!supportsEffort(forFlavor: nil)) + } + + @Test func modelChangeIsSupportedByEveryKnownFlavor() { + for flavor in AgentFlavor.knownFlavors { + #expect(flavor.supportsModelChange, "\(flavor.rawValue) should support model change") + } + #expect(!AgentFlavor.other("x").supportsModelChange) + #expect(!supportsModelChange(forFlavor: nil)) + } + + @Test func codexFamilyMatchesFlavorsTs() { + #expect(isCodexFamilyFlavor("codex")) + #expect(isCodexFamilyFlavor("gemini")) + #expect(isCodexFamilyFlavor("grok")) + #expect(isCodexFamilyFlavor("kimi")) + #expect(isCodexFamilyFlavor("copilot")) + #expect(isCodexFamilyFlavor("opencode")) + #expect(!isCodexFamilyFlavor("claude")) + #expect(!isCodexFamilyFlavor("cursor")) + #expect(!isCodexFamilyFlavor("pi")) + #expect(!isCodexFamilyFlavor("agy")) + #expect(!isCodexFamilyFlavor(nil)) + } + + // MARK: - Codex collaboration + Copilot agent modes + + @Test func codexCollaborationModesMatchModesTs() { + #expect(CodexCollaborationMode.allCases == [.default, .plan]) + #expect(CodexCollaborationMode.default.label == "Default") + #expect(CodexCollaborationMode.plan.label == "Plan") + } + + @Test func copilotAgentModesMatchCopilotModesTs() throws { + #expect(CopilotAgentMode.allCases == [.interactive, .plan, .autopilot]) + #expect(CopilotAgentMode.autopilot.label == "Autopilot") + // Legacy `fleet` coerces to interactive on decode and in normalize. + let fleet = try JSONDecoder().decode(CopilotAgentMode.self, from: Data("\"fleet\"".utf8)) + #expect(fleet == .interactive) + #expect(CopilotAgentMode.normalize("fleet") == .interactive) + #expect(CopilotAgentMode.normalize("plan") == .plan) + #expect(CopilotAgentMode.normalize(nil) == .interactive) + #expect(CopilotAgentMode.normalize("bogus") == .interactive) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift new file mode 100644 index 00000000..5c46149f --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift @@ -0,0 +1,160 @@ +import Foundation +import HapiProtocol +import Testing + +/// Decodes every golden chat fixture's `input` with the HapiProtocol wire +/// models. The full pipeline conformance (`expected` blocks) is an M2 gate; +/// this suite pins the earlier contract: every fixture input is a valid +/// `[DecryptedMessage]` (+ optional `AgentState`) for this decoder. +@Suite("Golden fixture decoding") +struct FixtureDecodingTests { + /// Highest fixture document schema this suite understands. Mirrors the + /// README rule: fail loudly when the on-disk version is newer. + private static let supportedFixtureVersion = 1 + + private struct ChatFixture: Decodable { + struct Input: Decodable { + let messages: [DecryptedMessage] + let agentState: AgentState? + } + + let fixtureVersion: Int + let name: String + let input: Input + } + + /// Repo-root `shared/fixtures`, resolved from this file's own location: + /// `ios/Packages/HapiKit/Tests/HapiProtocolTests/FixtureDecodingTests.swift` + /// → up 3 directories to the package root (`ios/Packages/HapiKit`) + /// → `../../../shared/fixtures` (the layout documented in + /// `shared/fixtures/README.md` and `ios/README.md`). + private static func fixturesDirectory() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/HapiProtocolTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root: ios/Packages/HapiKit + .appendingPathComponent("../../../shared/fixtures") + .standardizedFileURL + } + + private static func chatFixtureFiles() throws -> [URL] { + let chatDirectory = fixturesDirectory().appendingPathComponent("chat") + return try FileManager.default + .contentsOfDirectory(at: chatDirectory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private static func loadFixture(named name: String) throws -> ChatFixture { + let file = fixturesDirectory() + .appendingPathComponent("chat") + .appendingPathComponent("\(name).json") + return try JSONDecoder().decode(ChatFixture.self, from: Data(contentsOf: file)) + } + + @Test func fixturesVersionFileIsSupported() throws { + let versionFile = Self.fixturesDirectory().appendingPathComponent("VERSION") + let text = try String(contentsOf: versionFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let version = try #require(Int(text), "VERSION must be a single integer, got: \(text)") + #expect( + version <= Self.supportedFixtureVersion, + "fixtures were regenerated with a newer schema (\(version)); update the decoder support" + ) + } + + @Test func decodesEveryChatFixtureInput() throws { + let files = try Self.chatFixtureFiles() + #expect(!files.isEmpty, "no chat fixtures found under \(Self.fixturesDirectory().path)") + for file in files { + let data = try Data(contentsOf: file) + let fixture: ChatFixture + do { + fixture = try JSONDecoder().decode(ChatFixture.self, from: data) + } catch { + Issue.record("failed to decode \(file.lastPathComponent): \(error)") + continue + } + #expect( + fixture.fixtureVersion <= Self.supportedFixtureVersion, + "\(file.lastPathComponent) uses fixtureVersion \(fixture.fixtureVersion)" + ) + #expect( + fixture.name == file.deletingPathExtension().lastPathComponent, + "\(file.lastPathComponent) name field mismatch" + ) + } + } + + @Test func decodesClaudeAssistantTextMessageFields() throws { + let fixture = try Self.loadFixture(named: "claude-assistant-text") + let messages = fixture.input.messages + try #require(messages.count == 2) + + #expect(messages[0].id == "msg-user-001") + #expect(messages[0].seq == 1) + #expect(messages[0].localId == "local-9e1c2b6a") + #expect(messages[0].createdAt == 1_755_000_000_000) + #expect(messages[0].invokedAt == 1_755_000_000_350) + #expect(messages[0].scheduledAt == nil) + + #expect(messages[1].id == "msg-agent-002") + #expect(messages[1].seq == 2) + #expect(messages[1].localId == nil) + #expect(messages[1].createdAt == 1_755_000_005_200) + #expect(messages[1].invokedAt == nil) + + // The role-wrapped envelope survives verbatim as JSONValue. + let userEnvelope = try #require(messages[0].content.testObjectValue) + #expect(userEnvelope["role"] == "user") + let agentEnvelope = try #require(messages[1].content.testObjectValue) + #expect(agentEnvelope["role"] == "agent") + let payload = try #require(agentEnvelope["content"]?.testObjectValue) + #expect(payload["type"] == "output") + } + + @Test func decodesPendingPermissionAgentState() throws { + let fixture = try Self.loadFixture(named: "permission-synthesized-pending") + let agentState = try #require(fixture.input.agentState) + let requests = try #require(agentState.requests) + let request = try #require(requests["req-01J5XKQ8TZ3M"]) + #expect(request.tool == "Bash") + #expect(request.createdAt == 1_755_000_006_000) + #expect(request.arguments == JSONValue.object([ + "command": "bun add zod", + "description": "Install zod", + ])) + #expect(agentState.completedRequests?.isEmpty == true) + } + + @Test func decodesAttachmentMetadataFromUserAttachmentsFixture() throws { + let fixture = try Self.loadFixture(named: "user-text-with-attachments") + let message = try #require(fixture.input.messages.first) + let envelope = try #require(message.content.testObjectValue) + let payload = try #require(envelope["content"]?.testObjectValue) + let attachmentNodes = try #require(payload["attachments"]?.testArrayValue) + try #require(attachmentNodes.count == 2) + + // Round-trip the raw wire node into the typed attachment model. + let data = try JSONEncoder().encode(attachmentNodes[0]) + let attachment = try JSONDecoder().decode(AttachmentMetadata.self, from: data) + #expect(attachment.id == "att-01HZXK3Q") + #expect(attachment.filename == "crash.log") + #expect(attachment.mimeType == "text/plain") + #expect(attachment.size == 18_432) + #expect(attachment.path == "/uploads/att-01HZXK3Q/crash.log") + #expect(attachment.previewUrl == "/api/uploads/att-01HZXK3Q/preview") + } +} + +private extension JSONValue { + var testObjectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + var testArrayValue: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/SessionPatchingTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SessionPatchingTests.swift new file mode 100644 index 00000000..b071c2a1 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SessionPatchingTests.swift @@ -0,0 +1,309 @@ +import Foundation +import HapiProtocol +import Testing + +/// Ports the version-gate and detail-patch cases from +/// `web/src/hooks/useSSE.test.ts` (backed by `web/src/lib/sessionPatch.ts`). +@Suite("Session patching") +struct SessionPatchingTests { + private func makeSession( + updatedAt: Int = 2_000, + active: Bool = true, + activeAt: Int = 1_000, + metadata: SessionMetadata? = nil, + metadataVersion: Int = 1, + agentState: AgentState? = nil, + agentStateVersion: Int = 1, + thinking: Bool = false, + todos: [TodoItem]? = nil, + teamState: JSONValue? = nil, + todosUpdatedAt: Int? = 0, + teamStateUpdatedAt: Int? = 0, + model: String? = "gpt-5", + serviceTier: String? = nil, + permissionMode: PermissionMode? = .default, + copilotAgentMode: CopilotAgentMode? = .interactive + ) -> Session { + Session( + id: "session-1", + namespace: "default", + seq: 1, + createdAt: 1_000, + updatedAt: updatedAt, + active: active, + activeAt: activeAt, + metadata: metadata, + metadataVersion: metadataVersion, + agentState: agentState, + agentStateVersion: agentStateVersion, + thinking: thinking, + thinkingAt: 0, + todos: todos, + teamState: teamState, + todosUpdatedAt: todosUpdatedAt, + teamStateUpdatedAt: teamStateUpdatedAt, + model: model, + serviceTier: serviceTier, + permissionMode: permissionMode, + copilotAgentMode: copilotAgentMode + ) + } + + // MARK: - isNewerVersionedPatch (useSSE.test.ts "isNewerVersionedPatch") + + @Test func acceptsStrictlyNewerPatch() { + #expect(isNewerVersionedPatch(patchVersion: 5, cachedVersion: 4)) + } + + @Test func rejectsOlderPatch() { + // The bug case: stale buffered patch replayed on reconnect. + #expect(!isNewerVersionedPatch(patchVersion: 4, cachedVersion: 5)) + } + + @Test func rejectsSameVersionPatch() { + // Idempotent / duplicate replay. + #expect(!isNewerVersionedPatch(patchVersion: 5, cachedVersion: 5)) + } + + @Test func acceptsFirstWriteIntoFreshCache() { + #expect(isNewerVersionedPatch(patchVersion: 1, cachedVersion: 0)) + } + + // MARK: - applySessionDetailPatch (useSSE.test.ts "Copilot keep-alive") + + @Test func appliesCopilotAgentModeKeepAliveChange() throws { + let session = makeSession() + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + active: true, + thinking: false, + activeAt: 11_000, + copilotAgentMode: .plan + ))) + #expect(next.copilotAgentMode == .plan) + #expect(next.activeAt == 11_000) + } + + @Test func returnsNilForKeepAliveRepeatingCurrentCopilotMode() { + let session = makeSession() + let next = applySessionDetailPatch(session: session, patch: SessionPatch( + active: true, + thinking: false, + activeAt: 11_000, + copilotAgentMode: .interactive + )) + #expect(next == nil) + } + + // MARK: - Version gates + + @Test func dropsStaleMetadataPatch() { + let session = makeSession(metadataVersion: 5) + let next = applySessionDetailPatch(session: session, patch: SessionPatch( + metadata: VersionedValue(version: 4, value: SessionMetadata(path: "/new", host: "h")) + )) + // Wrapper presence is render-relevant, but the gate rejects it and + // nothing else changed — so the reference returns null. + #expect(next == nil) + } + + @Test func dropsSameVersionMetadataPatch() { + let session = makeSession(metadataVersion: 5) + let next = applySessionDetailPatch(session: session, patch: SessionPatch( + metadata: VersionedValue(version: 5, value: SessionMetadata(path: "/new", host: "h")) + )) + #expect(next == nil) + } + + @Test func appliesStrictlyNewerMetadataPatch() throws { + let session = makeSession(metadataVersion: 5) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + metadata: VersionedValue(version: 6, value: SessionMetadata(path: "/new", host: "h")) + ))) + #expect(next.metadata?.path == "/new") + #expect(next.metadataVersion == 6) + } + + @Test func appliesFirstMetadataWriteIntoVersionZero() throws { + let session = makeSession(metadataVersion: 0) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + metadata: VersionedValue(version: 1, value: SessionMetadata(path: "/p", host: "h")) + ))) + #expect(next.metadataVersion == 1) + #expect(next.metadata?.host == "h") + } + + @Test func dropsStaleAgentStatePatchButAppliesNewer() throws { + let pending = AgentState(requests: [ + "req-1": AgentStateRequest(tool: "Bash", createdAt: 1_000), + ]) + let session = makeSession(agentState: pending, agentStateVersion: 3) + + let stale = applySessionDetailPatch(session: session, patch: SessionPatch( + agentState: VersionedValue(version: 2, value: AgentState()) + )) + #expect(stale == nil) + + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + agentState: VersionedValue(version: 4, value: AgentState()) + ))) + #expect(next.agentState?.requests == nil) + #expect(next.agentStateVersion == 4) + } + + @Test func gatesTodosOnTodosUpdatedAtWatermark() throws { + let item = TodoItem(content: "port models", status: .inProgress, id: "t1") + let session = makeSession(todosUpdatedAt: 5) + + #expect(applySessionDetailPatch(session: session, patch: SessionPatch( + todos: VersionedValue(version: 5, value: [item]) + )) == nil) + + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + todos: VersionedValue(version: 6, value: [item]) + ))) + #expect(next.todos == [item]) + #expect(next.todosUpdatedAt == 6) + } + + @Test func treatsAbsentTodosWatermarkAsZero() throws { + let item = TodoItem(content: "x", status: .pending) + let session = makeSession(todosUpdatedAt: nil) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + todos: VersionedValue(version: 1, value: [item]) + ))) + #expect(next.todos == [item]) + #expect(next.todosUpdatedAt == 1) + } + + @Test func clearsTeamStateOnNullValueUnderNewerVersion() throws { + let session = makeSession(teamState: ["teamName": "alpha"], teamStateUpdatedAt: 1) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + teamState: VersionedValue(version: 2, value: nil) + ))) + #expect(next.teamState == nil) + #expect(next.teamStateUpdatedAt == 2) + } + + // MARK: - updatedAt monotonicity + + @Test func neverRewindsUpdatedAt() throws { + let session = makeSession(updatedAt: 2_000) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + thinking: true, + updatedAt: 1_500 + ))) + #expect(next.updatedAt == 2_000) + #expect(next.thinking) + } + + @Test func advancesUpdatedAtForward() throws { + let session = makeSession(updatedAt: 2_000) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + updatedAt: 3_000 + ))) + #expect(next.updatedAt == 3_000) + } + + @Test func staleUpdatedAtAloneIsANoOp() { + // Relevant by strict compare, but max() keeps the cached stamp and + // nothing else changes — the reference returns null. + let session = makeSession(updatedAt: 2_000) + #expect(applySessionDetailPatch(session: session, patch: SessionPatch( + updatedAt: 1_500 + )) == nil) + } + + // MARK: - Flat fields are unconditional + + @Test func appliesFlatFieldsEvenWhenVersionedPartIsStale() throws { + let session = makeSession(metadataVersion: 5) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + metadata: VersionedValue(version: 1, value: SessionMetadata(path: "/stale", host: "h")), + model: .value("opus") + ))) + #expect(next.model == "opus") + #expect(next.metadata == nil) + #expect(next.metadataVersion == 5) + } + + @Test func explicitNullClearsServiceTier() throws { + let session = makeSession(serviceTier: "fast") + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + serviceTier: .null + ))) + #expect(next.serviceTier == nil) + } + + @Test func absentServiceTierIsUntouched() throws { + let session = makeSession(serviceTier: "fast") + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + thinking: true + ))) + #expect(next.serviceTier == "fast") + } + + // MARK: - Keep-alive irrelevance + + @Test func subMinuteActiveAtKeepAliveIsIrrelevant() { + let session = makeSession(activeAt: 1_000) + #expect(isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch( + active: true, + thinking: false, + activeAt: 11_000, + model: .value("gpt-5"), + permissionMode: .default + ))) + #expect(applySessionDetailPatch(session: session, patch: SessionPatch( + activeAt: 11_000 + )) == nil) + } + + @Test func minuteActiveAtMoveIsRelevantAndApplied() throws { + let session = makeSession(activeAt: 1_000) + #expect(!isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch( + activeAt: 61_000 + ))) + let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + activeAt: 61_000 + ))) + #expect(next.activeAt == 61_000) + } + + @Test func emptyPatchIsIrrelevant() { + let session = makeSession() + #expect(isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch())) + #expect(applySessionDetailPatch(session: session, patch: SessionPatch()) == nil) + } + + // MARK: - Replicated TS surprises + + @Test func activeTurnStartedAtIsNeverApplied() throws { + // web/src/lib/sessionPatch.ts carries the field on the patch type but + // never assigns it; the port replicates that exactly. + let session = makeSession() + #expect(session.activeTurnStartedAt == nil) + + let alone = applySessionDetailPatch(session: session, patch: SessionPatch( + activeTurnStartedAt: .value(5_000) + )) + #expect(alone == nil) + + let combined = try #require(applySessionDetailPatch(session: session, patch: SessionPatch( + thinking: true, + activeTurnStartedAt: .value(5_000) + ))) + #expect(combined.thinking) + #expect(combined.activeTurnStartedAt == nil) + } + + @Test func scratchlistUpdatedAtIsARefetchTriggerOnly() { + // Always render-relevant (Session has no such field), never applied. + let session = makeSession() + #expect(!isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch( + scratchlistUpdatedAt: 5_000 + ))) + #expect(applySessionDetailPatch(session: session, patch: SessionPatch( + scratchlistUpdatedAt: 5_000 + )) == nil) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift new file mode 100644 index 00000000..b56fd236 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift @@ -0,0 +1,282 @@ +import Foundation +import HapiProtocol +import Testing + +/// Decoding tests for the `SyncEvent` union against the wire shapes in +/// `docs/api/client-contract/sse.md` and `SyncEventSchema` +/// (`shared/src/schemas.ts`). +@Suite("SyncEvent wire decoding") +struct SyncEventDecodingTests { + private func decode(_ json: String) throws -> SyncEvent { + try JSONDecoder().decode(SyncEvent.self, from: Data(json.utf8)) + } + + @Test func decodesSessionUpdatedPatch() throws { + let event = try decode(""" + { + "type": "session-updated", + "namespace": "default", + "sessionId": "s-1", + "data": { + "active": true, + "activeAt": 123, + "updatedAt": 456, + "serviceTier": null, + "metadata": {"version": 7, "value": null} + } + } + """) + guard case .sessionUpdated(let namespace, let sessionId, .patch(let patch)?) = event else { + Issue.record("expected .sessionUpdated with .patch, got \(event)") + return + } + #expect(namespace == "default") + #expect(sessionId == "s-1") + #expect(patch.active == true) + #expect(patch.activeAt == 123) + #expect(patch.updatedAt == 456) + // Explicit null is preserved as .null, distinct from an absent key. + #expect(patch.serviceTier == .null) + #expect(patch.model == nil) + #expect(patch.metadata == VersionedValue(version: 7, value: nil)) + } + + @Test func decodesSessionUpdatedFullSession() throws { + let event = try decode(""" + { + "type": "session-updated", + "sessionId": "s-1", + "data": { + "id": "s-1", + "namespace": "default", + "seq": 12, + "createdAt": 1000, + "updatedAt": 2000, + "active": true, + "activeAt": null, + "metadata": {"path": "/repo", "host": "mbp", "flavor": "claude", "codexSessionId": "ignored-unknown-field"}, + "metadataVersion": 3, + "agentState": null, + "agentStateVersion": 0, + "thinking": false, + "thinkingAt": 0, + "permissionMode": "acceptEdits", + "copilotAgentMode": "fleet" + } + } + """) + guard case .sessionUpdated(_, _, .session(let session)?) = event else { + Issue.record("expected .sessionUpdated with .session, got \(event)") + return + } + #expect(session.id == "s-1") + #expect(session.seq == 12) + // zod nullish transform: null activeAt becomes 0. + #expect(session.activeAt == 0) + #expect(session.metadata?.path == "/repo") + #expect(session.metadata?.flavor == "claude") + #expect(session.metadataVersion == 3) + #expect(session.permissionMode == .acceptEdits) + // Legacy fleet coerces to interactive (CopilotAgentModeSchema). + #expect(session.copilotAgentMode == .interactive) + #expect(session.model == nil) + } + + @Test func unknownPatchKeysFallBackToUnrecognized() throws { + // SessionPatchSchema is strict; a payload that is neither a full + // Session nor a known-keys-only patch must surface as unrecognized + // (the caller then refetches, like the web client on a zod failure). + let event = try decode(""" + { + "type": "session-updated", + "sessionId": "s-1", + "data": {"activeAt": 1, "someFutureField": 2} + } + """) + guard case .sessionUpdated(_, _, .unrecognized(let raw)?) = event else { + Issue.record("expected .unrecognized, got \(event)") + return + } + #expect(raw == ["activeAt": 1, "someFutureField": 2]) + } + + @Test func decodesSessionAddedLikeSessionUpdated() throws { + let event = try decode(""" + {"type": "session-added", "sessionId": "s-2", "data": {"thinking": true}} + """) + guard case .sessionAdded(_, "s-2", .patch(let patch)?) = event else { + Issue.record("expected .sessionAdded patch, got \(event)") + return + } + #expect(patch.thinking == true) + } + + @Test func decodesSessionRemovedAndEnded() throws { + let removed = try decode(""" + {"type": "session-removed", "namespace": "default", "sessionId": "s-3"} + """) + #expect(removed == .sessionRemoved(namespace: "default", sessionId: "s-3")) + + let ended = try decode(""" + {"type": "session-ended", "sessionId": "s-3", "reason": "completed"} + """) + #expect(ended == .sessionEnded(namespace: nil, sessionId: "s-3", reason: .completed)) + + // Unknown reasons degrade to nil instead of throwing. + let oddReason = try decode(""" + {"type": "session-ended", "sessionId": "s-3", "reason": "imploded"} + """) + #expect(oddReason == .sessionEnded(namespace: nil, sessionId: "s-3", reason: nil)) + } + + @Test func decodesMessageReceived() throws { + let event = try decode(""" + { + "type": "message-received", + "namespace": "default", + "sessionId": "s-1", + "message": { + "id": "m-1", + "seq": 41, + "localId": "local-1", + "content": {"role": "user", "content": {"type": "text", "text": "hi"}}, + "createdAt": 1755000000000, + "invokedAt": null + } + } + """) + guard case .messageReceived(_, "s-1", let message) = event else { + Issue.record("expected .messageReceived, got \(event)") + return + } + #expect(message.id == "m-1") + #expect(message.seq == 41) + #expect(message.localId == "local-1") + #expect(message.createdAt == 1_755_000_000_000) + #expect(message.invokedAt == nil) + } + + @Test func decodesQueueLifecycleEvents() throws { + let consumed = try decode(""" + {"type": "messages-consumed", "sessionId": "s-1", "localIds": ["a", "b"], "invokedAt": 99} + """) + #expect(consumed == .messagesConsumed(namespace: nil, sessionId: "s-1", localIds: ["a", "b"], invokedAt: 99)) + + let cancelled = try decode(""" + {"type": "message-cancelled", "sessionId": "s-1", "messageId": "m-9"} + """) + #expect(cancelled == .messageCancelled(namespace: nil, sessionId: "s-1", messageId: "m-9", localId: nil)) + + let invalidated = try decode(""" + {"type": "messages-invalidated", "sessionId": "s-1"} + """) + #expect(invalidated == .messagesInvalidated(namespace: nil, sessionId: "s-1")) + + let matured = try decode(""" + {"type": "scheduled-matured", "sessionId": "s-1"} + """) + #expect(matured == .scheduledMatured(namespace: nil, sessionId: "s-1")) + } + + @Test func decodesHeartbeatWithAndWithoutData() throws { + let full = try decode(""" + {"type": "heartbeat", "namespace": "default", "data": {"timestamp": 1755000000000}} + """) + #expect(full == .heartbeat(namespace: "default", timestamp: 1_755_000_000_000)) + + let bare = try decode(""" + {"type": "heartbeat"} + """) + #expect(bare == .heartbeat(namespace: nil, timestamp: nil)) + } + + @Test func decodesConnectionChangedHandshake() throws { + let ok = try decode(""" + {"type": "connection-changed", "data": {"status": "connected", "subscriptionId": "sub-1", "resume": "ok"}} + """) + guard case .connectionChanged(_, let payload?) = ok else { + Issue.record("expected payload, got \(ok)") + return + } + #expect(payload.status == "connected") + #expect(payload.subscriptionId == "sub-1") + #expect(payload.resume == .ok) + + // Unknown resume verdicts degrade to nil (treat as gap). + let odd = try decode(""" + {"type": "connection-changed", "data": {"status": "connected", "resume": "sideways"}} + """) + guard case .connectionChanged(_, let oddPayload?) = odd else { + Issue.record("expected payload, got \(odd)") + return + } + #expect(oddPayload.resume == nil) + } + + @Test func decodesToast() throws { + let event = try decode(""" + {"type": "toast", "namespace": "default", "data": {"title": "Ready", "body": "Session done", "sessionId": "s-1", "url": "/sessions/s-1"}} + """) + #expect(event == .toast( + namespace: "default", + data: ToastPayload(title: "Ready", body: "Session done", sessionId: "s-1", url: "/sessions/s-1") + )) + } + + @Test func decodesMachineUpdatedVariants() throws { + let removed = try decode(""" + {"type": "machine-updated", "machineId": "mac-1", "data": null} + """) + #expect(removed == .machineUpdated(namespace: nil, machineId: "mac-1", data: .removed)) + + let absent = try decode(""" + {"type": "machine-updated", "machineId": "mac-1"} + """) + #expect(absent == .machineUpdated(namespace: nil, machineId: "mac-1", data: nil)) + + let patch = try decode(""" + {"type": "machine-updated", "machineId": "mac-1", "data": {"active": false, "activeAt": 5}} + """) + #expect(patch == .machineUpdated( + namespace: nil, + machineId: "mac-1", + data: .patch(MachinePatch(active: false, activeAt: 5)) + )) + + let full = try decode(""" + { + "type": "machine-updated", + "machineId": "mac-1", + "data": { + "id": "mac-1", + "namespace": "default", + "seq": 2, + "createdAt": 1, + "updatedAt": 2, + "active": true, + "activeAt": 3, + "metadata": {"host": "mbp", "platform": "darwin", "happyCliVersion": "0.28.0"}, + "metadataVersion": 1, + "runnerState": {"status": "running", "pid": 42}, + "runnerStateVersion": 1 + } + } + """) + guard case .machineUpdated(_, _, .machine(let machine)?) = full else { + Issue.record("expected .machine, got \(full)") + return + } + #expect(machine.id == "mac-1") + #expect(machine.metadata?.platform == "darwin") + #expect(machine.runnerState?.isRunning == true) + #expect(machine.runnerState?.pid == 42) + #expect(machine.health == nil) + } + + @Test func unknownEventTypesNeverThrow() throws { + let event = try decode(""" + {"type": "hologram-sync", "namespace": "default", "hologramId": "h-1"} + """) + #expect(event == .unknown(type: "hologram-sync", namespace: "default")) + } +} diff --git a/ios/README.md b/ios/README.md index e3370a0a..3a101378 100644 --- a/ios/README.md +++ b/ios/README.md @@ -35,9 +35,18 @@ ios/ folder": add files here and they join the target without touching project.pbxproj. Packages/HapiKit/ Local SPM package with the real logic: - HapiProtocol Pure-Foundation protocol layer: wire models, JSONValue, - chat pipeline + message window logic (ported from - web/src/chat/**). Validated against shared/fixtures/**. + HapiProtocol Pure-Foundation protocol layer. As of M1a: + Models/ wire types mirroring shared/src/schemas.ts + (Session, SessionPatch + VersionedValue, + AgentState, DecryptedMessage, SessionSummary, + Machine, SyncEvent union, messages page) + Catalog/ permission-mode / flavor tables ported from + shared/src/{modes,flavors,copilotModes}.ts + Patch/ versioned session-patch application ported + from web/src/lib/sessionPatch.ts + The chat pipeline + message window logic (ported from + web/src/chat/**) land in M2, validated against + shared/fixtures/**. HapiClient Transport layer: APIClient, AuthManager (single-flight refresh), SSEClient, @Observable stores, snapshots. ``` @@ -47,11 +56,14 @@ with `swift test` and free of UI concerns. ### Fixtures -`HapiProtocolTests` will run the golden fixtures from the repo-root -`shared/fixtures/` directory, resolved relative to the package directory -(`ios/Packages/HapiKit` -> `../../../shared/fixtures`). The wiring lands in -M2 once the fixture generator (track K, K5) has produced them; until then the -tests are self-contained. +`HapiProtocolTests` reads the golden fixtures from the repo-root +`shared/fixtures/` directory, resolved from the test file's own `#filePath` +(package root `ios/Packages/HapiKit` -> `../../../shared/fixtures`), so the +suite needs a full repo checkout. Since M1a, `FixtureDecodingTests` decodes +every `chat/*.json` input as `[DecryptedMessage]` (+ `AgentState`), and +`CatalogTests` verifies the ported mode tables against +`catalogs/modes.json`; the full pipeline conformance against each fixture's +`expected` projection is the M2 gate. ## Milestones (track A of the native-clients plan)