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

130 lines
4.6 KiB
Swift

import Foundation
// Port of web/src/chat/tracer.ts — groups sidechain messages under their
// parent Agent/Task tool call. `TracedMessage` is `NormalizedMessage` with
// `sidechainId` populated (the field lives on the struct).
/// Port of `isSubagentToolName` (web/src/chat/subagentTool.ts).
public func isSubagentToolName(_ name: String) -> Bool {
name == "Task" || name == "Agent" || name.hasPrefix("Agent:") || name.hasPrefix("Task:")
}
private struct TracerState {
var promptToTaskId: [String: String] = [:]
var toolUseIdToTaskId: [String: String] = [:]
var uuidToSidechainId: [String: String] = [:]
var orphanMessages: [String: [NormalizedMessage]] = [:]
}
private func getMessageUuid(_ message: NormalizedMessage) -> String? {
guard let content = message.agentContent, let first = content.first else { return nil }
return first.uuid
}
private func getParentUuid(_ message: NormalizedMessage) -> String? {
guard let content = message.agentContent, let first = content.first else { return nil }
return first.parentUUID
}
private func getParentToolUseId(_ message: NormalizedMessage) -> String? {
guard message.agentContent != nil else { return nil }
return message.parentToolUseId
}
private func processOrphans(_ state: inout TracerState, parentUuid: String, sidechainId: String) -> [NormalizedMessage] {
var results: [NormalizedMessage] = []
guard let orphans = state.orphanMessages[parentUuid] else { return results }
state.orphanMessages.removeValue(forKey: parentUuid)
for orphan in orphans {
let uuid = getMessageUuid(orphan)
if let uuid {
state.uuidToSidechainId[uuid] = sidechainId
}
var traced = orphan
traced.sidechainId = sidechainId
results.append(traced)
if let uuid {
results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: sidechainId))
}
}
return results
}
/// Port of `traceMessages`.
public func traceMessages(_ messages: [NormalizedMessage]) -> [NormalizedMessage] {
var state = TracerState()
var results: [NormalizedMessage] = []
// Index Task/Agent prompts and tool_use ids (sidechain ones included).
for message in messages {
guard let content = message.agentContent else { continue }
for item in content {
guard case .toolCall(let toolCall) = item, isSubagentToolName(toolCall.name) else { continue }
state.toolUseIdToTaskId[toolCall.id] = message.id
guard let prompt = toolCall.input?.objectValue?["prompt"]?.stringValue else { continue }
state.promptToTaskId[prompt] = message.id
}
}
for message in messages {
if !message.isSidechain {
results.append(message)
continue
}
let uuid = getMessageUuid(message)
let parentUuid = getParentUuid(message)
// Preferred: group by the SDK's parent_tool_use_id.
var sidechainId: String?
if let parentToolUseId = getParentToolUseId(message) {
sidechainId = state.toolUseIdToTaskId[parentToolUseId]
}
// Fallback: sidechain-root prompt matching (pre-parentToolUseId data).
if sidechainId == nil, let content = message.agentContent {
for item in content {
guard case .sidechain(let sidechain) = item else { continue }
if let taskId = state.promptToTaskId[sidechain.prompt] {
sidechainId = taskId
break
}
}
}
if let sidechainId, let uuid {
state.uuidToSidechainId[uuid] = sidechainId
var traced = message
traced.sidechainId = sidechainId
results.append(traced)
results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: sidechainId))
continue
}
if let parentUuid {
if let parentSidechainId = state.uuidToSidechainId[parentUuid] {
if let uuid {
state.uuidToSidechainId[uuid] = parentSidechainId
}
var traced = message
traced.sidechainId = parentSidechainId
results.append(traced)
if let uuid {
results.append(contentsOf: processOrphans(&state, parentUuid: uuid, sidechainId: parentSidechainId))
}
} else {
state.orphanMessages[parentUuid, default: []].append(message)
}
continue
}
results.append(message)
}
return results
}