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

65 lines
2.2 KiB
Swift

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("<command-name>", caseInsensitive: true)
private let cliCommandStdoutRegex = JSRegex("<local-command-stdout>", 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 `<command-name>` block absorbs its
/// `<local-command-stdout>` 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
}