openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift
Peter Steinberger d769e31af2
perf(gateway): throttle sidebar narration updates (#160002)
* test: align frozen-base fixtures with current lifecycle contracts

Apply the prerequisite fixture repairs already landed in 0b6f02faae (#159836) so the narration comparison base can complete validation.

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* perf(gateway): throttle background sidebar narration with digests

Declare narration intent on targeted session subscriptions and pace bounded text snapshots on the server. Preserve full streams whenever any SDK owner renders a foreground transcript, and flush corrected final text before terminal events. Update bundled clients, protocol artifacts, and the event contract.

* fix(gateway): preserve foreground ownership across narration addresses

Track independent SDK observer IDs at the server so every full-stream interest survives alias collisions, rollback, and unrelated releases. Keep global requests bound to their acknowledged owner and pace distinct logical sessions separately.

Wait for foreground admission before history reads, retire stale admissions, and load subscription runtime on demand. Share the existing browser-safe UUID implementation and extend protocol and lifecycle regressions.

* test(gateway): expect resolved agent ownership in shared approval replay ACKs

* test(gateway): isolate the shared subscription lookup assertion

Narration also reads subscription intent for eligible recipients, so the
visible chat path legitimately performs three registry lookups. Check the
single admission lookup after visibility is revoked, when narration is
not entered. Preserve delivery, unrelated-recipient, visibility-revocation,
and event-sequencing assertions.

* ci: split UI component tests into their own core type shard

Apply the upstream partition fix so the narration merge retains complete
root coverage within the unchanged local shard-size budget.

(cherry picked from commit 52ae7e314d)

* test(ui): align narration fixtures with stream admission

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
2026-09-28 07:11:52 -07:00

253 lines
12 KiB
Swift

import Foundation
import OpenClawKit
import OpenClawProtocol
public enum OpenClawChatSessionKey {
public static func agentID(from sessionKey: String?) -> String? {
let parts = (sessionKey ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil }
let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return agentID.isEmpty ? nil : agentID
}
}
/// Canonical gateway payload mapping shared by the native Apple chat transports.
public enum OpenClawChatGatewayPayloadCodec {
public static func decodeSessionsList(_ data: Data, agentID: String?) throws -> OpenClawChatSessionsListResponse {
let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
return OpenClawChatSessionsListResponse(
ts: decoded.ts,
path: decoded.path,
count: decoded.count,
totalCount: decoded.totalCount,
offset: decoded.offset,
nextOffset: decoded.nextOffset,
hasMore: decoded.hasMore,
defaults: decoded.defaults,
sessions: decoded.sessions.map { row in
var row = row
row.agentId = OpenClawChatSessionKey.agentID(from: row.key) ?? row.agentId ?? agentID
return row
})
}
public static func decodeAgentsList(_ data: Data) throws -> OpenClawChatAgentsListResponse {
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
return OpenClawChatAgentsListResponse(
defaultId: result.defaultid,
agents: result.agents.filter(\.isSelectableAgent).map {
OpenClawChatAgentChoice(
id: $0.id,
name: OpenClawChatAgentChoice.normalizedName($0.name)
?? OpenClawChatAgentChoice.normalizedName($0.identity?["name"]?.value as? String),
emoji: OpenClawChatAgentChoice.textAvatar($0.identity?["emoji"]?.value as? String)
?? OpenClawChatAgentChoice.textAvatar($0.identity?["avatar"]?.value as? String),
workspaceGit: $0.workspacegit)
},
sessionRoutingContract: OpenClawChatSessionRoutingContract.make(
scope: result.scope.value as? String,
mainKey: result.mainkey,
defaultAgentID: result.defaultid))
}
public static func decodeProgressCard(_ data: Data, agentID: String?) throws -> ProgressCard? {
let result = try JSONDecoder().decode(ProgressCardGetResult.self, from: data)
guard !(result.card.value is NSNull) else { return nil }
let card = try GatewayPayloadDecoding.decode(result.card, as: ProgressCard.self)
if let agentID,
OpenClawChatSessionKey.agentID(from: card.sessionkey)?.lowercased() != agentID.lowercased()
{
throw NSError(domain: "OpenClawChatTransport", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Progress card response belongs to another agent.",
])
}
return card
}
public static func decodeQuestionAnswer(_ data: Data) throws -> QuestionAnswers {
struct AnsweredQuestion: Decodable {
enum Status: String, Decodable { case answered }
let status: Status
let answers: QuestionAnswers
}
return try JSONDecoder().decode(AnsweredQuestion.self, from: data).answers
}
private struct AgentWaitResponse: Decodable {
var status: String?
var endedAt: Double?
var error: String?
var stopReason: String?
var livenessState: String?
var yielded: Bool?
var pendingError: Bool?
var timeoutPhase: String?
var providerStarted: Bool?
var aborted: Bool?
}
public static func decodeAgentWaitObservation(_ data: Data) throws -> OpenClawChatRunObservation {
let decoded = try JSONDecoder().decode(AgentWaitResponse.self, from: data)
return OpenClawChatRunObservation.fromWaitResponse(
status: decoded.status,
endedAt: decoded.endedAt,
error: decoded.error,
stopReason: decoded.stopReason,
livenessState: decoded.livenessState,
yielded: decoded.yielded,
pendingError: decoded.pendingError,
timeoutPhase: decoded.timeoutPhase,
providerStarted: decoded.providerStarted,
aborted: decoded.aborted)
}
public static func decodeModelChoices(_ data: Data) throws -> [OpenClawChatModelChoice] {
let decoded = try JSONDecoder().decode(ModelsListResult.self, from: data)
return try decoded.models.map(self.modelChoice)
}
public static func decodeModelCatalog(_ data: Data) throws -> OpenClawChatModelCatalogSnapshot {
let decoded = try JSONDecoder().decode(ModelsListResult.self, from: data)
return try OpenClawChatModelCatalogSnapshot(
choices: decoded.models.map(self.modelChoice),
availabilityIsSessionScoped: true,
refreshFailed: decoded.refreshfailed == true,
modelSelectionPolicy: decoded.modelselectionpolicy.map {
try GatewayPayloadDecoding.decode(AnyCodable($0))
})
}
public static func decodeSessionRoutingIdentity(_ data: Data) throws -> OpenClawChatSessionRoutingIdentity {
let decoded = try JSONDecoder().decode(AgentsListResult.self, from: data)
guard let identity = OpenClawChatSessionRoutingIdentity(
scope: decoded.scope.value as? String,
mainSessionKey: decoded.mainkey,
defaultAgentID: decoded.defaultid)
else { throw CancellationError() }
return identity
}
public static func modelChoice(_ model: ModelChoice) throws -> OpenClawChatModelChoice {
let name = model.name.trimmingCharacters(in: .whitespacesAndNewlines)
return try OpenClawChatModelChoice(
modelID: model.id,
name: name.isEmpty ? model.id : model.name,
provider: model.provider,
available: model.available,
manualSelectionAllowed: model.manualselectionallowed,
unavailableReason: model.unavailablereason?.value as? String,
unavailableUntil: model.unavailableuntil,
contextWindow: model.contextwindow,
reasoning: model.reasoning,
supportsFastMode: model.supportsfastmode,
effectiveFastMode: model.effectivefastmode.map { try GatewayPayloadDecoding.decode($0) },
thinkingLevels: model.thinkinglevels.map { try GatewayPayloadDecoding.decode(AnyCodable($0)) },
thinkingDefault: model.thinkingdefault,
input: model.input.map { try GatewayPayloadDecoding.decode(AnyCodable($0)) },
agentRuntime: model.agentruntime.map { try GatewayPayloadDecoding.decode(AnyCodable($0)) })
}
public static func commandChoice(_ entry: CommandEntry) -> OpenClawChatCommandChoice {
let sourceValue = (entry.source.value as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let source: OpenClawChatCommandChoice.Source = switch sourceValue {
case "native":
.command
case "skill":
.skill
case "plugin":
.plugin
default:
.unknown
}
let aliases = (entry.textaliases ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let id = [
source.rawValue,
entry.name.trimmingCharacters(in: .whitespacesAndNewlines),
aliases.first ?? "",
].joined(separator: ":")
return OpenClawChatCommandChoice(
id: id,
name: entry.name,
textAliases: aliases,
description: entry.description,
source: source,
acceptsArgs: entry.acceptsargs)
}
private struct MetadataChangedPayload: Decodable {
let modelSelectionChanged: Bool?
}
public static func event(from frame: EventFrame) -> OpenClawChatTransportEvent? {
func decode<T: Decodable>(_ type: T.Type) -> T? {
frame.payload.flatMap { try? GatewayPayloadDecoding.decode($0, as: type) }
}
switch frame.event {
case "tick":
return .tick
case "chat.metadata.changed":
let payload = decode(MetadataChangedPayload.self)
return payload?.modelSelectionChanged == true ? .modelSelectionChanged : .chatMetadataChanged
case "config.changed":
return .modelSelectionChanged
case "sessions.changed":
return decode(OpenClawChatSessionsChangedEvent.self).map(OpenClawChatTransportEvent.sessionsChanged)
case "session.narration":
// Native foreground subscriptions use full streams; bounded narration
// tails cannot replace transcript messages.
return nil
case "session.observer":
return decode(SessionObserverDigest.self).map(OpenClawChatTransportEvent.sessionObserver)
case "seqGap":
return .seqGap
case "health":
guard let payload = frame.payload else { return nil }
let ok = (try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawGatewayHealthOK.self))?.ok ?? true
return .health(ok: ok)
case "chat":
return decode(OpenClawChatEventPayload.self).map(OpenClawChatTransportEvent.chat)
case "session.message":
guard let message = decode(OpenClawSessionMessageEventPayload.self)
else { return nil }
if var canonicalMessage = message.message,
canonicalMessage.transcriptMessageID?
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false,
let messageID = message.messageId?.trimmingCharacters(in: .whitespacesAndNewlines),
!messageID.isEmpty
{
// Live events carry durable transcript identity on their envelope.
// Preserve it on the row so history cannot replay the same message.
canonicalMessage.transcriptMessageID = messageID
return .sessionMessage(OpenClawSessionMessageEventPayload(
sessionKey: message.sessionKey,
agentId: message.agentId,
message: canonicalMessage,
messageId: message.messageId,
messageSeq: message.messageSeq,
hasActiveRun: message.hasActiveRun,
activeRunIds: message.activeRunIds,
activeRunIdsPresent: message.activeRunIdsPresent))
}
return .sessionMessage(message)
case "agent":
return decode(OpenClawAgentEventPayload.self).map(OpenClawChatTransportEvent.agent)
case "progressCard.changed":
return decode(ProgressCardChangedEvent.self).map(OpenClawChatTransportEvent.progressCardChanged)
case "question.requested":
return decode(QuestionRecord.self).map(OpenClawChatTransportEvent.questionRequested)
case "question.resolved":
return decode(OpenClawQuestionResolvedEvent.self).map(OpenClawChatTransportEvent.questionResolved)
default:
return nil
}
}
}