From 7c345676789397050e3a032ab729b732c1cbdf9d Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 16:32:24 +0800 Subject: [PATCH] feat(ios): chat pipeline port + fixture harness (A-M2bc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port web/src/chat/** file-for-file into HapiProtocol/Chat/: - ChatTypes: block/tool/permission/usage models; AgentEvent as a record-backed struct (verbatim wire projection) with a typed Kind view and exact-key-set constructors for every synthesized event shape - Normalize/NormalizeUser/NormalizeAgent: the full decode tree (codex / output incl. agy / event families, skip rules, stringify-never-drop) - Tracer: sidechain grouping via parentToolUseId with prompt-match and parentUuid-chain fallbacks (orphan reparenting included) - ReducerEvents (pipe-format limit parsing, dedupe, api-error folding), ReducerTools (BlockBox shared-mutation model, JS-spread-faithful permission merging via presentKeys), ReducerCliOutput, ReducerTimeline (tool pairing, stream coalescing, agent-run cards, title-changed synthesis, sentinel suppression), Reducer (pending-permission synthesis gates, latestUsage, goal filtering) - ToolGroups + codex-exploration family (codexCommandPresentation's grouping predicates); ToolPresentation (minimal title/subtitle, full knownTools catalog deferred to HapiUI) - JSInterop: JS semantics pinned once (nullish coalescing over wire values, truthiness, safeStringify, canonical JSON serializer with integer-preserving number formatting, JS-anchored regex helpers) - FixtureProjection: the normative projection (projection.ts) plus runChatFixturePipeline - ChatFixtureTests: one swift-testing case per shared/fixtures/chat/*.json (48), canonical-JSON byte comparison, per-fixture line-level diff on mismatch, VERSION gate Dictionary-order note: wherever the TS relies on JS object insertion order (agentState requests iteration), the port imposes ascending key order — equivalent to the reference replay because fixture inputs are canonically key-sorted on disk and JSON.parse preserves that order. --- .../Sources/HapiProtocol/Chat/ChatTypes.swift | 1005 +++++++++++++ .../HapiProtocol/Chat/FixtureProjection.swift | 169 +++ .../Sources/HapiProtocol/Chat/JSInterop.swift | 297 ++++ .../HapiProtocol/Chat/MessageEnvelope.swift | 74 + .../Sources/HapiProtocol/Chat/Normalize.swift | 80 ++ .../HapiProtocol/Chat/NormalizeAgent.swift | 1267 +++++++++++++++++ .../HapiProtocol/Chat/NormalizeUser.swift | 69 + .../Sources/HapiProtocol/Chat/Reducer.swift | 174 +++ .../HapiProtocol/Chat/ReducerCliOutput.swift | 64 + .../HapiProtocol/Chat/ReducerEvents.swift | 144 ++ .../HapiProtocol/Chat/ReducerTimeline.swift | 1045 ++++++++++++++ .../HapiProtocol/Chat/ReducerTools.swift | 331 +++++ .../HapiProtocol/Chat/ToolGroups.swift | 387 +++++ .../HapiProtocol/Chat/ToolPresentation.swift | 143 ++ .../Sources/HapiProtocol/Chat/Tracer.swift | 129 ++ .../HapiProtocolTests/ChatFixtureTests.swift | 146 ++ ios/README.md | 27 +- 17 files changed, 5546 insertions(+), 5 deletions(-) create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/JSInterop.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/MessageEnvelope.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Normalize.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeUser.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Reducer.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerCliOutput.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerEvents.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTools.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolGroups.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolPresentation.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Tracer.swift create mode 100644 ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift new file mode 100644 index 00000000..b5c90286 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift @@ -0,0 +1,1005 @@ +import Foundation + +// Chat pipeline types — port of web/src/chat/types.ts. +// +// TS `unknown` fields are `JSONValue`; the TS `undefined` vs JSON `null` +// distinction is preserved wherever the normative projection can observe it +// (`JSONValue?` where `nil` == absent/`undefined` and `.null` == JSON null). +// Fields the projection cannot observe (timing, presentation) collapse +// `null`/`undefined` into plain optionals. + +// MARK: - Usage + +/// Port of `UsageData` (types.ts). Field names keep the wire snake_case +/// identity in comments; Swift naming is camelCase. +public struct UsageData: Equatable, Sendable { + /// `input_tokens` + public var inputTokens: Double + /// `output_tokens` + public var outputTokens: Double + /// `cache_creation_input_tokens` + public var cacheCreationInputTokens: Double? + /// `cache_read_input_tokens` + public var cacheReadInputTokens: Double? + /// `context_tokens` + public var contextTokens: Double? + /// `context_window` + public var contextWindow: Double? + /// `thread_id` + public var threadId: String? + /// `scope_role` + public var scopeRole: String? + /// `service_tier` + public var serviceTier: String? + + public init( + inputTokens: Double, + outputTokens: Double, + cacheCreationInputTokens: Double? = nil, + cacheReadInputTokens: Double? = nil, + contextTokens: Double? = nil, + contextWindow: Double? = nil, + threadId: String? = nil, + scopeRole: String? = nil, + serviceTier: String? = nil + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + self.contextTokens = contextTokens + self.contextWindow = contextWindow + self.threadId = threadId + self.scopeRole = scopeRole + self.serviceTier = serviceTier + } +} + +// MARK: - AgentEvent + +/// Port of the `AgentEvent` union (types.ts). +/// +/// The web keeps events as raw records: the `'event'` payload family passes +/// `data` through verbatim (extra keys included — the union's last member is +/// `{type: string} & Record`), and the projection emits the +/// object untouched. To reproduce that byte-for-byte, the port stores the +/// full object and layers a typed view on top: +/// - `object` — the event exactly as the reference would hold it +/// - `kind` + typed accessors — the discriminated view reducers switch on +/// - static constructors — the synthesized shapes (system subtypes, codex +/// events, limit parsing, …) with exactly the reference's key sets. +public struct AgentEvent: Equatable, Sendable { + /// The full event record, `type` key included. + public var object: [String: JSONValue] + + public init(object: [String: JSONValue]) { + self.object = object + } + + public var type: String { + object["type"]?.stringValue ?? "" + } + + public subscript(key: String) -> JSONValue? { + object[key] + } + + public var wireValue: JSONValue { .object(object) } + + /// Typed view over `type` for exhaustive switching in consumers. + public enum Kind: Equatable, Sendable { + case switchMode + case message + case error + case titleChanged + case limitReached + case limitWarning + case ready + case apiError + case turnDuration + case microcompact + case compact + case compactSummary + case recap + case threadGoalUpdated + case threadGoalCleared + case abortRestore + case tokenCount + case agentRunStart + case agentRunUpdate + case agentRunTrace + case custom(String) + } + + public var kind: Kind { + switch type { + case "switch": return .switchMode + case "message": return .message + case "error": return .error + case "title-changed": return .titleChanged + case "limit-reached": return .limitReached + case "limit-warning": return .limitWarning + case "ready": return .ready + case "api-error": return .apiError + case "turn-duration": return .turnDuration + case "microcompact": return .microcompact + case "compact": return .compact + case "compact-summary": return .compactSummary + case "recap": return .recap + case "thread-goal-updated": return .threadGoalUpdated + case "thread-goal-cleared": return .threadGoalCleared + case "abort-restore": return .abortRestore + case "token-count": return .tokenCount + case "agent-run-start": return .agentRunStart + case "agent-run-update": return .agentRunUpdate + case "agent-run-trace": return .agentRunTrace + default: return .custom(type) + } + } + + // Typed constructors — key sets mirror normalizeAgent.ts / the reducers. + + public static func message(_ text: String) -> AgentEvent { + AgentEvent(object: ["type": .string("message"), "message": .string(text)]) + } + + public static func error(_ text: String) -> AgentEvent { + AgentEvent(object: ["type": .string("error"), "message": .string(text)]) + } + + public static func titleChanged(_ title: String) -> AgentEvent { + AgentEvent(object: ["type": .string("title-changed"), "title": .string(title)]) + } + + public static func limitReached(endsAt: Double, limitType: String) -> AgentEvent { + AgentEvent(object: [ + "type": .string("limit-reached"), + "endsAt": .number(endsAt), + "limitType": .string(limitType), + ]) + } + + public static func limitWarning(utilization: Double, endsAt: Double, limitType: String) -> AgentEvent { + AgentEvent(object: [ + "type": .string("limit-warning"), + "utilization": .number(utilization), + "endsAt": .number(endsAt), + "limitType": .string(limitType), + ]) + } + + /// `error` is carried verbatim; an absent wire value omits the key + /// (JSON.stringify drops `undefined` properties). + public static func apiError(retryAttempt: Double, maxRetries: Double, error: JSONValue?) -> AgentEvent { + var object: [String: JSONValue] = [ + "type": .string("api-error"), + "retryAttempt": .number(retryAttempt), + "maxRetries": .number(maxRetries), + ] + if let error { object["error"] = error } + return AgentEvent(object: object) + } + + public static func turnDuration(durationMs: Double, targetMessageId: String?) -> AgentEvent { + var object: [String: JSONValue] = [ + "type": .string("turn-duration"), + "durationMs": .number(durationMs), + ] + if let targetMessageId { object["targetMessageId"] = .string(targetMessageId) } + return AgentEvent(object: object) + } + + public static func microcompact(trigger: String, preTokens: Double, tokensSaved: Double) -> AgentEvent { + AgentEvent(object: [ + "type": .string("microcompact"), + "trigger": .string(trigger), + "preTokens": .number(preTokens), + "tokensSaved": .number(tokensSaved), + ]) + } + + public static func compact(trigger: String, preTokens: Double) -> AgentEvent { + AgentEvent(object: [ + "type": .string("compact"), + "trigger": .string(trigger), + "preTokens": .number(preTokens), + ]) + } + + public static func compactSummary(summary: String, tokensBefore: Double?, estimatedTokensAfter: Double?) -> AgentEvent { + var object: [String: JSONValue] = [ + "type": .string("compact-summary"), + "summary": .string(summary), + ] + if let tokensBefore { object["tokensBefore"] = .number(tokensBefore) } + if let estimatedTokensAfter { object["estimatedTokensAfter"] = .number(estimatedTokensAfter) } + return AgentEvent(object: object) + } + + public static func recap(text: String) -> AgentEvent { + AgentEvent(object: ["type": .string("recap"), "text": .string(text)]) + } + + public static func ready(agentId: String) -> AgentEvent { + AgentEvent(object: ["type": .string("ready"), "agentId": .string(agentId)]) + } + + public static func tokenCount(info: JSONValue?) -> AgentEvent { + var object: [String: JSONValue] = ["type": .string("token-count")] + if let info { object["info"] = info } + return AgentEvent(object: object) + } + + public static func threadGoalUpdated(threadId: String, turnId: String?, goal: JSONValue) -> AgentEvent { + var object: [String: JSONValue] = [ + "type": .string("thread-goal-updated"), + "threadId": .string(threadId), + "goal": goal, + ] + if let turnId { object["turnId"] = .string(turnId) } + return AgentEvent(object: object) + } + + public static func threadGoalCleared(threadId: String?) -> AgentEvent { + var object: [String: JSONValue] = ["type": .string("thread-goal-cleared")] + if let threadId { object["threadId"] = .string(threadId) } + return AgentEvent(object: object) + } +} + +// MARK: - Tool permission + +public enum ToolPermissionStatus: String, Equatable, Sendable { + case pending + case approved + case denied + case canceled +} + +/// Permission decision values shared with `AgentStateCompletedRequest`. +public typealias ToolPermissionDecision = AgentStateCompletedRequest.Decision + +/// Port of `ToolResultPermission` (types.ts) — the `permissions` object a +/// Claude tool_result block may embed. +public struct ToolResultPermission: Equatable, Sendable { + public enum Result: String, Equatable, Sendable { + case approved + case denied + } + + public var date: Double + public var result: Result + public var mode: String? + public var allowedTools: [String]? + public var decision: ToolPermissionDecision? + + public init( + date: Double, + result: Result, + mode: String? = nil, + allowedTools: [String]? = nil, + decision: ToolPermissionDecision? = nil + ) { + self.date = date + self.result = result + self.mode = mode + self.allowedTools = allowedTools + self.decision = decision + } +} + +/// Port of `ToolPermission` (types.ts). +/// +/// The reference merges permission objects with JS object spread, where a key +/// present with an `undefined` value still OVERWRITES the base's value. +/// `presentKeys` records which optional keys a given construction site +/// carried (mirroring the TS object literals), so `spreading(_:with:)` can +/// reproduce spread semantics exactly. `id`/`status` are present at every +/// site and excluded from the set. +public struct ToolPermission: Equatable, Sendable { + public struct PresentKeys: OptionSet, Equatable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let reason = PresentKeys(rawValue: 1 << 0) + public static let mode = PresentKeys(rawValue: 1 << 1) + public static let decision = PresentKeys(rawValue: 1 << 2) + public static let allowedTools = PresentKeys(rawValue: 1 << 3) + public static let answers = PresentKeys(rawValue: 1 << 4) + public static let date = PresentKeys(rawValue: 1 << 5) + public static let createdAt = PresentKeys(rawValue: 1 << 6) + public static let completedAt = PresentKeys(rawValue: 1 << 7) + } + + public var id: String + public var status: ToolPermissionStatus + public var reason: String? + public var mode: String? + public var allowedTools: [String]? + public var decision: ToolPermissionDecision? + /// Two wire shapes, kept raw: flat `{q: [answers]}` (AskUserQuestion) or + /// nested `{q: {answers: [...]}}` (request_user_input). + public var answers: JSONValue? + public var date: Double? + public var createdAt: Int? + public var completedAt: Int? + public var presentKeys: PresentKeys + + public init( + id: String, + status: ToolPermissionStatus, + reason: String? = nil, + mode: String? = nil, + allowedTools: [String]? = nil, + decision: ToolPermissionDecision? = nil, + answers: JSONValue? = nil, + date: Double? = nil, + createdAt: Int? = nil, + completedAt: Int? = nil, + presentKeys: PresentKeys = [] + ) { + self.id = id + self.status = status + self.reason = reason + self.mode = mode + self.allowedTools = allowedTools + self.decision = decision + self.answers = answers + self.date = date + self.createdAt = createdAt + self.completedAt = completedAt + self.presentKeys = presentKeys + } + + /// JS `{ ...base, ...seed }`: the seed's value wins for every key the + /// seed carries (even when that value is `undefined`); the base's value + /// survives for keys the seed does not carry. + public static func spreading(_ base: ToolPermission?, with seed: ToolPermission) -> ToolPermission { + guard let base else { return seed } + var merged = seed + if !seed.presentKeys.contains(.reason) { merged.reason = base.reason } + if !seed.presentKeys.contains(.mode) { merged.mode = base.mode } + if !seed.presentKeys.contains(.decision) { merged.decision = base.decision } + if !seed.presentKeys.contains(.allowedTools) { merged.allowedTools = base.allowedTools } + if !seed.presentKeys.contains(.answers) { merged.answers = base.answers } + if !seed.presentKeys.contains(.date) { merged.date = base.date } + if !seed.presentKeys.contains(.createdAt) { merged.createdAt = base.createdAt } + if !seed.presentKeys.contains(.completedAt) { merged.completedAt = base.completedAt } + merged.presentKeys = base.presentKeys.union(seed.presentKeys) + return merged + } +} + +// MARK: - Normalized agent content + +/// Port of the `ToolUse` member of `NormalizedAgentContent`. +public struct ToolUseContent: Equatable, Sendable { + public var id: String + public var name: String + /// `nil` == the tool_use block had no `input` key (TS `undefined`). + public var input: JSONValue? + public var description: String? + public var nativeTitle: String? + public var nativeKind: String? + /// `nil` == no `progress` key on the wire payload. + public var progress: JSONValue? + public var uuid: String + public var parentUUID: String? + + public init( + id: String, + name: String, + input: JSONValue? = nil, + description: String? = nil, + nativeTitle: String? = nil, + nativeKind: String? = nil, + progress: JSONValue? = nil, + uuid: String, + parentUUID: String? = nil + ) { + self.id = id + self.name = name + self.input = input + self.description = description + self.nativeTitle = nativeTitle + self.nativeKind = nativeKind + self.progress = progress + self.uuid = uuid + self.parentUUID = parentUUID + } +} + +/// Port of the `ToolResult` member of `NormalizedAgentContent`. +public struct ToolResultContent: Equatable, Sendable { + public var toolUseId: String + /// `nil` == the tool_result carried no content (TS `undefined`); + /// `.null` == an explicit JSON null result. + public var content: JSONValue? + public var isError: Bool + public var uuid: String + public var parentUUID: String? + public var permissions: ToolResultPermission? + + public init( + toolUseId: String, + content: JSONValue? = nil, + isError: Bool = false, + uuid: String, + parentUUID: String? = nil, + permissions: ToolResultPermission? = nil + ) { + self.toolUseId = toolUseId + self.content = content + self.isError = isError + self.uuid = uuid + self.parentUUID = parentUUID + self.permissions = permissions + } +} + +/// Port of `GeneratedImageContent`. +public struct GeneratedImageContent: Equatable, Sendable { + public var imageId: String + public var fileName: String + public var mimeType: String? + public var uuid: String + public var parentUUID: String? + public var source: InlineMediaSource? + + public init( + imageId: String, + fileName: String, + mimeType: String? = nil, + uuid: String, + parentUUID: String? = nil, + source: InlineMediaSource? = nil + ) { + self.imageId = imageId + self.fileName = fileName + self.mimeType = mimeType + self.uuid = uuid + self.parentUUID = parentUUID + self.source = source + } +} + +/// Port of `InlineMediaSource` (web/src/chat/inlineMediaSource.ts). +public struct InlineMediaSource: Equatable, Sendable { + public enum Ingress: String, Equatable, Sendable { + case mcp + case acp + case toolResult = "tool_result" + } + + public var ingress: Ingress + public var flavor: String? + public var toolCallId: String? + public var toolName: String? + + public init(ingress: Ingress, flavor: String? = nil, toolCallId: String? = nil, toolName: String? = nil) { + self.ingress = ingress + self.flavor = flavor + self.toolCallId = toolCallId + self.toolName = toolName + } + + /// Port of `inlineMediaSourceFromWire`. + public static func fromWire(_ value: JSONValue?) -> InlineMediaSource? { + guard let record = value?.objectValue else { return nil } + let rawIngress = record["ingress"]?.stringValue ?? record["path"]?.stringValue + guard let rawIngress, let ingress = Ingress(rawValue: rawIngress) else { return nil } + let toolCallId = record["toolCallId"]?.stringValue ?? record["tool_call_id"]?.stringValue + let toolName = record["toolName"]?.stringValue ?? record["tool_name"]?.stringValue + return InlineMediaSource( + ingress: ingress, + flavor: record["flavor"]?.stringValue, + toolCallId: toolCallId, + toolName: toolName + ) + } +} + +/// Port of `CodexReviewFinding`. +public struct CodexReviewFinding: Equatable, Sendable { + public var title: String + public var body: String + public var priority: Double? + public var confidenceScore: Double? + public var filePath: String? + public var lineStart: Double? + public var lineEnd: Double? + + public init( + title: String, + body: String, + priority: Double? = nil, + confidenceScore: Double? = nil, + filePath: String? = nil, + lineStart: Double? = nil, + lineEnd: Double? = nil + ) { + self.title = title + self.body = body + self.priority = priority + self.confidenceScore = confidenceScore + self.filePath = filePath + self.lineStart = lineStart + self.lineEnd = lineEnd + } +} + +/// Port of `CodexReview`. The TS object stores explicit `null`s for absent +/// members and the projection carries the object verbatim, so +/// `wireValue` emits every key with `null` where the optional is `nil`. +public struct CodexReview: Equatable, Sendable { + public var findings: [CodexReviewFinding] + public var overallCorrectness: String? + public var overallExplanation: String? + public var overallConfidenceScore: Double? + + public init( + findings: [CodexReviewFinding], + overallCorrectness: String? = nil, + overallExplanation: String? = nil, + overallConfidenceScore: Double? = nil + ) { + self.findings = findings + self.overallCorrectness = overallCorrectness + self.overallExplanation = overallExplanation + self.overallConfidenceScore = overallConfidenceScore + } + + public var wireValue: JSONValue { + .object([ + "findings": .array(findings.map { finding in + .object([ + "title": .string(finding.title), + "body": .string(finding.body), + "priority": finding.priority.map { JSONValue.number($0) } ?? .null, + "confidenceScore": finding.confidenceScore.map { JSONValue.number($0) } ?? .null, + "filePath": finding.filePath.map { JSONValue.string($0) } ?? .null, + "lineStart": finding.lineStart.map { JSONValue.number($0) } ?? .null, + "lineEnd": finding.lineEnd.map { JSONValue.number($0) } ?? .null, + ]) + }), + "overallCorrectness": overallCorrectness.map { JSONValue.string($0) } ?? .null, + "overallExplanation": overallExplanation.map { JSONValue.string($0) } ?? .null, + "overallConfidenceScore": overallConfidenceScore.map { JSONValue.number($0) } ?? .null, + ]) + } +} + +/// Port of `NormalizedAgentContent`. +public enum NormalizedAgentContent: Equatable, Sendable { + case text(TextContent) + case reasoning(TextContent) + case toolCall(ToolUseContent) + case toolResult(ToolResultContent) + case generatedImage(GeneratedImageContent) + case codexReview(review: CodexReview, uuid: String, parentUUID: String?) + case summary(String) + case sidechain(SidechainContent) + + public struct TextContent: Equatable, Sendable { + public var text: String + public var uuid: String + public var streamId: String? + public var parentUUID: String? + + public init(text: String, uuid: String, streamId: String? = nil, parentUUID: String? = nil) { + self.text = text + self.uuid = uuid + self.streamId = streamId + self.parentUUID = parentUUID + } + } + + public struct SidechainContent: Equatable, Sendable { + public var uuid: String + public var parentUUID: String? + public var prompt: String + + public init(uuid: String, parentUUID: String? = nil, prompt: String) { + self.uuid = uuid + self.parentUUID = parentUUID + self.prompt = prompt + } + } + + /// `content[0].uuid` access in the tracer — the summary member has none. + public var uuid: String? { + switch self { + case .text(let value), .reasoning(let value): return value.uuid + case .toolCall(let value): return value.uuid + case .toolResult(let value): return value.uuid + case .generatedImage(let value): return value.uuid + case .codexReview(_, let uuid, _): return uuid + case .summary: return nil + case .sidechain(let value): return value.uuid + } + } + + public var parentUUID: String? { + switch self { + case .text(let value), .reasoning(let value): return value.parentUUID + case .toolCall(let value): return value.parentUUID + case .toolResult(let value): return value.parentUUID + case .generatedImage(let value): return value.parentUUID + case .codexReview(_, _, let parentUUID): return parentUUID + case .summary: return nil + case .sidechain(let value): return value.parentUUID + } + } +} + +// MARK: - Normalized message + +/// Port of `NormalizedMessage` (types.ts) with the tracer's `sidechainId` +/// folded in (the TS `TracedMessage` is a spread-extension of it). +public struct NormalizedMessage: Equatable, Sendable { + public enum Content: Equatable, Sendable { + case user(text: String, attachments: [AttachmentMetadata]?) + case agent([NormalizedAgentContent]) + case event(AgentEvent) + } + + public var id: String + public var localId: String? + public var createdAt: Int + public var content: Content + public var isSidechain: Bool + /// SDK `parent_tool_use_id` — groups sidechain messages under their + /// parent Agent/Task card. + public var parentToolUseId: String? + public var meta: JSONValue? + public var usage: UsageData? + /// Client send-state (web optimistic rows). Never present on wire pages. + public var status: String? + public var originalText: String? + public var invokedAt: Int? + public var model: String? + /// Execution-machine wall clock (epoch ms) parsed from the Claude + /// entry's own `timestamp`; `nil` when unparseable. + public var agentTimestamp: Int? + /// Tracer output: the Task/Agent message id this sidechain message + /// groups under. + public var sidechainId: String? + + public init( + id: String, + localId: String? = nil, + createdAt: Int, + content: Content, + isSidechain: Bool = false, + parentToolUseId: String? = nil, + meta: JSONValue? = nil, + usage: UsageData? = nil, + status: String? = nil, + originalText: String? = nil, + invokedAt: Int? = nil, + model: String? = nil, + agentTimestamp: Int? = nil, + sidechainId: String? = nil + ) { + self.id = id + self.localId = localId + self.createdAt = createdAt + self.content = content + self.isSidechain = isSidechain + self.parentToolUseId = parentToolUseId + self.meta = meta + self.usage = usage + self.status = status + self.originalText = originalText + self.invokedAt = invokedAt + self.model = model + self.agentTimestamp = agentTimestamp + self.sidechainId = sidechainId + } + + public var agentContent: [NormalizedAgentContent]? { + if case .agent(let items) = content { return items } + return nil + } + + public var eventContent: AgentEvent? { + if case .event(let event) = content { return event } + return nil + } +} + +// MARK: - ChatToolCall + +public enum ToolCallState: String, Equatable, Sendable { + case pending + case running + case completed + case error +} + +/// Port of `ChatToolCall` (types.ts). +public struct ChatToolCall: Equatable, Sendable { + public var id: String + public var name: String + public var state: ToolCallState + /// `nil` == TS `undefined` (key omitted by the projection); `.null` == + /// an explicit JSON null input (kept by the projection). + public var input: JSONValue? + public var createdAt: Int + public var startedAt: Int? + public var completedAt: Int? + public var execStartedAt: Int? + public var execCompletedAt: Int? + public var description: String? + public var nativeTitle: String? + public var nativeKind: String? + /// Same `nil`-vs-`.null` contract as `input`. + public var result: JSONValue? + public var permission: ToolPermission? + + public init( + id: String, + name: String, + state: ToolCallState, + input: JSONValue? = nil, + createdAt: Int, + startedAt: Int? = nil, + completedAt: Int? = nil, + execStartedAt: Int? = nil, + execCompletedAt: Int? = nil, + description: String? = nil, + nativeTitle: String? = nil, + nativeKind: String? = nil, + result: JSONValue? = nil, + permission: ToolPermission? = nil + ) { + self.id = id + self.name = name + self.state = state + self.input = input + self.createdAt = createdAt + self.startedAt = startedAt + self.completedAt = completedAt + self.execStartedAt = execStartedAt + self.execCompletedAt = execCompletedAt + self.description = description + self.nativeTitle = nativeTitle + self.nativeKind = nativeKind + self.result = result + self.permission = permission + } +} + +// MARK: - Chat blocks + +public struct UserTextBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var text: String + public var attachments: [AttachmentMetadata]? + public var status: String? + public var originalText: String? + public var meta: JSONValue? +} + +public struct AgentTextBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var durationMs: Int? + public var usage: UsageData? + public var model: String? + public var text: String + public var meta: JSONValue? +} + +public struct AgentReasoningBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var durationMs: Int? + public var usage: UsageData? + public var model: String? + public var text: String + public var meta: JSONValue? +} + +public struct CodexReviewBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var durationMs: Int? + public var usage: UsageData? + public var model: String? + public var review: CodexReview + public var meta: JSONValue? +} + +public struct CliOutputBlock: Equatable, Sendable { + public enum Source: String, Equatable, Sendable { + case user + case assistant + } + + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var durationMs: Int? + public var usage: UsageData? + public var model: String? + public var text: String + public var source: Source + public var meta: JSONValue? +} + +public struct GeneratedImageBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var imageId: String + public var fileName: String + public var mimeType: String? + public var source: InlineMediaSource? + public var meta: JSONValue? +} + +public struct AgentEventBlock: Equatable, Sendable { + public var id: String + public var createdAt: Int + public var invokedAt: Int? + public var model: String? + public var event: AgentEvent + public var meta: JSONValue? +} + +public struct ToolCallBlock: Equatable, Sendable { + public var id: String + public var localId: String? + public var createdAt: Int + public var invokedAt: Int? + public var durationMs: Int? + public var usage: UsageData? + public var model: String? + public var tool: ChatToolCall + public var children: [ChatBlock] + public var meta: JSONValue? +} + +/// Port of the `ChatBlock` union (types.ts). +public enum ChatBlock: Equatable, Sendable { + case userText(UserTextBlock) + case agentText(AgentTextBlock) + case agentReasoning(AgentReasoningBlock) + case codexReview(CodexReviewBlock) + case cliOutput(CliOutputBlock) + case toolCall(ToolCallBlock) + case generatedImage(GeneratedImageBlock) + case agentEvent(AgentEventBlock) + + /// The TS `kind` discriminator string. + public var kind: String { + switch self { + case .userText: return "user-text" + case .agentText: return "agent-text" + case .agentReasoning: return "agent-reasoning" + case .codexReview: return "codex-review" + case .cliOutput: return "cli-output" + case .toolCall: return "tool-call" + case .generatedImage: return "generated-image" + case .agentEvent: return "agent-event" + } + } + + public var id: String { + switch self { + case .userText(let block): return block.id + case .agentText(let block): return block.id + case .agentReasoning(let block): return block.id + case .codexReview(let block): return block.id + case .cliOutput(let block): return block.id + case .toolCall(let block): return block.id + case .generatedImage(let block): return block.id + case .agentEvent(let block): return block.id + } + } + + public var createdAt: Int { + switch self { + case .userText(let block): return block.createdAt + case .agentText(let block): return block.createdAt + case .agentReasoning(let block): return block.createdAt + case .codexReview(let block): return block.createdAt + case .cliOutput(let block): return block.createdAt + case .toolCall(let block): return block.createdAt + case .generatedImage(let block): return block.createdAt + case .agentEvent(let block): return block.createdAt + } + } + + public var invokedAt: Int? { + switch self { + case .userText(let block): return block.invokedAt + case .agentText(let block): return block.invokedAt + case .agentReasoning(let block): return block.invokedAt + case .codexReview(let block): return block.invokedAt + case .cliOutput(let block): return block.invokedAt + case .toolCall(let block): return block.invokedAt + case .generatedImage(let block): return block.invokedAt + case .agentEvent(let block): return block.invokedAt + } + } + + // Settable case accessors so reducer code can mutate blocks in place + // through `BlockBox` (get–modify–set through optional chaining). + + public var asToolCall: ToolCallBlock? { + get { if case .toolCall(let value) = self { return value }; return nil } + set { if let newValue { self = .toolCall(newValue) } } + } + + public var asAgentText: AgentTextBlock? { + get { if case .agentText(let value) = self { return value }; return nil } + set { if let newValue { self = .agentText(newValue) } } + } + + public var asAgentReasoning: AgentReasoningBlock? { + get { if case .agentReasoning(let value) = self { return value }; return nil } + set { if let newValue { self = .agentReasoning(newValue) } } + } + + public var asCliOutput: CliOutputBlock? { + get { if case .cliOutput(let value) = self { return value }; return nil } + set { if let newValue { self = .cliOutput(newValue) } } + } + + public var asCodexReview: CodexReviewBlock? { + get { if case .codexReview(let value) = self { return value }; return nil } + set { if let newValue { self = .codexReview(newValue) } } + } + + public var asAgentEvent: AgentEventBlock? { + get { if case .agentEvent(let value) = self { return value }; return nil } + set { if let newValue { self = .agentEvent(newValue) } } + } +} + +// MARK: - Latest usage + +/// Port of `LatestUsage` (reducer.ts). +public struct LatestUsage: Equatable, Sendable { + public var inputTokens: Double + public var outputTokens: Double + public var cacheCreation: Double + public var cacheRead: Double + public var contextSize: Double + public var contextWindow: Double? + public var model: String? + public var timestamp: Int + + public init( + inputTokens: Double, + outputTokens: Double, + cacheCreation: Double, + cacheRead: Double, + contextSize: Double, + contextWindow: Double? = nil, + model: String? = nil, + timestamp: Int + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheCreation = cacheCreation + self.cacheRead = cacheRead + self.contextSize = contextSize + self.contextWindow = contextWindow + self.model = model + self.timestamp = timestamp + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift new file mode 100644 index 00000000..9097db9e --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/FixtureProjection.swift @@ -0,0 +1,169 @@ +import Foundation + +// Port of web/scripts/fixtures/projection.ts — the NORMATIVE projection the +// golden fixtures pin. Field-for-field: structure + semantics in, +// web-presentation out. Keep in sync with shared/fixtures/README.md. + +private func projectAttachments(_ attachments: [AttachmentMetadata]?) -> JSONValue? { + guard let attachments, !attachments.isEmpty else { return nil } + // previewUrl is a web-serving convenience — dropped. + return .array(attachments.map { attachment in + .object([ + "id": .string(attachment.id), + "filename": .string(attachment.filename), + "mimeType": .string(attachment.mimeType), + "size": .number(Double(attachment.size)), + "path": .string(attachment.path), + ]) + }) +} + +private func projectPermission(_ permission: ToolPermission?) -> JSONValue? { + guard let permission else { return nil } + // Dropped: id (duplicates the tool id), date/createdAt/completedAt. + var projected: [String: JSONValue] = ["status": .string(permission.status.rawValue)] + if let mode = permission.mode { projected["mode"] = .string(mode) } + if let decision = permission.decision { projected["decision"] = .string(decision.rawValue) } + if let allowedTools = permission.allowedTools { + projected["allowedTools"] = .array(allowedTools.map { .string($0) }) + } + if let answers = permission.answers { projected["answers"] = answers } + if let reason = permission.reason { projected["reason"] = .string(reason) } + return .object(projected) +} + +private func projectTool(_ tool: ChatToolCall) -> JSONValue { + // Dropped: timing fields, description/nativeTitle/nativeKind, progress. + var projected: [String: JSONValue] = [ + "id": .string(tool.id), + "name": .string(tool.name), + "state": .string(tool.state.rawValue), + ] + if let input = tool.input { projected["input"] = input } + if let result = tool.result { projected["result"] = result } + if let permission = projectPermission(tool.permission) { projected["permission"] = permission } + return .object(projected) +} + +private func projectBlockBase(_ block: ChatBlock) -> [String: JSONValue] { + var projected: [String: JSONValue] = [ + "kind": .string(block.kind), + "id": .string(block.id), + "createdAt": .number(Double(block.createdAt)), + ] + if let invokedAt = block.invokedAt { + projected["invokedAt"] = .number(Double(invokedAt)) + } + return projected +} + +private func localIdValue(_ localId: String?) -> JSONValue { + localId.map { JSONValue.string($0) } ?? .null +} + +/// Port of `projectChatBlock`. Dropped on every kind: meta, usage, model, +/// durationMs, status, originalText. +public func projectChatBlock(_ block: ChatBlock) -> JSONValue { + var projected = projectBlockBase(block) + switch block { + case .userText(let userText): + projected["localId"] = localIdValue(userText.localId) + projected["text"] = .string(userText.text) + if let attachments = projectAttachments(userText.attachments) { + projected["attachments"] = attachments + } + return .object(projected) + case .agentText(let agentText): + projected["localId"] = localIdValue(agentText.localId) + projected["text"] = .string(agentText.text) + return .object(projected) + case .agentReasoning(let reasoning): + projected["localId"] = localIdValue(reasoning.localId) + projected["text"] = .string(reasoning.text) + return .object(projected) + case .cliOutput(let cliOutput): + projected["localId"] = localIdValue(cliOutput.localId) + projected["text"] = .string(cliOutput.text) + projected["source"] = .string(cliOutput.source.rawValue) + return .object(projected) + case .codexReview(let review): + projected["localId"] = localIdValue(review.localId) + projected["review"] = review.review.wireValue + return .object(projected) + case .generatedImage(let image): + // `source` is a web rendering concern — natives fetch by imageId. + projected["localId"] = localIdValue(image.localId) + projected["imageId"] = .string(image.imageId) + projected["fileName"] = .string(image.fileName) + projected["mimeType"] = image.mimeType.map { JSONValue.string($0) } ?? .null + return .object(projected) + case .agentEvent(let eventBlock): + // The normalized AgentEvent record is wire-semantic by construction: + // carried verbatim. + projected["event"] = eventBlock.event.wireValue + return .object(projected) + case .toolCall(let toolCall): + projected["localId"] = localIdValue(toolCall.localId) + projected["tool"] = projectTool(toolCall.tool) + if !toolCall.children.isEmpty { + projected["children"] = .array(toolCall.children.map(projectChatBlock)) + } + return .object(projected) + } +} + +/// Port of `projectVisibleChatBlock`: tool groups keep membership, order and +/// boundary ids; presentation state is dropped. +public func projectVisibleChatBlock(_ block: VisibleChatBlock) -> JSONValue { + switch block { + case .block(let chatBlock): + return projectChatBlock(chatBlock) + case .toolGroup(let group): + var projected: [String: JSONValue] = [ + "kind": .string("tool-group"), + "id": .string(group.id), + "createdAt": .number(Double(group.createdAt)), + ] + if let invokedAt = group.invokedAt { + projected["invokedAt"] = .number(Double(invokedAt)) + } + projected["firstToolId"] = .string(group.firstToolId) + projected["lastToolId"] = .string(group.lastToolId) + projected["tools"] = .array(group.tools.map { projectChatBlock(.toolCall($0)) }) + return .object(projected) + } +} + +/// Port of `projectLatestUsage`. Dropped: cacheCreation/cacheRead, model, +/// timestamp. `contextWindow` stays, null included. +public func projectLatestUsage(_ usage: LatestUsage?) -> JSONValue { + guard let usage else { return .null } + return .object([ + "inputTokens": .number(usage.inputTokens), + "outputTokens": .number(usage.outputTokens), + "contextSize": .number(usage.contextSize), + "contextWindow": usage.contextWindow.map { JSONValue.number($0) } ?? .null, + ]) +} + +/// Port of `runFixturePipeline` (web/scripts/fixtures/pipeline.ts): the +/// exact pipeline a client must implement for chat rendering — normalize → +/// reduce → group — followed by the normative projection. +public func runChatFixturePipeline( + messages: [DecryptedMessage], + agentState: AgentState?, + hasMoreMessages: Bool +) -> JSONValue { + let normalized = messages.compactMap(normalizeDecryptedMessage) + let reduced = reduceChatBlocks(normalized, agentState: agentState) + let visibleBlocks = buildVisibleChatBlocks( + reduced.blocks, + options: ToolGroupingOptions(hasMoreMessages: hasMoreMessages) + ) + return .object([ + "blocks": .array(reduced.blocks.map(projectChatBlock)), + "hasReadyEvent": .bool(reduced.hasReadyEvent), + "latestUsage": projectLatestUsage(reduced.latestUsage), + "visibleBlocks": .array(visibleBlocks.map(projectVisibleChatBlock)), + ]) +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/JSInterop.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/JSInterop.swift new file mode 100644 index 00000000..bbd72c8c --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/JSInterop.swift @@ -0,0 +1,297 @@ +import Foundation + +// JavaScript-semantics helpers for the chat pipeline port. +// +// The web reference (`web/src/chat/**`) manipulates untyped JSON with +// JavaScript operators; this file pins the exact semantics the port relies +// on so every call site can stay a mechanical translation: +// +// - `isObject(x)` → `x != nil && typeof x === 'object'` (arrays too) +// - property access → object member lookup; `undefined` on arrays and +// non-objects (modelled as Swift `nil`) +// - `a ?? b` → JS nullish-coalescing over wire values, where +// BOTH absent-key (`undefined`, Swift `nil`) and +// JSON `null` fall through (`jsCoalesce`) +// - `Boolean(x)` / `if (x)` → `jsTruthy` +// - `asString` / `asNumber` → shared/src/utils.ts +// - `safeStringify` → shared/src/utils.ts (2-space JSON, sorted keys — +// fixture inputs are canonically sorted on disk, +// so sorted output matches the reference replay) + +// MARK: - JSONValue accessors + +extension JSONValue { + /// `typeof value === 'object' && value !== null` — arrays included. + var jsIsObjectLike: Bool { + switch self { + case .object, .array: return true + default: return false + } + } + + var objectValue: [String: JSONValue]? { + if case .object(let value) = self { return value } + return nil + } + + var arrayValue: [JSONValue]? { + if case .array(let value) = self { return value } + return nil + } + + var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + var numberValue: Double? { + if case .number(let value) = self { return value } + return nil + } + + var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + /// JS member access `value[key]`: a value for object members, `nil` + /// (i.e. `undefined`) for arrays, scalars and missing keys. + subscript(key: String) -> JSONValue? { + objectValue?[key] + } + + /// `Boolean(value)` — JSON has no NaN, so number truthiness is `!= 0`. + var jsTruthy: Bool { + switch self { + case .null: return false + case .bool(let value): return value + case .number(let value): return value != 0 + case .string(let value): return !value.isEmpty + case .array, .object: return true + } + } +} + +/// JS `a ?? b ?? …` over wire values: the first operand that is neither +/// absent (`nil`) nor JSON `null` wins; otherwise `nil`. +func jsCoalesce(_ values: JSONValue?...) -> JSONValue? { + for value in values { + if let value, value != .null { return value } + } + return nil +} + +/// shared/src/utils.ts `asString` — string values only, no coercion. +func asString(_ value: JSONValue?) -> String? { + value?.stringValue +} + +/// shared/src/utils.ts `asNumber` — finite numbers only (JSON numbers always are). +func asNumber(_ value: JSONValue?) -> Double? { + value?.numberValue +} + +// MARK: - safeStringify + +/// shared/src/utils.ts `safeStringify`: strings pass through raw; everything +/// else becomes `JSON.stringify(value, null, 2)`. Object keys are emitted +/// sorted — fixture inputs are canonically serialized (sorted keys), and +/// JSON.parse preserves that order, so the reference's insertion-order output +/// over fixture inputs is exactly sorted-key output. +func safeStringify(_ value: JSONValue) -> String { + if case .string(let text) = value { return text } + return serializeJSON(value, indent: 2) +} + +// MARK: - Canonical JSON + +/// Canonical serialization for fixture comparison (mirrors +/// web/scripts/fixtures/serialize.ts): recursively sorted object keys, +/// 4-space indent, LF line endings, single trailing newline. Numbers that +/// are mathematically integral (within the JS safe-integer range) serialize +/// without a fractional part, matching `JSON.stringify` — timestamps must +/// come out as `1755000000000`, never `1755000000000.0`. +public func toCanonicalJSON(_ value: JSONValue) -> String { + serializeJSON(value, indent: 4) + "\n" +} + +private func serializeJSON(_ value: JSONValue, indent: Int) -> String { + var out = String() + writeJSON(value, indent: indent, depth: 0, into: &out) + return out +} + +private func writeJSON(_ value: JSONValue, indent: Int, depth: Int, into out: inout String) { + switch value { + case .null: + out += "null" + case .bool(let flag): + out += flag ? "true" : "false" + case .number(let number): + out += jsNumberString(number) + case .string(let text): + writeJSONString(text, into: &out) + case .array(let items): + if items.isEmpty { + out += "[]" + return + } + let pad = String(repeating: " ", count: indent * (depth + 1)) + let closePad = String(repeating: " ", count: indent * depth) + out += "[\n" + for (index, item) in items.enumerated() { + out += pad + writeJSON(item, indent: indent, depth: depth + 1, into: &out) + out += index == items.count - 1 ? "\n" : ",\n" + } + out += closePad + "]" + case .object(let members): + if members.isEmpty { + out += "{}" + return + } + let pad = String(repeating: " ", count: indent * (depth + 1)) + let closePad = String(repeating: " ", count: indent * depth) + // JS `Object.keys(x).sort()` compares UTF-16 code units. + let keys = members.keys.sorted { $0.utf16.lexicographicallyPrecedes($1.utf16) } + out += "{\n" + for (index, key) in keys.enumerated() { + out += pad + writeJSONString(key, into: &out) + out += ": " + writeJSON(members[key]!, indent: indent, depth: depth + 1, into: &out) + out += index == keys.count - 1 ? "\n" : ",\n" + } + out += closePad + "}" + } +} + +/// `JSON.stringify` number formatting for the values this protocol carries: +/// integral doubles inside the JS safe-integer range print as integers; +/// everything else uses Swift's shortest round-trip representation (the same +/// algorithm family V8 uses, identical for the magnitudes on this wire). +func jsNumberString(_ number: Double) -> String { + if number.rounded() == number && abs(number) <= 9_007_199_254_740_991 { + return String(Int64(number)) + } + return String(number) +} + +private func writeJSONString(_ text: String, into out: inout String) { + out += "\"" + for scalar in text.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + case "\u{08}": out += "\\b" + case "\u{0C}": out += "\\f" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + out += "\"" +} + +// MARK: - JS string helpers + +extension String { + /// JS `String.prototype.trim()`. + var jsTrimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// JS `String.prototype.trimEnd()`. + var jsTrimmedEnd: String { + var view = Substring(self) + while let last = view.unicodeScalars.last, CharacterSet.whitespacesAndNewlines.contains(last) { + view.removeLast() + } + return String(view) + } + + /// JS `String.prototype.trimStart()`. + var jsTrimmedStart: String { + var view = Substring(self) + while let first = view.unicodeScalars.first, CharacterSet.whitespacesAndNewlines.contains(first) { + view.removeFirst() + } + return String(view) + } +} + +// MARK: - Regex helper + +/// Thin NSRegularExpression wrapper mirroring the few JS regex operations the +/// pipeline uses. Patterns are written with `\A`/`\z` anchors where the JS +/// source used whole-string `^`/`$` (ICU treats bare `$` as +/// "before a final line terminator", JS does not). +/// +/// `@unchecked Sendable`: NSRegularExpression is documented immutable and +/// thread-safe; instances here are file-scope constants. +struct JSRegex: @unchecked Sendable { + private let regex: NSRegularExpression + + init(_ pattern: String, caseInsensitive: Bool = false, anchorsMatchLines: Bool = false) { + var options: NSRegularExpression.Options = [] + if caseInsensitive { options.insert(.caseInsensitive) } + if anchorsMatchLines { options.insert(.anchorsMatchLines) } + // Patterns are compile-time constants ported from the reference; + // a syntax error would fail every fixture, loudly. + // swiftlint:disable:next force_try + self.regex = try! NSRegularExpression(pattern: pattern, options: options) + } + + /// JS `regex.test(text)`. + func test(_ text: String) -> Bool { + let range = NSRange(text.startIndex..., in: text) + return regex.firstMatch(in: text, options: [], range: range) != nil + } + + /// JS `text.match(regex)` — capture groups of the first match. + /// Index 0 is the whole match; unmatched optional groups are `nil`. + func firstMatch(in text: String) -> [String?]? { + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, options: [], range: range) else { return nil } + var groups: [String?] = [] + for index in 0.. String { + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, options: [], range: range), + let swiftRange = Range(match.range, in: text) else { return text } + var result = text + result.removeSubrange(swiftRange) + return result + } + + /// JS `text.replace(regex, '')` for a GLOBAL regex: removes every match. + func removingAllMatches(in text: String) -> String { + replacingAllMatches(in: text, with: "") + } + + /// JS `text.replace(regex, template)` for a GLOBAL regex. + func replacingAllMatches(in text: String, with template: String) -> String { + let range = NSRange(text.startIndex..., in: text) + return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: template) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/MessageEnvelope.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/MessageEnvelope.swift new file mode 100644 index 00000000..50aa7087 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/MessageEnvelope.swift @@ -0,0 +1,74 @@ +import Foundation + +// Port of the envelope helpers the chat pipeline uses from +// shared/src/messages.ts: role-wrapped envelope unwrapping, the Claude +// chat-visibility filter, and the redundant goal-status text test. + +/// Port of `RoleWrappedRecord`. +public struct RoleWrappedRecord: Equatable, Sendable { + public var role: String + public var content: JSONValue + public var meta: JSONValue? + + public init(role: String, content: JSONValue, meta: JSONValue? = nil) { + self.role = role + self.content = content + self.meta = meta + } +} + +private func asRoleWrappedRecord(_ value: JSONValue?) -> RoleWrappedRecord? { + guard let object = value?.objectValue else { return nil } + guard let role = object["role"]?.stringValue else { return nil } + // `'content' in value` — key presence, any value (JSON knows no undefined). + guard let content = object["content"] else { return nil } + return RoleWrappedRecord(role: role, content: content, meta: object["meta"]) +} + +/// Port of `unwrapRoleWrappedRecordEnvelope`: the value itself, or the +/// wrapped record probed at `message`, `data.message`, `payload.message`. +public func unwrapRoleWrappedRecordEnvelope(_ value: JSONValue) -> RoleWrappedRecord? { + if let record = asRoleWrappedRecord(value) { return record } + guard value.objectValue != nil else { return nil } + + if let record = asRoleWrappedRecord(value["message"]) { return record } + if let record = asRoleWrappedRecord(value["data"]?["message"]) { return record } + if let record = asRoleWrappedRecord(value["payload"]?["message"]) { return record } + return nil +} + +/// Port of `VISIBLE_CLAUDE_SYSTEM_SUBTYPES`. +private let visibleClaudeSystemSubtypes: Set = [ + "api_error", + "turn_duration", + "microcompact_boundary", + "compact_boundary", + "away_summary", +] + +/// Port of `isClaudeChatVisibleSystemSubtype`. +public func isClaudeChatVisibleSystemSubtype(_ subtype: JSONValue?) -> Bool { + guard let subtype = subtype?.stringValue else { return false } + return visibleClaudeSystemSubtypes.contains(subtype) +} + +/// Port of `isClaudeChatVisibleMessage` — `type`/`subtype` straight off the +/// wire record (non-string `type` values compare unequal, like in JS). +public func isClaudeChatVisibleMessage(type: JSONValue?, subtype: JSONValue?) -> Bool { + if type == .string("rate_limit_event") { return false } + if type == .string("tool_progress") { return false } + if type != .string("system") { return true } + return isClaudeChatVisibleSystemSubtype(subtype) +} + +/// Port of `isRedundantGoalStatusMessageText`. `·` is U+00B7. +public func isRedundantGoalStatusMessageText(_ value: String?) -> Bool { + guard let value else { return false } + let message = value.jsTrimmed + if message == "Goal cleared" { return true } + return goalStatusRegex.test(message) +} + +private let goalStatusRegex = JSRegex( + "\\AGoal (active|paused|complete|blocked|limited by (?:budget|usage))(?:\\z|\\s+\u{00B7}\\s+)" +) diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Normalize.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Normalize.swift new file mode 100644 index 00000000..66a3eec6 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Normalize.swift @@ -0,0 +1,80 @@ +import Foundation + +// Port of web/src/chat/normalize.ts — total decode of one wire message. + +/// Port of `normalizeDecryptedMessage`. Returns `nil` only on the two +/// legitimate drop paths (skippable Claude output, unknown codex content); +/// every other malformed shape degrades to stringified text. +public func normalizeDecryptedMessage(_ message: DecryptedMessage) -> NormalizedMessage? { + guard let record = unwrapRoleWrappedRecordEnvelope(message.content) else { + return NormalizedMessage( + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: .agent([.text(.init(text: safeStringify(message.content), uuid: message.id, parentUUID: nil))]), + isSidechain: false + // No meta / invokedAt on this branch (normalize.ts:10-20). + ) + } + + if record.role == "user" { + if var normalized = normalizeUserRecord( + messageId: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: record.content, + meta: record.meta + ) { + normalized.invokedAt = message.invokedAt + return normalized + } + return NormalizedMessage( + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: .user(text: safeStringify(record.content), attachments: nil), + isSidechain: false, + meta: record.meta, + invokedAt: message.invokedAt + ) + } + + if record.role == "agent" { + if isSkippableAgentContent(record.content) { + return nil + } + let normalized = normalizeAgentRecord( + messageId: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: record.content, + meta: record.meta + ) + if normalized == nil && isCodexContent(record.content) { + return nil + } + if var normalized { + normalized.invokedAt = message.invokedAt + return normalized + } + return NormalizedMessage( + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: .agent([.text(.init(text: safeStringify(record.content), uuid: message.id, parentUUID: nil))]), + isSidechain: false, + meta: record.meta, + invokedAt: message.invokedAt + ) + } + + return NormalizedMessage( + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + content: .agent([.text(.init(text: safeStringify(record.content), uuid: message.id, parentUUID: nil))]), + isSidechain: false, + meta: record.meta, + invokedAt: message.invokedAt + ) +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift new file mode 100644 index 00000000..c9370ad5 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeAgent.swift @@ -0,0 +1,1267 @@ +import Foundation + +// Port of web/src/chat/normalizeAgent.ts (1:1 function mapping, same order). + +/// `AGENT_MESSAGE_PAYLOAD_TYPE` (shared/src/modes.ts) — the generic agent +/// envelope used by the non-Claude-SDK flavors. +let agentMessagePayloadType = "codex" + +// MARK: - Small helpers + +/// Port of `parseAgentTimestampMs` (web/src/chat/agentTimestamp.ts): +/// ISO-8601 `timestamp` → epoch ms; `nil` when missing/unparseable. +func parseAgentTimestampMs(_ value: JSONValue?) -> Int? { + guard let text = value?.stringValue, !text.jsTrimmed.isEmpty else { return nil } + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + guard let date = withFraction.date(from: text) ?? plain.date(from: text) else { return nil } + return Int((date.timeIntervalSince1970 * 1000).rounded()) +} + +/// Port of `normalizeToolResultPermissions`. +func normalizeToolResultPermissions(_ value: JSONValue?) -> ToolResultPermission? { + guard let object = value?.objectValue else { return nil } + guard let date = asNumber(object["date"]) else { return nil } + guard let rawResult = object["result"]?.stringValue, + let result = ToolResultPermission.Result(rawValue: rawResult) else { return nil } + + let mode = asString(object["mode"]) + let allowedTools: [String]? = { + guard let items = object["allowedTools"]?.arrayValue else { return nil } + return items.compactMap(\.stringValue) + }() + let decision: ToolPermissionDecision? = { + guard let raw = object["decision"]?.stringValue else { return nil } + return ToolPermissionDecision(rawValue: raw) + }() + + return ToolResultPermission( + date: date, + result: result, + mode: mode, + allowedTools: allowedTools, + decision: decision + ) +} + +/// Port of `normalizeAgentEvent`: any record with a string `type` is an +/// event, carried verbatim. +func normalizeAgentEvent(_ value: JSONValue?) -> AgentEvent? { + guard let object = value?.objectValue, object["type"]?.stringValue != nil else { return nil } + return AgentEvent(object: object) +} + +/// Port of `normalizeThreadGoal`. Returns the goal as the exact record the +/// reference constructs (`tokenBudget` explicitly null when absent). +func normalizeThreadGoal(_ value: JSONValue?) -> JSONValue? { + guard let object = value?.objectValue else { return nil } + let threadId = asString(jsCoalesce(object["threadId"], object["thread_id"])) + let objective = asString(object["objective"]) + let status = asString(object["status"]) + guard let threadId, !threadId.isEmpty, let objective, !objective.isEmpty, + let status, !status.isEmpty else { return nil } + switch status { + case "active", "paused", "budgetLimited", "usageLimited", "blocked", "complete": + break + default: + return nil + } + return .object([ + "threadId": .string(threadId), + "objective": .string(objective), + "status": .string(status), + "tokenBudget": asNumber(jsCoalesce(object["tokenBudget"], object["token_budget"])) + .map { JSONValue.number($0) } ?? .null, + "tokensUsed": .number(asNumber(jsCoalesce(object["tokensUsed"], object["tokens_used"])) ?? 0), + "timeUsedSeconds": .number(asNumber(jsCoalesce(object["timeUsedSeconds"], object["time_used_seconds"])) ?? 0), + "createdAt": .number(asNumber(jsCoalesce(object["createdAt"], object["created_at"])) ?? 0), + "updatedAt": .number(asNumber(jsCoalesce(object["updatedAt"], object["updated_at"])) ?? 0), + ]) +} + +/// Port of `normalizeCodexTokenUsage`. +func normalizeCodexTokenUsage(_ value: JSONValue?, data: [String: JSONValue]?) -> UsageData? { + guard let info = value, info.objectValue != nil || info.arrayValue != nil else { return nil } + // TS `isObject` is truthy for arrays too; property access on an array + // yields undefined, which the accessor below reproduces. + let scope: JSONValue? = { + guard let data, let scopeValue = data["scope"], scopeValue.jsIsObjectLike else { return nil } + return scopeValue + }() + + func prop(_ base: JSONValue?, _ key: String) -> JSONValue? { + base?[key] + } + + // Prefer `last` over `total`; fall back to the info record itself. + let usageSource: JSONValue = { + let candidates = ["last", "lastTokenUsage", "last_token_usage", "total", "totalTokenUsage", "total_token_usage"] + for key in candidates { + if let candidate = prop(info, key), candidate.jsIsObjectLike { + return candidate + } + } + return info + }() + + guard let inputTokens = asNumber(jsCoalesce(prop(usageSource, "inputTokens"), prop(usageSource, "input_tokens"))), + let outputTokens = asNumber(jsCoalesce(prop(usageSource, "outputTokens"), prop(usageSource, "output_tokens"))) + else { return nil } + + let cacheRead = asNumber(jsCoalesce( + prop(usageSource, "cachedInputTokens"), + prop(usageSource, "cached_input_tokens"), + prop(usageSource, "cacheReadInputTokens"), + prop(usageSource, "cache_read_input_tokens") + )) + let contextTokens = asNumber(jsCoalesce( + prop(info, "contextTokens"), + prop(info, "context_tokens"), + prop(usageSource, "contextTokens"), + prop(usageSource, "context_tokens") + )) ?? inputTokens + let contextWindow = asNumber(jsCoalesce(prop(info, "modelContextWindow"), prop(info, "model_context_window"))) + let threadId = asString(jsCoalesce( + data?["thread_id"], + data?["threadId"], + prop(scope, "thread_id"), + prop(scope, "threadId"), + prop(info, "thread_id"), + prop(info, "threadId") + )) + let scopeRole = asString(jsCoalesce(data?["scope_role"], data?["scopeRole"], prop(scope, "role"))) + + return UsageData( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationInputTokens: nil, + cacheReadInputTokens: cacheRead, + contextTokens: contextTokens, + contextWindow: contextWindow, + threadId: threadId, + scopeRole: scopeRole + ) +} + +/// Port of `normalizePlanStatus`. +func normalizePlanStatus(_ value: JSONValue?) -> String { + let raw: String = { + guard let text = value?.stringValue else { return "" } + var lowered = text.jsTrimmed.lowercased() + lowered = lowered.replacingOccurrences(of: " ", with: "_") + lowered = lowered.replacingOccurrences(of: "-", with: "_") + // JS `[\s-]` also folds tabs/newlines; whitespace inside a status + // token is not observed on this wire, but stay faithful. + lowered = lowered.replacingOccurrences(of: "\t", with: "_") + lowered = lowered.replacingOccurrences(of: "\n", with: "_") + lowered = lowered.replacingOccurrences(of: "\r", with: "_") + return lowered + }() + if raw == "completed" || raw == "complete" || raw == "done" { return "completed" } + if raw == "in_progress" || raw == "inprogress" || raw == "active" || raw == "running" { return "in_progress" } + return "pending" +} + +/// Port of `normalizePlanEntries` — returns `{step, status}` records. +func normalizePlanEntries(_ value: JSONValue?) -> [JSONValue] { + let record = value?.objectValue + let entries: [JSONValue] = { + if let items = value?.arrayValue { return items } + if let items = record?["plan"]?.arrayValue { return items } + if let items = record?["items"]?.arrayValue { return items } + if let items = record?["steps"]?.arrayValue { return items } + return [] + }() + + var plan: [JSONValue] = [] + for entry in entries { + if let text = entry.stringValue { + plan.append(.object(["step": .string(text), "status": .string("pending")])) + continue + } + guard let object = entry.objectValue else { continue } + let step = asString(object["step"]) + ?? asString(object["content"]) + ?? asString(object["text"]) + ?? asString(object["title"]) + ?? asString(object["description"]) + guard let step, !step.isEmpty else { continue } + plan.append(.object([ + "step": .string(step), + "status": .string(normalizePlanStatus(jsCoalesce(object["status"], object["state"]))), + ])) + } + return plan +} + +// MARK: - Codex review parsing + +/// Port of `normalizeCodexReviewFinding`. +func normalizeCodexReviewFinding(_ value: JSONValue) -> CodexReviewFinding? { + guard let object = value.objectValue else { return nil } + guard let title = asString(object["title"]), !title.isEmpty, + let body = asString(object["body"]), !body.isEmpty else { return nil } + + let codeLocation: [String: JSONValue]? = + object["code_location"]?.objectValue ?? object["codeLocation"]?.objectValue + let lineRange: [String: JSONValue]? = codeLocation.flatMap { location in + location["line_range"]?.objectValue ?? location["lineRange"]?.objectValue + } + + return CodexReviewFinding( + title: title, + body: body, + priority: asNumber(object["priority"]), + confidenceScore: asNumber(jsCoalesce(object["confidence_score"], object["confidenceScore"])), + filePath: codeLocation.flatMap { location in + asString(jsCoalesce(location["absolute_file_path"], location["absoluteFilePath"], location["path"])) + }, + lineStart: lineRange.flatMap { asNumber($0["start"]) }, + lineEnd: lineRange.flatMap { asNumber($0["end"]) } + ) +} + +/// Port of `normalizeCodexReviewJson`. +func normalizeCodexReviewJson(_ value: JSONValue) -> CodexReview? { + guard let object = value.objectValue else { return nil } + let hasReviewMarker = object["findings"]?.arrayValue != nil + || object.keys.contains("overall_correctness") + || object.keys.contains("overallCorrectness") + || object.keys.contains("overall_explanation") + || object.keys.contains("overallExplanation") + guard hasReviewMarker else { return nil } + + let findings: [CodexReviewFinding] = object["findings"]?.arrayValue? + .compactMap(normalizeCodexReviewFinding) ?? [] + + let overallCorrectness = asString(jsCoalesce(object["overall_correctness"], object["overallCorrectness"])) + let overallExplanation = asString(jsCoalesce(object["overall_explanation"], object["overallExplanation"])) + let overallConfidenceScore = asNumber(jsCoalesce(object["overall_confidence_score"], object["overallConfidenceScore"])) + + // TS treats an empty-string correctness/explanation as falsy here. + let correctnessPresent = (overallCorrectness ?? "").isEmpty == false + let explanationPresent = (overallExplanation ?? "").isEmpty == false + if findings.isEmpty && !correctnessPresent && !explanationPresent && overallConfidenceScore == nil { + return nil + } + + return CodexReview( + findings: findings, + overallCorrectness: overallCorrectness, + overallExplanation: overallExplanation, + overallConfidenceScore: overallConfidenceScore + ) +} + +/// Port of `parseCodexReviewMessage`. +func parseCodexReviewMessage(_ message: String) -> CodexReview? { + let trimmed = message.jsTrimmed + guard trimmed.hasPrefix("{") && trimmed.hasSuffix("}") else { return nil } + guard let parsed = try? JSONDecoder().decode(JSONValue.self, from: Data(trimmed.utf8)) else { return nil } + return normalizeCodexReviewJson(parsed) +} + +// MARK: - Claude 'output' family + +/// Port of `normalizeAssistantOutput`. +func normalizeAssistantOutput( + messageId: String, + localId: String?, + createdAt: Int, + data: [String: JSONValue], + meta: JSONValue? +) -> NormalizedMessage? { + let uuid = asString(data["uuid"]) ?? messageId + let parentUUID = asString(data["parentUuid"]) + let isSidechain = data["isSidechain"]?.jsTruthy ?? false + let agentTimestamp = parseAgentTimestampMs(data["timestamp"]) + let parentToolUseId = asString(data["parentToolUseId"]) + + guard let message = data["message"], message.jsIsObjectLike else { return nil } + + let modelContent = message["content"] + var blocks: [NormalizedAgentContent] = [] + + if let text = modelContent?.stringValue { + blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID))) + } else if let items = modelContent?.arrayValue { + for block in items { + guard let object = block.objectValue, let type = object["type"]?.stringValue else { continue } + if type == "text", let text = object["text"]?.stringValue { + blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID))) + continue + } + if type == "thinking", let thinking = object["thinking"]?.stringValue { + blocks.append(.reasoning(.init(text: thinking, uuid: uuid, parentUUID: parentUUID))) + continue + } + if type == "tool_use", let id = object["id"]?.stringValue { + let name = asString(object["name"]) ?? "Tool" + let input = object["input"] // key-presence == TS `'input' in block` + let description: String? = { + guard let inputObject = input?.objectValue else { return nil } + return inputObject["description"]?.stringValue + }() + blocks.append(.toolCall(ToolUseContent( + id: id, + name: name, + input: input, + description: description, + uuid: uuid, + parentUUID: parentUUID + ))) + } + } + } + + let usage = message["usage"]?.objectValue + let inputTokens = usage.flatMap { asNumber($0["input_tokens"]) } + let outputTokens = usage.flatMap { asNumber($0["output_tokens"]) } + let model = asString(message["model"]) + + let usageData: UsageData? = { + guard let inputTokens, let outputTokens else { return nil } + return UsageData( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationInputTokens: usage.flatMap { asNumber($0["cache_creation_input_tokens"]) }, + cacheReadInputTokens: usage.flatMap { asNumber($0["cache_read_input_tokens"]) }, + contextWindow: usage.flatMap { asNumber($0["context_window"]) }, + serviceTier: usage.flatMap { asString($0["service_tier"]) } + ) + }() + + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent(blocks), + isSidechain: isSidechain, + parentToolUseId: parentToolUseId, + meta: meta, + usage: usageData, + model: model, + agentTimestamp: agentTimestamp + ) +} + +/// Port of `normalizeUserOutput` — tool results, sidechain markers, and +/// CLI-wrapped real user messages arriving through the agent output path. +func normalizeUserOutput( + messageId: String, + localId: String?, + createdAt: Int, + data: [String: JSONValue], + meta: JSONValue? +) -> NormalizedMessage? { + let uuid = asString(data["uuid"]) ?? messageId + let parentUUID = asString(data["parentUuid"]) + let isSidechain = data["isSidechain"]?.jsTruthy ?? false + let agentTimestamp = parseAgentTimestampMs(data["timestamp"]) + let parentToolUseId = asString(data["parentToolUseId"]) + + guard let message = data["message"], message.jsIsObjectLike else { return nil } + + let messageContent = message["content"] + + // All string-content user messages here are system-injected (subagent + // prompts, task notifications, system reminders): emit as sidechain so + // the uuid/parentUUID chain is preserved (sidechain and non-sidechain + // alike — the two TS branches collapse to one shape). + if let prompt = messageContent?.stringValue { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.sidechain(.init(uuid: uuid, parentUUID: parentUUID, prompt: prompt))]), + isSidechain: true, + parentToolUseId: parentToolUseId, + agentTimestamp: agentTimestamp + ) + } + + // Sidechain array content serialized as [{type:'text', …}] — join the + // text parts and treat as the sidechain prompt. + if isSidechain, let items = messageContent?.arrayValue { + let textParts: [String] = items.compactMap { block in + guard let object = block.objectValue, object["type"] == .string("text") else { return nil } + return object["text"]?.stringValue + } + if !textParts.isEmpty { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.sidechain(.init(uuid: uuid, parentUUID: parentUUID, prompt: textParts.joined(separator: "\n\n")))]), + isSidechain: true, + parentToolUseId: parentToolUseId, + agentTimestamp: agentTimestamp + ) + } + } + + // Non-sidechain array content that is entirely text blocks: a real user + // message the CLI wrapped as agent output — render in the user lane. + if !isSidechain, let items = messageContent?.arrayValue { + let textParts: [String] = items.compactMap { block in + guard let object = block.objectValue, object["type"] == .string("text") else { return nil } + return object["text"]?.stringValue + } + if !textParts.isEmpty && textParts.count == items.count { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .user(text: textParts.joined(separator: "\n\n"), attachments: nil), + isSidechain: false, + meta: meta, + agentTimestamp: agentTimestamp + ) + } + } + + var blocks: [NormalizedAgentContent] = [] + + if let items = messageContent?.arrayValue { + for block in items { + guard let object = block.objectValue, let type = object["type"]?.stringValue else { continue } + if type == "text", let text = object["text"]?.stringValue { + blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID))) + continue + } + if type == "tool_result", let toolUseId = object["tool_use_id"]?.stringValue { + let isError = object["is_error"]?.jsTruthy ?? false + let rawContent = object["content"] // key presence == `'content' in block` + // `embeddedToolUseResult ?? rawContent` — an absent or null + // `data.toolUseResult` falls through to the block content. + let content = jsCoalesce(data["toolUseResult"]) ?? rawContent + + let permissions = normalizeToolResultPermissions(object["permissions"]) + + blocks.append(.toolResult(ToolResultContent( + toolUseId: toolUseId, + content: content, + isError: isError, + uuid: uuid, + parentUUID: parentUUID, + permissions: permissions + ))) + } + } + } + + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent(blocks), + isSidechain: isSidechain, + parentToolUseId: parentToolUseId, + meta: meta, + agentTimestamp: agentTimestamp + ) +} + +// MARK: - agy (Antigravity) + +/// Port of `humanizeAgyActionType`: "RUN_COMMAND" → "Run command". +func humanizeAgyActionType(_ type: String) -> String { + let words = type.lowercased().split(separator: "_").map(String.init).filter { !$0.isEmpty } + guard !words.isEmpty else { return "Tool" } + return words.enumerated().map { index, word in + index == 0 ? word.prefix(1).uppercased() + word.dropFirst() : word + }.joined(separator: " ") +} + +/// Canonical tool ids for agy synthetic cards (normalizeAgent.ts). +public let agyTaskLogTool = "AgyTaskLog" +public let agyAsyncTaskTool = "AgyAsyncTask" +public let agyErrorTool = "AgyError" + +/// Port of `stripAgyActionPreamble`: drop the bookkeeping timestamp lines, +/// the per-file metadata header on reads, and the model-directed instruction +/// on write/edit confirmations; keep the substantive result. +func stripAgyActionPreamble(_ content: String, name: String, rawActionName: String?) -> String { + var result = content + + // /^(?:Created At:.*(?:\r?\n|$))?(?:Completed At:.*(?:\r?\n|$))?/ — + // anchored optional lines, in this order. + if let stripped = stripLeadingLine(of: result, prefix: "Created At:") { result = stripped } + if let stripped = stripLeadingLine(of: result, prefix: "Completed At:") { result = stripped } + + if name == "Read" || rawActionName == "VIEW_FILE" { + // /^(?:(?:File Path:|Total Lines:|Total Bytes:|Showing lines\b).*(?:\r?\n|$))+/ + while true { + let next = stripLeadingLine(of: result, prefix: "File Path:") + ?? stripLeadingLine(of: result, prefix: "Total Lines:") + ?? stripLeadingLine(of: result, prefix: "Total Bytes:") + ?? stripLeadingLine(of: result, wordBoundedPrefix: "Showing lines") + guard let next else { break } + result = next + } + // /^The following code has been modified to include a line number.*(?:\r?\n|$)/ + if let stripped = stripLeadingLine( + of: result, + prefix: "The following code has been modified to include a line number" + ) { result = stripped } + } + + if name == "Write" || name == "Edit" || rawActionName == "CODE_ACTION" { + // /\s*If relevant, proactively run terminal commands[\s\S]*$/ — + // first occurrence, the whitespace run directly before it included, + // to end of string (`trimEnd` of the prefix is exactly what the + // greedy leading `\s*` consumes). + if let range = result.range(of: "If relevant, proactively run terminal commands") { + result = String(result[.. String? { + guard text.hasPrefix(prefix) else { return nil } + return dropFirstLine(of: text) +} + +/// Same, requiring a `\b` word boundary right after the prefix. +private func stripLeadingLine(of text: String, wordBoundedPrefix prefix: String) -> String? { + guard text.hasPrefix(prefix) else { return nil } + let afterIndex = text.index(text.startIndex, offsetBy: prefix.count) + if afterIndex < text.endIndex { + let next = text[afterIndex] + if next.isLetter || next.isNumber || next == "_" { return nil } + } + return dropFirstLine(of: text) +} + +private func dropFirstLine(of text: String) -> String { + guard let newlineRange = text.range(of: "\n") else { return "" } + // Also swallow a preceding \r (the JS pattern is `\r?\n`). + return String(text[newlineRange.upperBound...]) +} + +/// Port of `stripAgyReadArtifacts`: drop the trailing +/// "The above content shows/does NOT show…" note. +func stripAgyReadArtifacts(_ content: String) -> String { + // /\n?The above content (?:shows|does NOT show)[\s\S]*$/ + let showsRange = content.range(of: "The above content shows") + let notShowsRange = content.range(of: "The above content does NOT show") + let matchRange: Range? = { + switch (showsRange, notShowsRange) { + case (nil, nil): return nil + case (let some?, nil): return some + case (nil, let some?): return some + case (let a?, let b?): return a.lowerBound < b.lowerBound ? a : b + } + }() + guard let matchRange else { return content.jsTrimmedEnd } + var start = matchRange.lowerBound + if start > content.startIndex { + let previous = content.index(before: start) + if content[previous] == "\n" { start = previous } + } + return String(content[.. String { + agyEchoedTaskResultRegex.removingFirstMatch(in: text).jsTrimmed +} + +private let agyEchoedTaskResultRegex = JSRegex("\\n*\\[Message\\]\\s+timestamp=[\\s\\S]*\\z") + +/// Port of `parseAgyAsyncTaskMessage`. +func parseAgyAsyncTaskMessage(_ raw: String) -> (body: String, summary: String, isError: Bool) { + var body = raw + let contentEq = raw.range(of: "content=") + let endTag = raw.range(of: "") + if let contentEq { + let end = endTag?.lowerBound ?? raw.endIndex + // JS slice tolerates end < start (yields ''): mirror that. + body = end > contentEq.upperBound ? String(raw[contentEq.upperBound.. String in + var view = Substring(line) + while let first = view.first, first == " " || first == "\t" { + view.removeFirst() + } + return String(view) + } + .joined(separator: "\n") + .jsTrimmed + + let taskNumber: String? = agyTaskIdRegex.firstMatch(in: raw).flatMap { $0.count > 1 ? $0[1] : nil } + let taskLabel = taskNumber.map { "task-\($0)" } ?? "Background task" + let failMatch = agyFailExitRegex.firstMatch(in: body) + let isError = failMatch != nil + var outcome = "" + if let failMatch, failMatch.count > 1, let exitCode = failMatch[1] { + outcome = "failed (exit \(exitCode))" + } else if agyCompletedRegex.test(body) { + outcome = "completed" + } + let summary = outcome.isEmpty ? taskLabel : "\(taskLabel) \u{00B7} \(outcome)" + return (body, summary, isError) +} + +private let agyTaskIdRegex = JSRegex("task-(\\d+)") +private let agyFailExitRegex = JSRegex("failed with exit code:?\\s*(\\d+)", caseInsensitive: true) +private let agyCompletedRegex = JSRegex("completed successfully", caseInsensitive: true) + +/// Port of `parseAgyErrorMessage`. +func parseAgyErrorMessage(_ raw: String) -> (body: String, summary: String) { + // /^Created At:.*(?:\r?\n)?/gm — drop every line starting "Created At:". + var body = raw + .components(separatedBy: "\n") + .filter { !$0.hasPrefix("Created At:") } + .joined(separator: "\n") + // /\n?Guidance:[\s\S]*$/ — cut from the first "Guidance:" (preceding \n + // included) to the end. + if let range = body.range(of: "Guidance:") { + var start = range.lowerBound + if start > body.startIndex { + let previous = body.index(before: start) + if body[previous] == "\n" { start = previous } + } + body = String(body[.. body.startIndex { + let previous = body.index(before: start) + if body[previous] == "\n" { start = previous } + } + let lineEnd = body.range(of: "\n", range: range.upperBound.. [(String, JSONValue?)] +} + +private let agyToolSpecs: [String: AgyToolSpec] = [ + "run_command": AgyToolSpec(name: "Bash") { args in + [("command", args["CommandLine"]), ("cwd", args["Cwd"])] + }, + "view_file": AgyToolSpec(name: "Read") { args in + [("file_path", jsCoalesce(args["AbsolutePath"], args["RelativePath"]))] + }, + "write_to_file": AgyToolSpec(name: "Write") { args in + [("file_path", args["TargetFile"]), ("content", args["CodeContent"])] + }, + "replace_file_content": AgyToolSpec(name: "Edit") { args in + [ + ("file_path", args["TargetFile"]), + ("old_string", args["TargetContent"]), + ("new_string", args["ReplacementContent"]), + ] + }, + "grep_search": AgyToolSpec(name: "Grep") { args in + [ + ("pattern", jsCoalesce(args["Query"], args["SearchQuery"])), + ("path", jsCoalesce(args["SearchDirectory"], args["SearchPath"])), + ] + }, + "list_dir": AgyToolSpec(name: "LS") { args in + [("path", jsCoalesce(args["DirectoryPath"], args["AbsolutePath"]))] + }, +] + +private let agyArgKeyMap: [String: String] = [ + "CommandLine": "command", + "Cwd": "cwd", + "AbsolutePath": "file_path", + "RelativePath": "file_path", + "TargetFile": "file_path", + "FilePath": "file_path", + "Path": "path", + "DirectoryPath": "path", + "Query": "query", + "SearchQuery": "query", + "Pattern": "pattern", + "Url": "url", + "URL": "url", +] + +private let agyArgNoise: Set = ["toolAction", "toolSummary", "WaitMsBeforeAsync", "Blocking"] + +/// A value agy considers "empty" and drops: null, undefined, or ''. +private func isAgyEmptyValue(_ value: JSONValue?) -> Bool { + guard let value else { return true } + if value == .null { return true } + if value == .string("") { return true } + return false +} + +/// Port of `normalizeAgyToolInput`. +private func normalizeAgyToolInput(_ args: [String: JSONValue]) -> JSONValue? { + var out: [String: JSONValue] = [:] + for (key, value) in args { + if agyArgNoise.contains(key) { continue } + if isAgyEmptyValue(value) { continue } + out[agyArgKeyMap[key] ?? key] = value + } + return out.isEmpty ? nil : .object(out) +} + +/// Port of `mapAgyToolCall`. +private func mapAgyToolCall( + toolName: String?, + actionType: String, + args: [String: JSONValue]? +) -> (name: String, input: JSONValue?, description: String?) { + let description = args.flatMap { asString($0["toolSummary"]) } + let spec = toolName.flatMap { agyToolSpecs[$0] } + if let spec { + var built: [String: JSONValue] = [:] + for (key, value) in spec.buildInput(args ?? [:]) { + if !isAgyEmptyValue(value) { built[key] = value } + } + return (spec.name, built.isEmpty ? nil : .object(built), description) + } + let name = humanizeAgyActionType(toolName ?? actionType) + let input = args.flatMap(normalizeAgyToolInput) + return (name, input, description) +} + +// MARK: - Skip filters + +/// Port of `isSkippableAgentContent`. +public func isSkippableAgentContent(_ content: JSONValue) -> Bool { + guard let object = content.objectValue, object["type"] == .string("output") else { return false } + guard let data = object["data"]?.objectValue else { return false } + if data["isMeta"]?.jsTruthy == true || data["isCompactSummary"]?.jsTruthy == true { return true } + // Empty away_summary recaps and empty agy planner steps carry nothing + // to render — skip cleanly. + if data["type"] == .string("system"), data["subtype"] == .string("away_summary"), + (asString(data["content"]) ?? "").jsTrimmed.isEmpty { + return true + } + if data["type"] == .string("agy_message"), + (asString(data["content"]) ?? "").jsTrimmed.isEmpty { + return true + } + return !isClaudeChatVisibleMessage(type: data["type"], subtype: data["subtype"]) +} + +/// Port of `isCodexContent`. +public func isCodexContent(_ content: JSONValue) -> Bool { + content.objectValue?["type"] == .string(agentMessagePayloadType) +} + +// MARK: - normalizeAgentRecord + +private let piStreamSnapshotIdRegex = JSRegex("\\Api-.+-turn-\\d+-message-\\d+-text-\\d+\\z") +private let agyTaskLogRegex = JSRegex("\\AInside the task-(\\d+) log\\b") + +/// Port of `normalizeAgentRecord` — dispatch on the agent payload family. +// swiftlint:disable:next cyclomatic_complexity function_body_length +func normalizeAgentRecord( + messageId: String, + localId: String?, + createdAt: Int, + content: JSONValue, + meta: JSONValue? +) -> NormalizedMessage? { + guard let contentObject = content.objectValue, + let contentType = contentObject["type"]?.stringValue else { return nil } + + if contentType == "output" { + guard let data = contentObject["data"]?.objectValue, + let dataType = data["type"]?.stringValue else { return nil } + + // Skip meta/compact-summary messages (parity with hapi-app). + if data["isMeta"]?.jsTruthy == true { return nil } + if data["isCompactSummary"]?.jsTruthy == true { return nil } + if !isClaudeChatVisibleMessage(type: data["type"], subtype: data["subtype"]) { return nil } + + if dataType == "assistant" { + return normalizeAssistantOutput(messageId: messageId, localId: localId, createdAt: createdAt, data: data, meta: meta) + } + if dataType == "user" { + return normalizeUserOutput(messageId: messageId, localId: localId, createdAt: createdAt, data: data, meta: meta) + } + if dataType == "summary", let summary = data["summary"]?.stringValue { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.summary(summary)]), + isSidechain: false, + meta: meta + ) + } + if dataType == "system", data["subtype"] == .string("api_error") { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.apiError( + retryAttempt: asNumber(data["retryAttempt"]) ?? 0, + maxRetries: asNumber(data["maxRetries"]) ?? 0, + error: data["error"] + )), + isSidechain: false, + meta: meta + ) + } + if dataType == "system", data["subtype"] == .string("turn_duration") { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.turnDuration( + durationMs: asNumber(data["durationMs"]) ?? 0, + targetMessageId: asString(data["messageId"]) + )), + isSidechain: false, + meta: meta + ) + } + if dataType == "system", data["subtype"] == .string("away_summary") { + // Empty recaps were dropped by isSkippableAgentContent upstream. + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.recap(text: asString(data["content"]) ?? "")), + isSidechain: false, + meta: meta + ) + } + if dataType == "system", data["subtype"] == .string("microcompact_boundary") { + let metadata = data["microcompactMetadata"]?.objectValue + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.microcompact( + trigger: metadata.flatMap { asString($0["trigger"]) } ?? "auto", + preTokens: metadata.flatMap { asNumber($0["preTokens"]) } ?? 0, + tokensSaved: metadata.flatMap { asNumber($0["tokensSaved"]) } ?? 0 + )), + isSidechain: false, + meta: meta + ) + } + if dataType == "system", data["subtype"] == .string("compact_boundary") { + let metadata = data["compactMetadata"]?.objectValue + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.compact( + trigger: metadata.flatMap { asString($0["trigger"]) } ?? "auto", + preTokens: metadata.flatMap { asNumber($0["preTokens"]) } ?? 0 + )), + isSidechain: false, + meta: meta + ) + } + + // agy (Antigravity) PTY messages. + if dataType == "agy_message" { + let text = stripAgyEchoedTaskResult(asString(data["content"]) ?? "") + if text.jsTrimmed.isEmpty { return nil } + // Transitional "Inside the task-NNN log…" narration → compact chip. + if let taskLog = agyTaskLogRegex.firstMatch(in: text), let taskNumber = taskLog[1] { + let toolCallId = "\(messageId):tasklog" + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([ + .toolCall(ToolUseContent( + id: toolCallId, + name: agyTaskLogTool, + input: .object(["task": .string("task-\(taskNumber)")]), + description: nil, + uuid: messageId, + parentUUID: nil + )), + .toolResult(ToolResultContent( + toolUseId: toolCallId, + content: .string(""), + isError: false, + uuid: messageId, + parentUUID: nil + )), + ]), + isSidechain: false, + meta: meta + ) + } + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.text(.init(text: text, uuid: messageId, parentUUID: nil))]), + isSidechain: false, + meta: meta, + model: asString(data["model"]) + ) + } + + // agy tool ACTION → completed tool card. + if dataType == "agy_tool_action" { + let rawActionName = asString(data["name"]) ?? "Tool" + let toolCallId = asString(data["toolUseId"]) ?? messageId + + var name: String + var input: JSONValue? + var description: String? + var resultContent: String + var isError = false + if rawActionName == "SYSTEM_MESSAGE" { + let parsed = parseAgyAsyncTaskMessage(asString(data["content"]) ?? "") + name = agyAsyncTaskTool + input = nil + description = parsed.summary + resultContent = parsed.body + isError = parsed.isError + } else if rawActionName == "ERROR_MESSAGE" { + let parsed = parseAgyErrorMessage(asString(data["content"]) ?? "") + name = agyErrorTool + input = nil + description = parsed.summary + resultContent = parsed.body + isError = true + } else { + let mapped = mapAgyToolCall( + toolName: asString(data["toolName"]), + actionType: rawActionName, + args: data["input"]?.objectValue + ) + name = mapped.name + input = mapped.input + description = mapped.description + resultContent = stripAgyActionPreamble(asString(data["content"]) ?? "", name: name, rawActionName: rawActionName) + if name == "Read" || rawActionName == "VIEW_FILE" { + resultContent = stripAgyReadArtifacts(resultContent) + } + } + let nativeKind: String? = (name == "Read" || rawActionName == "VIEW_FILE") ? "agy-numbered-read" : nil + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([ + .toolCall(ToolUseContent( + id: toolCallId, + name: name, + input: input, + description: description, + nativeKind: nativeKind, + uuid: messageId, + parentUUID: nil + )), + .toolResult(ToolResultContent( + toolUseId: toolCallId, + content: .string(resultContent), + isError: isError, + uuid: messageId, + parentUUID: nil + )), + ]), + isSidechain: false, + meta: meta + ) + } + return nil + } + + if contentType == "event" { + guard let event = normalizeAgentEvent(contentObject["data"]) else { return nil } + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(event), + isSidechain: false, + meta: meta + ) + } + + if contentType == agentMessagePayloadType { + guard let data = contentObject["data"]?.objectValue, + let dataType = data["type"]?.stringValue else { return nil } + + if dataType == "agent-run-start" || dataType == "agent-run-update" || dataType == "agent-run-trace" { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(AgentEvent(object: data)), + isSidechain: false, + meta: meta + ) + } + + if dataType == "generated-image" { + guard let imageId = asString(jsCoalesce(data["imageId"], data["image_id"])), !imageId.isEmpty else { return nil } + let uuid = asString(data["id"]) ?? messageId + let source = InlineMediaSource.fromWire(data["source"]) + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.generatedImage(GeneratedImageContent( + imageId: imageId, + fileName: asString(jsCoalesce(data["fileName"], data["file_name"])) ?? "generated-image", + mimeType: asString(jsCoalesce(data["mimeType"], data["mime_type"])), + uuid: uuid, + parentUUID: nil, + source: source + ))]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "error", let message = data["message"]?.stringValue { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.error(message)), + isSidechain: false, + meta: meta + ) + } + + if dataType == "message", let message = data["message"]?.stringValue { + let streamId = asString(data["id"]) + let isPiStreamSnapshot = data["streamSnapshot"] == .bool(true) + || (streamId.map { piStreamSnapshotIdRegex.test($0) } ?? false) + let review = isPiStreamSnapshot ? nil : parseCodexReviewMessage(message) + if let review { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.codexReview(review: review, uuid: messageId, parentUUID: nil)]), + isSidechain: false, + meta: meta + ) + } + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.text(.init(text: message, uuid: messageId, streamId: streamId, parentUUID: nil))]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "reasoning", let message = data["message"]?.stringValue { + let streamId = asString(data["id"]) ?? messageId + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.reasoning(.init(text: message, uuid: messageId, streamId: streamId, parentUUID: nil))]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "context_compacted" { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.compact( + trigger: asString(data["trigger"]) ?? "auto", + preTokens: asNumber(jsCoalesce(data["preTokens"], data["pre_tokens"])) ?? 0 + )), + isSidechain: false, + meta: meta + ) + } + + if dataType == "compact-summary", let summary = data["summary"]?.stringValue { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.compactSummary( + summary: summary, + tokensBefore: asNumber(data["tokensBefore"]), + estimatedTokensAfter: asNumber(data["estimatedTokensAfter"]) + )), + isSidechain: false, + meta: meta + ) + } + + if dataType == "token_count" { + guard let usage = normalizeCodexTokenUsage(data["info"], data: data) else { return nil } + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.tokenCount(info: data["info"])), + isSidechain: false, + meta: meta, + usage: usage + ) + } + + if dataType == "thread_goal_updated" { + guard let goal = normalizeThreadGoal(data["goal"]) else { return nil } + let goalThreadId = goal["threadId"]?.stringValue ?? "" + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.threadGoalUpdated( + threadId: asString(jsCoalesce(data["threadId"], data["thread_id"])) ?? goalThreadId, + turnId: asString(jsCoalesce(data["turnId"], data["turn_id"])), + goal: goal + )), + isSidechain: false, + meta: meta + ) + } + + if dataType == "thread_goal_cleared" { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .event(.threadGoalCleared( + threadId: asString(jsCoalesce(data["threadId"], data["thread_id"])) + )), + isSidechain: false, + meta: meta + ) + } + + if dataType == "tool-call", let callId = data["callId"]?.stringValue { + let uuid = asString(data["id"]) ?? messageId + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.toolCall(ToolUseContent( + id: callId, + name: asString(data["name"]) ?? "unknown", + input: data["input"], + description: asString(data["description"]), + nativeTitle: asString(jsCoalesce(data["nativeTitle"], data["title"])), + nativeKind: asString(jsCoalesce(data["nativeKind"], data["kind"])), + progress: data["progress"], // key presence == `'progress' in data` + uuid: uuid, + parentUUID: nil + ))]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "tool-call-result", let callId = data["callId"]?.stringValue { + let uuid = asString(data["id"]) ?? messageId + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([.toolResult(ToolResultContent( + toolUseId: callId, + content: data["output"], + isError: data["is_error"]?.jsTruthy ?? false, + uuid: uuid, + parentUUID: nil + ))]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "plan" { + let plan = normalizePlanEntries(jsCoalesce(data["entries"], data["items"]) ?? .object(data)) + if plan.isEmpty { return nil } + let uuid = asString(data["id"]) ?? messageId + let planPayload: JSONValue = .object(["plan": .array(plan), "source": .string("cursor")]) + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([ + .toolCall(ToolUseContent( + id: "cursor-plan-state", + name: "update_plan", + input: planPayload, + description: nil, + uuid: uuid, + parentUUID: nil + )), + .toolResult(ToolResultContent( + toolUseId: "cursor-plan-state", + content: planPayload, + isError: false, + uuid: uuid, + parentUUID: nil + )), + ]), + isSidechain: false, + meta: meta + ) + } + + if dataType == "plan_update" { + let plan = normalizePlanEntries( + jsCoalesce(data["plan"], data["update"], data["items"], data["steps"]) ?? .object(data) + ) + if plan.isEmpty { return nil } + let uuid = asString(data["id"]) ?? messageId + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .agent([ + .toolCall(ToolUseContent( + id: "codex-plan-state", + name: "update_plan", + input: .object(["plan": .array(plan), "source": .string("codex")]), + description: nil, + uuid: uuid, + parentUUID: nil + )), + .toolResult(ToolResultContent( + toolUseId: "codex-plan-state", + content: .object(["plan": .array(plan), "source": .string("codex"), "status": .string("updated")]), + isError: false, + uuid: "\(uuid):result", + parentUUID: nil + )), + ]), + isSidechain: false, + meta: meta + ) + } + } + + return nil +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeUser.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeUser.swift new file mode 100644 index 00000000..a107c72e --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeUser.swift @@ -0,0 +1,69 @@ +import Foundation + +// Port of web/src/chat/normalizeUser.ts. + +/// Port of `parseAttachments`: entries are accepted only when `id`, +/// `filename`, `mimeType` (strings), `size` (number) and `path` (string) are +/// all present; invalid entries are skipped and an empty result means +/// "no attachments" (`nil`). +func parseAttachments(_ raw: JSONValue?) -> [AttachmentMetadata]? { + guard let items = raw?.arrayValue else { return nil } + var attachments: [AttachmentMetadata] = [] + for item in items { + guard let object = item.objectValue, + let id = object["id"]?.stringValue, + let filename = object["filename"]?.stringValue, + let mimeType = object["mimeType"]?.stringValue, + let size = object["size"]?.numberValue, + let path = object["path"]?.stringValue, + // The wire model stores byte sizes as Int; real sizes are + // always integral (TS would accept a fractional number). + let intSize = Int(exactly: size) + else { continue } + attachments.append(AttachmentMetadata( + id: id, + filename: filename, + mimeType: mimeType, + size: intSize, + path: path, + previewUrl: object["previewUrl"]?.stringValue + )) + } + return attachments.isEmpty ? nil : attachments +} + +/// Port of `normalizeUserRecord`. +func normalizeUserRecord( + messageId: String, + localId: String?, + createdAt: Int, + content: JSONValue, + meta: JSONValue? +) -> NormalizedMessage? { + if let text = content.stringValue { + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .user(text: text, attachments: nil), + isSidechain: false, + meta: meta + ) + } + + if let object = content.objectValue, + object["type"] == .string("text"), + let text = object["text"]?.stringValue { + let attachments = parseAttachments(object["attachments"]) + return NormalizedMessage( + id: messageId, + localId: localId, + createdAt: createdAt, + content: .user(text: text, attachments: attachments), + isSidechain: false, + meta: meta + ) + } + + return nil +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Reducer.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Reducer.swift new file mode 100644 index 00000000..b0867955 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Reducer.swift @@ -0,0 +1,174 @@ +import Foundation + +// Port of web/src/chat/reducer.ts — the pipeline entry point: +// trace → timeline → pending-permission synthesis → latestUsage → +// api-error fold → dedupe → silent-goal filtering. + +/// Port of `calculateContextSize`. +private func calculateContextSize(_ usage: UsageData) -> Double { + if let contextTokens = usage.contextTokens { + return contextTokens + } + return (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0) + usage.inputTokens +} + +/// Port of `isUsageVisibleInParentContext` — subagent usage must not drive +/// the parent thread's context bar. +private func isUsageVisibleInParentContext(_ msg: NormalizedMessage) -> Bool { + if msg.isSidechain { return false } + return msg.usage?.scopeRole != "child" +} + +private let goalCommandRegex = JSRegex("\\A\\s*/goal(?:\\s|\\z)", caseInsensitive: true) + +/// Port of `getLatestThreadGoal`. Returns the normalized goal record +/// (advisory; not part of the fixture projection). +func getLatestThreadGoal(_ normalized: [NormalizedMessage]) -> JSONValue? { + var sawNewerNonGoalUserMessage = false + for msg in normalized.reversed() { + if case .user(let text, _) = msg.content { + if !goalCommandRegex.test(text) { + sawNewerNonGoalUserMessage = true + } + continue + } + guard let event = msg.eventContent else { continue } + if event.type == "thread-goal-cleared" { return nil } + if event.type == "thread-goal-updated" { + let goal = event["goal"] + if goal?["status"] == .string("complete") && sawNewerNonGoalUserMessage { + return nil + } + return goal + } + } + return nil +} + +/// Port of `isRedundantGoalStatusMessage`. +private func isRedundantGoalStatusMessage(_ event: AgentEvent) -> Bool { + guard event.type == "message" else { return false } + return isRedundantGoalStatusMessageText(event["message"]?.stringValue) +} + +/// Port of `isSilentGoalEventBlock`. +private func isSilentGoalEventBlock(_ block: ChatBlock) -> Bool { + guard case .agentEvent(let eventBlock) = block else { return false } + return eventBlock.event.type == "thread-goal-updated" + || eventBlock.event.type == "thread-goal-cleared" + || isRedundantGoalStatusMessage(eventBlock.event) +} + +/// Port of `filterSilentGoalBlocks` — recursive over tool children. +func filterSilentGoalBlocks(_ blocks: [ChatBlock]) -> [ChatBlock] { + var filtered: [ChatBlock] = [] + + for block in blocks { + if isSilentGoalEventBlock(block) { continue } + if case .toolCall(var toolBlock) = block, !toolBlock.children.isEmpty { + toolBlock.children = filterSilentGoalBlocks(toolBlock.children) + filtered.append(.toolCall(toolBlock)) + continue + } + filtered.append(block) + } + + return filtered +} + +/// Result of `reduceChatBlocks`. +public struct ReducedChat: Equatable, Sendable { + public var blocks: [ChatBlock] + public var hasReadyEvent: Bool + public var latestUsage: LatestUsage? + /// The latest thread goal record (advisory, dropped by the projection). + public var latestGoal: JSONValue? +} + +/// Port of `reduceChatBlocks`. +public func reduceChatBlocks( + _ normalized: [NormalizedMessage], + agentState: AgentState?, + goalStateMessages: [NormalizedMessage]? = nil +) -> ReducedChat { + let permissionsById = getPermissions(agentState) + let toolIdsInMessages = collectToolIdsFromMessages(normalized) + let titleChangesByToolUseId = collectTitleChanges(normalized) + + let traced = traceMessages(normalized) + var groups: [String: [NormalizedMessage]] = [:] + var root: [NormalizedMessage] = [] + + for msg in traced { + if let sidechainId = msg.sidechainId { + groups[sidechainId, default: []].append(msg) + } else { + root.append(msg) + } + } + + let reducerContext = ReducerContext( + permissionsById: permissionsById, + groups: groups, + consumedGroupIds: [], + titleChangesByToolUseId: titleChangesByToolUseId, + emittedTitleChangeToolUseIds: [] + ) + var rootResult = reduceTimeline(root, context: reducerContext) + let hasReadyEvent = rootResult.hasReadyEvent + + // Synthesize a tool card only for a *pending* permission that has no + // tool call/result in the transcript, and only when it is not older than + // the oldest message in the current window (older ones surface when the + // user pages back). + let oldestMessageTime: Int? = normalized.map(\.createdAt).min() + + for (id, entry) in permissionsById.entriesInOrder { + if entry.permission.status != .pending { continue } + if toolIdsInMessages.contains(id) { continue } + if rootResult.toolBlocksById[id] != nil { continue } + + let createdAt = entry.permission.createdAt + ?? Int(Date().timeIntervalSince1970 * 1000) + + if let oldestMessageTime, createdAt < oldestMessageTime { + continue + } + + ensureToolBlock(&rootResult.blocks, &rootResult.toolBlocksById, id: id, seed: ToolBlockSeed( + createdAt: createdAt, + localId: nil, + name: entry.toolName, + input: entry.input, + description: nil, + permission: entry.permission + )) + } + + // Latest usage: the most recent parent-context message with usage data. + var latestUsage: LatestUsage? + for msg in normalized.reversed() { + if let usage = msg.usage, isUsageVisibleInParentContext(msg) { + latestUsage = LatestUsage( + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreation: usage.cacheCreationInputTokens ?? 0, + cacheRead: usage.cacheReadInputTokens ?? 0, + contextSize: calculateContextSize(usage), + contextWindow: usage.contextWindow, + model: msg.model, + timestamp: msg.createdAt + ) + break + } + } + + let materialized = rootResult.blocks.materialized + + return ReducedChat( + blocks: filterSilentGoalBlocks(dedupeAgentEvents(foldApiErrorEvents(materialized))), + hasReadyEvent: hasReadyEvent, + latestUsage: latestUsage, + latestGoal: getLatestThreadGoal(goalStateMessages ?? normalized) + ) +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerCliOutput.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerCliOutput.swift new file mode 100644 index 00000000..2d5b8d6c --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerCliOutput.swift @@ -0,0 +1,64 @@ +import Foundation + +// Port of web/src/chat/reducerCliOutput.ts — CLI-echo detection and the +// command-name → local-command-stdout merge. + +private let cliTagRegex = JSRegex("<(?:local-command-[a-z-]+|command-(?:name|message|args))>", caseInsensitive: true) +private let cliCommandNameRegex = JSRegex("", caseInsensitive: true) +private let cliCommandStdoutRegex = JSRegex("", caseInsensitive: true) + +private func getMetaSentFrom(_ meta: JSONValue?) -> String? { + meta?.objectValue?["sentFrom"]?.stringValue +} + +private func hasCliOutputTags(_ text: String) -> Bool { + cliTagRegex.test(text) +} + +private func hasCommandNameTag(_ text: String) -> Bool { + cliCommandNameRegex.test(text) +} + +private func hasLocalCommandStdoutTag(_ text: String) -> Bool { + cliCommandStdoutRegex.test(text) +} + +/// Port of `isCliOutputText`. +func isCliOutputText(_ text: String, meta: JSONValue?) -> Bool { + getMetaSentFrom(meta) == "cli" && hasCliOutputTags(text) +} + +/// Port of `mergeCliOutputBlocks`: a `` block absorbs its +/// `` follow-up (same source), preferring the command +/// block's metadata. +func mergeCliOutputBlocks(_ blocks: [BlockBox]) -> [BlockBox] { + var merged: [BlockBox] = [] + + for box in blocks { + guard case .cliOutput(let block) = box.block else { + merged.append(box) + continue + } + + if let prevBox = merged.last, + case .cliOutput(let prev) = prevBox.block, + prev.source == block.source, + hasCommandNameTag(prev.text), + !hasLocalCommandStdoutTag(prev.text), + hasLocalCommandStdoutTag(block.text) { + let separator = (prev.text.hasSuffix("\n") || block.text.hasPrefix("\n")) ? "" : "\n" + var next = prev + next.text = prev.text + separator + block.text + next.invokedAt = prev.invokedAt ?? block.invokedAt + next.durationMs = prev.durationMs ?? block.durationMs + next.usage = prev.usage ?? block.usage + next.model = prev.model ?? block.model + prevBox.block = .cliOutput(next) + continue + } + + merged.append(box) + } + + return merged +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerEvents.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerEvents.swift new file mode 100644 index 00000000..ab622b50 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerEvents.swift @@ -0,0 +1,144 @@ +import Foundation + +// Port of web/src/chat/reducerEvents.ts — CLI pipe-format limit parsing, +// consecutive-event dedupe, api-error folding. + +private let limitReachedRegex = JSRegex("\\AClaude AI usage limit reached\\|(\\d+)(?:\\|([^|]*))?\\z") +private let limitWarningRegex = JSRegex("\\AClaude AI usage limit warning\\|(\\d+)\\|(\\d+)\\|([^|]*)\\z") + +/// Port of `parseClaudeUsageLimit`. +func parseClaudeUsageLimit(_ text: String) -> AgentEvent? { + if let match = limitReachedRegex.firstMatch(in: text), let rawTimestamp = match[1] { + if let timestamp = Double(rawTimestamp), timestamp.isFinite { + // `reachedMatch[2] || ''` — an absent or empty capture stays ''. + return .limitReached(endsAt: timestamp, limitType: match.count > 2 ? (match[2] ?? "") : "") + } + } + + if let match = limitWarningRegex.firstMatch(in: text), + let rawTimestamp = match[1], let rawUtilization = match[2] { + if let timestamp = Double(rawTimestamp), timestamp.isFinite, + let utilizationInt = Double(rawUtilization), utilizationInt.isFinite { + let limitType = match.count > 3 ? (match[3] ?? "") : "" + return .limitWarning(utilization: utilizationInt / 100, endsAt: timestamp, limitType: limitType) + } + } + + return nil +} + +/// Port of `parseMessageAsEvent`. +func parseMessageAsEvent(_ msg: NormalizedMessage) -> AgentEvent? { + if msg.isSidechain { return nil } + guard let content = msg.agentContent else { return nil } + + for item in content { + if case .text(let text) = item { + if let limitEvent = parseClaudeUsageLimit(text.text) { + return limitEvent + } + } + } + + return nil +} + +/// Port of `dedupeAgentEvents`: drops a consecutive repeat of the same +/// title-changed/message/error event (and, generically, any byte-identical +/// event), plus a message event echoing the immediately preceding +/// title-changed title. +public func dedupeAgentEvents(_ blocks: [ChatBlock]) -> [ChatBlock] { + var result: [ChatBlock] = [] + var prevEventKey: String? + var prevTitleChangedTo: String? + + for block in blocks { + guard case .agentEvent(let eventBlock) = block else { + result.append(block) + prevEventKey = nil + prevTitleChangedTo = nil + continue + } + + let event = eventBlock.event + if event.type == "title-changed", let title = event["title"]?.stringValue { + let trimmed = title.jsTrimmed + let key = "title-changed:\(trimmed)" + if key == prevEventKey { + continue + } + result.append(block) + prevEventKey = key + prevTitleChangedTo = trimmed + continue + } + + if event.type == "message", let message = event["message"]?.stringValue { + let trimmed = message.jsTrimmed + let key = "message:\(trimmed)" + if key == prevEventKey { + continue + } + if let prevTitle = prevTitleChangedTo, trimmed == prevTitle { + continue + } + result.append(block) + prevEventKey = key + prevTitleChangedTo = nil + continue + } + + if event.type == "error", let message = event["message"]?.stringValue { + let trimmed = message.jsTrimmed + let key = "error:\(trimmed)" + if key == prevEventKey { + continue + } + result.append(block) + prevEventKey = key + prevTitleChangedTo = nil + continue + } + + // Generic key: TS uses JSON.stringify(event); the canonical + // serialization is its deterministic stand-in (fixture inputs are + // key-sorted, so the reference's insertion order is sorted too). + let key = "event:\(toCanonicalJSON(event.wireValue))" + + if key == prevEventKey { + continue + } + + result.append(block) + prevEventKey = key + prevTitleChangedTo = nil + } + + return result +} + +/// Port of `foldApiErrorEvents`: consecutive api-error events keep only the +/// latest state. +public func foldApiErrorEvents(_ blocks: [ChatBlock]) -> [ChatBlock] { + var result: [ChatBlock] = [] + + for block in blocks { + guard case .agentEvent(let eventBlock) = block else { + result.append(block) + continue + } + + if eventBlock.event.type != "api-error" { + result.append(block) + continue + } + + if let last = result.last, case .agentEvent(let prev) = last, prev.event.type == "api-error" { + result[result.count - 1] = block + } else { + result.append(block) + } + } + + return result +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift new file mode 100644 index 00000000..2dd8987c --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift @@ -0,0 +1,1045 @@ +import Foundation + +// Port of web/src/chat/reducerTimeline.ts — the timeline reducer: tool +// pairing, stream coalescing, agent-run cards, title-changed synthesis, +// sentinel suppression. + +// MARK: - Event record helpers + +private func getEventString(_ event: [String: JSONValue], _ key: String) -> String? { + asString(event[key]) +} + +private func getEventNumber(_ event: [String: JSONValue], _ key: String) -> Double? { + asNumber(event[key]) +} + +private func getAgentRunStartedAt(_ event: [String: JSONValue]) -> Int? { + (getEventNumber(event, "startedAt") ?? getEventNumber(event, "started_at")).map { Int($0.rounded()) } +} + +private func getAgentRunCompletedAt(_ event: [String: JSONValue]) -> Int? { + (getEventNumber(event, "completedAt") ?? getEventNumber(event, "completed_at")).map { Int($0.rounded()) } +} + +private func getAgentRunCardId(_ event: [String: JSONValue], fallback: String) -> String { + getEventString(event, "cardId") ?? getEventString(event, "card_id") ?? fallback +} + +private func isFallbackAgentRunCardId(_ cardId: String, agentId: String?) -> Bool { + guard let agentId else { return false } + return cardId == "codex-agent:\(agentId)" +} + +private func mapAgentRunStatusToToolState(_ status: String?) -> ToolCallState { + if status == "completed" { return .completed } + if status == "failed" || status == "error" || status == "canceled" || status == "cancelled" + || status == "notFound" || status == "not_found" { + return .error + } + if status == "pending" { return .pending } + return .running +} + +private func isTerminalAgentRunState(_ state: ToolCallState) -> Bool { + state == .completed || state == .error +} + +private func isNonTerminalAgentRunState(_ state: ToolCallState) -> Bool { + state == .running || state == .pending +} + +private func shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal( + _ block: ToolCallBlock, + nextState: ToolCallState, + event: [String: JSONValue] +) -> Bool { + guard isTerminalAgentRunState(block.tool.state) else { return false } + guard isNonTerminalAgentRunState(nextState) else { return false } + + let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind") + return activityKind == "wait_agent" || activityKind == "close_agent" +} + +private func isCloseAgentCleanupUpdate(_ event: [String: JSONValue]) -> Bool { + let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind") + if activityKind == "close_agent" || activityKind == "closed" { return true } + + let activity = getEventString(event, "activity") + let statusText = getEventString(event, "statusText") ?? getEventString(event, "status_text") + guard activityKind == "canceled" && (activity == "Closed" || statusText == "Closed") else { return false } + + guard let result = event["result"]?.objectValue else { return false } + return result["previous_status"]?.objectValue != nil || result["previousStatus"]?.objectValue != nil +} + +private func shouldIgnoreAgentRunCloseCleanupAfterTerminal( + _ block: ToolCallBlock, + status: String?, + event: [String: JSONValue] +) -> Bool { + guard isTerminalAgentRunState(block.tool.state) else { return false } + if status == "failed" || status == "error" { return false } + return isCloseAgentCleanupUpdate(event) +} + +private func getAgentRunDisplayPatch(_ event: [String: JSONValue]) -> [String: JSONValue] { + var patch: [String: JSONValue] = [:] + let summary = getEventString(event, "summary") + let activity = getEventString(event, "activity") + let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind") + + // TS truthiness: an empty string does not patch. + if let summary, !summary.isEmpty { patch["summary"] = .string(summary) } + if let activity, !activity.isEmpty { patch["activity"] = .string(activity) } + if let activityKind, !activityKind.isEmpty { patch["activityKind"] = .string(activityKind) } + + return patch +} + +private let whitespaceRunRegex = JSRegex("\\s+") + +/// JS `text.replace(/\s+/g, ' ').trim()`. +private func collapseWhitespace(_ text: String) -> String { + whitespaceRunRegex.replacingAllMatches(in: text, with: " ").jsTrimmed +} + +private func getAgentRunFingerprint(_ event: [String: JSONValue]) -> String? { + if let summary = getEventString(event, "summary"), !summary.isEmpty { return summary } + + let input = event["input"]?.objectValue + // TS: `asString(input.message) ?? asString(input.prompt)` — a non-string + // `message` falls through to `prompt`. + let direct = input.flatMap { asString($0["message"]) ?? asString($0["prompt"]) } + if let direct, !direct.isEmpty { return collapseWhitespace(direct) } + + if let items = input?["items"]?.arrayValue { + let text = items + .compactMap { item -> String? in + guard let object = item.objectValue else { return nil } + let part = asString(object["text"]) + return (part?.isEmpty == false) ? part : nil + } + .joined(separator: "\n\n") + let collapsed = collapseWhitespace(text) + return collapsed.isEmpty ? nil : collapsed + } + + return nil +} + +private func isAgentNotFoundUpdate(_ event: [String: JSONValue]) -> Bool { + let status = getEventString(event, "status") + let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind") + return status == "notFound" || status == "not_found" || activityKind == "not_found" +} + +private func isAgentToolOnlyUpdate(_ event: [String: JSONValue]) -> Bool { + let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind") + return activityKind == "wait_agent" + || activityKind == "send_input" + || activityKind == "resume_agent" + || activityKind == "close_agent" + || isAgentNotFoundUpdate(event) +} + +private func isOrphanAgentRunBlock(_ block: ToolCallBlock) -> Bool { + if !block.children.isEmpty { return false } + if block.tool.result != nil { return false } + if block.tool.state == .completed || block.tool.state == .error { return false } + if let input = block.tool.input?.objectValue { + let agentId = asString(input["agentId"]) ?? asString(input["agent_id"]) + if let agentId, !agentId.isEmpty { return false } + } + return true +} + +private func prefixAgentTraceId(agentId: String, kind: String, id: String) -> String { + let prefix = "codex-agent:\(agentId):" + return id.hasPrefix(prefix) ? id : "\(prefix)\(kind):\(id)" +} + +/// Port of `normalizeTraceMessage` — one agent-run trace payload becomes +/// synthetic timeline messages for the nested reduction. +private func normalizeTraceMessage( + agentId: String, + message: JSONValue?, + source: NormalizedMessage +) -> [NormalizedMessage] { + guard let data = message?.objectValue, let dataType = data["type"]?.stringValue else { return [] } + + let traceId = prefixAgentTraceId(agentId: agentId, kind: "trace", id: asString(data["id"]) ?? "\(source.id):trace") + let createdAt = source.createdAt + + func base(content: NormalizedMessage.Content) -> NormalizedMessage { + NormalizedMessage( + id: traceId, + localId: nil, + createdAt: createdAt, + content: content, + isSidechain: false, + meta: source.meta + ) + } + + if dataType == "error", let messageText = data["message"]?.stringValue { + return [base(content: .event(.error(messageText)))] + } + + if dataType == "message", let messageText = data["message"]?.stringValue { + return [base(content: .agent([.text(.init(text: messageText, uuid: traceId, parentUUID: nil))]))] + } + + if dataType == "reasoning", let messageText = data["message"]?.stringValue { + return [base(content: .agent([.reasoning(.init(text: messageText, uuid: traceId, streamId: traceId, parentUUID: nil))]))] + } + + if dataType == "tool-call", let rawCallId = data["callId"]?.stringValue { + let callId = prefixAgentTraceId(agentId: agentId, kind: "call", id: rawCallId) + return [base(content: .agent([.toolCall(ToolUseContent( + id: callId, + name: asString(data["name"]) ?? "unknown", + input: data["input"], + description: nil, + uuid: traceId, + parentUUID: nil + ))]))] + } + + if dataType == "tool-call-result", let rawCallId = data["callId"]?.stringValue { + let callId = prefixAgentTraceId(agentId: agentId, kind: "call", id: rawCallId) + return [base(content: .agent([.toolResult(ToolResultContent( + toolUseId: callId, + content: data["output"], + isError: data["is_error"]?.jsTruthy ?? false, + uuid: traceId, + parentUUID: nil + ))]))] + } + + if dataType == "token_count" { + return [] + } + + if dataType == "ready" || dataType == "task_complete" { + return [base(content: .event(.ready(agentId: agentId)))] + } + + return [base(content: .event(.message( + asString(data["statusText"]) ?? asString(data["status"]) ?? dataType + )))] +} + +// MARK: - Reducer context + +/// Shared mutable context threaded through nested reductions (the TS object +/// is shared by reference; a class reproduces that). +final class ReducerContext { + let permissionsById: PermissionMap + let groups: [String: [NormalizedMessage]] + var consumedGroupIds: Set + let titleChangesByToolUseId: [String: String] + var emittedTitleChangeToolUseIds: Set + + init( + permissionsById: PermissionMap, + groups: [String: [NormalizedMessage]], + consumedGroupIds: Set = [], + titleChangesByToolUseId: [String: String], + emittedTitleChangeToolUseIds: Set = [] + ) { + self.permissionsById = permissionsById + self.groups = groups + self.consumedGroupIds = consumedGroupIds + self.titleChangesByToolUseId = titleChangesByToolUseId + self.emittedTitleChangeToolUseIds = emittedTitleChangeToolUseIds + } +} + +struct TimelineResult { + var blocks: [BlockBox] + var toolBlocksById: [String: BlockBox] + var hasReadyEvent: Bool +} + +// MARK: - Block mutation helpers + +private func setEarliestStartedAt(_ box: BlockBox, _ startedAt: Int?) { + guard let startedAt, var block = box.block.asToolCall else { return } + let next = block.tool.startedAt.map { min($0, startedAt) } ?? startedAt + if next != block.tool.startedAt { + block.tool.startedAt = next + box.block = .toolCall(block) + } +} + +/// Only ever fed a real Claude `agentTimestamp`; nil is a no-op. +private func setEarliestExecStartedAt(_ box: BlockBox, _ execStartedAt: Int?) { + guard let execStartedAt, var block = box.block.asToolCall else { return } + let next = block.tool.execStartedAt.map { min($0, execStartedAt) } ?? execStartedAt + if next != block.tool.execStartedAt { + block.tool.execStartedAt = next + box.block = .toolCall(block) + } +} + +private func isDurationTargetBlock(_ block: ChatBlock) -> Bool { + switch block { + case .agentText, .agentReasoning, .codexReview, .cliOutput, .toolCall: + return true + default: + return false + } +} + +private func setDurationMs(_ box: BlockBox, _ durationMs: Int) { + switch box.block { + case .agentText(var block): + block.durationMs = durationMs + box.block = .agentText(block) + case .agentReasoning(var block): + block.durationMs = durationMs + box.block = .agentReasoning(block) + case .codexReview(var block): + block.durationMs = durationMs + box.block = .codexReview(block) + case .cliOutput(var block): + block.durationMs = durationMs + box.block = .cliOutput(block) + case .toolCall(var block): + block.durationMs = durationMs + box.block = .toolCall(block) + default: + break + } +} + +// MARK: - reduceTimeline + +/// Port of `reduceTimeline`. +// swiftlint:disable:next cyclomatic_complexity function_body_length +func reduceTimeline(_ messages: [NormalizedMessage], context: ReducerContext) -> TimelineResult { + var blocks: [BlockBox] = [] + var toolBlocksById: [String: BlockBox] = [:] + var agentRunBlocksByCardId: [String: BlockBox] = [:] + var agentRunCardByAgentId: [String: String] = [:] + var agentRunTraceMessagesByCardId: [String: [NormalizedMessage]] = [:] + var pendingAgentRunCardByFingerprint: [String: String] = [:] + var textBlocksByStreamId: [String: BlockBox] = [:] + var reasoningBlocksByStreamId: [String: BlockBox] = [:] + var hasReadyEvent = false + + func ensureAgentRunBlock( + cardId: String, + createdAt: Int, + invokedAt: Int?, + model: String?, + localId: String?, + meta: JSONValue?, + input: JSONValue? + ) -> BlockBox { + let box = ensureToolBlock(&blocks, &toolBlocksById, id: cardId, seed: ToolBlockSeed( + createdAt: createdAt, + invokedAt: invokedAt, + model: model, + localId: localId, + meta: meta, + name: "CodexAgent", + input: input, + description: nil + )) + agentRunBlocksByCardId[cardId] = box + return box + } + + func refreshAgentRunChildren(cardId: String) { + guard let box = agentRunBlocksByCardId[cardId], var block = box.block.asToolCall else { return } + let traceMessages = agentRunTraceMessagesByCardId[cardId] ?? [] + if traceMessages.isEmpty { + block.children = [] + box.block = .toolCall(block) + return + } + + let child = reduceTimeline(traceMessages, context: ReducerContext( + permissionsById: context.permissionsById, + groups: [:], + consumedGroupIds: [], + titleChangesByToolUseId: collectTitleChanges(traceMessages), + emittedTitleChangeToolUseIds: [] + )) + block.children = child.blocks.materialized + box.block = .toolCall(block) + } + + func patchAgentRunInput(_ box: BlockBox, _ patch: [String: JSONValue]) { + guard var block = box.block.asToolCall else { return } + var merged = block.tool.input?.objectValue ?? [:] + for (key, value) in patch { merged[key] = value } + block.tool.input = .object(merged) + box.block = .toolCall(block) + } + + func removeAgentRunBlock(cardId: String) { + guard let box = agentRunBlocksByCardId[cardId] else { return } + if let index = blocks.firstIndex(where: { $0 === box }) { + blocks.remove(at: index) + } + toolBlocksById.removeValue(forKey: cardId) + agentRunBlocksByCardId.removeValue(forKey: cardId) + agentRunTraceMessagesByCardId.removeValue(forKey: cardId) + for (fingerprint, pendingCardId) in pendingAgentRunCardByFingerprint where pendingCardId == cardId { + pendingAgentRunCardByFingerprint.removeValue(forKey: fingerprint) + } + } + + func mergeAgentRunBlock(fromCardId: String, toCardId: String, toBox: BlockBox) { + if fromCardId == toCardId { return } + + guard let fromBox = agentRunBlocksByCardId[fromCardId], fromBox !== toBox, + let fromBlock = fromBox.block.asToolCall, + var toBlock = toBox.block.asToolCall else { return } + + let fromInput = fromBlock.tool.input?.objectValue ?? [:] + let toInput = toBlock.tool.input?.objectValue ?? [:] + if !fromInput.isEmpty || !toInput.isEmpty { + var merged = fromInput + for (key, value) in toInput { merged[key] = value } + toBlock.tool.input = .object(merged) + } + + toBlock.createdAt = min(toBlock.createdAt, fromBlock.createdAt) + toBlock.tool.createdAt = min(toBlock.tool.createdAt, fromBlock.tool.createdAt) + if let fromStartedAt = fromBlock.tool.startedAt { + toBlock.tool.startedAt = toBlock.tool.startedAt.map { min($0, fromStartedAt) } ?? fromStartedAt + } + if let fromCompletedAt = fromBlock.tool.completedAt { + toBlock.tool.completedAt = toBlock.tool.completedAt.map { max($0, fromCompletedAt) } ?? fromCompletedAt + } + if let fromExecStartedAt = fromBlock.tool.execStartedAt { + toBlock.tool.execStartedAt = toBlock.tool.execStartedAt.map { min($0, fromExecStartedAt) } ?? fromExecStartedAt + } + if let fromExecCompletedAt = fromBlock.tool.execCompletedAt { + toBlock.tool.execCompletedAt = toBlock.tool.execCompletedAt.map { max($0, fromExecCompletedAt) } ?? fromExecCompletedAt + } + toBlock.durationMs = toBlock.durationMs ?? fromBlock.durationMs + toBlock.usage = toBlock.usage ?? fromBlock.usage + toBlock.model = toBlock.model ?? fromBlock.model + + if !isTerminalAgentRunState(toBlock.tool.state) && isTerminalAgentRunState(fromBlock.tool.state) { + toBlock.tool.state = fromBlock.tool.state + } + if toBlock.tool.result == nil, let fromResult = fromBlock.tool.result { + toBlock.tool.result = fromResult + } + + toBox.block = .toolCall(toBlock) + + let fromTrace = agentRunTraceMessagesByCardId[fromCardId] ?? [] + let toTrace = agentRunTraceMessagesByCardId[toCardId] ?? [] + if !fromTrace.isEmpty || !toTrace.isEmpty { + let mergedTrace = (toTrace + fromTrace).sorted { left, right in + if left.createdAt != right.createdAt { return left.createdAt < right.createdAt } + return left.id.utf16.lexicographicallyPrecedes(right.id.utf16) + } + agentRunTraceMessagesByCardId[toCardId] = mergedTrace + agentRunTraceMessagesByCardId.removeValue(forKey: fromCardId) + refreshAgentRunChildren(cardId: toCardId) + } else if toBlock.children.isEmpty && !fromBlock.children.isEmpty { + if var refreshed = toBox.block.asToolCall { + refreshed.children = fromBlock.children + toBox.block = .toolCall(refreshed) + } + } + + if let index = blocks.firstIndex(where: { $0 === fromBox }) { + blocks.remove(at: index) + } + toolBlocksById.removeValue(forKey: fromCardId) + agentRunBlocksByCardId.removeValue(forKey: fromCardId) + + for (fingerprint, pendingCardId) in pendingAgentRunCardByFingerprint where pendingCardId == fromCardId { + pendingAgentRunCardByFingerprint[fingerprint] = toCardId + } + for (mappedAgentId, mappedCardId) in agentRunCardByAgentId where mappedCardId == fromCardId { + agentRunCardByAgentId[mappedAgentId] = toCardId + } + } + + // Pre-scan: UUIDs of system-injected user turns, used to identify the + // "No response requested." sentinel auto-replies below. + var injectedTurnUuids = Set() + for msg in messages { + guard msg.isSidechain, let content = msg.agentContent else { continue } + for item in content { + if case .sidechain(let sidechain) = item { + injectedTurnUuids.insert(sidechain.uuid) + } + } + } + + for msg in messages { + if case .event(let msgEvent) = msg.content { + if msgEvent.type == "ready" { + hasReadyEvent = true + continue + } + if msgEvent.type == "token-count" { + continue + } + // abort-restore is a composer side-effect signal, not a visible + // chat event. + if msgEvent.type == "abort-restore" { + continue + } + if msgEvent.type == "turn-duration" { + let targetId = msgEvent["targetMessageId"]?.stringValue + let durationMs = Int((msgEvent["durationMs"]?.numberValue ?? 0).rounded()) + var foundIndex = -1 + + if let targetId { + foundIndex = blocks.lastIndex(where: { box in + isDurationTargetBlock(box.block) + && (box.block.id == targetId || box.block.id.hasPrefix("\(targetId):")) + }) ?? -1 + if foundIndex == -1 { + foundIndex = blocks.lastIndex(where: { $0.block.asToolCall?.tool.id == targetId }) ?? -1 + } + } + + if foundIndex == -1 { + foundIndex = blocks.lastIndex(where: { isDurationTargetBlock($0.block) }) ?? -1 + } + + if foundIndex != -1, isDurationTargetBlock(blocks[foundIndex].block) { + setDurationMs(blocks[foundIndex], durationMs) + } + continue + } + + if msgEvent.type == "agent-run-start" || msgEvent.type == "agent-run-update" || msgEvent.type == "agent-run-trace" { + let event = msgEvent.object + let agentId = getEventString(event, "agentId") ?? getEventString(event, "agent_id") + let fallbackCardId = agentId.map { "codex-agent:\($0)" } ?? msg.id + let rawCardId = getAgentRunCardId(event, fallback: fallbackCardId) + let previousCardId = agentId.flatMap { agentRunCardByAgentId[$0] } + let previousIsFallback = previousCardId.map { isFallbackAgentRunCardId($0, agentId: agentId) } ?? false + let rawIsFallback = isFallbackAgentRunCardId(rawCardId, agentId: agentId) + let cardId: String = { + if agentId != nil, let previousCardId, !previousIsFallback, rawIsFallback { + return previousCardId + } + return rawCardId + }() + let mergeFromCardId: String? = { + if agentId != nil, let previousCardId, previousCardId != cardId, previousIsFallback, !rawIsFallback { + return previousCardId + } + return nil + }() + let fingerprint = getAgentRunFingerprint(event) + + if msgEvent.type == "agent-run-update", + agentId != nil, + previousCardId == nil, + rawIsFallback, + isAgentToolOnlyUpdate(event) { + continue + } + + if msgEvent.type == "agent-run-start", agentId == nil, let fingerprint { + let previousPendingCardId = pendingAgentRunCardByFingerprint[fingerprint] + let previousBlock = previousPendingCardId.flatMap { agentRunBlocksByCardId[$0] } + if let previousPendingCardId, + previousPendingCardId != cardId, + let previousBlock, + let previousToolBlock = previousBlock.block.asToolCall, + isOrphanAgentRunBlock(previousToolBlock) { + removeAgentRunBlock(cardId: previousPendingCardId) + } + pendingAgentRunCardByFingerprint[fingerprint] = cardId + } + + let box = ensureAgentRunBlock( + cardId: cardId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + input: event["input"] + ) + + if let mergeFromCardId { + mergeAgentRunBlock(fromCardId: mergeFromCardId, toCardId: cardId, toBox: box) + } + if let agentId { + agentRunCardByAgentId[agentId] = cardId + } + + if msgEvent.type == "agent-run-start" { + let status = getEventString(event, "status") ?? "running" + let startedAt = getAgentRunStartedAt(event) ?? msg.createdAt + var patch: [String: JSONValue] = [:] + // TS patches `agentId` unconditionally — getEventString + // yields null (not undefined), which JSON keeps. + patch["agentId"] = agentId.map { JSONValue.string($0) } ?? .null + patch["agentStatus"] = .string(status) + patch["statusText"] = .string( + getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? "Starting" + ) + for (key, value) in getAgentRunDisplayPatch(event) { patch[key] = value } + patchAgentRunInput(box, patch) + let nextState = mapAgentRunStatusToToolState(status) + if var block = box.block.asToolCall { + block.tool.state = nextState + box.block = .toolCall(block) + } + if nextState == .running { + setEarliestStartedAt(box, startedAt) + } + continue + } + + if msgEvent.type == "agent-run-update" { + let status = getEventString(event, "status") ?? "running" + let nextState = mapAgentRunStatusToToolState(status) + let startedAt = getAgentRunStartedAt(event) + if let blockValue = box.block.asToolCall, + shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal(blockValue, nextState: nextState, event: event) + || shouldIgnoreAgentRunCloseCleanupAfterTerminal(blockValue, status: status, event: event) { + continue + } + var patch: [String: JSONValue] = [:] + patch["agentId"] = agentId.map { JSONValue.string($0) } ?? .null + patch["agentStatus"] = .string(status) + patch["statusText"] = .string( + getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? status + ) + for (key, value) in getAgentRunDisplayPatch(event) { patch[key] = value } + patchAgentRunInput(box, patch) + if var block = box.block.asToolCall { + block.tool.state = nextState + box.block = .toolCall(block) + } + if nextState == .running { + setEarliestStartedAt(box, startedAt ?? msg.createdAt) + } + if nextState == .completed || nextState == .error { + setEarliestStartedAt(box, startedAt) + if var block = box.block.asToolCall { + block.tool.completedAt = getAgentRunCompletedAt(event) ?? msg.createdAt + box.block = .toolCall(block) + } + } + if var block = box.block.asToolCall { + if let result = event["result"] { + block.tool.result = result + box.block = .toolCall(block) + } else if let error = event["error"] { + block.tool.result = error + box.block = .toolCall(block) + } else if let spawnResult = event["spawnResult"] { + block.tool.result = spawnResult + box.block = .toolCall(block) + } + } + continue + } + + if msgEvent.type == "agent-run-trace" { + guard let traceAgentId = agentId else { continue } + let startedAt = getAgentRunStartedAt(event) + let traceCardId = agentRunCardByAgentId[traceAgentId] ?? cardId + let traceInput: JSONValue? = agentRunBlocksByCardId[traceCardId] != nil + ? nil + : .object(["agentId": .string(traceAgentId)]) + let traceBox = ensureAgentRunBlock( + cardId: traceCardId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + input: traceInput + ) + guard let traceBlockValue = traceBox.block.asToolCall else { continue } + var tracePatch: [String: JSONValue] = [ + "agentId": .string(traceAgentId), + "agentStatus": .string(traceBlockValue.tool.state.rawValue), + ] + for (key, value) in getAgentRunDisplayPatch(event) { tracePatch[key] = value } + if !isTerminalAgentRunState(traceBlockValue.tool.state) { + tracePatch["statusText"] = .string( + getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? "Running" + ) + } + patchAgentRunInput(traceBox, tracePatch) + var traceMessages = agentRunTraceMessagesByCardId[traceCardId] ?? [] + traceMessages.append(contentsOf: normalizeTraceMessage(agentId: traceAgentId, message: event["message"], source: msg)) + agentRunTraceMessagesByCardId[traceCardId] = traceMessages + refreshAgentRunChildren(cardId: traceCardId) + if var block = traceBox.block.asToolCall, + block.tool.state != .completed && block.tool.state != .error { + block.tool.state = .running + traceBox.block = .toolCall(block) + setEarliestStartedAt(traceBox, startedAt ?? msg.createdAt) + } + continue + } + } + + blocks.append(BlockBox(.agentEvent(AgentEventBlock( + id: msg.id, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + event: msgEvent, + meta: msg.meta + )))) + continue + } + + if let event = parseMessageAsEvent(msg) { + blocks.append(BlockBox(.agentEvent(AgentEventBlock( + id: msg.id, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + event: event, + meta: msg.meta + )))) + continue + } + + if case .user(let text, let attachments) = msg.content { + if isCliOutputText(text, meta: msg.meta) { + blocks.append(BlockBox(.cliOutput(CliOutputBlock( + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + durationMs: nil, + usage: nil, + model: nil, + text: text, + source: .user, + meta: msg.meta + )))) + continue + } + blocks.append(BlockBox(.userText(UserTextBlock( + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + text: text, + attachments: attachments, + status: msg.status, + originalText: msg.originalText, + meta: msg.meta + )))) + continue + } + + if case .agent(let contentItems) = msg.content { + // Suppress a text block repeating the Task tool prompt (already + // shown in the tool card) — only that exact prompt text. + let taskToolCall: ToolUseContent? = contentItems.lazy.compactMap { item -> ToolUseContent? in + if case .toolCall(let toolCall) = item, isSubagentToolName(toolCall.name) { return toolCall } + return nil + }.first + let taskPromptText: String? = taskToolCall?.input?.objectValue?["prompt"]?.stringValue + + for (idx, item) in contentItems.enumerated() { + switch item { + case .text(let textContent): + // Skip "No response requested." — the sentinel + // auto-response to system-injected messages. + if contentItems.count == 1, + let parentUUID = textContent.parentUUID, + injectedTurnUuids.contains(parentUUID) { + let trimmedText = textContent.text.jsTrimmed + if trimmedText == "No response requested." || trimmedText == "No response requested" { + continue + } + } + + if let taskPromptText, textContent.text.jsTrimmed == taskPromptText.jsTrimmed { + continue + } + + if isCliOutputText(textContent.text, meta: msg.meta) { + blocks.append(BlockBox(.cliOutput(CliOutputBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + durationMs: nil, + usage: msg.usage, + model: msg.model, + text: textContent.text, + source: .assistant, + meta: msg.meta + )))) + continue + } + if let streamId = textContent.streamId, let existingBox = textBlocksByStreamId[streamId] { + if var existing = existingBox.block.asAgentText { + existing.text = textContent.text + existing.usage = msg.usage + existing.model = msg.model + existing.meta = msg.meta + existing.invokedAt = msg.invokedAt + existingBox.block = .agentText(existing) + continue + } + } + + let box = BlockBox(.agentText(AgentTextBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + durationMs: nil, + usage: msg.usage, + model: msg.model, + text: textContent.text, + meta: msg.meta + ))) + blocks.append(box) + if let streamId = textContent.streamId { + textBlocksByStreamId[streamId] = box + } + continue + + case .generatedImage(let image): + blocks.append(BlockBox(.generatedImage(GeneratedImageBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + imageId: image.imageId, + fileName: image.fileName, + mimeType: image.mimeType, + source: image.source, + meta: msg.meta + )))) + continue + + case .reasoning(let reasoningContent): + if let streamId = reasoningContent.streamId, let existingBox = reasoningBlocksByStreamId[streamId] { + if var existing = existingBox.block.asAgentReasoning { + existing.text = reasoningContent.text + existing.usage = msg.usage + existing.model = msg.model + existing.meta = msg.meta + existing.invokedAt = msg.invokedAt + existingBox.block = .agentReasoning(existing) + continue + } + } + + let box = BlockBox(.agentReasoning(AgentReasoningBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + durationMs: nil, + usage: msg.usage, + model: msg.model, + text: reasoningContent.text, + meta: msg.meta + ))) + blocks.append(box) + if let streamId = reasoningContent.streamId { + reasoningBlocksByStreamId[streamId] = box + } + continue + + case .codexReview(let review, _, _): + blocks.append(BlockBox(.codexReview(CodexReviewBlock( + id: "\(msg.id):\(idx)", + localId: msg.localId, + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + durationMs: nil, + usage: msg.usage, + model: msg.model, + review: review, + meta: msg.meta + )))) + continue + + case .summary(let summary): + blocks.append(BlockBox(.agentEvent(AgentEventBlock( + id: "\(msg.id):\(idx)", + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + event: .message(summary), + meta: msg.meta + )))) + continue + + case .toolCall(let toolCall): + if isChangeTitleToolName(toolCall.name) { + let title = context.titleChangesByToolUseId[toolCall.id] + ?? extractTitleFromChangeTitleInput(toolCall.input) + if let title, !context.emittedTitleChangeToolUseIds.contains(toolCall.id) { + context.emittedTitleChangeToolUseIds.insert(toolCall.id) + blocks.append(BlockBox(.agentEvent(AgentEventBlock( + id: "\(msg.id):\(idx)", + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + event: .titleChanged(title), + meta: msg.meta + )))) + } + continue + } + + let permission = context.permissionsById[toolCall.id]?.permission + + let box = ensureToolBlock(&blocks, &toolBlocksById, id: toolCall.id, seed: ToolBlockSeed( + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + name: toolCall.name, + input: toolCall.input, + description: toolCall.description, + nativeTitle: toolCall.nativeTitle, + nativeKind: toolCall.nativeKind, + progress: toolCall.progress, + permission: permission, + agentTimestamp: msg.agentTimestamp + )) + + if var block = box.block.asToolCall, block.tool.state == .pending { + block.tool.state = .running + box.block = .toolCall(block) + } + // Backfill both clocks regardless of state so a + // tool_result reduced before its tool_use still lowers + // startedAt to the earlier tool_use receive time. + setEarliestStartedAt(box, msg.createdAt) + setEarliestExecStartedAt(box, msg.agentTimestamp) + + if isSubagentToolName(toolCall.name) && !context.consumedGroupIds.contains(msg.id) { + let sidechain = context.groups[msg.id] + if let sidechain, !sidechain.isEmpty { + context.consumedGroupIds.insert(msg.id) + let child = reduceTimeline(sidechain, context: context) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + if var block = box.block.asToolCall { + block.children = child.blocks.materialized + box.block = .toolCall(block) + } + } + } + continue + + case .toolResult(let toolResult): + let title = context.titleChangesByToolUseId[toolResult.toolUseId] + if let title { + if !context.emittedTitleChangeToolUseIds.contains(toolResult.toolUseId) { + context.emittedTitleChangeToolUseIds.insert(toolResult.toolUseId) + blocks.append(BlockBox(.agentEvent(AgentEventBlock( + id: "\(msg.id):\(idx)", + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + event: .titleChanged(title), + meta: msg.meta + )))) + } + continue + } + + let permissionEntry = context.permissionsById[toolResult.toolUseId] + let permissionFromResult: ToolPermission? = toolResult.permissions.map { perms in + ToolPermission( + id: toolResult.toolUseId, + status: perms.result == .approved ? .approved : .denied, + mode: perms.mode, + allowedTools: perms.allowedTools, + decision: perms.decision, + date: perms.date, + // The TS literal carries these keys (values may + // be undefined): date, mode, allowedTools, + // decision — id/status are always present. + presentKeys: [.date, .mode, .allowedTools, .decision] + ) + } + + let permission: ToolPermission? = { + if let fromResult = permissionFromResult, let entryPermission = permissionEntry?.permission { + var merged = ToolPermission.spreading(entryPermission, with: fromResult) + merged.allowedTools = fromResult.allowedTools ?? entryPermission.allowedTools + merged.decision = fromResult.decision ?? entryPermission.decision + merged.presentKeys.formUnion([.allowedTools, .decision]) + return merged + } + return permissionFromResult ?? permissionEntry?.permission + }() + + let box = ensureToolBlock(&blocks, &toolBlocksById, id: toolResult.toolUseId, seed: ToolBlockSeed( + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + name: permissionEntry?.toolName ?? "Tool", + input: permissionEntry?.input ?? .null, + description: nil, + permission: permission + // No agentTimestamp seed: execStartedAt must only + // ever originate from a tool_use entry. + )) + + if var block = box.block.asToolCall { + block.tool.result = toolResult.content + block.tool.completedAt = msg.createdAt + block.tool.execCompletedAt = msg.agentTimestamp + block.tool.state = toolResult.isError ? .error : .completed + box.block = .toolCall(block) + } + continue + + case .sidechain(let sidechain): + // Extract task-notification summaries as visible events. + let trimmedPrompt = sidechain.prompt.jsTrimmedStart + if trimmedPrompt.hasPrefix("") { + if let summaryStart = trimmedPrompt.range(of: ""), + let summaryEnd = trimmedPrompt.range(of: "", range: summaryStart.upperBound..`. +/// +/// The reference iterates JS object keys in insertion order; fixture inputs +/// are canonically serialized (recursively sorted keys) and `JSON.parse` +/// preserves that order, so the order the reference actually observes when +/// replaying fixtures is ascending key order — which is what this map +/// imposes deterministically (completed requests first, then pending, +/// each sorted by request id). +struct PermissionMap { + private(set) var order: [String] = [] + private(set) var byId: [String: PermissionEntry] = [:] + + subscript(id: String) -> PermissionEntry? { + byId[id] + } + + func has(_ id: String) -> Bool { + byId[id] != nil + } + + mutating func set(_ id: String, _ entry: PermissionEntry) { + if byId[id] == nil { order.append(id) } + byId[id] = entry + } + + var entriesInOrder: [(id: String, entry: PermissionEntry)] { + order.map { ($0, byId[$0]!) } + } +} + +/// Port of `getPermissions` — completed requests first (they win the id), +/// then pending requests. +func getPermissions(_ agentState: AgentState?) -> PermissionMap { + var map = PermissionMap() + + if let completed = agentState?.completedRequests { + for id in completed.keys.sorted(by: { $0.utf16.lexicographicallyPrecedes($1.utf16) }) { + let entry = completed[id]! + let status: ToolPermissionStatus = { + switch entry.status { + case .canceled: return .canceled + case .denied: return .denied + case .approved: return .approved + } + }() + map.set(id, PermissionEntry( + toolName: entry.tool, + input: entry.arguments, + permission: ToolPermission( + id: id, + status: status, + reason: entry.reason, + mode: entry.mode, + allowedTools: entry.allowTools, + decision: entry.decision, + answers: entry.answers, + createdAt: entry.createdAt, + completedAt: entry.completedAt, + // Keys the TS object literal carries (spread overwrites + // these on merge even when the value is undefined); + // `date` is absent from this construction site. + presentKeys: [.reason, .mode, .decision, .allowedTools, .answers, .createdAt, .completedAt] + ) + )) + } + } + + if let requests = agentState?.requests { + for id in requests.keys.sorted(by: { $0.utf16.lexicographicallyPrecedes($1.utf16) }) { + if map.has(id) { continue } + let request = requests[id]! + map.set(id, PermissionEntry( + toolName: request.tool, + input: request.arguments, + permission: ToolPermission( + id: id, + status: .pending, + createdAt: request.createdAt, + presentKeys: [.createdAt] + ) + )) + } + } + + return map +} + +/// Seed for `ensureToolBlock` — mirrors the TS parameter object. Optional +/// fields left `nil` reproduce an absent/`undefined` TS property. +struct ToolBlockSeed { + var createdAt: Int + var invokedAt: Int? + var durationMs: Int? + var usage: UsageData? + var model: String? + var localId: String? + var meta: JSONValue? + var name: String + var input: JSONValue? + var description: String? + var nativeTitle: String? + var nativeKind: String? + var progress: JSONValue? + var permission: ToolPermission? + var agentTimestamp: Int? + + init( + createdAt: Int, + invokedAt: Int? = nil, + durationMs: Int? = nil, + usage: UsageData? = nil, + model: String? = nil, + localId: String? = nil, + meta: JSONValue? = nil, + name: String, + input: JSONValue? = nil, + description: String? = nil, + nativeTitle: String? = nil, + nativeKind: String? = nil, + progress: JSONValue? = nil, + permission: ToolPermission? = nil, + agentTimestamp: Int? = nil + ) { + self.createdAt = createdAt + self.invokedAt = invokedAt + self.durationMs = durationMs + self.usage = usage + self.model = model + self.localId = localId + self.meta = meta + self.name = name + self.input = input + self.description = description + self.nativeTitle = nativeTitle + self.nativeKind = nativeKind + self.progress = progress + self.permission = permission + self.agentTimestamp = agentTimestamp + } +} + +private func isPlaceholderToolName(_ name: String) -> Bool { + let normalized = name.jsTrimmed.lowercased() + // 'generic' is agy's non-specific result wrapper — never let it + // overwrite a specific name set by the permission request. + return normalized.isEmpty || normalized == "tool" || normalized == "unknown" || normalized == "generic" +} + +/// Port of `ensureToolBlock`: create-or-merge the tool card for `id`. +/// Mutates the shared box in place; appends a new box to `blocks` (and the +/// map) when the id is unseen. +@discardableResult +func ensureToolBlock( + _ blocks: inout [BlockBox], + _ toolBlocksById: inout [String: BlockBox], + id: String, + seed: ToolBlockSeed +) -> BlockBox { + if let existingBox = toolBlocksById[id], var existing = existingBox.block.asToolCall { + // Preserve earliest createdAt for stable ordering. + if seed.createdAt < existing.createdAt { + existing.createdAt = seed.createdAt + existing.tool.createdAt = seed.createdAt + } + if let seedPermission = seed.permission { + let nextPermission = ToolPermission.spreading(existing.tool.permission, with: seedPermission) + var nextState = existing.tool.state + if existing.tool.state == .running && seedPermission.status == .pending { + nextState = .pending + } + existing.tool.permission = nextPermission + existing.tool.state = nextState + } + if !seed.name.isEmpty && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name)) { + existing.tool.name = seed.name + } + if let input = seed.input, input != .null { + existing.tool.input = input + } + if let description = seed.description { + existing.tool.description = description + } + if let nativeTitle = seed.nativeTitle { + existing.tool.nativeTitle = nativeTitle + } + if let nativeKind = seed.nativeKind { + existing.tool.nativeKind = nativeKind + } + if let progress = seed.progress, existing.tool.state == .running { + existing.tool.result = progress + } + // The tool_use records when the tool was invoked; a later + // tool_result must not overwrite it. + if let invokedAt = seed.invokedAt, existing.invokedAt == nil { + existing.invokedAt = invokedAt + } + if let durationMs = seed.durationMs { + existing.durationMs = durationMs + } + if let usage = seed.usage { + existing.usage = usage + } + if let model = seed.model { + existing.model = model + } + existingBox.block = .toolCall(existing) + return existingBox + } + + let initialState: ToolCallState = { + if seed.permission?.status == .pending { return .pending } + if seed.permission?.status == .denied || seed.permission?.status == .canceled { return .error } + return .running + }() + + let tool = ChatToolCall( + id: id, + name: seed.name, + state: initialState, + input: seed.input, + createdAt: seed.createdAt, + startedAt: initialState == .running ? seed.createdAt : nil, + completedAt: nil, + // Exec start is only ever a real Claude entry timestamp (never the + // hub receive time); nil keeps the hub-time fallback. + execStartedAt: initialState == .running ? seed.agentTimestamp : nil, + execCompletedAt: nil, + description: seed.description, + nativeTitle: seed.nativeTitle, + nativeKind: seed.nativeKind, + result: seed.progress, + permission: seed.permission + ) + + let block = ToolCallBlock( + id: id, + localId: seed.localId, + createdAt: seed.createdAt, + invokedAt: seed.invokedAt, + durationMs: seed.durationMs, + usage: seed.usage, + model: seed.model, + tool: tool, + children: [], + meta: seed.meta + ) + + let box = BlockBox(.toolCall(block)) + toolBlocksById[id] = box + blocks.append(box) + return box +} + +/// Port of `collectToolIdsFromMessages`. +func collectToolIdsFromMessages(_ messages: [NormalizedMessage]) -> Set { + var ids = Set() + for msg in messages { + guard let content = msg.agentContent else { continue } + for item in content { + switch item { + case .toolCall(let toolCall): + ids.insert(toolCall.id) + case .toolResult(let toolResult): + ids.insert(toolResult.toolUseId) + default: + break + } + } + } + return ids +} + +/// Port of `isChangeTitleToolName`. +func isChangeTitleToolName(_ name: String) -> Bool { + name == "mcp__hapi__change_title" || name == "hapi__change_title" +} + +/// Port of `extractTitleFromChangeTitleInput`. +func extractTitleFromChangeTitleInput(_ input: JSONValue?) -> String? { + guard let title = input?.objectValue?["title"]?.stringValue else { return nil } + let trimmed = title.jsTrimmed + return trimmed.isEmpty ? nil : trimmed +} + +/// Port of `collectTitleChanges` — tool_use id → requested title. +func collectTitleChanges(_ messages: [NormalizedMessage]) -> [String: String] { + var map: [String: String] = [:] + for msg in messages { + guard let content = msg.agentContent else { continue } + for item in content { + guard case .toolCall(let toolCall) = item else { continue } + guard isChangeTitleToolName(toolCall.name) else { continue } + guard let title = extractTitleFromChangeTitleInput(toolCall.input) else { continue } + map[toolCall.id] = title + } + } + return map +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolGroups.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolGroups.swift new file mode 100644 index 00000000..aec4df45 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolGroups.swift @@ -0,0 +1,387 @@ +import Foundation + +// Port of web/src/chat/toolGroups.ts — collapsing runs of low-signal tool +// calls into tool-group blocks, plus the codex-exploration grouping family +// (which needs the CodexBash command_actions parsing from +// web/src/chat/codexCommandPresentation.ts, ported in ToolPresentation.swift). + +// MARK: - Tool input access (web/src/lib/toolInputUtils.ts) + +/// Port of `getInputString` — non-string values yield nil; the `Any` variant +/// additionally skips empty strings (JS truthiness). +func getInputString(_ input: JSONValue?, _ key: String) -> String? { + input?.objectValue?[key]?.stringValue +} + +/// Port of `getInputStringAny`. +func getInputStringAny(_ input: JSONValue?, _ keys: [String]) -> String? { + for key in keys { + if let value = getInputString(input, key), !value.isEmpty { + return value + } + } + return nil +} + +// MARK: - Group model + +public enum ToolGroupActionKind: String, Equatable, Sendable, CaseIterable { + case read + case search + case command + case mutation + case web + case other +} + +/// Port of `ToolGroupSummary`. +public struct ToolGroupSummary: Equatable, Sendable { + public var totalTools: Int + public var countsByKind: [ToolGroupActionKind: Int] + public var fileTargets: [String] + public var commandTargets: [String] + public var searchTargets: [String] + public var urlTargets: [String] + public var otherTargets: [String] + public var errorCount: Int + public var runningCount: Int + public var pendingCount: Int +} + +/// Port of `ToolGroupBlock`. +public struct ToolGroupBlock: Equatable, Sendable { + public var id: String + public var createdAt: Int + public var invokedAt: Int? + public var firstToolId: String + public var lastToolId: String + public var tools: [ToolCallBlock] + public var defaultOpen: Bool + public var historyState: HistoryState + public var needsOlderHistory: Bool + public var activityTitle: String? + public var presentationMode: PresentationMode + public var summary: ToolGroupSummary + + public enum HistoryState: String, Equatable, Sendable { + case complete + case needsOlderHistory = "needs-older-history" + } + + public enum PresentationMode: String, Equatable, Sendable { + case `default` + case codexExploration = "codex-exploration" + } +} + +/// Port of `VisibleChatBlock`. +public enum VisibleChatBlock: Equatable, Sendable { + case block(ChatBlock) + case toolGroup(ToolGroupBlock) +} + +public enum VisibleChatBlockRole: String, Equatable, Sendable { + case user + case assistant + case system +} + +/// Port of `visibleBlockRole`. +public func visibleBlockRole(_ block: VisibleChatBlock) -> VisibleChatBlockRole { + switch block { + case .toolGroup: + return .assistant + case .block(let chatBlock): + switch chatBlock { + case .userText: return .user + case .agentEvent: return .system + case .cliOutput(let cliOutput): return cliOutput.source == .user ? .user : .assistant + default: return .assistant + } + } +} + +/// Port of `ToolGroupingOptions`. +public struct ToolGroupingOptions: Sendable { + public var hasMoreMessages: Bool + public var previousGroups: [ToolGroupBlock] + /// Tri-state like the TS optional boolean: only an explicit `false` + /// opens codex-exploration groups by default. + public var codexExplorationCollapsed: Bool? + + public init(hasMoreMessages: Bool, previousGroups: [ToolGroupBlock] = [], codexExplorationCollapsed: Bool? = nil) { + self.hasMoreMessages = hasMoreMessages + self.previousGroups = previousGroups + self.codexExplorationCollapsed = codexExplorationCollapsed + } +} + +// MARK: - Name tables + +private let planToolNames: Set = [ + "TodoWrite", + "update_plan", + "ExitPlanMode", + "exit_plan_mode", + "CodexReasoning", +] + +private let milestoneToolNames: Set = [ + "Task", + "Agent", + "CodexAgent", + "TeamCreate", + "TeamDelete", + "SendMessage", + "AgyTaskLog", + "Skill", + "spawn_agent", + "send_input", + "send_message", + "resume_agent", + "followup_task", + "wait_agent", + "close_agent", + "interrupt_agent", + "list_agents", +] + +private let interactiveToolNames: Set = [ + "CodexPermission", +] + +/// Port of `isAskUserQuestionToolName` (web ToolCard/askUserQuestion.ts). +public func isAskUserQuestionToolName(_ toolName: String) -> Bool { + toolName == "AskUserQuestion" || toolName == "ask_user_question" || toolName == "CursorAskQuestion" +} + +/// Port of `isRequestUserInputToolName` (web ToolCard/requestUserInput.ts). +public func isRequestUserInputToolName(_ toolName: String) -> Bool { + toolName == "request_user_input" +} + +// MARK: - Summaries + +private func pushUnique(_ target: inout [String], _ value: String?) { + guard let value, !value.isEmpty else { return } + if target.contains(value) { return } + target.append(value) +} + +/// Port of `normalizeCommandInput`. +private func normalizeCommandInput(_ input: JSONValue?) -> String? { + if let direct = getInputStringAny(input, ["command", "cmd"]) { return direct } + + guard let command = input?.objectValue?["command"]?.arrayValue else { return nil } + let parts = command.compactMap { part -> String? in + guard let text = part.stringValue, !text.isEmpty else { return nil } + return text + } + return parts.isEmpty ? nil : parts.joined(separator: " ") +} + +/// Port of `getToolGroupActionKind`. +public func getToolGroupActionKind(_ block: ToolCallBlock) -> ToolGroupActionKind { + let name = block.tool.name + + if name == "Read" || name == "NotebookRead" { return .read } + if name == "Grep" || name == "Glob" || name == "LS" { return .search } + if name == "Bash" || name == "CodexBash" || name == "shell_command" || name == "run_shell_command" { return .command } + if name == "Edit" || name == "MultiEdit" || name == "Write" || name == "NotebookEdit" + || name == "CodexPatch" || name == "CodexDiff" { + return .mutation + } + if name == "WebFetch" || name == "WebSearch" { return .web } + return .other +} + +private func getPrimaryFileTarget(_ block: ToolCallBlock) -> String? { + getInputStringAny(block.tool.input, ["file_path", "path", "file", "filePath", "notebook_path", "name"]) +} + +private func getPrimarySearchTarget(_ block: ToolCallBlock) -> String? { + getInputStringAny(block.tool.input, ["pattern", "query"]) +} + +private func getPrimaryUrlTarget(_ block: ToolCallBlock) -> String? { + getInputStringAny(block.tool.input, ["url"]) +} + +private func getPrimaryOtherTarget(_ block: ToolCallBlock) -> String? { + if let fileTarget = getPrimaryFileTarget(block) { return fileTarget } + if let searchTarget = getPrimarySearchTarget(block) { return searchTarget } + if let commandTarget = normalizeCommandInput(block.tool.input) { return commandTarget } + if let urlTarget = getPrimaryUrlTarget(block) { return urlTarget } + return block.tool.name +} + +/// Port of `summarizeToolGroup`. +private func summarizeToolGroup(_ tools: [ToolCallBlock]) -> ToolGroupSummary { + var countsByKind: [ToolGroupActionKind: Int] = [ + .read: 0, .search: 0, .command: 0, .mutation: 0, .web: 0, .other: 0, + ] + var fileTargets: [String] = [] + var commandTargets: [String] = [] + var searchTargets: [String] = [] + var urlTargets: [String] = [] + var otherTargets: [String] = [] + var errorCount = 0 + var runningCount = 0 + var pendingCount = 0 + + for tool in tools { + let kind = getToolGroupActionKind(tool) + countsByKind[kind, default: 0] += 1 + + switch tool.tool.state { + case .error: errorCount += 1 + case .running: runningCount += 1 + case .pending: pendingCount += 1 + case .completed: break + } + + if kind == .read || kind == .mutation { + pushUnique(&fileTargets, getPrimaryFileTarget(tool)) + continue + } + if kind == .search { + pushUnique(&searchTargets, getPrimarySearchTarget(tool)) + continue + } + if kind == .command { + pushUnique(&commandTargets, normalizeCommandInput(tool.tool.input)) + continue + } + if kind == .web { + pushUnique(&urlTargets, getPrimaryUrlTarget(tool) ?? getPrimarySearchTarget(tool)) + continue + } + pushUnique(&otherTargets, getPrimaryOtherTarget(tool)) + } + + return ToolGroupSummary( + totalTools: tools.count, + countsByKind: countsByKind, + fileTargets: fileTargets, + commandTargets: commandTargets, + searchTargets: searchTargets, + urlTargets: urlTargets, + otherTargets: otherTargets, + errorCount: errorCount, + runningCount: runningCount, + pendingCount: pendingCount + ) +} + +// MARK: - Eligibility + +private func isInteractiveToolBlock(_ block: ToolCallBlock) -> Bool { + interactiveToolNames.contains(block.tool.name) + || block.tool.permission?.status == .pending + || isAskUserQuestionToolName(block.tool.name) + || isRequestUserInputToolName(block.tool.name) +} + +/// Port of `isEligibleForToolGrouping`. +public func isEligibleForToolGrouping(_ block: ToolCallBlock) -> Bool { + if isSubagentToolName(block.tool.name) { return false } + if planToolNames.contains(block.tool.name) { return false } + if milestoneToolNames.contains(block.tool.name) { return false } + if isInteractiveToolBlock(block) { return false } + if block.tool.name == "CodexBash" && !getCodexCommandActions(block).isEmpty { + return isCodexExplorationTool(block) + } + return true +} + +private func getGroupingFamily(_ block: ToolCallBlock) -> ToolGroupBlock.PresentationMode? { + guard isEligibleForToolGrouping(block) else { return nil } + return isCodexExplorationTool(block) ? .codexExploration : .default +} + +/// Port of `createToolGroupId` — reuses a previous group's id when the run +/// still shares a boundary tool with it (scroll-anchor stability). +private func createToolGroupId( + tools: [ToolCallBlock], + needsOlderHistory: Bool, + previousGroups: [ToolGroupBlock] +) -> String { + let firstToolId = tools.first?.id ?? "unknown" + let lastToolId = tools.last?.id ?? firstToolId + + if let previous = previousGroups.first(where: { $0.firstToolId == firstToolId || $0.lastToolId == lastToolId }) { + return previous.id + } + + return needsOlderHistory + ? "tool-group:\(lastToolId)" + : "tool-group:\(firstToolId)" +} + +// MARK: - buildVisibleChatBlocks + +/// Port of `buildVisibleChatBlocks`. +public func buildVisibleChatBlocks( + _ blocks: [ChatBlock], + options: ToolGroupingOptions +) -> [VisibleChatBlock] { + var visibleBlocks: [VisibleChatBlock] = [] + let previousGroups = options.previousGroups + + var index = 0 + while index < blocks.count { + let block = blocks[index] + guard case .toolCall(let toolBlock) = block else { + visibleBlocks.append(.block(block)) + index += 1 + continue + } + guard let groupingFamily = getGroupingFamily(toolBlock) else { + visibleBlocks.append(.block(block)) + index += 1 + continue + } + + var tools: [ToolCallBlock] = [toolBlock] + var cursor = index + 1 + while cursor < blocks.count { + guard case .toolCall(let candidate) = blocks[cursor], + getGroupingFamily(candidate) == groupingFamily else { + break + } + tools.append(candidate) + cursor += 1 + } + + if tools.count < 2 && groupingFamily != .codexExploration { + visibleBlocks.append(.block(block)) + index += 1 + continue + } + + let startsAtOldestVisibleBoundary = visibleBlocks.isEmpty + let needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary + let activityTitle: String? = { + guard case .block(.toolCall(let previousBlock))? = visibleBlocks.last, + previousBlock.tool.name == "CodexReasoning" else { return nil } + return getInputStringAny(previousBlock.tool.input, ["title"]) + }() + visibleBlocks.append(.toolGroup(ToolGroupBlock( + id: createToolGroupId(tools: tools, needsOlderHistory: needsOlderHistory, previousGroups: previousGroups), + createdAt: tools[0].createdAt, + invokedAt: tools[0].invokedAt, + firstToolId: tools[0].id, + lastToolId: tools[tools.count - 1].id, + tools: tools, + defaultOpen: groupingFamily == .codexExploration && options.codexExplorationCollapsed == false, + historyState: needsOlderHistory ? .needsOlderHistory : .complete, + needsOlderHistory: needsOlderHistory, + activityTitle: activityTitle, + presentationMode: groupingFamily, + summary: summarizeToolGroup(tools) + ))) + index = cursor + } + + return visibleBlocks +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolPresentation.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolPresentation.swift new file mode 100644 index 00000000..7ac5cc67 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ToolPresentation.swift @@ -0,0 +1,143 @@ +import Foundation + +// Tool presentation helpers. +// +// Part 1 ports web/src/chat/codexCommandPresentation.ts — REQUIRED by the +// grouping pipeline (`isEligibleForToolGrouping` keys the codex-exploration +// family off these), so it is covered by the golden fixtures. +// +// Part 2 is a fixture-irrelevant convenience for the M2 UI milestones: a +// compact per-tool title/subtitle derivation. The web's full presentation +// catalog (web/src/components/ToolCard/knownTools.tsx, including the rich +// codex command-action rendering) is deliberately deferred to the HapiUI +// work — nothing here feeds the normative projection. + +// MARK: - Codex command actions (fixture-relevant) + +/// Port of `CodexCommandAction`. +public enum CodexCommandAction: Equatable, Sendable { + case read(command: String, name: String, path: String) + case listFiles(command: String, path: String?) + case search(command: String, query: String?, path: String?) + case unknown(command: String) +} + +private func nonEmptyString(_ value: JSONValue?) -> String? { + guard let text = value?.stringValue, !text.isEmpty else { return nil } + return text +} + +/// Port of `parseAction`. +private func parseCodexCommandAction(_ value: JSONValue) -> CodexCommandAction? { + guard let action = value.objectValue else { return nil } + guard let type = nonEmptyString(action["type"]), let command = nonEmptyString(action["command"]) else { return nil } + + if type == "read" { + guard let name = nonEmptyString(action["name"]), let path = nonEmptyString(action["path"]) else { return nil } + return .read(command: command, name: name, path: path) + } + if type == "listFiles" { + return .listFiles(command: command, path: nonEmptyString(action["path"])) + } + if type == "search" { + return .search(command: command, query: nonEmptyString(action["query"]), path: nonEmptyString(action["path"])) + } + if type == "unknown" { + return .unknown(command: command) + } + return nil +} + +/// Port of `getCodexCommandActions`. +public func getCodexCommandActions(_ block: ToolCallBlock) -> [CodexCommandAction] { + guard block.tool.name == "CodexBash", let input = block.tool.input?.objectValue else { + return [] + } + let raw = jsCoalesce(input["command_actions"], input["commandActions"]) + guard let items = raw?.arrayValue else { return [] } + return items.compactMap(parseCodexCommandAction) +} + +/// Port of `isCodexExplorationTool`. +public func isCodexExplorationTool(_ block: ToolCallBlock) -> Bool { + let input = block.tool.input?.objectValue + let source = input.flatMap { nonEmptyString(jsCoalesce($0["command_source"], $0["commandSource"])) } + if source?.lowercased() == "usershell" { return false } + + let actions = getCodexCommandActions(block) + guard !actions.isEmpty else { return false } + return actions.allSatisfy { action in + switch action { + case .read, .listFiles, .search: return true + case .unknown: return false + } + } +} + +// MARK: - Per-tool title/subtitle (presentation-only, not fixture-covered) + +/// Minimal per-tool card labeling for native list rows. Mirrors the common +/// cases of the web `knownTools` catalog; the full catalog (icons, rich +/// views, codex command-action lines) lands with the HapiUI chat views. +public struct ToolPresentation: Equatable, Sendable { + public var title: String + public var subtitle: String? + + public static func forTool(_ tool: ChatToolCall) -> ToolPresentation { + let input = tool.input + switch tool.name { + case "Bash", "CodexBash", "shell_command", "run_shell_command": + return ToolPresentation( + title: tool.description ?? "Terminal", + subtitle: getInputStringAny(input, ["command", "cmd"]) + ) + case "Read", "NotebookRead": + return ToolPresentation( + title: "Read", + subtitle: getInputStringAny(input, ["file_path", "path", "notebook_path"]) + ) + case "Write": + return ToolPresentation(title: "Write", subtitle: getInputStringAny(input, ["file_path", "path"])) + case "Edit", "MultiEdit": + return ToolPresentation(title: "Edit", subtitle: getInputStringAny(input, ["file_path", "path"])) + case "Grep": + return ToolPresentation(title: "Grep", subtitle: getInputStringAny(input, ["pattern", "query"])) + case "Glob": + return ToolPresentation(title: "Glob", subtitle: getInputStringAny(input, ["pattern"])) + case "LS": + return ToolPresentation(title: "List files", subtitle: getInputStringAny(input, ["path"])) + case "WebFetch", "WebSearch": + return ToolPresentation( + title: tool.name == "WebFetch" ? "Fetch" : "Web search", + subtitle: getInputStringAny(input, ["url", "query"]) + ) + case "Task", "Agent": + return ToolPresentation( + title: getInputStringAny(input, ["description"]) ?? "Subagent", + subtitle: getInputStringAny(input, ["subagent_type"]) + ) + case "TodoWrite", "update_plan": + return ToolPresentation(title: "Plan", subtitle: nil) + case "AskUserQuestion", "ask_user_question", "CursorAskQuestion": + return ToolPresentation(title: "Question", subtitle: nil) + case "request_user_input": + return ToolPresentation(title: "Input requested", subtitle: nil) + case "CodexAgent": + return ToolPresentation( + title: "Agent run", + subtitle: getInputStringAny(input, ["statusText", "summary", "activity"]) + ) + case agyAsyncTaskTool: + return ToolPresentation(title: "Background task", subtitle: tool.description) + case agyErrorTool: + return ToolPresentation(title: "Invalid tool call", subtitle: tool.description) + case agyTaskLogTool: + return ToolPresentation(title: "Task log", subtitle: getInputStringAny(input, ["task"])) + default: + return ToolPresentation( + title: tool.nativeTitle ?? tool.description ?? tool.name, + subtitle: getInputStringAny(input, ["file_path", "path", "pattern", "query", "command", "url"]) + ) + } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Tracer.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Tracer.swift new file mode 100644 index 00000000..7db55813 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/Tracer.swift @@ -0,0 +1,129 @@ +import Foundation + +// Port of web/src/chat/tracer.ts — groups sidechain messages under their +// parent Agent/Task tool call. `TracedMessage` is `NormalizedMessage` with +// `sidechainId` populated (the field lives on the struct). + +/// Port of `isSubagentToolName` (web/src/chat/subagentTool.ts). +public func isSubagentToolName(_ name: String) -> Bool { + name == "Task" || name == "Agent" || name.hasPrefix("Agent:") || name.hasPrefix("Task:") +} + +private struct TracerState { + var promptToTaskId: [String: String] = [:] + var toolUseIdToTaskId: [String: String] = [:] + var uuidToSidechainId: [String: String] = [:] + var orphanMessages: [String: [NormalizedMessage]] = [:] +} + +private func getMessageUuid(_ message: NormalizedMessage) -> String? { + guard let content = message.agentContent, let first = content.first else { return nil } + return first.uuid +} + +private func getParentUuid(_ message: NormalizedMessage) -> String? { + guard let content = message.agentContent, let first = content.first else { return nil } + return first.parentUUID +} + +private func getParentToolUseId(_ message: NormalizedMessage) -> String? { + guard message.agentContent != nil else { return nil } + return message.parentToolUseId +} + +private func processOrphans(_ state: inout TracerState, parentUuid: String, sidechainId: String) -> [NormalizedMessage] { + var results: [NormalizedMessage] = [] + guard let orphans = state.orphanMessages[parentUuid] else { return results } + state.orphanMessages.removeValue(forKey: parentUuid) + + for orphan in orphans { + let uuid = getMessageUuid(orphan) + if let uuid { + state.uuidToSidechainId[uuid] = sidechainId + } + + var traced = orphan + traced.sidechainId = sidechainId + results.append(traced) + + if let uuid { + results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: sidechainId)) + } + } + + return results +} + +/// Port of `traceMessages`. +public func traceMessages(_ messages: [NormalizedMessage]) -> [NormalizedMessage] { + var state = TracerState() + var results: [NormalizedMessage] = [] + + // Index Task/Agent prompts and tool_use ids (sidechain ones included). + for message in messages { + guard let content = message.agentContent else { continue } + for item in content { + guard case .toolCall(let toolCall) = item, isSubagentToolName(toolCall.name) else { continue } + state.toolUseIdToTaskId[toolCall.id] = message.id + guard let prompt = toolCall.input?.objectValue?["prompt"]?.stringValue else { continue } + state.promptToTaskId[prompt] = message.id + } + } + + for message in messages { + if !message.isSidechain { + results.append(message) + continue + } + + let uuid = getMessageUuid(message) + let parentUuid = getParentUuid(message) + + // Preferred: group by the SDK's parent_tool_use_id. + var sidechainId: String? + if let parentToolUseId = getParentToolUseId(message) { + sidechainId = state.toolUseIdToTaskId[parentToolUseId] + } + + // Fallback: sidechain-root prompt matching (pre-parentToolUseId data). + if sidechainId == nil, let content = message.agentContent { + for item in content { + guard case .sidechain(let sidechain) = item else { continue } + if let taskId = state.promptToTaskId[sidechain.prompt] { + sidechainId = taskId + break + } + } + } + + if let sidechainId, let uuid { + state.uuidToSidechainId[uuid] = sidechainId + var traced = message + traced.sidechainId = sidechainId + results.append(traced) + results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: sidechainId)) + continue + } + + if let parentUuid { + if let parentSidechainId = state.uuidToSidechainId[parentUuid] { + if let uuid { + state.uuidToSidechainId[uuid] = parentSidechainId + } + var traced = message + traced.sidechainId = parentSidechainId + results.append(traced) + if let uuid { + results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: parentSidechainId)) + } + } else { + state.orphanMessages[parentUuid, default: []].append(message) + } + continue + } + + results.append(message) + } + + return results +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift new file mode 100644 index 00000000..a90117c1 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/ChatFixtureTests.swift @@ -0,0 +1,146 @@ +import Foundation +import HapiProtocol +import Testing + +/// Golden conformance suite for the chat pipeline port: every fixture in +/// `shared/fixtures/chat/` is decoded, run through the ported pipeline +/// (normalize → reduce → group → normative projection), serialized to +/// canonical JSON and compared byte-for-byte against the stored `expected`. +/// +/// One parameterized test case per fixture file, so a red CI run names the +/// exact fixtures that diverge; on mismatch the failure message carries a +/// line-level diff (first divergence with context) to make the macOS CI +/// output directly actionable from a Linux dev box. +@Suite("Chat pipeline golden fixtures") +struct ChatFixtureTests { + /// 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 ChatFixtureDocument: Decodable { + struct Input: Decodable { + let messages: [DecryptedMessage] + let agentState: AgentState? + let options: Options + } + + struct Options: Decodable { + let hasMoreMessages: Bool + } + + let fixtureVersion: Int + let name: String + let input: Input + let expected: JSONValue + } + + /// Repo-root `shared/fixtures`, resolved from this file's own location + /// (same scheme as FixtureDecodingTests): + /// `ios/Packages/HapiKit/Tests/HapiProtocolTests/…` → package root → + /// `../../../shared/fixtures`. + private static func fixturesDirectory() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/HapiProtocolTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // package root: ios/Packages/HapiKit + .appendingPathComponent("../../../shared/fixtures") + .standardizedFileURL + } + + /// Sorted fixture file names — the parameterized-test argument list. + /// Missing directory yields an empty list, which the guard test flags. + static let chatFixtureNames: [String] = { + let chatDirectory = fixturesDirectory().appendingPathComponent("chat") + let contents = (try? FileManager.default.contentsOfDirectory( + at: chatDirectory, + includingPropertiesForKeys: nil + )) ?? [] + return contents + .filter { $0.pathExtension == "json" } + .map { $0.lastPathComponent } + .sorted() + }() + + private static func fixtureURL(_ fileName: String) -> URL { + fixturesDirectory().appendingPathComponent("chat").appendingPathComponent(fileName) + } + + @Test func hasChatFixturesOnDisk() { + #expect( + !Self.chatFixtureNames.isEmpty, + "no chat fixtures found under \(Self.fixturesDirectory().path)" + ) + } + + @Test func fixturesVersionIsSupported() 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 pipeline port" + ) + } + + @Test("fixture", arguments: ChatFixtureTests.chatFixtureNames) + func fixtureMatchesPipeline(_ fileName: String) throws { + let data = try Data(contentsOf: Self.fixtureURL(fileName)) + let document = try JSONDecoder().decode(ChatFixtureDocument.self, from: data) + + #expect(document.fixtureVersion <= Self.supportedFixtureVersion, + "\(fileName) uses fixtureVersion \(document.fixtureVersion)") + #expect("\(document.name).json" == fileName, "\(fileName) name field mismatch") + + let projected = runChatFixturePipeline( + messages: document.input.messages, + agentState: document.input.agentState, + hasMoreMessages: document.input.options.hasMoreMessages + ) + + let actual = toCanonicalJSON(projected) + let expected = toCanonicalJSON(document.expected) + + if actual != expected { + Issue.record(Comment(rawValue: Self.diff(expected: expected, actual: actual, label: fileName))) + } + } + + /// Readable line-level diff: the first differing line with surrounding + /// context from both documents, so the CI log pinpoints the divergence + /// without downloading artifacts. + private static func diff(expected: String, actual: String, label: String) -> String { + let expectedLines = expected.components(separatedBy: "\n") + let actualLines = actual.components(separatedBy: "\n") + let commonCount = min(expectedLines.count, actualLines.count) + + var firstDiff = commonCount + for index in 0.. String { + let end = min(lines.count, firstDiff + 4) + guard contextStart < end else { return " " } + return lines[contextStart.." : " " + return String(format: "%@ %4d | %@", marker, lineNumber, line) + }.joined(separator: "\n") + } + + return """ + \(label): projection mismatch at line \(firstDiff + 1) \ + (expected \(expectedLines.count) lines, actual \(actualLines.count)) + --- expected (fixture) --- + \(window(expectedLines)) + --- actual (port) --- + \(window(actualLines)) + """ + } +} diff --git a/ios/README.md b/ios/README.md index 51ae0a05..4bda2b2f 100644 --- a/ios/README.md +++ b/ios/README.md @@ -49,9 +49,23 @@ ios/ 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/**. + Chat/ the chat normalization/reduction pipeline + ported file-for-file from web/src/chat/** + (M2b+M2c): Normalize/NormalizeUser/ + NormalizeAgent (decode tree incl. agy), + Tracer (sidechain grouping), Reducer* + (timeline, tool pairing, stream coalescing, + agent-run cards, events dedupe/folding, + cli-output merge), ToolGroups (+ codex + exploration family), the normative fixture + projection (FixtureProjection), and the + JS-semantics interop layer (JSInterop: + nullish coalescing, truthiness, canonical + JSON serializer). Validated block-for-block + against shared/fixtures/chat/** by + ChatFixtureTests (one parameterized test + per fixture, line-level diff on mismatch). + The message window logic (M2d) lands next. HapiClient Transport layer. As of M1b+M1c: APIClient typed REST client (Endpoints/*): Bearer auth, 401 -> refresh -> @@ -121,8 +135,11 @@ with `swift test` and free of UI concerns. 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. +`catalogs/modes.json`. Since M2b/M2c, `ChatFixtureTests` is the pipeline +gate: for every chat fixture it runs the ported normalize → reduce → group +pipeline over the stored `input`, applies the normative projection, and +compares canonical JSON byte-for-byte against the stored `expected` — +failures are per-fixture and print the first differing line with context. ## Milestones (track A of the native-clients plan)