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

298 lines
11 KiB
Swift

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..<match.numberOfRanges {
let groupRange = match.range(at: index)
if groupRange.location == NSNotFound {
groups.append(nil)
} else if let swiftRange = Range(groupRange, in: text) {
groups.append(String(text[swiftRange]))
} else {
groups.append(nil)
}
}
return groups
}
/// JS `text.replace(regex, '')` for a NON-global regex: removes the first
/// match only.
func removingFirstMatch(in text: String) -> 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)
}
}