mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +00:00
merge: A-M2bc iOS chat pipeline port + fixture harness
# Conflicts: # ios/README.md
This commit is contained in:
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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user