merge: A-M2bc iOS chat pipeline port + fixture harness

# Conflicts:
#	ios/README.md
This commit is contained in:
weishu
2026-08-17 16:34:23 +08:00
17 changed files with 5546 additions and 5 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
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)),
])
}
@@ -0,0 +1,297 @@
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)
}
}
@@ -0,0 +1,74 @@
import Foundation
// Port of the envelope helpers the chat pipeline uses from
// shared/src/messages.ts: role-wrapped envelope unwrapping, the Claude
// chat-visibility filter, and the redundant goal-status text test.
/// Port of `RoleWrappedRecord`.
public struct RoleWrappedRecord: Equatable, Sendable {
public var role: String
public var content: JSONValue
public var meta: JSONValue?
public init(role: String, content: JSONValue, meta: JSONValue? = nil) {
self.role = role
self.content = content
self.meta = meta
}
}
private func asRoleWrappedRecord(_ value: JSONValue?) -> RoleWrappedRecord? {
guard let object = value?.objectValue else { return nil }
guard let role = object["role"]?.stringValue else { return nil }
// `'content' in value` — key presence, any value (JSON knows no undefined).
guard let content = object["content"] else { return nil }
return RoleWrappedRecord(role: role, content: content, meta: object["meta"])
}
/// Port of `unwrapRoleWrappedRecordEnvelope`: the value itself, or the
/// wrapped record probed at `message`, `data.message`, `payload.message`.
public func unwrapRoleWrappedRecordEnvelope(_ value: JSONValue) -> RoleWrappedRecord? {
if let record = asRoleWrappedRecord(value) { return record }
guard value.objectValue != nil else { return nil }
if let record = asRoleWrappedRecord(value["message"]) { return record }
if let record = asRoleWrappedRecord(value["data"]?["message"]) { return record }
if let record = asRoleWrappedRecord(value["payload"]?["message"]) { return record }
return nil
}
/// Port of `VISIBLE_CLAUDE_SYSTEM_SUBTYPES`.
private let visibleClaudeSystemSubtypes: Set<String> = [
"api_error",
"turn_duration",
"microcompact_boundary",
"compact_boundary",
"away_summary",
]
/// Port of `isClaudeChatVisibleSystemSubtype`.
public func isClaudeChatVisibleSystemSubtype(_ subtype: JSONValue?) -> Bool {
guard let subtype = subtype?.stringValue else { return false }
return visibleClaudeSystemSubtypes.contains(subtype)
}
/// Port of `isClaudeChatVisibleMessage` — `type`/`subtype` straight off the
/// wire record (non-string `type` values compare unequal, like in JS).
public func isClaudeChatVisibleMessage(type: JSONValue?, subtype: JSONValue?) -> Bool {
if type == .string("rate_limit_event") { return false }
if type == .string("tool_progress") { return false }
if type != .string("system") { return true }
return isClaudeChatVisibleSystemSubtype(subtype)
}
/// Port of `isRedundantGoalStatusMessageText`. `·` is U+00B7.
public func isRedundantGoalStatusMessageText(_ value: String?) -> Bool {
guard let value else { return false }
let message = value.jsTrimmed
if message == "Goal cleared" { return true }
return goalStatusRegex.test(message)
}
private let goalStatusRegex = JSRegex(
"\\AGoal (active|paused|complete|blocked|limited by (?:budget|usage))(?:\\z|\\s+\u{00B7}\\s+)"
)
@@ -0,0 +1,80 @@
import Foundation
// Port of web/src/chat/normalize.ts — total decode of one wire message.
/// Port of `normalizeDecryptedMessage`. Returns `nil` only on the two
/// legitimate drop paths (skippable Claude output, unknown codex content);
/// every other malformed shape degrades to stringified text.
public func normalizeDecryptedMessage(_ message: DecryptedMessage) -> NormalizedMessage? {
guard let record = unwrapRoleWrappedRecordEnvelope(message.content) else {
return NormalizedMessage(
id: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: .agent([.text(.init(text: safeStringify(message.content), uuid: message.id, parentUUID: nil))]),
isSidechain: false
// No meta / invokedAt on this branch (normalize.ts:10-20).
)
}
if record.role == "user" {
if var normalized = normalizeUserRecord(
messageId: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: record.content,
meta: record.meta
) {
normalized.invokedAt = message.invokedAt
return normalized
}
return NormalizedMessage(
id: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: .user(text: safeStringify(record.content), attachments: nil),
isSidechain: false,
meta: record.meta,
invokedAt: message.invokedAt
)
}
if record.role == "agent" {
if isSkippableAgentContent(record.content) {
return nil
}
let normalized = normalizeAgentRecord(
messageId: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: record.content,
meta: record.meta
)
if normalized == nil && isCodexContent(record.content) {
return nil
}
if var normalized {
normalized.invokedAt = message.invokedAt
return normalized
}
return NormalizedMessage(
id: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: .agent([.text(.init(text: safeStringify(record.content), uuid: message.id, parentUUID: nil))]),
isSidechain: false,
meta: record.meta,
invokedAt: message.invokedAt
)
}
return NormalizedMessage(
id: message.id,
localId: message.localId,
createdAt: message.createdAt,
content: .agent([.text(.init(text: safeStringify(record.content), uuid: message.id, parentUUID: nil))]),
isSidechain: false,
meta: record.meta,
invokedAt: message.invokedAt
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import Foundation
// Port of web/src/chat/normalizeUser.ts.
/// Port of `parseAttachments`: entries are accepted only when `id`,
/// `filename`, `mimeType` (strings), `size` (number) and `path` (string) are
/// all present; invalid entries are skipped and an empty result means
/// "no attachments" (`nil`).
func parseAttachments(_ raw: JSONValue?) -> [AttachmentMetadata]? {
guard let items = raw?.arrayValue else { return nil }
var attachments: [AttachmentMetadata] = []
for item in items {
guard let object = item.objectValue,
let id = object["id"]?.stringValue,
let filename = object["filename"]?.stringValue,
let mimeType = object["mimeType"]?.stringValue,
let size = object["size"]?.numberValue,
let path = object["path"]?.stringValue,
// The wire model stores byte sizes as Int; real sizes are
// always integral (TS would accept a fractional number).
let intSize = Int(exactly: size)
else { continue }
attachments.append(AttachmentMetadata(
id: id,
filename: filename,
mimeType: mimeType,
size: intSize,
path: path,
previewUrl: object["previewUrl"]?.stringValue
))
}
return attachments.isEmpty ? nil : attachments
}
/// Port of `normalizeUserRecord`.
func normalizeUserRecord(
messageId: String,
localId: String?,
createdAt: Int,
content: JSONValue,
meta: JSONValue?
) -> NormalizedMessage? {
if let text = content.stringValue {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .user(text: text, attachments: nil),
isSidechain: false,
meta: meta
)
}
if let object = content.objectValue,
object["type"] == .string("text"),
let text = object["text"]?.stringValue {
let attachments = parseAttachments(object["attachments"])
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .user(text: text, attachments: attachments),
isSidechain: false,
meta: meta
)
}
return nil
}
@@ -0,0 +1,174 @@
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)
)
}
@@ -0,0 +1,64 @@
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
}
@@ -0,0 +1,144 @@
import Foundation
// Port of web/src/chat/reducerEvents.ts — CLI pipe-format limit parsing,
// consecutive-event dedupe, api-error folding.
private let limitReachedRegex = JSRegex("\\AClaude AI usage limit reached\\|(\\d+)(?:\\|([^|]*))?\\z")
private let limitWarningRegex = JSRegex("\\AClaude AI usage limit warning\\|(\\d+)\\|(\\d+)\\|([^|]*)\\z")
/// Port of `parseClaudeUsageLimit`.
func parseClaudeUsageLimit(_ text: String) -> AgentEvent? {
if let match = limitReachedRegex.firstMatch(in: text), let rawTimestamp = match[1] {
if let timestamp = Double(rawTimestamp), timestamp.isFinite {
// `reachedMatch[2] || ''` — an absent or empty capture stays ''.
return .limitReached(endsAt: timestamp, limitType: match.count > 2 ? (match[2] ?? "") : "")
}
}
if let match = limitWarningRegex.firstMatch(in: text),
let rawTimestamp = match[1], let rawUtilization = match[2] {
if let timestamp = Double(rawTimestamp), timestamp.isFinite,
let utilizationInt = Double(rawUtilization), utilizationInt.isFinite {
let limitType = match.count > 3 ? (match[3] ?? "") : ""
return .limitWarning(utilization: utilizationInt / 100, endsAt: timestamp, limitType: limitType)
}
}
return nil
}
/// Port of `parseMessageAsEvent`.
func parseMessageAsEvent(_ msg: NormalizedMessage) -> AgentEvent? {
if msg.isSidechain { return nil }
guard let content = msg.agentContent else { return nil }
for item in content {
if case .text(let text) = item {
if let limitEvent = parseClaudeUsageLimit(text.text) {
return limitEvent
}
}
}
return nil
}
/// Port of `dedupeAgentEvents`: drops a consecutive repeat of the same
/// title-changed/message/error event (and, generically, any byte-identical
/// event), plus a message event echoing the immediately preceding
/// title-changed title.
public func dedupeAgentEvents(_ blocks: [ChatBlock]) -> [ChatBlock] {
var result: [ChatBlock] = []
var prevEventKey: String?
var prevTitleChangedTo: String?
for block in blocks {
guard case .agentEvent(let eventBlock) = block else {
result.append(block)
prevEventKey = nil
prevTitleChangedTo = nil
continue
}
let event = eventBlock.event
if event.type == "title-changed", let title = event["title"]?.stringValue {
let trimmed = title.jsTrimmed
let key = "title-changed:\(trimmed)"
if key == prevEventKey {
continue
}
result.append(block)
prevEventKey = key
prevTitleChangedTo = trimmed
continue
}
if event.type == "message", let message = event["message"]?.stringValue {
let trimmed = message.jsTrimmed
let key = "message:\(trimmed)"
if key == prevEventKey {
continue
}
if let prevTitle = prevTitleChangedTo, trimmed == prevTitle {
continue
}
result.append(block)
prevEventKey = key
prevTitleChangedTo = nil
continue
}
if event.type == "error", let message = event["message"]?.stringValue {
let trimmed = message.jsTrimmed
let key = "error:\(trimmed)"
if key == prevEventKey {
continue
}
result.append(block)
prevEventKey = key
prevTitleChangedTo = nil
continue
}
// Generic key: TS uses JSON.stringify(event); the canonical
// serialization is its deterministic stand-in (fixture inputs are
// key-sorted, so the reference's insertion order is sorted too).
let key = "event:\(toCanonicalJSON(event.wireValue))"
if key == prevEventKey {
continue
}
result.append(block)
prevEventKey = key
prevTitleChangedTo = nil
}
return result
}
/// Port of `foldApiErrorEvents`: consecutive api-error events keep only the
/// latest state.
public func foldApiErrorEvents(_ blocks: [ChatBlock]) -> [ChatBlock] {
var result: [ChatBlock] = []
for block in blocks {
guard case .agentEvent(let eventBlock) = block else {
result.append(block)
continue
}
if eventBlock.event.type != "api-error" {
result.append(block)
continue
}
if let last = result.last, case .agentEvent(let prev) = last, prev.event.type == "api-error" {
result[result.count - 1] = block
} else {
result.append(block)
}
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
import Foundation
// Port of web/src/chat/reducerTools.ts.
//
// The TS reducer mutates block objects shared by reference between the
// timeline array and the by-id map. `BlockBox` reproduces that: a reference
// wrapper around the value-typed `ChatBlock`, mutated in place through the
// settable case accessors (`box.block.asToolCall?.tool.state = …`).
/// Reference cell for one timeline block during reduction. The final
/// pipeline output materializes back to plain `[ChatBlock]`.
final class BlockBox {
var block: ChatBlock
init(_ block: ChatBlock) {
self.block = block
}
}
extension Array where Element == BlockBox {
var materialized: [ChatBlock] {
map(\.block)
}
}
/// Port of `PermissionEntry`.
struct PermissionEntry {
var toolName: String
/// `nil` == the request carried no arguments (TS `undefined`).
var input: JSONValue?
var permission: ToolPermission
}
/// Ordered stand-in for the TS `Map<string, PermissionEntry>`.
///
/// The reference iterates JS object keys in insertion order; fixture inputs
/// are canonically serialized (recursively sorted keys) and `JSON.parse`
/// preserves that order, so the order the reference actually observes when
/// replaying fixtures is ascending key order — which is what this map
/// imposes deterministically (completed requests first, then pending,
/// each sorted by request id).
struct PermissionMap {
private(set) var order: [String] = []
private(set) var byId: [String: PermissionEntry] = [:]
subscript(id: String) -> PermissionEntry? {
byId[id]
}
func has(_ id: String) -> Bool {
byId[id] != nil
}
mutating func set(_ id: String, _ entry: PermissionEntry) {
if byId[id] == nil { order.append(id) }
byId[id] = entry
}
var entriesInOrder: [(id: String, entry: PermissionEntry)] {
order.map { ($0, byId[$0]!) }
}
}
/// Port of `getPermissions` — completed requests first (they win the id),
/// then pending requests.
func getPermissions(_ agentState: AgentState?) -> PermissionMap {
var map = PermissionMap()
if let completed = agentState?.completedRequests {
for id in completed.keys.sorted(by: { $0.utf16.lexicographicallyPrecedes($1.utf16) }) {
let entry = completed[id]!
let status: ToolPermissionStatus = {
switch entry.status {
case .canceled: return .canceled
case .denied: return .denied
case .approved: return .approved
}
}()
map.set(id, PermissionEntry(
toolName: entry.tool,
input: entry.arguments,
permission: ToolPermission(
id: id,
status: status,
reason: entry.reason,
mode: entry.mode,
allowedTools: entry.allowTools,
decision: entry.decision,
answers: entry.answers,
createdAt: entry.createdAt,
completedAt: entry.completedAt,
// Keys the TS object literal carries (spread overwrites
// these on merge even when the value is undefined);
// `date` is absent from this construction site.
presentKeys: [.reason, .mode, .decision, .allowedTools, .answers, .createdAt, .completedAt]
)
))
}
}
if let requests = agentState?.requests {
for id in requests.keys.sorted(by: { $0.utf16.lexicographicallyPrecedes($1.utf16) }) {
if map.has(id) { continue }
let request = requests[id]!
map.set(id, PermissionEntry(
toolName: request.tool,
input: request.arguments,
permission: ToolPermission(
id: id,
status: .pending,
createdAt: request.createdAt,
presentKeys: [.createdAt]
)
))
}
}
return map
}
/// Seed for `ensureToolBlock` — mirrors the TS parameter object. Optional
/// fields left `nil` reproduce an absent/`undefined` TS property.
struct ToolBlockSeed {
var createdAt: Int
var invokedAt: Int?
var durationMs: Int?
var usage: UsageData?
var model: String?
var localId: String?
var meta: JSONValue?
var name: String
var input: JSONValue?
var description: String?
var nativeTitle: String?
var nativeKind: String?
var progress: JSONValue?
var permission: ToolPermission?
var agentTimestamp: Int?
init(
createdAt: Int,
invokedAt: Int? = nil,
durationMs: Int? = nil,
usage: UsageData? = nil,
model: String? = nil,
localId: String? = nil,
meta: JSONValue? = nil,
name: String,
input: JSONValue? = nil,
description: String? = nil,
nativeTitle: String? = nil,
nativeKind: String? = nil,
progress: JSONValue? = nil,
permission: ToolPermission? = nil,
agentTimestamp: Int? = nil
) {
self.createdAt = createdAt
self.invokedAt = invokedAt
self.durationMs = durationMs
self.usage = usage
self.model = model
self.localId = localId
self.meta = meta
self.name = name
self.input = input
self.description = description
self.nativeTitle = nativeTitle
self.nativeKind = nativeKind
self.progress = progress
self.permission = permission
self.agentTimestamp = agentTimestamp
}
}
private func isPlaceholderToolName(_ name: String) -> Bool {
let normalized = name.jsTrimmed.lowercased()
// 'generic' is agy's non-specific result wrapper — never let it
// overwrite a specific name set by the permission request.
return normalized.isEmpty || normalized == "tool" || normalized == "unknown" || normalized == "generic"
}
/// Port of `ensureToolBlock`: create-or-merge the tool card for `id`.
/// Mutates the shared box in place; appends a new box to `blocks` (and the
/// map) when the id is unseen.
@discardableResult
func ensureToolBlock(
_ blocks: inout [BlockBox],
_ toolBlocksById: inout [String: BlockBox],
id: String,
seed: ToolBlockSeed
) -> BlockBox {
if let existingBox = toolBlocksById[id], var existing = existingBox.block.asToolCall {
// Preserve earliest createdAt for stable ordering.
if seed.createdAt < existing.createdAt {
existing.createdAt = seed.createdAt
existing.tool.createdAt = seed.createdAt
}
if let seedPermission = seed.permission {
let nextPermission = ToolPermission.spreading(existing.tool.permission, with: seedPermission)
var nextState = existing.tool.state
if existing.tool.state == .running && seedPermission.status == .pending {
nextState = .pending
}
existing.tool.permission = nextPermission
existing.tool.state = nextState
}
if !seed.name.isEmpty && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name)) {
existing.tool.name = seed.name
}
if let input = seed.input, input != .null {
existing.tool.input = input
}
if let description = seed.description {
existing.tool.description = description
}
if let nativeTitle = seed.nativeTitle {
existing.tool.nativeTitle = nativeTitle
}
if let nativeKind = seed.nativeKind {
existing.tool.nativeKind = nativeKind
}
if let progress = seed.progress, existing.tool.state == .running {
existing.tool.result = progress
}
// The tool_use records when the tool was invoked; a later
// tool_result must not overwrite it.
if let invokedAt = seed.invokedAt, existing.invokedAt == nil {
existing.invokedAt = invokedAt
}
if let durationMs = seed.durationMs {
existing.durationMs = durationMs
}
if let usage = seed.usage {
existing.usage = usage
}
if let model = seed.model {
existing.model = model
}
existingBox.block = .toolCall(existing)
return existingBox
}
let initialState: ToolCallState = {
if seed.permission?.status == .pending { return .pending }
if seed.permission?.status == .denied || seed.permission?.status == .canceled { return .error }
return .running
}()
let tool = ChatToolCall(
id: id,
name: seed.name,
state: initialState,
input: seed.input,
createdAt: seed.createdAt,
startedAt: initialState == .running ? seed.createdAt : nil,
completedAt: nil,
// Exec start is only ever a real Claude entry timestamp (never the
// hub receive time); nil keeps the hub-time fallback.
execStartedAt: initialState == .running ? seed.agentTimestamp : nil,
execCompletedAt: nil,
description: seed.description,
nativeTitle: seed.nativeTitle,
nativeKind: seed.nativeKind,
result: seed.progress,
permission: seed.permission
)
let block = ToolCallBlock(
id: id,
localId: seed.localId,
createdAt: seed.createdAt,
invokedAt: seed.invokedAt,
durationMs: seed.durationMs,
usage: seed.usage,
model: seed.model,
tool: tool,
children: [],
meta: seed.meta
)
let box = BlockBox(.toolCall(block))
toolBlocksById[id] = box
blocks.append(box)
return box
}
/// Port of `collectToolIdsFromMessages`.
func collectToolIdsFromMessages(_ messages: [NormalizedMessage]) -> Set<String> {
var ids = Set<String>()
for msg in messages {
guard let content = msg.agentContent else { continue }
for item in content {
switch item {
case .toolCall(let toolCall):
ids.insert(toolCall.id)
case .toolResult(let toolResult):
ids.insert(toolResult.toolUseId)
default:
break
}
}
}
return ids
}
/// Port of `isChangeTitleToolName`.
func isChangeTitleToolName(_ name: String) -> Bool {
name == "mcp__hapi__change_title" || name == "hapi__change_title"
}
/// Port of `extractTitleFromChangeTitleInput`.
func extractTitleFromChangeTitleInput(_ input: JSONValue?) -> String? {
guard let title = input?.objectValue?["title"]?.stringValue else { return nil }
let trimmed = title.jsTrimmed
return trimmed.isEmpty ? nil : trimmed
}
/// Port of `collectTitleChanges` — tool_use id → requested title.
func collectTitleChanges(_ messages: [NormalizedMessage]) -> [String: String] {
var map: [String: String] = [:]
for msg in messages {
guard let content = msg.agentContent else { continue }
for item in content {
guard case .toolCall(let toolCall) = item else { continue }
guard isChangeTitleToolName(toolCall.name) else { continue }
guard let title = extractTitleFromChangeTitleInput(toolCall.input) else { continue }
map[toolCall.id] = title
}
}
return map
}
@@ -0,0 +1,387 @@
import Foundation
// Port of web/src/chat/toolGroups.ts — collapsing runs of low-signal tool
// calls into tool-group blocks, plus the codex-exploration grouping family
// (which needs the CodexBash command_actions parsing from
// web/src/chat/codexCommandPresentation.ts, ported in ToolPresentation.swift).
// MARK: - Tool input access (web/src/lib/toolInputUtils.ts)
/// Port of `getInputString` — non-string values yield nil; the `Any` variant
/// additionally skips empty strings (JS truthiness).
func getInputString(_ input: JSONValue?, _ key: String) -> String? {
input?.objectValue?[key]?.stringValue
}
/// Port of `getInputStringAny`.
func getInputStringAny(_ input: JSONValue?, _ keys: [String]) -> String? {
for key in keys {
if let value = getInputString(input, key), !value.isEmpty {
return value
}
}
return nil
}
// MARK: - Group model
public enum ToolGroupActionKind: String, Equatable, Sendable, CaseIterable {
case read
case search
case command
case mutation
case web
case other
}
/// Port of `ToolGroupSummary`.
public struct ToolGroupSummary: Equatable, Sendable {
public var totalTools: Int
public var countsByKind: [ToolGroupActionKind: Int]
public var fileTargets: [String]
public var commandTargets: [String]
public var searchTargets: [String]
public var urlTargets: [String]
public var otherTargets: [String]
public var errorCount: Int
public var runningCount: Int
public var pendingCount: Int
}
/// Port of `ToolGroupBlock`.
public struct ToolGroupBlock: Equatable, Sendable {
public var id: String
public var createdAt: Int
public var invokedAt: Int?
public var firstToolId: String
public var lastToolId: String
public var tools: [ToolCallBlock]
public var defaultOpen: Bool
public var historyState: HistoryState
public var needsOlderHistory: Bool
public var activityTitle: String?
public var presentationMode: PresentationMode
public var summary: ToolGroupSummary
public enum HistoryState: String, Equatable, Sendable {
case complete
case needsOlderHistory = "needs-older-history"
}
public enum PresentationMode: String, Equatable, Sendable {
case `default`
case codexExploration = "codex-exploration"
}
}
/// Port of `VisibleChatBlock`.
public enum VisibleChatBlock: Equatable, Sendable {
case block(ChatBlock)
case toolGroup(ToolGroupBlock)
}
public enum VisibleChatBlockRole: String, Equatable, Sendable {
case user
case assistant
case system
}
/// Port of `visibleBlockRole`.
public func visibleBlockRole(_ block: VisibleChatBlock) -> VisibleChatBlockRole {
switch block {
case .toolGroup:
return .assistant
case .block(let chatBlock):
switch chatBlock {
case .userText: return .user
case .agentEvent: return .system
case .cliOutput(let cliOutput): return cliOutput.source == .user ? .user : .assistant
default: return .assistant
}
}
}
/// Port of `ToolGroupingOptions`.
public struct ToolGroupingOptions: Sendable {
public var hasMoreMessages: Bool
public var previousGroups: [ToolGroupBlock]
/// Tri-state like the TS optional boolean: only an explicit `false`
/// opens codex-exploration groups by default.
public var codexExplorationCollapsed: Bool?
public init(hasMoreMessages: Bool, previousGroups: [ToolGroupBlock] = [], codexExplorationCollapsed: Bool? = nil) {
self.hasMoreMessages = hasMoreMessages
self.previousGroups = previousGroups
self.codexExplorationCollapsed = codexExplorationCollapsed
}
}
// MARK: - Name tables
private let planToolNames: Set<String> = [
"TodoWrite",
"update_plan",
"ExitPlanMode",
"exit_plan_mode",
"CodexReasoning",
]
private let milestoneToolNames: Set<String> = [
"Task",
"Agent",
"CodexAgent",
"TeamCreate",
"TeamDelete",
"SendMessage",
"AgyTaskLog",
"Skill",
"spawn_agent",
"send_input",
"send_message",
"resume_agent",
"followup_task",
"wait_agent",
"close_agent",
"interrupt_agent",
"list_agents",
]
private let interactiveToolNames: Set<String> = [
"CodexPermission",
]
/// Port of `isAskUserQuestionToolName` (web ToolCard/askUserQuestion.ts).
public func isAskUserQuestionToolName(_ toolName: String) -> Bool {
toolName == "AskUserQuestion" || toolName == "ask_user_question" || toolName == "CursorAskQuestion"
}
/// Port of `isRequestUserInputToolName` (web ToolCard/requestUserInput.ts).
public func isRequestUserInputToolName(_ toolName: String) -> Bool {
toolName == "request_user_input"
}
// MARK: - Summaries
private func pushUnique(_ target: inout [String], _ value: String?) {
guard let value, !value.isEmpty else { return }
if target.contains(value) { return }
target.append(value)
}
/// Port of `normalizeCommandInput`.
private func normalizeCommandInput(_ input: JSONValue?) -> String? {
if let direct = getInputStringAny(input, ["command", "cmd"]) { return direct }
guard let command = input?.objectValue?["command"]?.arrayValue else { return nil }
let parts = command.compactMap { part -> String? in
guard let text = part.stringValue, !text.isEmpty else { return nil }
return text
}
return parts.isEmpty ? nil : parts.joined(separator: " ")
}
/// Port of `getToolGroupActionKind`.
public func getToolGroupActionKind(_ block: ToolCallBlock) -> ToolGroupActionKind {
let name = block.tool.name
if name == "Read" || name == "NotebookRead" { return .read }
if name == "Grep" || name == "Glob" || name == "LS" { return .search }
if name == "Bash" || name == "CodexBash" || name == "shell_command" || name == "run_shell_command" { return .command }
if name == "Edit" || name == "MultiEdit" || name == "Write" || name == "NotebookEdit"
|| name == "CodexPatch" || name == "CodexDiff" {
return .mutation
}
if name == "WebFetch" || name == "WebSearch" { return .web }
return .other
}
private func getPrimaryFileTarget(_ block: ToolCallBlock) -> String? {
getInputStringAny(block.tool.input, ["file_path", "path", "file", "filePath", "notebook_path", "name"])
}
private func getPrimarySearchTarget(_ block: ToolCallBlock) -> String? {
getInputStringAny(block.tool.input, ["pattern", "query"])
}
private func getPrimaryUrlTarget(_ block: ToolCallBlock) -> String? {
getInputStringAny(block.tool.input, ["url"])
}
private func getPrimaryOtherTarget(_ block: ToolCallBlock) -> String? {
if let fileTarget = getPrimaryFileTarget(block) { return fileTarget }
if let searchTarget = getPrimarySearchTarget(block) { return searchTarget }
if let commandTarget = normalizeCommandInput(block.tool.input) { return commandTarget }
if let urlTarget = getPrimaryUrlTarget(block) { return urlTarget }
return block.tool.name
}
/// Port of `summarizeToolGroup`.
private func summarizeToolGroup(_ tools: [ToolCallBlock]) -> ToolGroupSummary {
var countsByKind: [ToolGroupActionKind: Int] = [
.read: 0, .search: 0, .command: 0, .mutation: 0, .web: 0, .other: 0,
]
var fileTargets: [String] = []
var commandTargets: [String] = []
var searchTargets: [String] = []
var urlTargets: [String] = []
var otherTargets: [String] = []
var errorCount = 0
var runningCount = 0
var pendingCount = 0
for tool in tools {
let kind = getToolGroupActionKind(tool)
countsByKind[kind, default: 0] += 1
switch tool.tool.state {
case .error: errorCount += 1
case .running: runningCount += 1
case .pending: pendingCount += 1
case .completed: break
}
if kind == .read || kind == .mutation {
pushUnique(&fileTargets, getPrimaryFileTarget(tool))
continue
}
if kind == .search {
pushUnique(&searchTargets, getPrimarySearchTarget(tool))
continue
}
if kind == .command {
pushUnique(&commandTargets, normalizeCommandInput(tool.tool.input))
continue
}
if kind == .web {
pushUnique(&urlTargets, getPrimaryUrlTarget(tool) ?? getPrimarySearchTarget(tool))
continue
}
pushUnique(&otherTargets, getPrimaryOtherTarget(tool))
}
return ToolGroupSummary(
totalTools: tools.count,
countsByKind: countsByKind,
fileTargets: fileTargets,
commandTargets: commandTargets,
searchTargets: searchTargets,
urlTargets: urlTargets,
otherTargets: otherTargets,
errorCount: errorCount,
runningCount: runningCount,
pendingCount: pendingCount
)
}
// MARK: - Eligibility
private func isInteractiveToolBlock(_ block: ToolCallBlock) -> Bool {
interactiveToolNames.contains(block.tool.name)
|| block.tool.permission?.status == .pending
|| isAskUserQuestionToolName(block.tool.name)
|| isRequestUserInputToolName(block.tool.name)
}
/// Port of `isEligibleForToolGrouping`.
public func isEligibleForToolGrouping(_ block: ToolCallBlock) -> Bool {
if isSubagentToolName(block.tool.name) { return false }
if planToolNames.contains(block.tool.name) { return false }
if milestoneToolNames.contains(block.tool.name) { return false }
if isInteractiveToolBlock(block) { return false }
if block.tool.name == "CodexBash" && !getCodexCommandActions(block).isEmpty {
return isCodexExplorationTool(block)
}
return true
}
private func getGroupingFamily(_ block: ToolCallBlock) -> ToolGroupBlock.PresentationMode? {
guard isEligibleForToolGrouping(block) else { return nil }
return isCodexExplorationTool(block) ? .codexExploration : .default
}
/// Port of `createToolGroupId` — reuses a previous group's id when the run
/// still shares a boundary tool with it (scroll-anchor stability).
private func createToolGroupId(
tools: [ToolCallBlock],
needsOlderHistory: Bool,
previousGroups: [ToolGroupBlock]
) -> String {
let firstToolId = tools.first?.id ?? "unknown"
let lastToolId = tools.last?.id ?? firstToolId
if let previous = previousGroups.first(where: { $0.firstToolId == firstToolId || $0.lastToolId == lastToolId }) {
return previous.id
}
return needsOlderHistory
? "tool-group:\(lastToolId)"
: "tool-group:\(firstToolId)"
}
// MARK: - buildVisibleChatBlocks
/// Port of `buildVisibleChatBlocks`.
public func buildVisibleChatBlocks(
_ blocks: [ChatBlock],
options: ToolGroupingOptions
) -> [VisibleChatBlock] {
var visibleBlocks: [VisibleChatBlock] = []
let previousGroups = options.previousGroups
var index = 0
while index < blocks.count {
let block = blocks[index]
guard case .toolCall(let toolBlock) = block else {
visibleBlocks.append(.block(block))
index += 1
continue
}
guard let groupingFamily = getGroupingFamily(toolBlock) else {
visibleBlocks.append(.block(block))
index += 1
continue
}
var tools: [ToolCallBlock] = [toolBlock]
var cursor = index + 1
while cursor < blocks.count {
guard case .toolCall(let candidate) = blocks[cursor],
getGroupingFamily(candidate) == groupingFamily else {
break
}
tools.append(candidate)
cursor += 1
}
if tools.count < 2 && groupingFamily != .codexExploration {
visibleBlocks.append(.block(block))
index += 1
continue
}
let startsAtOldestVisibleBoundary = visibleBlocks.isEmpty
let needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary
let activityTitle: String? = {
guard case .block(.toolCall(let previousBlock))? = visibleBlocks.last,
previousBlock.tool.name == "CodexReasoning" else { return nil }
return getInputStringAny(previousBlock.tool.input, ["title"])
}()
visibleBlocks.append(.toolGroup(ToolGroupBlock(
id: createToolGroupId(tools: tools, needsOlderHistory: needsOlderHistory, previousGroups: previousGroups),
createdAt: tools[0].createdAt,
invokedAt: tools[0].invokedAt,
firstToolId: tools[0].id,
lastToolId: tools[tools.count - 1].id,
tools: tools,
defaultOpen: groupingFamily == .codexExploration && options.codexExplorationCollapsed == false,
historyState: needsOlderHistory ? .needsOlderHistory : .complete,
needsOlderHistory: needsOlderHistory,
activityTitle: activityTitle,
presentationMode: groupingFamily,
summary: summarizeToolGroup(tools)
)))
index = cursor
}
return visibleBlocks
}
@@ -0,0 +1,143 @@
import Foundation
// Tool presentation helpers.
//
// Part 1 ports web/src/chat/codexCommandPresentation.ts — REQUIRED by the
// grouping pipeline (`isEligibleForToolGrouping` keys the codex-exploration
// family off these), so it is covered by the golden fixtures.
//
// Part 2 is a fixture-irrelevant convenience for the M2 UI milestones: a
// compact per-tool title/subtitle derivation. The web's full presentation
// catalog (web/src/components/ToolCard/knownTools.tsx, including the rich
// codex command-action rendering) is deliberately deferred to the HapiUI
// work — nothing here feeds the normative projection.
// MARK: - Codex command actions (fixture-relevant)
/// Port of `CodexCommandAction`.
public enum CodexCommandAction: Equatable, Sendable {
case read(command: String, name: String, path: String)
case listFiles(command: String, path: String?)
case search(command: String, query: String?, path: String?)
case unknown(command: String)
}
private func nonEmptyString(_ value: JSONValue?) -> String? {
guard let text = value?.stringValue, !text.isEmpty else { return nil }
return text
}
/// Port of `parseAction`.
private func parseCodexCommandAction(_ value: JSONValue) -> CodexCommandAction? {
guard let action = value.objectValue else { return nil }
guard let type = nonEmptyString(action["type"]), let command = nonEmptyString(action["command"]) else { return nil }
if type == "read" {
guard let name = nonEmptyString(action["name"]), let path = nonEmptyString(action["path"]) else { return nil }
return .read(command: command, name: name, path: path)
}
if type == "listFiles" {
return .listFiles(command: command, path: nonEmptyString(action["path"]))
}
if type == "search" {
return .search(command: command, query: nonEmptyString(action["query"]), path: nonEmptyString(action["path"]))
}
if type == "unknown" {
return .unknown(command: command)
}
return nil
}
/// Port of `getCodexCommandActions`.
public func getCodexCommandActions(_ block: ToolCallBlock) -> [CodexCommandAction] {
guard block.tool.name == "CodexBash", let input = block.tool.input?.objectValue else {
return []
}
let raw = jsCoalesce(input["command_actions"], input["commandActions"])
guard let items = raw?.arrayValue else { return [] }
return items.compactMap(parseCodexCommandAction)
}
/// Port of `isCodexExplorationTool`.
public func isCodexExplorationTool(_ block: ToolCallBlock) -> Bool {
let input = block.tool.input?.objectValue
let source = input.flatMap { nonEmptyString(jsCoalesce($0["command_source"], $0["commandSource"])) }
if source?.lowercased() == "usershell" { return false }
let actions = getCodexCommandActions(block)
guard !actions.isEmpty else { return false }
return actions.allSatisfy { action in
switch action {
case .read, .listFiles, .search: return true
case .unknown: return false
}
}
}
// MARK: - Per-tool title/subtitle (presentation-only, not fixture-covered)
/// Minimal per-tool card labeling for native list rows. Mirrors the common
/// cases of the web `knownTools` catalog; the full catalog (icons, rich
/// views, codex command-action lines) lands with the HapiUI chat views.
public struct ToolPresentation: Equatable, Sendable {
public var title: String
public var subtitle: String?
public static func forTool(_ tool: ChatToolCall) -> ToolPresentation {
let input = tool.input
switch tool.name {
case "Bash", "CodexBash", "shell_command", "run_shell_command":
return ToolPresentation(
title: tool.description ?? "Terminal",
subtitle: getInputStringAny(input, ["command", "cmd"])
)
case "Read", "NotebookRead":
return ToolPresentation(
title: "Read",
subtitle: getInputStringAny(input, ["file_path", "path", "notebook_path"])
)
case "Write":
return ToolPresentation(title: "Write", subtitle: getInputStringAny(input, ["file_path", "path"]))
case "Edit", "MultiEdit":
return ToolPresentation(title: "Edit", subtitle: getInputStringAny(input, ["file_path", "path"]))
case "Grep":
return ToolPresentation(title: "Grep", subtitle: getInputStringAny(input, ["pattern", "query"]))
case "Glob":
return ToolPresentation(title: "Glob", subtitle: getInputStringAny(input, ["pattern"]))
case "LS":
return ToolPresentation(title: "List files", subtitle: getInputStringAny(input, ["path"]))
case "WebFetch", "WebSearch":
return ToolPresentation(
title: tool.name == "WebFetch" ? "Fetch" : "Web search",
subtitle: getInputStringAny(input, ["url", "query"])
)
case "Task", "Agent":
return ToolPresentation(
title: getInputStringAny(input, ["description"]) ?? "Subagent",
subtitle: getInputStringAny(input, ["subagent_type"])
)
case "TodoWrite", "update_plan":
return ToolPresentation(title: "Plan", subtitle: nil)
case "AskUserQuestion", "ask_user_question", "CursorAskQuestion":
return ToolPresentation(title: "Question", subtitle: nil)
case "request_user_input":
return ToolPresentation(title: "Input requested", subtitle: nil)
case "CodexAgent":
return ToolPresentation(
title: "Agent run",
subtitle: getInputStringAny(input, ["statusText", "summary", "activity"])
)
case agyAsyncTaskTool:
return ToolPresentation(title: "Background task", subtitle: tool.description)
case agyErrorTool:
return ToolPresentation(title: "Invalid tool call", subtitle: tool.description)
case agyTaskLogTool:
return ToolPresentation(title: "Task log", subtitle: getInputStringAny(input, ["task"]))
default:
return ToolPresentation(
title: tool.nativeTitle ?? tool.description ?? tool.name,
subtitle: getInputStringAny(input, ["file_path", "path", "pattern", "query", "command", "url"])
)
}
}
}
@@ -0,0 +1,129 @@
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
}
@@ -0,0 +1,146 @@
import Foundation
import HapiProtocol
import Testing
/// Golden conformance suite for the chat pipeline port: every fixture in
/// `shared/fixtures/chat/` is decoded, run through the ported pipeline
/// (normalize → reduce → group → normative projection), serialized to
/// canonical JSON and compared byte-for-byte against the stored `expected`.
///
/// One parameterized test case per fixture file, so a red CI run names the
/// exact fixtures that diverge; on mismatch the failure message carries a
/// line-level diff (first divergence with context) to make the macOS CI
/// output directly actionable from a Linux dev box.
@Suite("Chat pipeline golden fixtures")
struct ChatFixtureTests {
/// Highest fixture document schema this suite understands. Mirrors the
/// README rule: fail loudly when the on-disk version is newer.
private static let supportedFixtureVersion = 1
private struct ChatFixtureDocument: Decodable {
struct Input: Decodable {
let messages: [DecryptedMessage]
let agentState: AgentState?
let options: Options
}
struct Options: Decodable {
let hasMoreMessages: Bool
}
let fixtureVersion: Int
let name: String
let input: Input
let expected: JSONValue
}
/// Repo-root `shared/fixtures`, resolved from this file's own location
/// (same scheme as FixtureDecodingTests):
/// `ios/Packages/HapiKit/Tests/HapiProtocolTests/…` → package root →
/// `../../../shared/fixtures`.
private static func fixturesDirectory() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/HapiProtocolTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // package root: ios/Packages/HapiKit
.appendingPathComponent("../../../shared/fixtures")
.standardizedFileURL
}
/// Sorted fixture file names — the parameterized-test argument list.
/// Missing directory yields an empty list, which the guard test flags.
static let chatFixtureNames: [String] = {
let chatDirectory = fixturesDirectory().appendingPathComponent("chat")
let contents = (try? FileManager.default.contentsOfDirectory(
at: chatDirectory,
includingPropertiesForKeys: nil
)) ?? []
return contents
.filter { $0.pathExtension == "json" }
.map { $0.lastPathComponent }
.sorted()
}()
private static func fixtureURL(_ fileName: String) -> URL {
fixturesDirectory().appendingPathComponent("chat").appendingPathComponent(fileName)
}
@Test func hasChatFixturesOnDisk() {
#expect(
!Self.chatFixtureNames.isEmpty,
"no chat fixtures found under \(Self.fixturesDirectory().path)"
)
}
@Test func fixturesVersionIsSupported() throws {
let versionFile = Self.fixturesDirectory().appendingPathComponent("VERSION")
let text = try String(contentsOf: versionFile, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
let version = try #require(Int(text), "VERSION must be a single integer, got: \(text)")
#expect(
version <= Self.supportedFixtureVersion,
"fixtures were regenerated with a newer schema (\(version)); update the pipeline port"
)
}
@Test("fixture", arguments: ChatFixtureTests.chatFixtureNames)
func fixtureMatchesPipeline(_ fileName: String) throws {
let data = try Data(contentsOf: Self.fixtureURL(fileName))
let document = try JSONDecoder().decode(ChatFixtureDocument.self, from: data)
#expect(document.fixtureVersion <= Self.supportedFixtureVersion,
"\(fileName) uses fixtureVersion \(document.fixtureVersion)")
#expect("\(document.name).json" == fileName, "\(fileName) name field mismatch")
let projected = runChatFixturePipeline(
messages: document.input.messages,
agentState: document.input.agentState,
hasMoreMessages: document.input.options.hasMoreMessages
)
let actual = toCanonicalJSON(projected)
let expected = toCanonicalJSON(document.expected)
if actual != expected {
Issue.record(Comment(rawValue: Self.diff(expected: expected, actual: actual, label: fileName)))
}
}
/// Readable line-level diff: the first differing line with surrounding
/// context from both documents, so the CI log pinpoints the divergence
/// without downloading artifacts.
private static func diff(expected: String, actual: String, label: String) -> String {
let expectedLines = expected.components(separatedBy: "\n")
let actualLines = actual.components(separatedBy: "\n")
let commonCount = min(expectedLines.count, actualLines.count)
var firstDiff = commonCount
for index in 0..<commonCount where expectedLines[index] != actualLines[index] {
firstDiff = index
break
}
if firstDiff == commonCount && expectedLines.count == actualLines.count {
return "\(label): documents differ but no differing line found (line-ending issue?)"
}
let contextStart = max(0, firstDiff - 3)
func window(_ lines: [String]) -> String {
let end = min(lines.count, firstDiff + 4)
guard contextStart < end else { return " <past end of document>" }
return lines[contextStart..<end].enumerated().map { offset, line in
let lineNumber = contextStart + offset + 1
let marker = (contextStart + offset) == firstDiff ? ">" : " "
return String(format: "%@ %4d | %@", marker, lineNumber, line)
}.joined(separator: "\n")
}
return """
\(label): projection mismatch at line \(firstDiff + 1) \
(expected \(expectedLines.count) lines, actual \(actualLines.count))
--- expected (fixture) ---
\(window(expectedLines))
--- actual (port) ---
\(window(actualLines))
"""
}
}
+22 -5
View File
@@ -63,9 +63,23 @@ ios/
(hapicompanion://bind?hub=&code= and the
web /?hub=&token= URL), form-decoding in
lockstep with the Android port
The chat pipeline + message window logic (ported from
web/src/chat/**) land in M2, validated against
shared/fixtures/**.
Chat/ the chat normalization/reduction pipeline
ported file-for-file from web/src/chat/**
(M2b+M2c): Normalize/NormalizeUser/
NormalizeAgent (decode tree incl. agy),
Tracer (sidechain grouping), Reducer*
(timeline, tool pairing, stream coalescing,
agent-run cards, events dedupe/folding,
cli-output merge), ToolGroups (+ codex
exploration family), the normative fixture
projection (FixtureProjection), and the
JS-semantics interop layer (JSInterop:
nullish coalescing, truthiness, canonical
JSON serializer). Validated block-for-block
against shared/fixtures/chat/** by
ChatFixtureTests (one parameterized test
per fixture, line-level diff on mismatch).
The message window logic (M2d) lands next.
HapiClient Transport layer. As of M1b+M1c+M1d:
APIClient typed REST client (Endpoints/*):
Bearer auth, 401 -> refresh ->
@@ -140,8 +154,11 @@ with `swift test` and free of UI concerns.
suite needs a full repo checkout. Since M1a, `FixtureDecodingTests` decodes
every `chat/*.json` input as `[DecryptedMessage]` (+ `AgentState`), and
`CatalogTests` verifies the ported mode tables against
`catalogs/modes.json`; the full pipeline conformance against each fixture's
`expected` projection is the M2 gate.
`catalogs/modes.json`. Since M2b/M2c, `ChatFixtureTests` is the pipeline
gate: for every chat fixture it runs the ported normalize → reduce → group
pipeline over the stored `input`, applies the normative projection, and
compares canonical JSON byte-for-byte against the stored `expected` —
failures are per-fixture and print the first differing line with context.
## Pairing (M1d)