Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/MessageEnvelope.swift
T
weishu 7c34567678 feat(ios): chat pipeline port + fixture harness (A-M2bc)
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.
2026-08-17 16:32:24 +08:00

75 lines
2.9 KiB
Swift

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<String> = [
"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+)"
)