mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* test: align frozen-base fixtures with current lifecycle contracts Apply the prerequisite fixture repairs already landed in0b6f02faae(#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 commit52ae7e314d) * test(ui): align narration fixtures with stream admission --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
253 lines
12 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|