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.
175 lines
6.3 KiB
Swift
175 lines
6.3 KiB
Swift
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)
|
|
)
|
|
}
|