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

1268 lines
51 KiB
Swift

import Foundation
// Port of web/src/chat/normalizeAgent.ts (1:1 function mapping, same order).
/// `AGENT_MESSAGE_PAYLOAD_TYPE` (shared/src/modes.ts) — the generic agent
/// envelope used by the non-Claude-SDK flavors.
let agentMessagePayloadType = "codex"
// MARK: - Small helpers
/// Port of `parseAgentTimestampMs` (web/src/chat/agentTimestamp.ts):
/// ISO-8601 `timestamp` → epoch ms; `nil` when missing/unparseable.
func parseAgentTimestampMs(_ value: JSONValue?) -> Int? {
guard let text = value?.stringValue, !text.jsTrimmed.isEmpty else { return nil }
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
guard let date = withFraction.date(from: text) ?? plain.date(from: text) else { return nil }
return Int((date.timeIntervalSince1970 * 1000).rounded())
}
/// Port of `normalizeToolResultPermissions`.
func normalizeToolResultPermissions(_ value: JSONValue?) -> ToolResultPermission? {
guard let object = value?.objectValue else { return nil }
guard let date = asNumber(object["date"]) else { return nil }
guard let rawResult = object["result"]?.stringValue,
let result = ToolResultPermission.Result(rawValue: rawResult) else { return nil }
let mode = asString(object["mode"])
let allowedTools: [String]? = {
guard let items = object["allowedTools"]?.arrayValue else { return nil }
return items.compactMap(\.stringValue)
}()
let decision: ToolPermissionDecision? = {
guard let raw = object["decision"]?.stringValue else { return nil }
return ToolPermissionDecision(rawValue: raw)
}()
return ToolResultPermission(
date: date,
result: result,
mode: mode,
allowedTools: allowedTools,
decision: decision
)
}
/// Port of `normalizeAgentEvent`: any record with a string `type` is an
/// event, carried verbatim.
func normalizeAgentEvent(_ value: JSONValue?) -> AgentEvent? {
guard let object = value?.objectValue, object["type"]?.stringValue != nil else { return nil }
return AgentEvent(object: object)
}
/// Port of `normalizeThreadGoal`. Returns the goal as the exact record the
/// reference constructs (`tokenBudget` explicitly null when absent).
func normalizeThreadGoal(_ value: JSONValue?) -> JSONValue? {
guard let object = value?.objectValue else { return nil }
let threadId = asString(jsCoalesce(object["threadId"], object["thread_id"]))
let objective = asString(object["objective"])
let status = asString(object["status"])
guard let threadId, !threadId.isEmpty, let objective, !objective.isEmpty,
let status, !status.isEmpty else { return nil }
switch status {
case "active", "paused", "budgetLimited", "usageLimited", "blocked", "complete":
break
default:
return nil
}
return .object([
"threadId": .string(threadId),
"objective": .string(objective),
"status": .string(status),
"tokenBudget": asNumber(jsCoalesce(object["tokenBudget"], object["token_budget"]))
.map { JSONValue.number($0) } ?? .null,
"tokensUsed": .number(asNumber(jsCoalesce(object["tokensUsed"], object["tokens_used"])) ?? 0),
"timeUsedSeconds": .number(asNumber(jsCoalesce(object["timeUsedSeconds"], object["time_used_seconds"])) ?? 0),
"createdAt": .number(asNumber(jsCoalesce(object["createdAt"], object["created_at"])) ?? 0),
"updatedAt": .number(asNumber(jsCoalesce(object["updatedAt"], object["updated_at"])) ?? 0),
])
}
/// Port of `normalizeCodexTokenUsage`.
func normalizeCodexTokenUsage(_ value: JSONValue?, data: [String: JSONValue]?) -> UsageData? {
guard let info = value, info.objectValue != nil || info.arrayValue != nil else { return nil }
// TS `isObject` is truthy for arrays too; property access on an array
// yields undefined, which the accessor below reproduces.
let scope: JSONValue? = {
guard let data, let scopeValue = data["scope"], scopeValue.jsIsObjectLike else { return nil }
return scopeValue
}()
func prop(_ base: JSONValue?, _ key: String) -> JSONValue? {
base?[key]
}
// Prefer `last` over `total`; fall back to the info record itself.
let usageSource: JSONValue = {
let candidates = ["last", "lastTokenUsage", "last_token_usage", "total", "totalTokenUsage", "total_token_usage"]
for key in candidates {
if let candidate = prop(info, key), candidate.jsIsObjectLike {
return candidate
}
}
return info
}()
guard let inputTokens = asNumber(jsCoalesce(prop(usageSource, "inputTokens"), prop(usageSource, "input_tokens"))),
let outputTokens = asNumber(jsCoalesce(prop(usageSource, "outputTokens"), prop(usageSource, "output_tokens")))
else { return nil }
let cacheRead = asNumber(jsCoalesce(
prop(usageSource, "cachedInputTokens"),
prop(usageSource, "cached_input_tokens"),
prop(usageSource, "cacheReadInputTokens"),
prop(usageSource, "cache_read_input_tokens")
))
let contextTokens = asNumber(jsCoalesce(
prop(info, "contextTokens"),
prop(info, "context_tokens"),
prop(usageSource, "contextTokens"),
prop(usageSource, "context_tokens")
)) ?? inputTokens
let contextWindow = asNumber(jsCoalesce(prop(info, "modelContextWindow"), prop(info, "model_context_window")))
let threadId = asString(jsCoalesce(
data?["thread_id"],
data?["threadId"],
prop(scope, "thread_id"),
prop(scope, "threadId"),
prop(info, "thread_id"),
prop(info, "threadId")
))
let scopeRole = asString(jsCoalesce(data?["scope_role"], data?["scopeRole"], prop(scope, "role")))
return UsageData(
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheCreationInputTokens: nil,
cacheReadInputTokens: cacheRead,
contextTokens: contextTokens,
contextWindow: contextWindow,
threadId: threadId,
scopeRole: scopeRole
)
}
/// Port of `normalizePlanStatus`.
func normalizePlanStatus(_ value: JSONValue?) -> String {
let raw: String = {
guard let text = value?.stringValue else { return "" }
var lowered = text.jsTrimmed.lowercased()
lowered = lowered.replacingOccurrences(of: " ", with: "_")
lowered = lowered.replacingOccurrences(of: "-", with: "_")
// JS `[\s-]` also folds tabs/newlines; whitespace inside a status
// token is not observed on this wire, but stay faithful.
lowered = lowered.replacingOccurrences(of: "\t", with: "_")
lowered = lowered.replacingOccurrences(of: "\n", with: "_")
lowered = lowered.replacingOccurrences(of: "\r", with: "_")
return lowered
}()
if raw == "completed" || raw == "complete" || raw == "done" { return "completed" }
if raw == "in_progress" || raw == "inprogress" || raw == "active" || raw == "running" { return "in_progress" }
return "pending"
}
/// Port of `normalizePlanEntries` — returns `{step, status}` records.
func normalizePlanEntries(_ value: JSONValue?) -> [JSONValue] {
let record = value?.objectValue
let entries: [JSONValue] = {
if let items = value?.arrayValue { return items }
if let items = record?["plan"]?.arrayValue { return items }
if let items = record?["items"]?.arrayValue { return items }
if let items = record?["steps"]?.arrayValue { return items }
return []
}()
var plan: [JSONValue] = []
for entry in entries {
if let text = entry.stringValue {
plan.append(.object(["step": .string(text), "status": .string("pending")]))
continue
}
guard let object = entry.objectValue else { continue }
let step = asString(object["step"])
?? asString(object["content"])
?? asString(object["text"])
?? asString(object["title"])
?? asString(object["description"])
guard let step, !step.isEmpty else { continue }
plan.append(.object([
"step": .string(step),
"status": .string(normalizePlanStatus(jsCoalesce(object["status"], object["state"]))),
]))
}
return plan
}
// MARK: - Codex review parsing
/// Port of `normalizeCodexReviewFinding`.
func normalizeCodexReviewFinding(_ value: JSONValue) -> CodexReviewFinding? {
guard let object = value.objectValue else { return nil }
guard let title = asString(object["title"]), !title.isEmpty,
let body = asString(object["body"]), !body.isEmpty else { return nil }
let codeLocation: [String: JSONValue]? =
object["code_location"]?.objectValue ?? object["codeLocation"]?.objectValue
let lineRange: [String: JSONValue]? = codeLocation.flatMap { location in
location["line_range"]?.objectValue ?? location["lineRange"]?.objectValue
}
return CodexReviewFinding(
title: title,
body: body,
priority: asNumber(object["priority"]),
confidenceScore: asNumber(jsCoalesce(object["confidence_score"], object["confidenceScore"])),
filePath: codeLocation.flatMap { location in
asString(jsCoalesce(location["absolute_file_path"], location["absoluteFilePath"], location["path"]))
},
lineStart: lineRange.flatMap { asNumber($0["start"]) },
lineEnd: lineRange.flatMap { asNumber($0["end"]) }
)
}
/// Port of `normalizeCodexReviewJson`.
func normalizeCodexReviewJson(_ value: JSONValue) -> CodexReview? {
guard let object = value.objectValue else { return nil }
let hasReviewMarker = object["findings"]?.arrayValue != nil
|| object.keys.contains("overall_correctness")
|| object.keys.contains("overallCorrectness")
|| object.keys.contains("overall_explanation")
|| object.keys.contains("overallExplanation")
guard hasReviewMarker else { return nil }
let findings: [CodexReviewFinding] = object["findings"]?.arrayValue?
.compactMap(normalizeCodexReviewFinding) ?? []
let overallCorrectness = asString(jsCoalesce(object["overall_correctness"], object["overallCorrectness"]))
let overallExplanation = asString(jsCoalesce(object["overall_explanation"], object["overallExplanation"]))
let overallConfidenceScore = asNumber(jsCoalesce(object["overall_confidence_score"], object["overallConfidenceScore"]))
// TS treats an empty-string correctness/explanation as falsy here.
let correctnessPresent = (overallCorrectness ?? "").isEmpty == false
let explanationPresent = (overallExplanation ?? "").isEmpty == false
if findings.isEmpty && !correctnessPresent && !explanationPresent && overallConfidenceScore == nil {
return nil
}
return CodexReview(
findings: findings,
overallCorrectness: overallCorrectness,
overallExplanation: overallExplanation,
overallConfidenceScore: overallConfidenceScore
)
}
/// Port of `parseCodexReviewMessage`.
func parseCodexReviewMessage(_ message: String) -> CodexReview? {
let trimmed = message.jsTrimmed
guard trimmed.hasPrefix("{") && trimmed.hasSuffix("}") else { return nil }
guard let parsed = try? JSONDecoder().decode(JSONValue.self, from: Data(trimmed.utf8)) else { return nil }
return normalizeCodexReviewJson(parsed)
}
// MARK: - Claude 'output' family
/// Port of `normalizeAssistantOutput`.
func normalizeAssistantOutput(
messageId: String,
localId: String?,
createdAt: Int,
data: [String: JSONValue],
meta: JSONValue?
) -> NormalizedMessage? {
let uuid = asString(data["uuid"]) ?? messageId
let parentUUID = asString(data["parentUuid"])
let isSidechain = data["isSidechain"]?.jsTruthy ?? false
let agentTimestamp = parseAgentTimestampMs(data["timestamp"])
let parentToolUseId = asString(data["parentToolUseId"])
guard let message = data["message"], message.jsIsObjectLike else { return nil }
let modelContent = message["content"]
var blocks: [NormalizedAgentContent] = []
if let text = modelContent?.stringValue {
blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID)))
} else if let items = modelContent?.arrayValue {
for block in items {
guard let object = block.objectValue, let type = object["type"]?.stringValue else { continue }
if type == "text", let text = object["text"]?.stringValue {
blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID)))
continue
}
if type == "thinking", let thinking = object["thinking"]?.stringValue {
blocks.append(.reasoning(.init(text: thinking, uuid: uuid, parentUUID: parentUUID)))
continue
}
if type == "tool_use", let id = object["id"]?.stringValue {
let name = asString(object["name"]) ?? "Tool"
let input = object["input"] // key-presence == TS `'input' in block`
let description: String? = {
guard let inputObject = input?.objectValue else { return nil }
return inputObject["description"]?.stringValue
}()
blocks.append(.toolCall(ToolUseContent(
id: id,
name: name,
input: input,
description: description,
uuid: uuid,
parentUUID: parentUUID
)))
}
}
}
let usage = message["usage"]?.objectValue
let inputTokens = usage.flatMap { asNumber($0["input_tokens"]) }
let outputTokens = usage.flatMap { asNumber($0["output_tokens"]) }
let model = asString(message["model"])
let usageData: UsageData? = {
guard let inputTokens, let outputTokens else { return nil }
return UsageData(
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheCreationInputTokens: usage.flatMap { asNumber($0["cache_creation_input_tokens"]) },
cacheReadInputTokens: usage.flatMap { asNumber($0["cache_read_input_tokens"]) },
contextWindow: usage.flatMap { asNumber($0["context_window"]) },
serviceTier: usage.flatMap { asString($0["service_tier"]) }
)
}()
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent(blocks),
isSidechain: isSidechain,
parentToolUseId: parentToolUseId,
meta: meta,
usage: usageData,
model: model,
agentTimestamp: agentTimestamp
)
}
/// Port of `normalizeUserOutput` — tool results, sidechain markers, and
/// CLI-wrapped real user messages arriving through the agent output path.
func normalizeUserOutput(
messageId: String,
localId: String?,
createdAt: Int,
data: [String: JSONValue],
meta: JSONValue?
) -> NormalizedMessage? {
let uuid = asString(data["uuid"]) ?? messageId
let parentUUID = asString(data["parentUuid"])
let isSidechain = data["isSidechain"]?.jsTruthy ?? false
let agentTimestamp = parseAgentTimestampMs(data["timestamp"])
let parentToolUseId = asString(data["parentToolUseId"])
guard let message = data["message"], message.jsIsObjectLike else { return nil }
let messageContent = message["content"]
// All string-content user messages here are system-injected (subagent
// prompts, task notifications, system reminders): emit as sidechain so
// the uuid/parentUUID chain is preserved (sidechain and non-sidechain
// alike — the two TS branches collapse to one shape).
if let prompt = messageContent?.stringValue {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.sidechain(.init(uuid: uuid, parentUUID: parentUUID, prompt: prompt))]),
isSidechain: true,
parentToolUseId: parentToolUseId,
agentTimestamp: agentTimestamp
)
}
// Sidechain array content serialized as [{type:'text', …}] — join the
// text parts and treat as the sidechain prompt.
if isSidechain, let items = messageContent?.arrayValue {
let textParts: [String] = items.compactMap { block in
guard let object = block.objectValue, object["type"] == .string("text") else { return nil }
return object["text"]?.stringValue
}
if !textParts.isEmpty {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.sidechain(.init(uuid: uuid, parentUUID: parentUUID, prompt: textParts.joined(separator: "\n\n")))]),
isSidechain: true,
parentToolUseId: parentToolUseId,
agentTimestamp: agentTimestamp
)
}
}
// Non-sidechain array content that is entirely text blocks: a real user
// message the CLI wrapped as agent output — render in the user lane.
if !isSidechain, let items = messageContent?.arrayValue {
let textParts: [String] = items.compactMap { block in
guard let object = block.objectValue, object["type"] == .string("text") else { return nil }
return object["text"]?.stringValue
}
if !textParts.isEmpty && textParts.count == items.count {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .user(text: textParts.joined(separator: "\n\n"), attachments: nil),
isSidechain: false,
meta: meta,
agentTimestamp: agentTimestamp
)
}
}
var blocks: [NormalizedAgentContent] = []
if let items = messageContent?.arrayValue {
for block in items {
guard let object = block.objectValue, let type = object["type"]?.stringValue else { continue }
if type == "text", let text = object["text"]?.stringValue {
blocks.append(.text(.init(text: text, uuid: uuid, parentUUID: parentUUID)))
continue
}
if type == "tool_result", let toolUseId = object["tool_use_id"]?.stringValue {
let isError = object["is_error"]?.jsTruthy ?? false
let rawContent = object["content"] // key presence == `'content' in block`
// `embeddedToolUseResult ?? rawContent` — an absent or null
// `data.toolUseResult` falls through to the block content.
let content = jsCoalesce(data["toolUseResult"]) ?? rawContent
let permissions = normalizeToolResultPermissions(object["permissions"])
blocks.append(.toolResult(ToolResultContent(
toolUseId: toolUseId,
content: content,
isError: isError,
uuid: uuid,
parentUUID: parentUUID,
permissions: permissions
)))
}
}
}
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent(blocks),
isSidechain: isSidechain,
parentToolUseId: parentToolUseId,
meta: meta,
agentTimestamp: agentTimestamp
)
}
// MARK: - agy (Antigravity)
/// Port of `humanizeAgyActionType`: "RUN_COMMAND" → "Run command".
func humanizeAgyActionType(_ type: String) -> String {
let words = type.lowercased().split(separator: "_").map(String.init).filter { !$0.isEmpty }
guard !words.isEmpty else { return "Tool" }
return words.enumerated().map { index, word in
index == 0 ? word.prefix(1).uppercased() + word.dropFirst() : word
}.joined(separator: " ")
}
/// Canonical tool ids for agy synthetic cards (normalizeAgent.ts).
public let agyTaskLogTool = "AgyTaskLog"
public let agyAsyncTaskTool = "AgyAsyncTask"
public let agyErrorTool = "AgyError"
/// Port of `stripAgyActionPreamble`: drop the bookkeeping timestamp lines,
/// the per-file metadata header on reads, and the model-directed instruction
/// on write/edit confirmations; keep the substantive result.
func stripAgyActionPreamble(_ content: String, name: String, rawActionName: String?) -> String {
var result = content
// /^(?:Created At:.*(?:\r?\n|$))?(?:Completed At:.*(?:\r?\n|$))?/ —
// anchored optional lines, in this order.
if let stripped = stripLeadingLine(of: result, prefix: "Created At:") { result = stripped }
if let stripped = stripLeadingLine(of: result, prefix: "Completed At:") { result = stripped }
if name == "Read" || rawActionName == "VIEW_FILE" {
// /^(?:(?:File Path:|Total Lines:|Total Bytes:|Showing lines\b).*(?:\r?\n|$))+/
while true {
let next = stripLeadingLine(of: result, prefix: "File Path:")
?? stripLeadingLine(of: result, prefix: "Total Lines:")
?? stripLeadingLine(of: result, prefix: "Total Bytes:")
?? stripLeadingLine(of: result, wordBoundedPrefix: "Showing lines")
guard let next else { break }
result = next
}
// /^The following code has been modified to include a line number.*(?:\r?\n|$)/
if let stripped = stripLeadingLine(
of: result,
prefix: "The following code has been modified to include a line number"
) { result = stripped }
}
if name == "Write" || name == "Edit" || rawActionName == "CODE_ACTION" {
// /\s*If relevant, proactively run terminal commands[\s\S]*$/ —
// first occurrence, the whitespace run directly before it included,
// to end of string (`trimEnd` of the prefix is exactly what the
// greedy leading `\s*` consumes).
if let range = result.range(of: "If relevant, proactively run terminal commands") {
result = String(result[..<range.lowerBound]).jsTrimmedEnd
}
}
return result.jsTrimmed
}
/// Remove one leading line (terminator included) when the text starts with
/// `prefix`; `nil` when it does not.
private func stripLeadingLine(of text: String, prefix: String) -> String? {
guard text.hasPrefix(prefix) else { return nil }
return dropFirstLine(of: text)
}
/// Same, requiring a `\b` word boundary right after the prefix.
private func stripLeadingLine(of text: String, wordBoundedPrefix prefix: String) -> String? {
guard text.hasPrefix(prefix) else { return nil }
let afterIndex = text.index(text.startIndex, offsetBy: prefix.count)
if afterIndex < text.endIndex {
let next = text[afterIndex]
if next.isLetter || next.isNumber || next == "_" { return nil }
}
return dropFirstLine(of: text)
}
private func dropFirstLine(of text: String) -> String {
guard let newlineRange = text.range(of: "\n") else { return "" }
// Also swallow a preceding \r (the JS pattern is `\r?\n`).
return String(text[newlineRange.upperBound...])
}
/// Port of `stripAgyReadArtifacts`: drop the trailing
/// "The above content shows/does NOT show…" note.
func stripAgyReadArtifacts(_ content: String) -> String {
// /\n?The above content (?:shows|does NOT show)[\s\S]*$/
let showsRange = content.range(of: "The above content shows")
let notShowsRange = content.range(of: "The above content does NOT show")
let matchRange: Range<String.Index>? = {
switch (showsRange, notShowsRange) {
case (nil, nil): return nil
case (let some?, nil): return some
case (nil, let some?): return some
case (let a?, let b?): return a.lowerBound < b.lowerBound ? a : b
}
}()
guard let matchRange else { return content.jsTrimmedEnd }
var start = matchRange.lowerBound
if start > content.startIndex {
let previous = content.index(before: start)
if content[previous] == "\n" { start = previous }
}
return String(content[..<start]).jsTrimmedEnd
}
/// Port of `stripAgyEchoedTaskResult`: strip an echoed
/// "[Message] timestamp=…" task-result block from planner prose.
func stripAgyEchoedTaskResult(_ text: String) -> String {
agyEchoedTaskResultRegex.removingFirstMatch(in: text).jsTrimmed
}
private let agyEchoedTaskResultRegex = JSRegex("\\n*\\[Message\\]\\s+timestamp=[\\s\\S]*\\z")
/// Port of `parseAgyAsyncTaskMessage`.
func parseAgyAsyncTaskMessage(_ raw: String) -> (body: String, summary: String, isError: Bool) {
var body = raw
let contentEq = raw.range(of: "content=")
let endTag = raw.range(of: "</SYSTEM_MESSAGE>")
if let contentEq {
let end = endTag?.lowerBound ?? raw.endIndex
// JS slice tolerates end < start (yields ''): mirror that.
body = end > contentEq.upperBound ? String(raw[contentEq.upperBound..<end]) : ""
} else if let endTag {
body = String(raw[..<endTag.lowerBound])
}
// De-indent: /^[\t ]+/gm → strip leading tabs/spaces per line.
body = body
.components(separatedBy: "\n")
.map { line -> String in
var view = Substring(line)
while let first = view.first, first == " " || first == "\t" {
view.removeFirst()
}
return String(view)
}
.joined(separator: "\n")
.jsTrimmed
let taskNumber: String? = agyTaskIdRegex.firstMatch(in: raw).flatMap { $0.count > 1 ? $0[1] : nil }
let taskLabel = taskNumber.map { "task-\($0)" } ?? "Background task"
let failMatch = agyFailExitRegex.firstMatch(in: body)
let isError = failMatch != nil
var outcome = ""
if let failMatch, failMatch.count > 1, let exitCode = failMatch[1] {
outcome = "failed (exit \(exitCode))"
} else if agyCompletedRegex.test(body) {
outcome = "completed"
}
let summary = outcome.isEmpty ? taskLabel : "\(taskLabel) \u{00B7} \(outcome)"
return (body, summary, isError)
}
private let agyTaskIdRegex = JSRegex("task-(\\d+)")
private let agyFailExitRegex = JSRegex("failed with exit code:?\\s*(\\d+)", caseInsensitive: true)
private let agyCompletedRegex = JSRegex("completed successfully", caseInsensitive: true)
/// Port of `parseAgyErrorMessage`.
func parseAgyErrorMessage(_ raw: String) -> (body: String, summary: String) {
// /^Created At:.*(?:\r?\n)?/gm — drop every line starting "Created At:".
var body = raw
.components(separatedBy: "\n")
.filter { !$0.hasPrefix("Created At:") }
.joined(separator: "\n")
// /\n?Guidance:[\s\S]*$/ — cut from the first "Guidance:" (preceding \n
// included) to the end.
if let range = body.range(of: "Guidance:") {
var start = range.lowerBound
if start > body.startIndex {
let previous = body.index(before: start)
if body[previous] == "\n" { start = previous }
}
body = String(body[..<start])
}
// /\n?Retries remaining:.*$/m — remove the first such segment up to its
// line end (preceding \n included).
if let range = body.range(of: "Retries remaining:") {
var start = range.lowerBound
if start > body.startIndex {
let previous = body.index(before: start)
if body[previous] == "\n" { start = previous }
}
let lineEnd = body.range(of: "\n", range: range.upperBound..<body.endIndex)?.lowerBound ?? body.endIndex
body.removeSubrange(start..<lineEnd)
}
body = body.jsTrimmed
let summary = agyInvalidToolCallRegex.test(raw) ? "Invalid tool call" : "Error"
return (body, summary)
}
private let agyInvalidToolCallRegex = JSRegex("invalid tool call", caseInsensitive: true)
/// Port of `AGY_TOOL_SPECS` + `AGY_ARG_KEY_MAP` + `AGY_ARG_NOISE`.
private struct AgyToolSpec: Sendable {
let name: String
let buildInput: @Sendable ([String: JSONValue]) -> [(String, JSONValue?)]
}
private let agyToolSpecs: [String: AgyToolSpec] = [
"run_command": AgyToolSpec(name: "Bash") { args in
[("command", args["CommandLine"]), ("cwd", args["Cwd"])]
},
"view_file": AgyToolSpec(name: "Read") { args in
[("file_path", jsCoalesce(args["AbsolutePath"], args["RelativePath"]))]
},
"write_to_file": AgyToolSpec(name: "Write") { args in
[("file_path", args["TargetFile"]), ("content", args["CodeContent"])]
},
"replace_file_content": AgyToolSpec(name: "Edit") { args in
[
("file_path", args["TargetFile"]),
("old_string", args["TargetContent"]),
("new_string", args["ReplacementContent"]),
]
},
"grep_search": AgyToolSpec(name: "Grep") { args in
[
("pattern", jsCoalesce(args["Query"], args["SearchQuery"])),
("path", jsCoalesce(args["SearchDirectory"], args["SearchPath"])),
]
},
"list_dir": AgyToolSpec(name: "LS") { args in
[("path", jsCoalesce(args["DirectoryPath"], args["AbsolutePath"]))]
},
]
private let agyArgKeyMap: [String: String] = [
"CommandLine": "command",
"Cwd": "cwd",
"AbsolutePath": "file_path",
"RelativePath": "file_path",
"TargetFile": "file_path",
"FilePath": "file_path",
"Path": "path",
"DirectoryPath": "path",
"Query": "query",
"SearchQuery": "query",
"Pattern": "pattern",
"Url": "url",
"URL": "url",
]
private let agyArgNoise: Set<String> = ["toolAction", "toolSummary", "WaitMsBeforeAsync", "Blocking"]
/// A value agy considers "empty" and drops: null, undefined, or ''.
private func isAgyEmptyValue(_ value: JSONValue?) -> Bool {
guard let value else { return true }
if value == .null { return true }
if value == .string("") { return true }
return false
}
/// Port of `normalizeAgyToolInput`.
private func normalizeAgyToolInput(_ args: [String: JSONValue]) -> JSONValue? {
var out: [String: JSONValue] = [:]
for (key, value) in args {
if agyArgNoise.contains(key) { continue }
if isAgyEmptyValue(value) { continue }
out[agyArgKeyMap[key] ?? key] = value
}
return out.isEmpty ? nil : .object(out)
}
/// Port of `mapAgyToolCall`.
private func mapAgyToolCall(
toolName: String?,
actionType: String,
args: [String: JSONValue]?
) -> (name: String, input: JSONValue?, description: String?) {
let description = args.flatMap { asString($0["toolSummary"]) }
let spec = toolName.flatMap { agyToolSpecs[$0] }
if let spec {
var built: [String: JSONValue] = [:]
for (key, value) in spec.buildInput(args ?? [:]) {
if !isAgyEmptyValue(value) { built[key] = value }
}
return (spec.name, built.isEmpty ? nil : .object(built), description)
}
let name = humanizeAgyActionType(toolName ?? actionType)
let input = args.flatMap(normalizeAgyToolInput)
return (name, input, description)
}
// MARK: - Skip filters
/// Port of `isSkippableAgentContent`.
public func isSkippableAgentContent(_ content: JSONValue) -> Bool {
guard let object = content.objectValue, object["type"] == .string("output") else { return false }
guard let data = object["data"]?.objectValue else { return false }
if data["isMeta"]?.jsTruthy == true || data["isCompactSummary"]?.jsTruthy == true { return true }
// Empty away_summary recaps and empty agy planner steps carry nothing
// to render — skip cleanly.
if data["type"] == .string("system"), data["subtype"] == .string("away_summary"),
(asString(data["content"]) ?? "").jsTrimmed.isEmpty {
return true
}
if data["type"] == .string("agy_message"),
(asString(data["content"]) ?? "").jsTrimmed.isEmpty {
return true
}
return !isClaudeChatVisibleMessage(type: data["type"], subtype: data["subtype"])
}
/// Port of `isCodexContent`.
public func isCodexContent(_ content: JSONValue) -> Bool {
content.objectValue?["type"] == .string(agentMessagePayloadType)
}
// MARK: - normalizeAgentRecord
private let piStreamSnapshotIdRegex = JSRegex("\\Api-.+-turn-\\d+-message-\\d+-text-\\d+\\z")
private let agyTaskLogRegex = JSRegex("\\AInside the task-(\\d+) log\\b")
/// Port of `normalizeAgentRecord` — dispatch on the agent payload family.
// swiftlint:disable:next cyclomatic_complexity function_body_length
func normalizeAgentRecord(
messageId: String,
localId: String?,
createdAt: Int,
content: JSONValue,
meta: JSONValue?
) -> NormalizedMessage? {
guard let contentObject = content.objectValue,
let contentType = contentObject["type"]?.stringValue else { return nil }
if contentType == "output" {
guard let data = contentObject["data"]?.objectValue,
let dataType = data["type"]?.stringValue else { return nil }
// Skip meta/compact-summary messages (parity with hapi-app).
if data["isMeta"]?.jsTruthy == true { return nil }
if data["isCompactSummary"]?.jsTruthy == true { return nil }
if !isClaudeChatVisibleMessage(type: data["type"], subtype: data["subtype"]) { return nil }
if dataType == "assistant" {
return normalizeAssistantOutput(messageId: messageId, localId: localId, createdAt: createdAt, data: data, meta: meta)
}
if dataType == "user" {
return normalizeUserOutput(messageId: messageId, localId: localId, createdAt: createdAt, data: data, meta: meta)
}
if dataType == "summary", let summary = data["summary"]?.stringValue {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.summary(summary)]),
isSidechain: false,
meta: meta
)
}
if dataType == "system", data["subtype"] == .string("api_error") {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.apiError(
retryAttempt: asNumber(data["retryAttempt"]) ?? 0,
maxRetries: asNumber(data["maxRetries"]) ?? 0,
error: data["error"]
)),
isSidechain: false,
meta: meta
)
}
if dataType == "system", data["subtype"] == .string("turn_duration") {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.turnDuration(
durationMs: asNumber(data["durationMs"]) ?? 0,
targetMessageId: asString(data["messageId"])
)),
isSidechain: false,
meta: meta
)
}
if dataType == "system", data["subtype"] == .string("away_summary") {
// Empty recaps were dropped by isSkippableAgentContent upstream.
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.recap(text: asString(data["content"]) ?? "")),
isSidechain: false,
meta: meta
)
}
if dataType == "system", data["subtype"] == .string("microcompact_boundary") {
let metadata = data["microcompactMetadata"]?.objectValue
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.microcompact(
trigger: metadata.flatMap { asString($0["trigger"]) } ?? "auto",
preTokens: metadata.flatMap { asNumber($0["preTokens"]) } ?? 0,
tokensSaved: metadata.flatMap { asNumber($0["tokensSaved"]) } ?? 0
)),
isSidechain: false,
meta: meta
)
}
if dataType == "system", data["subtype"] == .string("compact_boundary") {
let metadata = data["compactMetadata"]?.objectValue
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.compact(
trigger: metadata.flatMap { asString($0["trigger"]) } ?? "auto",
preTokens: metadata.flatMap { asNumber($0["preTokens"]) } ?? 0
)),
isSidechain: false,
meta: meta
)
}
// agy (Antigravity) PTY messages.
if dataType == "agy_message" {
let text = stripAgyEchoedTaskResult(asString(data["content"]) ?? "")
if text.jsTrimmed.isEmpty { return nil }
// Transitional "Inside the task-NNN log…" narration → compact chip.
if let taskLog = agyTaskLogRegex.firstMatch(in: text), let taskNumber = taskLog[1] {
let toolCallId = "\(messageId):tasklog"
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([
.toolCall(ToolUseContent(
id: toolCallId,
name: agyTaskLogTool,
input: .object(["task": .string("task-\(taskNumber)")]),
description: nil,
uuid: messageId,
parentUUID: nil
)),
.toolResult(ToolResultContent(
toolUseId: toolCallId,
content: .string(""),
isError: false,
uuid: messageId,
parentUUID: nil
)),
]),
isSidechain: false,
meta: meta
)
}
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.text(.init(text: text, uuid: messageId, parentUUID: nil))]),
isSidechain: false,
meta: meta,
model: asString(data["model"])
)
}
// agy tool ACTION → completed tool card.
if dataType == "agy_tool_action" {
let rawActionName = asString(data["name"]) ?? "Tool"
let toolCallId = asString(data["toolUseId"]) ?? messageId
var name: String
var input: JSONValue?
var description: String?
var resultContent: String
var isError = false
if rawActionName == "SYSTEM_MESSAGE" {
let parsed = parseAgyAsyncTaskMessage(asString(data["content"]) ?? "")
name = agyAsyncTaskTool
input = nil
description = parsed.summary
resultContent = parsed.body
isError = parsed.isError
} else if rawActionName == "ERROR_MESSAGE" {
let parsed = parseAgyErrorMessage(asString(data["content"]) ?? "")
name = agyErrorTool
input = nil
description = parsed.summary
resultContent = parsed.body
isError = true
} else {
let mapped = mapAgyToolCall(
toolName: asString(data["toolName"]),
actionType: rawActionName,
args: data["input"]?.objectValue
)
name = mapped.name
input = mapped.input
description = mapped.description
resultContent = stripAgyActionPreamble(asString(data["content"]) ?? "", name: name, rawActionName: rawActionName)
if name == "Read" || rawActionName == "VIEW_FILE" {
resultContent = stripAgyReadArtifacts(resultContent)
}
}
let nativeKind: String? = (name == "Read" || rawActionName == "VIEW_FILE") ? "agy-numbered-read" : nil
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([
.toolCall(ToolUseContent(
id: toolCallId,
name: name,
input: input,
description: description,
nativeKind: nativeKind,
uuid: messageId,
parentUUID: nil
)),
.toolResult(ToolResultContent(
toolUseId: toolCallId,
content: .string(resultContent),
isError: isError,
uuid: messageId,
parentUUID: nil
)),
]),
isSidechain: false,
meta: meta
)
}
return nil
}
if contentType == "event" {
guard let event = normalizeAgentEvent(contentObject["data"]) else { return nil }
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(event),
isSidechain: false,
meta: meta
)
}
if contentType == agentMessagePayloadType {
guard let data = contentObject["data"]?.objectValue,
let dataType = data["type"]?.stringValue else { return nil }
if dataType == "agent-run-start" || dataType == "agent-run-update" || dataType == "agent-run-trace" {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(AgentEvent(object: data)),
isSidechain: false,
meta: meta
)
}
if dataType == "generated-image" {
guard let imageId = asString(jsCoalesce(data["imageId"], data["image_id"])), !imageId.isEmpty else { return nil }
let uuid = asString(data["id"]) ?? messageId
let source = InlineMediaSource.fromWire(data["source"])
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.generatedImage(GeneratedImageContent(
imageId: imageId,
fileName: asString(jsCoalesce(data["fileName"], data["file_name"])) ?? "generated-image",
mimeType: asString(jsCoalesce(data["mimeType"], data["mime_type"])),
uuid: uuid,
parentUUID: nil,
source: source
))]),
isSidechain: false,
meta: meta
)
}
if dataType == "error", let message = data["message"]?.stringValue {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.error(message)),
isSidechain: false,
meta: meta
)
}
if dataType == "message", let message = data["message"]?.stringValue {
let streamId = asString(data["id"])
let isPiStreamSnapshot = data["streamSnapshot"] == .bool(true)
|| (streamId.map { piStreamSnapshotIdRegex.test($0) } ?? false)
let review = isPiStreamSnapshot ? nil : parseCodexReviewMessage(message)
if let review {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.codexReview(review: review, uuid: messageId, parentUUID: nil)]),
isSidechain: false,
meta: meta
)
}
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.text(.init(text: message, uuid: messageId, streamId: streamId, parentUUID: nil))]),
isSidechain: false,
meta: meta
)
}
if dataType == "reasoning", let message = data["message"]?.stringValue {
let streamId = asString(data["id"]) ?? messageId
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.reasoning(.init(text: message, uuid: messageId, streamId: streamId, parentUUID: nil))]),
isSidechain: false,
meta: meta
)
}
if dataType == "context_compacted" {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.compact(
trigger: asString(data["trigger"]) ?? "auto",
preTokens: asNumber(jsCoalesce(data["preTokens"], data["pre_tokens"])) ?? 0
)),
isSidechain: false,
meta: meta
)
}
if dataType == "compact-summary", let summary = data["summary"]?.stringValue {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.compactSummary(
summary: summary,
tokensBefore: asNumber(data["tokensBefore"]),
estimatedTokensAfter: asNumber(data["estimatedTokensAfter"])
)),
isSidechain: false,
meta: meta
)
}
if dataType == "token_count" {
guard let usage = normalizeCodexTokenUsage(data["info"], data: data) else { return nil }
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.tokenCount(info: data["info"])),
isSidechain: false,
meta: meta,
usage: usage
)
}
if dataType == "thread_goal_updated" {
guard let goal = normalizeThreadGoal(data["goal"]) else { return nil }
let goalThreadId = goal["threadId"]?.stringValue ?? ""
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.threadGoalUpdated(
threadId: asString(jsCoalesce(data["threadId"], data["thread_id"])) ?? goalThreadId,
turnId: asString(jsCoalesce(data["turnId"], data["turn_id"])),
goal: goal
)),
isSidechain: false,
meta: meta
)
}
if dataType == "thread_goal_cleared" {
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .event(.threadGoalCleared(
threadId: asString(jsCoalesce(data["threadId"], data["thread_id"]))
)),
isSidechain: false,
meta: meta
)
}
if dataType == "tool-call", let callId = data["callId"]?.stringValue {
let uuid = asString(data["id"]) ?? messageId
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.toolCall(ToolUseContent(
id: callId,
name: asString(data["name"]) ?? "unknown",
input: data["input"],
description: asString(data["description"]),
nativeTitle: asString(jsCoalesce(data["nativeTitle"], data["title"])),
nativeKind: asString(jsCoalesce(data["nativeKind"], data["kind"])),
progress: data["progress"], // key presence == `'progress' in data`
uuid: uuid,
parentUUID: nil
))]),
isSidechain: false,
meta: meta
)
}
if dataType == "tool-call-result", let callId = data["callId"]?.stringValue {
let uuid = asString(data["id"]) ?? messageId
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([.toolResult(ToolResultContent(
toolUseId: callId,
content: data["output"],
isError: data["is_error"]?.jsTruthy ?? false,
uuid: uuid,
parentUUID: nil
))]),
isSidechain: false,
meta: meta
)
}
if dataType == "plan" {
let plan = normalizePlanEntries(jsCoalesce(data["entries"], data["items"]) ?? .object(data))
if plan.isEmpty { return nil }
let uuid = asString(data["id"]) ?? messageId
let planPayload: JSONValue = .object(["plan": .array(plan), "source": .string("cursor")])
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([
.toolCall(ToolUseContent(
id: "cursor-plan-state",
name: "update_plan",
input: planPayload,
description: nil,
uuid: uuid,
parentUUID: nil
)),
.toolResult(ToolResultContent(
toolUseId: "cursor-plan-state",
content: planPayload,
isError: false,
uuid: uuid,
parentUUID: nil
)),
]),
isSidechain: false,
meta: meta
)
}
if dataType == "plan_update" {
let plan = normalizePlanEntries(
jsCoalesce(data["plan"], data["update"], data["items"], data["steps"]) ?? .object(data)
)
if plan.isEmpty { return nil }
let uuid = asString(data["id"]) ?? messageId
return NormalizedMessage(
id: messageId,
localId: localId,
createdAt: createdAt,
content: .agent([
.toolCall(ToolUseContent(
id: "codex-plan-state",
name: "update_plan",
input: .object(["plan": .array(plan), "source": .string("codex")]),
description: nil,
uuid: uuid,
parentUUID: nil
)),
.toolResult(ToolResultContent(
toolUseId: "codex-plan-state",
content: .object(["plan": .array(plan), "source": .string("codex"), "status": .string("updated")]),
isError: false,
uuid: "\(uuid):result",
parentUUID: nil
)),
]),
isSidechain: false,
meta: meta
)
}
}
return nil
}