mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
170 lines
7.1 KiB
Swift
170 lines
7.1 KiB
Swift
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)),
|
|
])
|
|
}
|