refactor(apple): share chat gateway payload mapping (#106154)

* refactor(apple): share chat gateway payload mapping

* test(apple): track shared event mapping
This commit is contained in:
Peter Steinberger
2026-07-13 02:39:01 -07:00
committed by GitHub
parent 87e2bafe00
commit 73a0bf4b7c
12 changed files with 304 additions and 319 deletions
@@ -79,18 +79,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
}
private struct ModelsListResponse: Decodable {
var models: [Model]
struct Model: Decodable {
var id: String
var name: String
var provider: String
var contextWindow: Int?
var reasoning: Bool?
}
}
private struct ChatSendParams: Codable {
var sessionKey: String
var agentId: String?
@@ -113,21 +101,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
var timeoutMs: Int
}
private struct AgentWaitResponse: Codable {
var runId: String?
var status: String?
var startedAt: Double?
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?
}
init(
gateway: GatewayNodeSession,
globalAgentId: String? = nil,
@@ -271,44 +244,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
try self.encodeParams(CommandsListRequestParams(
scope: "text",
includeArgs: true,
agentId: self.agentID(fromSessionKey: sessionKey) ?? agentId))
}
static func agentID(fromSessionKey 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
}
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)
}
static func decodeModelChoices(_ data: Data) throws -> [OpenClawChatModelChoice] {
let decoded = try JSONDecoder().decode(ModelsListResponse.self, from: data)
return decoded.models.map { model in
let name = model.name.trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawChatModelChoice(
modelID: model.id,
name: name.isEmpty ? model.id : model.name,
provider: model.provider,
contextWindow: model.contextWindow,
reasoning: model.reasoning)
}
agentId: OpenClawChatSessionKey.agentID(from: sessionKey) ?? agentId))
}
static func makeCreateSessionParamsJSON(
@@ -392,7 +328,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
let sessionKey = rawSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let selected = selectedAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let override = overrideAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.agentID(fromSessionKey: sessionKey) != nil {
if OpenClawChatSessionKey.agentID(from: sessionKey) != nil {
return SessionTarget(sessionKey: sessionKey, agentID: override)
}
if sessionKey.lowercased().hasPrefix("agent:") {
@@ -477,7 +413,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
method: "models.list",
paramsJSON: nil,
timeoutSeconds: 15)
return try Self.decodeModelChoices(response)
return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(response)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
@@ -536,7 +472,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
func forkSession(parentKey: String) async throws -> String {
let target = self.sessionTarget(for: parentKey)
let childAgentID = target.agentID ?? Self.agentID(fromSessionKey: target.sessionKey)
let childAgentID = target.agentID ?? OpenClawChatSessionKey.agentID(from: target.sessionKey)
let json = try Self.makeForkSessionParamsJSON(
parentKey: target.sessionKey,
agentId: childAgentID)
@@ -605,10 +541,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
let json = try Self.makeCommandsListParamsJSON(
sessionKey: sessionKey,
agentId: Self.agentID(fromSessionKey: sessionKey) ?? self.globalAgentId)
agentId: OpenClawChatSessionKey.agentID(from: sessionKey) ?? self.globalAgentId)
let res = try await gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15)
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res)
return decoded.commands.map(Self.mapCommandChoice)
return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice)
}
func sendMessage(
@@ -714,37 +650,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
}
private static func mapCommandChoice(_ 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)
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int) async -> OpenClawChatRunObservation
@@ -773,7 +678,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
paramsJSON: json,
timeoutSeconds: requestTimeoutSeconds,
ifCurrentRoute: expectedRoute)
let observation = try Self.decodeAgentWaitObservation(res)
let observation = try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(res)
GatewayDiagnostics.log("agent.wait completed runId=\(runId) observation=\(observation)")
return observation
} catch {
@@ -795,7 +700,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
let stream = await self.gateway.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
if let mapped = Self.mapEventFrame(evt) {
if let mapped = OpenClawChatGatewayPayloadCodec.event(from: evt) {
continuation.yield(mapped)
}
}
@@ -806,57 +711,4 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
}
}
static func mapEventFrame(_ evt: EventFrame) -> OpenClawChatTransportEvent? {
switch evt.event {
case "tick":
return .tick
case "sessions.changed":
guard let payload = evt.payload else { return nil }
guard let change = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawChatSessionsChangedEvent.self)
else {
return nil
}
return .sessionsChanged(change)
case "seqGap":
return .seqGap
case "health":
guard let payload = evt.payload else { return nil }
let ok = (try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawGatewayHealthOK.self))?.ok ?? true
return .health(ok: ok)
case "chat":
guard let payload = evt.payload else { return nil }
guard let chatPayload = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawChatEventPayload.self)
else {
return nil
}
return .chat(chatPayload)
case "session.message":
guard let payload = evt.payload else { return nil }
guard let message = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawSessionMessageEventPayload.self)
else {
return nil
}
return .sessionMessage(message)
case "agent":
guard let payload = evt.payload else { return nil }
guard let agentPayload = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawAgentEventPayload.self)
else {
return nil
}
return .agent(agentPayload)
default:
return nil
}
}
}
@@ -46,30 +46,39 @@ struct IOSGatewayChatTransportTests {
@Test func `agent wait distinguishes terminal and retryable timeouts`() throws {
let data = Data(#"{"status":"completed"}"#.utf8)
#expect(try IOSGatewayChatTransport.decodeAgentWaitObservation(data) == .terminal(.completed))
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(data) == .terminal(.completed))
let pending = Data(#"{"status":"pending"}"#.utf8)
#expect(try IOSGatewayChatTransport.decodeAgentWaitObservation(pending) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(pending) == .checkAgain)
let queued = Data(#"{"status":"timeout","timeoutPhase":"queue","providerStarted":false}"#.utf8)
#expect(try IOSGatewayChatTransport.decodeAgentWaitObservation(queued) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(queued) == .checkAgain)
let providerTimeout = Data(
#"{"status":"timeout","timeoutPhase":"provider","providerStarted":true}"#.utf8)
#expect(
try IOSGatewayChatTransport.decodeAgentWaitObservation(providerTimeout) ==
try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(providerTimeout) ==
.terminal(.failed(message: "Run timed out")))
let stopped = Data(#"{"status":"timeout","stopReason":"stop"}"#.utf8)
#expect(
try IOSGatewayChatTransport.decodeAgentWaitObservation(stopped) ==
try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(stopped) ==
.terminal(.failed(message: "Run cancelled")))
}
@Test func `model patch result decodes authoritative Luna thinking state`() throws {
let data = Data(
#"{"entry":{"thinkingLevel":"ultra"},"resolved":{"modelProvider":"openai","model":"gpt-5.6-luna","thinkingLevel":"max","thinkingLevels":[{"id":"off","label":"off"},{"id":"max","label":"max"}]}}"#
.utf8)
#"""
{
"entry":{"thinkingLevel":"ultra"},
"resolved":{
"modelProvider":"openai",
"model":"gpt-5.6-luna",
"thinkingLevel":"max",
"thinkingLevels":[{"id":"off","label":"off"},{"id":"max","label":"max"}]
}
}
"""#.utf8)
let result = try IOSGatewayChatTransport.decodeModelPatchResult(data)
@@ -109,8 +118,22 @@ struct IOSGatewayChatTransportTests {
@Test func `hello advertises guarded chat send capability`() throws {
let data = Data(
#"{"type":"hello-ok","protocol":4,"server":{"version":"test","connId":"test"},"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}"#
.utf8)
#"""
{
"type":"hello-ok",
"protocol":4,
"server":{"version":"test","connId":"test"},
"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},
"snapshot":{
"presence":[],
"health":{},
"stateVersion":{"presence":0,"health":0},
"uptimeMs":0
},
"auth":{},
"policy":{}
}
"""#.utf8)
let hello = try JSONDecoder().decode(HelloOk.self, from: data)
#expect(hello.supportsServerCapability(.chatSendRoutingContract))
}
@@ -191,11 +214,17 @@ struct IOSGatewayChatTransportTests {
let data = Data(
#"""
{"models":[
{"id":"claude-opus-4","name":"Claude Opus 4","provider":"anthropic","contextWindow":200000,"reasoning":true},
{
"id":"claude-opus-4",
"name":"Claude Opus 4",
"provider":"anthropic",
"contextWindow":200000,
"reasoning":true
},
{"id":"gpt-5","name":" ","provider":"openai","extra":"ignored"}
]}
"""#.utf8)
let choices = try IOSGatewayChatTransport.decodeModelChoices(data)
let choices = try OpenClawChatGatewayPayloadCodec.decodeModelChoices(data)
#expect(choices.count == 2)
#expect(choices[0].modelID == "claude-opus-4")
@@ -421,7 +450,7 @@ struct IOSGatewayChatTransportTests {
payload: payload,
seq: 1,
stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
switch mapped {
case let .sessionMessage(message):
@@ -449,7 +478,7 @@ struct IOSGatewayChatTransportTests {
seq: 1,
stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
guard case let .sessionsChanged(change) = mapped else {
Issue.record("expected .sessionsChanged, got \(String(describing: mapped))")
return
@@ -467,7 +496,7 @@ struct IOSGatewayChatTransportTests {
"state": AnyCodable("final"),
])
let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
switch mapped {
case let .chat(chat):
@@ -486,7 +515,7 @@ struct IOSGatewayChatTransportTests {
payload: AnyCodable(["a": AnyCodable(1)]),
seq: 1,
stateversion: nil)
let mapped = IOSGatewayChatTransport.mapEventFrame(frame)
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
#expect(mapped == nil)
}
}
+1
View File
@@ -89,6 +89,7 @@ let package = Package(
"OpenClaw",
"OpenClawMacCLI",
"OpenClawDiscovery",
.product(name: "OpenClawChatUI", package: "OpenClawKit"),
.product(name: "OpenClawKit", package: "OpenClawKit"),
.product(name: "OpenClawMLXTTSProtocol", package: "OpenClawMLXTTSProtocol"),
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
@@ -19,19 +19,6 @@ private enum WebChatSwiftUILayout {
}
struct MacGatewayChatTransport: OpenClawChatTransport {
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?
}
/// Shared across transport value copies so the live view model and its
/// snapshot observer cannot diverge on the owner of the bare global alias.
private final class RoutingIdentity: @unchecked Sendable {
@@ -116,8 +103,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
method: "models.list",
params: [:],
timeoutMs: 15000)
let result = try JSONDecoder().decode(ModelsListResult.self, from: data)
return result.models.map(Self.mapModelChoice)
return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(data)
} catch {
webChatSwiftLogger.warning(
"models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)")
@@ -322,7 +308,9 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
"scope": AnyCodable("text"),
"includeArgs": AnyCodable(true),
]
if let agentID = Self.agentID(fromSessionKey: sessionKey) ?? self.routingIdentity.currentAgentID() {
if let agentID = OpenClawChatSessionKey.agentID(from: sessionKey)
?? self.routingIdentity.currentAgentID()
{
params["agentId"] = AnyCodable(agentID)
}
let data = try await GatewayConnection.shared.request(
@@ -330,7 +318,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
params: params,
timeoutMs: 15000)
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: data)
return decoded.commands.map(Self.mapCommandChoice)
return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice)
}
func createSession(
@@ -342,8 +330,8 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
var params: [String: AnyCodable] = [
"key": AnyCodable(key),
]
if let agentID = Self.agentID(fromSessionKey: key)
?? parentSessionKey.flatMap(Self.agentID(fromSessionKey:))
if let agentID = OpenClawChatSessionKey.agentID(from: key)
?? parentSessionKey.flatMap({ OpenClawChatSessionKey.agentID(from: $0) })
?? self.routingIdentity.currentAgentID()
{
params["agentId"] = AnyCodable(agentID)
@@ -424,18 +412,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
],
timeoutMs: Double(timeoutMs + 5000),
ifCurrentRoute: route)
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)
return try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(data)
} catch {
webChatSwiftLogger.warning(
"agent.wait failed runId=\(runId, privacy: .public) "
@@ -503,108 +480,12 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return .health(ok: ok)
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return nil }
let ok = (try? JSONDecoder().decode(
OpenClawGatewayHealthOK.self,
from: JSONEncoder().encode(payload)))?.ok ?? true
return .health(ok: ok)
case "tick":
return .tick
case "sessions.changed":
guard let payload = evt.payload else { return nil }
guard let change = try? JSONDecoder().decode(
OpenClawChatSessionsChangedEvent.self,
from: JSONEncoder().encode(payload))
else {
return nil
}
return .sessionsChanged(change)
case "chat":
guard let payload = evt.payload else { return nil }
guard let chat = try? JSONDecoder().decode(
OpenClawChatEventPayload.self,
from: JSONEncoder().encode(payload))
else {
return nil
}
return .chat(chat)
case "session.message":
guard let payload = evt.payload else { return nil }
guard let message = try? JSONDecoder().decode(
OpenClawSessionMessageEventPayload.self,
from: JSONEncoder().encode(payload))
else {
return nil
}
return .sessionMessage(message)
case "agent":
guard let payload = evt.payload else { return nil }
guard let agent = try? JSONDecoder().decode(
OpenClawAgentEventPayload.self,
from: JSONEncoder().encode(payload))
else {
return nil
}
return .agent(agent)
default:
return nil
}
return OpenClawChatGatewayPayloadCodec.event(from: evt)
case .seqGap:
return .seqGap
}
}
private static func mapModelChoice(_ model: OpenClawProtocol.ModelChoice) -> OpenClawChatModelChoice {
OpenClawChatModelChoice(
modelID: model.id,
name: model.name,
provider: model.provider,
contextWindow: model.contextwindow,
reasoning: model.reasoning)
}
static func agentID(fromSessionKey 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
}
private static func mapCommandChoice(_ 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)
}
}
// MARK: - Window controller
@@ -0,0 +1,92 @@
import Foundation
import OpenClawChatUI
import OpenClawProtocol
import Testing
struct ChatGatewayPayloadCodecTests {
@Test func `session key extracts canonical agent identity`() {
#expect(OpenClawChatSessionKey.agentID(from: " agent:Reviewer:main ") == "Reviewer")
#expect(OpenClawChatSessionKey.agentID(from: "agent::main") == nil)
#expect(OpenClawChatSessionKey.agentID(from: "global") == nil)
}
@Test func `agent wait distinguishes terminal and retryable timeouts`() throws {
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"completed"}"#.utf8)) == .terminal(.completed))
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"pending"}"#.utf8)) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"timeout","timeoutPhase":"queue"}"#.utf8)) == .checkAgain)
#expect(try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(
Data(#"{"status":"timeout","timeoutPhase":"provider"}"#.utf8)) ==
.terminal(.failed(message: "Run timed out")))
}
@Test func `model choices preserve metadata and replace blank names`() throws {
let choices = try OpenClawChatGatewayPayloadCodec.decodeModelChoices(Data(
#"{"models":[{"id":"gpt-5","name":" ","provider":"openai","contextWindow":200000,"reasoning":true}]}"#
.utf8))
#expect(choices == [OpenClawChatModelChoice(
modelID: "gpt-5",
name: "gpt-5",
provider: "openai",
contextWindow: 200_000,
reasoning: true)])
}
@Test func `command choice normalizes source aliases and identity`() {
let choice = OpenClawChatGatewayPayloadCodec.commandChoice(CommandEntry(
name: "review",
textaliases: [" /review ", ""],
description: "Review changes",
source: AnyCodable("plugin"),
scope: AnyCodable("text"),
acceptsargs: true))
#expect(choice.id == "plugin:review:/review")
#expect(choice.textAliases == ["/review"])
#expect(choice.source == .plugin)
#expect(choice.acceptsArgs)
}
@Test func `event frames map to shared chat transport events`() {
let sessionsChanged = EventFrame(
type: "event",
event: "sessions.changed",
payload: AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
"agentId": AnyCodable("main"),
"reason": AnyCodable("command-metadata"),
]))
guard case let .sessionsChanged(change) = OpenClawChatGatewayPayloadCodec.event(from: sessionsChanged)
else {
Issue.record("expected sessionsChanged")
return
}
#expect(change == .init(
sessionKey: "agent:main:main",
agentId: "main",
reason: "command-metadata"))
let chat = EventFrame(
type: "event",
event: "chat",
payload: AnyCodable([
"runId": AnyCodable("run-1"),
"sessionKey": AnyCodable("main"),
"state": AnyCodable("final"),
]))
guard case let .chat(payload) = OpenClawChatGatewayPayloadCodec.event(from: chat) else {
Issue.record("expected chat")
return
}
#expect(payload.runId == "run-1")
#expect(payload.sessionKey == "main")
#expect(payload.state == "final")
#expect(OpenClawChatGatewayPayloadCodec.event(from: EventFrame(
type: "event",
event: "unknown")) == nil)
}
}
+1
View File
@@ -50,6 +50,7 @@ let package = Package(
name: "OpenClawChatUI",
dependencies: [
"OpenClawKit",
"OpenClawProtocol",
.product(name: "Markdown", package: "swift-markdown"),
.product(name: "SwiftMath", package: "SwiftMath"),
],
@@ -0,0 +1,136 @@
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 {
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 decoded.models.map(self.modelChoice)
}
public static func modelChoice(_ model: ModelChoice) -> OpenClawChatModelChoice {
let name = model.name.trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawChatModelChoice(
modelID: model.id,
name: name.isEmpty ? model.id : model.name,
provider: model.provider,
contextWindow: model.contextwindow,
reasoning: model.reasoning)
}
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)
}
public static func event(from frame: EventFrame) -> OpenClawChatTransportEvent? {
switch frame.event {
case "tick":
return .tick
case "sessions.changed":
guard let payload = frame.payload,
let change = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawChatSessionsChangedEvent.self)
else { return nil }
return .sessionsChanged(change)
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":
guard let payload = frame.payload,
let chat = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawChatEventPayload.self)
else { return nil }
return .chat(chat)
case "session.message":
guard let payload = frame.payload,
let message = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawSessionMessageEventPayload.self)
else { return nil }
return .sessionMessage(message)
case "agent":
guard let payload = frame.payload,
let agent = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawAgentEventPayload.self)
else { return nil }
return .agent(agent)
default:
return nil
}
}
}
@@ -876,7 +876,7 @@ extension OpenClawChatViewModel {
guard let agentID else { return nil }
let normalized = raw.lowercased()
if normalized == "global" { return "global" }
if Self.agentID(fromSessionKey: raw) != nil { return raw }
if OpenClawChatSessionKey.agentID(from: raw) != nil { return raw }
// A malformed ownership prefix must fail closed, not become a nested
// key such as agent:<id>:agent::main.
guard !normalized.hasPrefix("agent:") else { return nil }
@@ -64,7 +64,7 @@ extension OpenClawChatViewModel {
let normalizedCandidate = candidate.lowercased()
let targetKey: String
let targetAgentID: String?
if Self.agentID(fromSessionKey: candidate) != nil || normalizedCandidate == "unknown" {
if OpenClawChatSessionKey.agentID(from: candidate) != nil || normalizedCandidate == "unknown" {
targetKey = candidate
targetAgentID = nil
} else if normalizedCandidate == "global" {
@@ -79,7 +79,7 @@ extension OpenClawChatViewModel {
}
static func transcriptCacheAgentID(sessionKey: String, agentID: String?) -> String? {
guard Self.agentID(fromSessionKey: sessionKey) == nil else { return nil }
guard OpenClawChatSessionKey.agentID(from: sessionKey) == nil else { return nil }
let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
@@ -542,7 +542,7 @@ public final class OpenClawChatViewModel {
}
private var usesMutableAgentRouting: Bool {
Self.agentID(fromSessionKey: self.sessionKey) == nil
OpenClawChatSessionKey.agentID(from: self.sessionKey) == nil
}
private func usesMutableContractRouting(for contract: String?) -> Bool {
@@ -550,7 +550,7 @@ public final class OpenClawChatViewModel {
}
func usesMutableContractRouting(sessionKey: String, contract: String?) -> Bool {
if Self.agentID(fromSessionKey: sessionKey) == nil {
if OpenClawChatSessionKey.agentID(from: sessionKey) == nil {
return true
}
let parts = sessionKey
@@ -722,7 +722,7 @@ extension OpenClawChatViewModel {
key: self.sessionKey,
generation: self.sessionGeneration,
agentID: self.activeAgentId,
deliveryAgentID: Self.agentID(fromSessionKey: self.sessionKey) ?? self.activeAgentId,
deliveryAgentID: OpenClawChatSessionKey.agentID(from: self.sessionKey) ?? self.activeAgentId,
sessionRoutingContract: self.sessionRoutingContract)
}
@@ -1552,25 +1552,16 @@ extension OpenClawChatViewModel {
private func generatedNewSessionKey() -> String {
let baseKey = "ios-\(UUID().uuidString.lowercased())"
guard let agentID = Self.agentID(fromSessionKey: sessionKey) ??
guard let agentID = OpenClawChatSessionKey.agentID(from: sessionKey) ??
activeAgentId ??
Self.agentID(fromSessionKey: resolvedMainSessionKey) ??
sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first
OpenClawChatSessionKey.agentID(from: resolvedMainSessionKey) ??
sessions.lazy.compactMap({ OpenClawChatSessionKey.agentID(from: $0.key) }).first
else {
return baseKey
}
return "agent:\(agentID):\(baseKey)"
}
static func agentID(fromSessionKey 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
}
private func modelLabel(for modelID: String) -> String {
self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ??
modelID
+4 -2
View File
@@ -26,9 +26,11 @@ const ALLOWLIST_FILE = "scripts/protocol-event-coverage.allowlist.json";
// Scan roots per client. The sentinel files are the primary event dispatch
// surfaces; if one moves, the check must fail loudly instead of silently
// passing with an empty handled set.
// passing with an empty handled set. Apple event mapping is shared by iOS and
// macOS, so the iOS coverage owner lives in OpenClawChatUI.
const IOS_SCAN_ROOTS = ["apps/ios/Sources", "apps/shared/OpenClawKit/Sources"];
const IOS_SENTINEL_FILE = "apps/ios/Sources/Chat/IOSGatewayChatTransport.swift";
const IOS_SENTINEL_FILE =
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift";
const ANDROID_SCAN_ROOT = "apps/android/app/src/main/java/ai/openclaw/app";
const ANDROID_SENTINEL_FILES = [
"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",