Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTimeline.swift
T
3873e58496 fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows

Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.

Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.

Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.

* fix(ios,android): mirror stream-stable block ids in native chat ports

The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.

Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.

* fix(web,ios,android): reject blank stream ids as block identity

Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.

Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.

* fix(ios): use normalized stream id for block construction identity

The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.

Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.

* fix(web): pin blank stream-id identity contract in golden fixtures

Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.

* feat(hub): make title provider max_tokens and timeout env-tunable

Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.

Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.

* docs(hub): document title provider max_tokens/timeout env knobs

Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).

---------

Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
2026-09-06 14:20:19 +08:00

1064 lines
47 KiB
Swift

import Foundation
// Port of web/src/chat/reducerTimeline.ts — the timeline reducer: tool
// pairing, stream coalescing, agent-run cards, title-changed synthesis,
// sentinel suppression.
// MARK: - Event record helpers
private func getEventString(_ event: [String: JSONValue], _ key: String) -> String? {
asString(event[key])
}
private func getEventNumber(_ event: [String: JSONValue], _ key: String) -> Double? {
asNumber(event[key])
}
private func getAgentRunStartedAt(_ event: [String: JSONValue]) -> Int? {
(getEventNumber(event, "startedAt") ?? getEventNumber(event, "started_at")).map { Int($0.rounded()) }
}
private func getAgentRunCompletedAt(_ event: [String: JSONValue]) -> Int? {
(getEventNumber(event, "completedAt") ?? getEventNumber(event, "completed_at")).map { Int($0.rounded()) }
}
private func getAgentRunCardId(_ event: [String: JSONValue], fallback: String) -> String {
getEventString(event, "cardId") ?? getEventString(event, "card_id") ?? fallback
}
private func isFallbackAgentRunCardId(_ cardId: String, agentId: String?) -> Bool {
guard let agentId else { return false }
return cardId == "codex-agent:\(agentId)"
}
private func mapAgentRunStatusToToolState(_ status: String?) -> ToolCallState {
if status == "completed" { return .completed }
if status == "failed" || status == "error" || status == "canceled" || status == "cancelled"
|| status == "notFound" || status == "not_found" {
return .error
}
if status == "pending" { return .pending }
return .running
}
private func isTerminalAgentRunState(_ state: ToolCallState) -> Bool {
state == .completed || state == .error
}
private func isNonTerminalAgentRunState(_ state: ToolCallState) -> Bool {
state == .running || state == .pending
}
private func shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal(
_ block: ToolCallBlock,
nextState: ToolCallState,
event: [String: JSONValue]
) -> Bool {
guard isTerminalAgentRunState(block.tool.state) else { return false }
guard isNonTerminalAgentRunState(nextState) else { return false }
let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind")
return activityKind == "wait_agent" || activityKind == "close_agent"
}
private func isCloseAgentCleanupUpdate(_ event: [String: JSONValue]) -> Bool {
let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind")
if activityKind == "close_agent" || activityKind == "closed" { return true }
let activity = getEventString(event, "activity")
let statusText = getEventString(event, "statusText") ?? getEventString(event, "status_text")
guard activityKind == "canceled" && (activity == "Closed" || statusText == "Closed") else { return false }
guard let result = event["result"]?.objectValue else { return false }
return result["previous_status"]?.objectValue != nil || result["previousStatus"]?.objectValue != nil
}
private func shouldIgnoreAgentRunCloseCleanupAfterTerminal(
_ block: ToolCallBlock,
status: String?,
event: [String: JSONValue]
) -> Bool {
guard isTerminalAgentRunState(block.tool.state) else { return false }
if status == "failed" || status == "error" { return false }
return isCloseAgentCleanupUpdate(event)
}
private func getAgentRunDisplayPatch(_ event: [String: JSONValue]) -> [String: JSONValue] {
var patch: [String: JSONValue] = [:]
let summary = getEventString(event, "summary")
let activity = getEventString(event, "activity")
let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind")
// TS truthiness: an empty string does not patch.
if let summary, !summary.isEmpty { patch["summary"] = .string(summary) }
if let activity, !activity.isEmpty { patch["activity"] = .string(activity) }
if let activityKind, !activityKind.isEmpty { patch["activityKind"] = .string(activityKind) }
return patch
}
private let whitespaceRunRegex = JSRegex("\\s+")
/// JS `text.replace(/\s+/g, ' ').trim()`.
private func collapseWhitespace(_ text: String) -> String {
whitespaceRunRegex.replacingAllMatches(in: text, with: " ").jsTrimmed
}
private func getAgentRunFingerprint(_ event: [String: JSONValue]) -> String? {
if let summary = getEventString(event, "summary"), !summary.isEmpty { return summary }
let input = event["input"]?.objectValue
// TS: `asString(input.message) ?? asString(input.prompt)` — a non-string
// `message` falls through to `prompt`.
let direct = input.flatMap { asString($0["message"]) ?? asString($0["prompt"]) }
if let direct, !direct.isEmpty { return collapseWhitespace(direct) }
if let items = input?["items"]?.arrayValue {
let text = items
.compactMap { item -> String? in
guard let object = item.objectValue else { return nil }
let part = asString(object["text"])
return (part?.isEmpty == false) ? part : nil
}
.joined(separator: "\n\n")
let collapsed = collapseWhitespace(text)
return collapsed.isEmpty ? nil : collapsed
}
return nil
}
private func isAgentNotFoundUpdate(_ event: [String: JSONValue]) -> Bool {
let status = getEventString(event, "status")
let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind")
return status == "notFound" || status == "not_found" || activityKind == "not_found"
}
private func isAgentToolOnlyUpdate(_ event: [String: JSONValue]) -> Bool {
let activityKind = getEventString(event, "activityKind") ?? getEventString(event, "activity_kind")
return activityKind == "wait_agent"
|| activityKind == "send_input"
|| activityKind == "resume_agent"
|| activityKind == "close_agent"
|| isAgentNotFoundUpdate(event)
}
private func isOrphanAgentRunBlock(_ block: ToolCallBlock) -> Bool {
if !block.children.isEmpty { return false }
if block.tool.result != nil { return false }
if block.tool.state == .completed || block.tool.state == .error { return false }
if let input = block.tool.input?.objectValue {
let agentId = asString(input["agentId"]) ?? asString(input["agent_id"])
if let agentId, !agentId.isEmpty { return false }
}
return true
}
private func prefixAgentTraceId(agentId: String, kind: String, id: String) -> String {
let prefix = "codex-agent:\(agentId):"
return id.hasPrefix(prefix) ? id : "\(prefix)\(kind):\(id)"
}
/// Port of `normalizeTraceMessage` — one agent-run trace payload becomes
/// synthetic timeline messages for the nested reduction.
private func normalizeTraceMessage(
agentId: String,
message: JSONValue?,
source: NormalizedMessage
) -> [NormalizedMessage] {
guard let data = message?.objectValue, let dataType = data["type"]?.stringValue else { return [] }
let traceId = prefixAgentTraceId(agentId: agentId, kind: "trace", id: asString(data["id"]) ?? "\(source.id):trace")
let createdAt = source.createdAt
func base(content: NormalizedMessage.Content) -> NormalizedMessage {
NormalizedMessage(
id: traceId,
localId: nil,
createdAt: createdAt,
content: content,
isSidechain: false,
meta: source.meta
)
}
if dataType == "error", let messageText = data["message"]?.stringValue {
return [base(content: .event(.error(messageText)))]
}
if dataType == "message", let messageText = data["message"]?.stringValue {
return [base(content: .agent([.text(.init(text: messageText, uuid: traceId, parentUUID: nil))]))]
}
if dataType == "reasoning", let messageText = data["message"]?.stringValue {
return [base(content: .agent([.reasoning(.init(text: messageText, uuid: traceId, streamId: traceId, parentUUID: nil))]))]
}
if dataType == "tool-call", let rawCallId = data["callId"]?.stringValue {
let callId = prefixAgentTraceId(agentId: agentId, kind: "call", id: rawCallId)
return [base(content: .agent([.toolCall(ToolUseContent(
id: callId,
name: asString(data["name"]) ?? "unknown",
input: data["input"],
description: nil,
uuid: traceId,
parentUUID: nil
))]))]
}
if dataType == "tool-call-result", let rawCallId = data["callId"]?.stringValue {
let callId = prefixAgentTraceId(agentId: agentId, kind: "call", id: rawCallId)
return [base(content: .agent([.toolResult(ToolResultContent(
toolUseId: callId,
content: data["output"],
isError: data["is_error"]?.jsTruthy ?? false,
uuid: traceId,
parentUUID: nil
))]))]
}
if dataType == "token_count" {
return []
}
if dataType == "ready" || dataType == "task_complete" {
return [base(content: .event(.ready(agentId: agentId)))]
}
return [base(content: .event(.message(
asString(data["statusText"]) ?? asString(data["status"]) ?? dataType
)))]
}
// MARK: - Reducer context
/// Shared mutable context threaded through nested reductions (the TS object
/// is shared by reference; a class reproduces that).
final class ReducerContext {
let permissionsById: PermissionMap
let groups: [String: [NormalizedMessage]]
var consumedGroupIds: Set<String>
let titleChangesByToolUseId: [String: String]
var emittedTitleChangeToolUseIds: Set<String>
init(
permissionsById: PermissionMap,
groups: [String: [NormalizedMessage]],
consumedGroupIds: Set<String> = [],
titleChangesByToolUseId: [String: String],
emittedTitleChangeToolUseIds: Set<String> = []
) {
self.permissionsById = permissionsById
self.groups = groups
self.consumedGroupIds = consumedGroupIds
self.titleChangesByToolUseId = titleChangesByToolUseId
self.emittedTitleChangeToolUseIds = emittedTitleChangeToolUseIds
}
}
struct TimelineResult {
var blocks: [BlockBox]
var toolBlocksById: [String: BlockBox]
var hasReadyEvent: Bool
}
// MARK: - Block mutation helpers
private func setEarliestStartedAt(_ box: BlockBox, _ startedAt: Int?) {
guard let startedAt, var block = box.block.asToolCall else { return }
let next = block.tool.startedAt.map { min($0, startedAt) } ?? startedAt
if next != block.tool.startedAt {
block.tool.startedAt = next
box.block = .toolCall(block)
}
}
/// Only ever fed a real Claude `agentTimestamp`; nil is a no-op.
private func setEarliestExecStartedAt(_ box: BlockBox, _ execStartedAt: Int?) {
guard let execStartedAt, var block = box.block.asToolCall else { return }
let next = block.tool.execStartedAt.map { min($0, execStartedAt) } ?? execStartedAt
if next != block.tool.execStartedAt {
block.tool.execStartedAt = next
box.block = .toolCall(block)
}
}
private func isDurationTargetBlock(_ block: ChatBlock) -> Bool {
switch block {
case .agentText, .agentReasoning, .codexReview, .cliOutput, .toolCall:
return true
default:
return false
}
}
private func setDurationMs(_ box: BlockBox, _ durationMs: Int) {
switch box.block {
case .agentText(var block):
block.durationMs = durationMs
box.block = .agentText(block)
case .agentReasoning(var block):
block.durationMs = durationMs
box.block = .agentReasoning(block)
case .codexReview(var block):
block.durationMs = durationMs
box.block = .codexReview(block)
case .cliOutput(var block):
block.durationMs = durationMs
box.block = .cliOutput(block)
case .toolCall(var block):
block.durationMs = durationMs
box.block = .toolCall(block)
default:
break
}
}
// MARK: - reduceTimeline
/// Port of `reduceTimeline`.
/// Stream identity must match the wire-level semantics in shared/src/messages.ts
/// (blank ids are not streams): a blank value falls back to row-derived ids
/// instead of colliding every blank-id row onto one block identity.
private func nonBlankStreamId(_ value: String?) -> String? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return value
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
func reduceTimeline(_ messages: [NormalizedMessage], context: ReducerContext) -> TimelineResult {
var blocks: [BlockBox] = []
var toolBlocksById: [String: BlockBox] = [:]
var agentRunBlocksByCardId: [String: BlockBox] = [:]
var agentRunCardByAgentId: [String: String] = [:]
var agentRunTraceMessagesByCardId: [String: [NormalizedMessage]] = [:]
var pendingAgentRunCardByFingerprint: [String: String] = [:]
var textBlocksByStreamId: [String: BlockBox] = [:]
var reasoningBlocksByStreamId: [String: BlockBox] = [:]
var hasReadyEvent = false
func ensureAgentRunBlock(
cardId: String,
createdAt: Int,
invokedAt: Int?,
model: String?,
localId: String?,
meta: JSONValue?,
input: JSONValue?
) -> BlockBox {
let box = ensureToolBlock(&blocks, &toolBlocksById, id: cardId, seed: ToolBlockSeed(
createdAt: createdAt,
invokedAt: invokedAt,
model: model,
localId: localId,
meta: meta,
name: "CodexAgent",
input: input,
description: nil
))
agentRunBlocksByCardId[cardId] = box
return box
}
func refreshAgentRunChildren(cardId: String) {
guard let box = agentRunBlocksByCardId[cardId], var block = box.block.asToolCall else { return }
let traceMessages = agentRunTraceMessagesByCardId[cardId] ?? []
if traceMessages.isEmpty {
block.children = []
box.block = .toolCall(block)
return
}
let child = reduceTimeline(traceMessages, context: ReducerContext(
permissionsById: context.permissionsById,
groups: [:],
consumedGroupIds: [],
titleChangesByToolUseId: collectTitleChanges(traceMessages),
emittedTitleChangeToolUseIds: []
))
block.children = child.blocks.materialized
box.block = .toolCall(block)
}
func patchAgentRunInput(_ box: BlockBox, _ patch: [String: JSONValue]) {
guard var block = box.block.asToolCall else { return }
var merged = block.tool.input?.objectValue ?? [:]
for (key, value) in patch { merged[key] = value }
block.tool.input = .object(merged)
box.block = .toolCall(block)
}
func removeAgentRunBlock(cardId: String) {
guard let box = agentRunBlocksByCardId[cardId] else { return }
if let index = blocks.firstIndex(where: { $0 === box }) {
blocks.remove(at: index)
}
toolBlocksById.removeValue(forKey: cardId)
agentRunBlocksByCardId.removeValue(forKey: cardId)
agentRunTraceMessagesByCardId.removeValue(forKey: cardId)
for (fingerprint, pendingCardId) in pendingAgentRunCardByFingerprint where pendingCardId == cardId {
pendingAgentRunCardByFingerprint.removeValue(forKey: fingerprint)
}
}
func mergeAgentRunBlock(fromCardId: String, toCardId: String, toBox: BlockBox) {
if fromCardId == toCardId { return }
guard let fromBox = agentRunBlocksByCardId[fromCardId], fromBox !== toBox,
let fromBlock = fromBox.block.asToolCall,
var toBlock = toBox.block.asToolCall else { return }
let fromInput = fromBlock.tool.input?.objectValue ?? [:]
let toInput = toBlock.tool.input?.objectValue ?? [:]
if !fromInput.isEmpty || !toInput.isEmpty {
var merged = fromInput
for (key, value) in toInput { merged[key] = value }
toBlock.tool.input = .object(merged)
}
toBlock.createdAt = min(toBlock.createdAt, fromBlock.createdAt)
toBlock.tool.createdAt = min(toBlock.tool.createdAt, fromBlock.tool.createdAt)
if let fromStartedAt = fromBlock.tool.startedAt {
toBlock.tool.startedAt = toBlock.tool.startedAt.map { min($0, fromStartedAt) } ?? fromStartedAt
}
if let fromCompletedAt = fromBlock.tool.completedAt {
toBlock.tool.completedAt = toBlock.tool.completedAt.map { max($0, fromCompletedAt) } ?? fromCompletedAt
}
if let fromExecStartedAt = fromBlock.tool.execStartedAt {
toBlock.tool.execStartedAt = toBlock.tool.execStartedAt.map { min($0, fromExecStartedAt) } ?? fromExecStartedAt
}
if let fromExecCompletedAt = fromBlock.tool.execCompletedAt {
toBlock.tool.execCompletedAt = toBlock.tool.execCompletedAt.map { max($0, fromExecCompletedAt) } ?? fromExecCompletedAt
}
toBlock.durationMs = toBlock.durationMs ?? fromBlock.durationMs
toBlock.usage = toBlock.usage ?? fromBlock.usage
toBlock.model = toBlock.model ?? fromBlock.model
if !isTerminalAgentRunState(toBlock.tool.state) && isTerminalAgentRunState(fromBlock.tool.state) {
toBlock.tool.state = fromBlock.tool.state
}
if toBlock.tool.result == nil, let fromResult = fromBlock.tool.result {
toBlock.tool.result = fromResult
}
toBox.block = .toolCall(toBlock)
let fromTrace = agentRunTraceMessagesByCardId[fromCardId] ?? []
let toTrace = agentRunTraceMessagesByCardId[toCardId] ?? []
if !fromTrace.isEmpty || !toTrace.isEmpty {
let mergedTrace = (toTrace + fromTrace).sorted { left, right in
if left.createdAt != right.createdAt { return left.createdAt < right.createdAt }
return left.id.utf16.lexicographicallyPrecedes(right.id.utf16)
}
agentRunTraceMessagesByCardId[toCardId] = mergedTrace
agentRunTraceMessagesByCardId.removeValue(forKey: fromCardId)
refreshAgentRunChildren(cardId: toCardId)
} else if toBlock.children.isEmpty && !fromBlock.children.isEmpty {
if var refreshed = toBox.block.asToolCall {
refreshed.children = fromBlock.children
toBox.block = .toolCall(refreshed)
}
}
if let index = blocks.firstIndex(where: { $0 === fromBox }) {
blocks.remove(at: index)
}
toolBlocksById.removeValue(forKey: fromCardId)
agentRunBlocksByCardId.removeValue(forKey: fromCardId)
for (fingerprint, pendingCardId) in pendingAgentRunCardByFingerprint where pendingCardId == fromCardId {
pendingAgentRunCardByFingerprint[fingerprint] = toCardId
}
for (mappedAgentId, mappedCardId) in agentRunCardByAgentId where mappedCardId == fromCardId {
agentRunCardByAgentId[mappedAgentId] = toCardId
}
}
// Pre-scan: UUIDs of system-injected user turns, used to identify the
// "No response requested." sentinel auto-replies below.
var injectedTurnUuids = Set<String>()
for msg in messages {
guard msg.isSidechain, let content = msg.agentContent else { continue }
for item in content {
if case .sidechain(let sidechain) = item {
injectedTurnUuids.insert(sidechain.uuid)
}
}
}
for msg in messages {
if case .event(let msgEvent) = msg.content {
if msgEvent.type == "ready" {
hasReadyEvent = true
continue
}
if msgEvent.type == "token-count" {
continue
}
// abort-restore is a composer side-effect signal, not a visible
// chat event.
if msgEvent.type == "abort-restore" {
continue
}
if msgEvent.type == "turn-duration" {
let targetId = msgEvent["targetMessageId"]?.stringValue
let durationMs = Int((msgEvent["durationMs"]?.numberValue ?? 0).rounded())
var foundIndex = -1
if let targetId {
foundIndex = blocks.lastIndex(where: { box in
isDurationTargetBlock(box.block)
&& (box.block.id == targetId || box.block.id.hasPrefix("\(targetId):"))
}) ?? -1
if foundIndex == -1 {
foundIndex = blocks.lastIndex(where: { $0.block.asToolCall?.tool.id == targetId }) ?? -1
}
}
if foundIndex == -1 {
foundIndex = blocks.lastIndex(where: { isDurationTargetBlock($0.block) }) ?? -1
}
if foundIndex != -1, isDurationTargetBlock(blocks[foundIndex].block) {
setDurationMs(blocks[foundIndex], durationMs)
}
continue
}
if msgEvent.type == "agent-run-start" || msgEvent.type == "agent-run-update" || msgEvent.type == "agent-run-trace" {
let event = msgEvent.object
let agentId = getEventString(event, "agentId") ?? getEventString(event, "agent_id")
let fallbackCardId = agentId.map { "codex-agent:\($0)" } ?? msg.id
let rawCardId = getAgentRunCardId(event, fallback: fallbackCardId)
let previousCardId = agentId.flatMap { agentRunCardByAgentId[$0] }
let previousIsFallback = previousCardId.map { isFallbackAgentRunCardId($0, agentId: agentId) } ?? false
let rawIsFallback = isFallbackAgentRunCardId(rawCardId, agentId: agentId)
let cardId: String = {
if agentId != nil, let previousCardId, !previousIsFallback, rawIsFallback {
return previousCardId
}
return rawCardId
}()
let mergeFromCardId: String? = {
if agentId != nil, let previousCardId, previousCardId != cardId, previousIsFallback, !rawIsFallback {
return previousCardId
}
return nil
}()
let fingerprint = getAgentRunFingerprint(event)
if msgEvent.type == "agent-run-update",
agentId != nil,
previousCardId == nil,
rawIsFallback,
isAgentToolOnlyUpdate(event) {
continue
}
if msgEvent.type == "agent-run-start", agentId == nil, let fingerprint {
let previousPendingCardId = pendingAgentRunCardByFingerprint[fingerprint]
let previousBlock = previousPendingCardId.flatMap { agentRunBlocksByCardId[$0] }
if let previousPendingCardId,
previousPendingCardId != cardId,
let previousBlock,
let previousToolBlock = previousBlock.block.asToolCall,
isOrphanAgentRunBlock(previousToolBlock) {
removeAgentRunBlock(cardId: previousPendingCardId)
}
pendingAgentRunCardByFingerprint[fingerprint] = cardId
}
let box = ensureAgentRunBlock(
cardId: cardId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
localId: msg.localId,
meta: msg.meta,
input: event["input"]
)
if let mergeFromCardId {
mergeAgentRunBlock(fromCardId: mergeFromCardId, toCardId: cardId, toBox: box)
}
if let agentId {
agentRunCardByAgentId[agentId] = cardId
}
if msgEvent.type == "agent-run-start" {
let status = getEventString(event, "status") ?? "running"
let startedAt = getAgentRunStartedAt(event) ?? msg.createdAt
var patch: [String: JSONValue] = [:]
// TS patches `agentId` unconditionally — getEventString
// yields null (not undefined), which JSON keeps.
patch["agentId"] = agentId.map { JSONValue.string($0) } ?? .null
patch["agentStatus"] = .string(status)
patch["statusText"] = .string(
getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? "Starting"
)
for (key, value) in getAgentRunDisplayPatch(event) { patch[key] = value }
patchAgentRunInput(box, patch)
let nextState = mapAgentRunStatusToToolState(status)
if var block = box.block.asToolCall {
block.tool.state = nextState
box.block = .toolCall(block)
}
if nextState == .running {
setEarliestStartedAt(box, startedAt)
}
continue
}
if msgEvent.type == "agent-run-update" {
let status = getEventString(event, "status") ?? "running"
let nextState = mapAgentRunStatusToToolState(status)
let startedAt = getAgentRunStartedAt(event)
if let blockValue = box.block.asToolCall,
shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal(blockValue, nextState: nextState, event: event)
|| shouldIgnoreAgentRunCloseCleanupAfterTerminal(blockValue, status: status, event: event) {
continue
}
var patch: [String: JSONValue] = [:]
patch["agentId"] = agentId.map { JSONValue.string($0) } ?? .null
patch["agentStatus"] = .string(status)
patch["statusText"] = .string(
getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? status
)
for (key, value) in getAgentRunDisplayPatch(event) { patch[key] = value }
patchAgentRunInput(box, patch)
if var block = box.block.asToolCall {
block.tool.state = nextState
box.block = .toolCall(block)
}
if nextState == .running {
setEarliestStartedAt(box, startedAt ?? msg.createdAt)
}
if nextState == .completed || nextState == .error {
setEarliestStartedAt(box, startedAt)
if var block = box.block.asToolCall {
block.tool.completedAt = getAgentRunCompletedAt(event) ?? msg.createdAt
box.block = .toolCall(block)
}
}
if var block = box.block.asToolCall {
if let result = event["result"] {
block.tool.result = result
box.block = .toolCall(block)
} else if let error = event["error"] {
block.tool.result = error
box.block = .toolCall(block)
} else if let spawnResult = event["spawnResult"] {
block.tool.result = spawnResult
box.block = .toolCall(block)
}
}
continue
}
if msgEvent.type == "agent-run-trace" {
guard let traceAgentId = agentId else { continue }
let startedAt = getAgentRunStartedAt(event)
let traceCardId = agentRunCardByAgentId[traceAgentId] ?? cardId
let traceInput: JSONValue? = agentRunBlocksByCardId[traceCardId] != nil
? nil
: .object(["agentId": .string(traceAgentId)])
let traceBox = ensureAgentRunBlock(
cardId: traceCardId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
localId: msg.localId,
meta: msg.meta,
input: traceInput
)
guard let traceBlockValue = traceBox.block.asToolCall else { continue }
var tracePatch: [String: JSONValue] = [
"agentId": .string(traceAgentId),
"agentStatus": .string(traceBlockValue.tool.state.rawValue),
]
for (key, value) in getAgentRunDisplayPatch(event) { tracePatch[key] = value }
if !isTerminalAgentRunState(traceBlockValue.tool.state) {
tracePatch["statusText"] = .string(
getEventString(event, "statusText") ?? getEventString(event, "status_text") ?? "Running"
)
}
patchAgentRunInput(traceBox, tracePatch)
var traceMessages = agentRunTraceMessagesByCardId[traceCardId] ?? []
traceMessages.append(contentsOf: normalizeTraceMessage(agentId: traceAgentId, message: event["message"], source: msg))
agentRunTraceMessagesByCardId[traceCardId] = traceMessages
refreshAgentRunChildren(cardId: traceCardId)
if var block = traceBox.block.asToolCall,
block.tool.state != .completed && block.tool.state != .error {
block.tool.state = .running
traceBox.block = .toolCall(block)
setEarliestStartedAt(traceBox, startedAt ?? msg.createdAt)
}
continue
}
}
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: msg.id,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: msgEvent,
meta: msg.meta
))))
continue
}
if let event = parseMessageAsEvent(msg) {
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: msg.id,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: event,
meta: msg.meta
))))
continue
}
if case .user(let text, let attachments) = msg.content {
if isCliOutputText(text, meta: msg.meta) {
blocks.append(BlockBox(.cliOutput(CliOutputBlock(
id: msg.id,
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
durationMs: nil,
usage: nil,
model: nil,
text: text,
source: .user,
meta: msg.meta
))))
continue
}
blocks.append(BlockBox(.userText(UserTextBlock(
id: msg.id,
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
text: text,
attachments: attachments,
status: msg.status,
originalText: msg.originalText,
meta: msg.meta
))))
continue
}
if case .agent(let contentItems) = msg.content {
// Suppress a text block repeating the Task tool prompt (already
// shown in the tool card) — only that exact prompt text.
let taskToolCall: ToolUseContent? = contentItems.lazy.compactMap { item -> ToolUseContent? in
if case .toolCall(let toolCall) = item, isSubagentToolName(toolCall.name) { return toolCall }
return nil
}.first
let taskPromptText: String? = taskToolCall?.input?.objectValue?["prompt"]?.stringValue
for (idx, item) in contentItems.enumerated() {
switch item {
case .text(let textContent):
// Skip "No response requested." — the sentinel
// auto-response to system-injected messages.
if contentItems.count == 1,
let parentUUID = textContent.parentUUID,
injectedTurnUuids.contains(parentUUID) {
let trimmedText = textContent.text.jsTrimmed
if trimmedText == "No response requested." || trimmedText == "No response requested" {
continue
}
}
if let taskPromptText, textContent.text.jsTrimmed == taskPromptText.jsTrimmed {
continue
}
if isCliOutputText(textContent.text, meta: msg.meta) {
blocks.append(BlockBox(.cliOutput(CliOutputBlock(
id: "\(msg.id):\(idx)",
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
durationMs: nil,
usage: msg.usage,
model: msg.model,
text: textContent.text,
source: .assistant,
meta: msg.meta
))))
continue
}
let streamId = nonBlankStreamId(textContent.streamId)
if let streamId, let existingBox = textBlocksByStreamId[streamId] {
if var existing = existingBox.block.asAgentText {
existing.text = textContent.text
existing.usage = msg.usage
existing.model = msg.model
existing.meta = msg.meta
existing.invokedAt = msg.invokedAt
existingBox.block = .agentText(existing)
continue
}
}
let box = BlockBox(.agentText(AgentTextBlock(
// Stream-stable id (mirrors web/src/chat/reducerTimeline.ts):
// snapshots of one stream arrive as separate rows that
// the window keeps swapping, so a row-derived id would
// churn per snapshot and remount the rendered block.
id: streamId ?? "\(msg.id):\(idx)",
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
durationMs: nil,
usage: msg.usage,
model: msg.model,
text: textContent.text,
meta: msg.meta
)))
blocks.append(box)
if let streamId {
textBlocksByStreamId[streamId] = box
}
continue
case .generatedImage(let image):
blocks.append(BlockBox(.generatedImage(GeneratedImageBlock(
id: "\(msg.id):\(idx)",
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
imageId: image.imageId,
fileName: image.fileName,
mimeType: image.mimeType,
source: image.source,
meta: msg.meta
))))
continue
case .reasoning(let reasoningContent):
let streamId = nonBlankStreamId(reasoningContent.streamId)
if let streamId, let existingBox = reasoningBlocksByStreamId[streamId] {
if var existing = existingBox.block.asAgentReasoning {
existing.text = reasoningContent.text
existing.usage = msg.usage
existing.model = msg.model
existing.meta = msg.meta
existing.invokedAt = msg.invokedAt
existingBox.block = .agentReasoning(existing)
continue
}
}
let box = BlockBox(.agentReasoning(AgentReasoningBlock(
// Stream-stable id (mirrors web/src/chat/reducerTimeline.ts):
// keeps the reasoning block identity stable across its
// snapshot rows so clients update it in place.
id: streamId ?? "\(msg.id):\(idx)",
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
durationMs: nil,
usage: msg.usage,
model: msg.model,
text: reasoningContent.text,
meta: msg.meta
)))
blocks.append(box)
if let streamId {
reasoningBlocksByStreamId[streamId] = box
}
continue
case .codexReview(let review, _, _):
blocks.append(BlockBox(.codexReview(CodexReviewBlock(
id: "\(msg.id):\(idx)",
localId: msg.localId,
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
durationMs: nil,
usage: msg.usage,
model: msg.model,
review: review,
meta: msg.meta
))))
continue
case .summary(let summary):
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: "\(msg.id):\(idx)",
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: .message(summary),
meta: msg.meta
))))
continue
case .toolCall(let toolCall):
if isChangeTitleToolName(toolCall.name) {
let title = context.titleChangesByToolUseId[toolCall.id]
?? extractTitleFromChangeTitleInput(toolCall.input)
if let title, !context.emittedTitleChangeToolUseIds.contains(toolCall.id) {
context.emittedTitleChangeToolUseIds.insert(toolCall.id)
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: "\(msg.id):\(idx)",
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: .titleChanged(title),
meta: msg.meta
))))
}
continue
}
let permission = context.permissionsById[toolCall.id]?.permission
let box = ensureToolBlock(&blocks, &toolBlocksById, id: toolCall.id, seed: ToolBlockSeed(
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
usage: msg.usage,
model: msg.model,
localId: msg.localId,
meta: msg.meta,
name: toolCall.name,
input: toolCall.input,
description: toolCall.description,
nativeTitle: toolCall.nativeTitle,
nativeKind: toolCall.nativeKind,
progress: toolCall.progress,
permission: permission,
agentTimestamp: msg.agentTimestamp
))
if var block = box.block.asToolCall, block.tool.state == .pending {
block.tool.state = .running
box.block = .toolCall(block)
}
// Backfill both clocks regardless of state so a
// tool_result reduced before its tool_use still lowers
// startedAt to the earlier tool_use receive time.
setEarliestStartedAt(box, msg.createdAt)
setEarliestExecStartedAt(box, msg.agentTimestamp)
if isSubagentToolName(toolCall.name) && !context.consumedGroupIds.contains(msg.id) {
let sidechain = context.groups[msg.id]
if let sidechain, !sidechain.isEmpty {
context.consumedGroupIds.insert(msg.id)
let child = reduceTimeline(sidechain, context: context)
hasReadyEvent = hasReadyEvent || child.hasReadyEvent
if var block = box.block.asToolCall {
block.children = child.blocks.materialized
box.block = .toolCall(block)
}
}
}
continue
case .toolResult(let toolResult):
let title = context.titleChangesByToolUseId[toolResult.toolUseId]
if let title {
if !context.emittedTitleChangeToolUseIds.contains(toolResult.toolUseId) {
context.emittedTitleChangeToolUseIds.insert(toolResult.toolUseId)
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: "\(msg.id):\(idx)",
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: .titleChanged(title),
meta: msg.meta
))))
}
continue
}
let permissionEntry = context.permissionsById[toolResult.toolUseId]
let permissionFromResult: ToolPermission? = toolResult.permissions.map { perms in
ToolPermission(
id: toolResult.toolUseId,
status: perms.result == .approved ? .approved : .denied,
mode: perms.mode,
allowedTools: perms.allowedTools,
decision: perms.decision,
date: perms.date,
// The TS literal carries these keys (values may
// be undefined): date, mode, allowedTools,
// decision — id/status are always present.
presentKeys: [.date, .mode, .allowedTools, .decision]
)
}
let permission: ToolPermission? = {
if let fromResult = permissionFromResult, let entryPermission = permissionEntry?.permission {
var merged = ToolPermission.spreading(entryPermission, with: fromResult)
merged.allowedTools = fromResult.allowedTools ?? entryPermission.allowedTools
merged.decision = fromResult.decision ?? entryPermission.decision
merged.presentKeys.formUnion([.allowedTools, .decision])
return merged
}
return permissionFromResult ?? permissionEntry?.permission
}()
let box = ensureToolBlock(&blocks, &toolBlocksById, id: toolResult.toolUseId, seed: ToolBlockSeed(
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
usage: msg.usage,
model: msg.model,
localId: msg.localId,
meta: msg.meta,
name: permissionEntry?.toolName ?? "Tool",
input: permissionEntry?.input ?? .null,
description: nil,
permission: permission
// No agentTimestamp seed: execStartedAt must only
// ever originate from a tool_use entry.
))
if var block = box.block.asToolCall {
block.tool.result = toolResult.content
block.tool.completedAt = msg.createdAt
block.tool.execCompletedAt = msg.agentTimestamp
block.tool.state = toolResult.isError ? .error : .completed
box.block = .toolCall(block)
}
continue
case .sidechain(let sidechain):
// Extract task-notification summaries as visible events.
let trimmedPrompt = sidechain.prompt.jsTrimmedStart
if trimmedPrompt.hasPrefix("<task-notification>") {
if let summaryStart = trimmedPrompt.range(of: "<summary>"),
let summaryEnd = trimmedPrompt.range(of: "</summary>", range: summaryStart.upperBound..<trimmedPrompt.endIndex) {
let summary = String(trimmedPrompt[summaryStart.upperBound..<summaryEnd.lowerBound]).jsTrimmed
if !summary.isEmpty {
blocks.append(BlockBox(.agentEvent(AgentEventBlock(
id: "\(msg.id):\(idx)",
createdAt: msg.createdAt,
invokedAt: msg.invokedAt,
model: msg.model,
event: .message(summary),
meta: msg.meta
))))
}
}
}
// The prompt text itself is never rendered.
continue
}
}
}
}
return TimelineResult(
blocks: mergeCliOutputBlocks(blocks),
toolBlocksById: toolBlocksById,
hasReadyEvent: hasReadyEvent
)
}