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

170 lines
7.1 KiB
Swift

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)),
])
}