mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user