Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/NormalizeUser.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

70 lines
2.3 KiB
Swift

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
}