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.
145 lines
4.7 KiB
Swift
145 lines
4.7 KiB
Swift
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
|
|
}
|