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

175 lines
6.3 KiB
Swift

import Foundation
// Port of web/src/chat/reducer.ts — the pipeline entry point:
// trace → timeline → pending-permission synthesis → latestUsage →
// api-error fold → dedupe → silent-goal filtering.
/// Port of `calculateContextSize`.
private func calculateContextSize(_ usage: UsageData) -> Double {
if let contextTokens = usage.contextTokens {
return contextTokens
}
return (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0) + usage.inputTokens
}
/// Port of `isUsageVisibleInParentContext` — subagent usage must not drive
/// the parent thread's context bar.
private func isUsageVisibleInParentContext(_ msg: NormalizedMessage) -> Bool {
if msg.isSidechain { return false }
return msg.usage?.scopeRole != "child"
}
private let goalCommandRegex = JSRegex("\\A\\s*/goal(?:\\s|\\z)", caseInsensitive: true)
/// Port of `getLatestThreadGoal`. Returns the normalized goal record
/// (advisory; not part of the fixture projection).
func getLatestThreadGoal(_ normalized: [NormalizedMessage]) -> JSONValue? {
var sawNewerNonGoalUserMessage = false
for msg in normalized.reversed() {
if case .user(let text, _) = msg.content {
if !goalCommandRegex.test(text) {
sawNewerNonGoalUserMessage = true
}
continue
}
guard let event = msg.eventContent else { continue }
if event.type == "thread-goal-cleared" { return nil }
if event.type == "thread-goal-updated" {
let goal = event["goal"]
if goal?["status"] == .string("complete") && sawNewerNonGoalUserMessage {
return nil
}
return goal
}
}
return nil
}
/// Port of `isRedundantGoalStatusMessage`.
private func isRedundantGoalStatusMessage(_ event: AgentEvent) -> Bool {
guard event.type == "message" else { return false }
return isRedundantGoalStatusMessageText(event["message"]?.stringValue)
}
/// Port of `isSilentGoalEventBlock`.
private func isSilentGoalEventBlock(_ block: ChatBlock) -> Bool {
guard case .agentEvent(let eventBlock) = block else { return false }
return eventBlock.event.type == "thread-goal-updated"
|| eventBlock.event.type == "thread-goal-cleared"
|| isRedundantGoalStatusMessage(eventBlock.event)
}
/// Port of `filterSilentGoalBlocks` — recursive over tool children.
func filterSilentGoalBlocks(_ blocks: [ChatBlock]) -> [ChatBlock] {
var filtered: [ChatBlock] = []
for block in blocks {
if isSilentGoalEventBlock(block) { continue }
if case .toolCall(var toolBlock) = block, !toolBlock.children.isEmpty {
toolBlock.children = filterSilentGoalBlocks(toolBlock.children)
filtered.append(.toolCall(toolBlock))
continue
}
filtered.append(block)
}
return filtered
}
/// Result of `reduceChatBlocks`.
public struct ReducedChat: Equatable, Sendable {
public var blocks: [ChatBlock]
public var hasReadyEvent: Bool
public var latestUsage: LatestUsage?
/// The latest thread goal record (advisory, dropped by the projection).
public var latestGoal: JSONValue?
}
/// Port of `reduceChatBlocks`.
public func reduceChatBlocks(
_ normalized: [NormalizedMessage],
agentState: AgentState?,
goalStateMessages: [NormalizedMessage]? = nil
) -> ReducedChat {
let permissionsById = getPermissions(agentState)
let toolIdsInMessages = collectToolIdsFromMessages(normalized)
let titleChangesByToolUseId = collectTitleChanges(normalized)
let traced = traceMessages(normalized)
var groups: [String: [NormalizedMessage]] = [:]
var root: [NormalizedMessage] = []
for msg in traced {
if let sidechainId = msg.sidechainId {
groups[sidechainId, default: []].append(msg)
} else {
root.append(msg)
}
}
let reducerContext = ReducerContext(
permissionsById: permissionsById,
groups: groups,
consumedGroupIds: [],
titleChangesByToolUseId: titleChangesByToolUseId,
emittedTitleChangeToolUseIds: []
)
var rootResult = reduceTimeline(root, context: reducerContext)
let hasReadyEvent = rootResult.hasReadyEvent
// Synthesize a tool card only for a *pending* permission that has no
// tool call/result in the transcript, and only when it is not older than
// the oldest message in the current window (older ones surface when the
// user pages back).
let oldestMessageTime: Int? = normalized.map(\.createdAt).min()
for (id, entry) in permissionsById.entriesInOrder {
if entry.permission.status != .pending { continue }
if toolIdsInMessages.contains(id) { continue }
if rootResult.toolBlocksById[id] != nil { continue }
let createdAt = entry.permission.createdAt
?? Int(Date().timeIntervalSince1970 * 1000)
if let oldestMessageTime, createdAt < oldestMessageTime {
continue
}
ensureToolBlock(&rootResult.blocks, &rootResult.toolBlocksById, id: id, seed: ToolBlockSeed(
createdAt: createdAt,
localId: nil,
name: entry.toolName,
input: entry.input,
description: nil,
permission: entry.permission
))
}
// Latest usage: the most recent parent-context message with usage data.
var latestUsage: LatestUsage?
for msg in normalized.reversed() {
if let usage = msg.usage, isUsageVisibleInParentContext(msg) {
latestUsage = LatestUsage(
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheCreation: usage.cacheCreationInputTokens ?? 0,
cacheRead: usage.cacheReadInputTokens ?? 0,
contextSize: calculateContextSize(usage),
contextWindow: usage.contextWindow,
model: msg.model,
timestamp: msg.createdAt
)
break
}
}
let materialized = rootResult.blocks.materialized
return ReducedChat(
blocks: filterSilentGoalBlocks(dedupeAgentEvents(foldApiErrorEvents(materialized))),
hasReadyEvent: hasReadyEvent,
latestUsage: latestUsage,
latestGoal: getLatestThreadGoal(goalStateMessages ?? normalized)
)
}