mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
3f006ba0fc
* fix(ui): complete pairing setup lifecycle Redeemed and expired mobile setup codes stayed on screen as usable QR codes, so a successful pairing had no visible outcome and expired bearer material still looked live. The Gateway now mints an opaque, non-authorizing setupId beside each setup credential, returns its authoritative expiresAtMs, records the terminal outcome of the exact redemption, and broadcasts device.pair.setup.completed. Because that broadcast is dropped for buffered operator sockets, the completion is persisted first and can be reconciled through the new device.pair.setupStatus method: the Control UI asks for the recorded outcome before it may present a credential as expired, so a pairing that succeeds is never shown as a failure. The Control UI models one closed lifecycle (selection, loading, waiting, success, expired, error) correlated only by the active setupId, and Pairing help now carries the external-link affordance. * fix(ui): preserve unknown pairing outcomes * test(ui): target pairing terminal headings * test(ui): align pairing completion fixture * fix(gateway-protocol): decode setup-code results from older gateways Older protocol-v4 gateways omit the new setupId and expiresAtMs lifecycle fields, so requiring them in the generated native model broke decoding an existing device.pair.setupCode response. Keep both optional at the wire boundary, require lifecycle metadata before the Control UI enters its waiting state so a missing outcome stays visible, and cover the legacy payload with Swift and schema regressions. * fix(ui): surface rejected pairing dialog loads The lazy pairing chunk could reject while its overlay was already open, leaving the shell rendering nothing at all. Record the rejection on the shell and render a recoverable modal with a reason and a retry so the open action always ends in a visible outcome. * fix(state): preserve pairing setup schema compatibility * test(gateway): cover pairing setup release train * fix(gateway): commit pairing setup completion atomically * refactor(state): distinguish setup transaction helpers * refactor(state): remove obsolete bootstrap restore path * fix(gateway): preserve setup handoff type safety * fix(gateway): keep pairing completion terminal after consume * fix(gateway): validate Watch binding during setup commit * fix(gateway): revalidate setup credential expiry at commit * chore: refresh Plugin SDK API contracts after rebase * fix(pairing): prune expired setup completions * chore: retrigger CI * fix(protocol): deduplicate setup expiry field * fix(protocol): refresh pairing setup clients * fix(gateway): make pairing setup completion durable * fix(ui): retire expired pairing credentials immediately * fix(ui): keep pairing dialog visible while loading * fix(macos): align setup result initializer order * fix(gateway): restore generic bootstrap retries * chore(ui): record pairing startup budget * chore(ui): refresh pairing startup budget * style(gateway): format maintenance imports * test(gateway): cover session-sharing mock * fix(state): defer setup correlation schema --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
225 lines
8.1 KiB
Swift
225 lines
8.1 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
import Testing
|
|
|
|
struct GatewayModelsCompatibilityTests {
|
|
@Test
|
|
func `agents workspace encoding remains AnyCodable`() {
|
|
let file = AgentsWorkspaceFile(
|
|
path: "notes.txt",
|
|
name: "notes.txt",
|
|
size: 5,
|
|
updatedatms: 1,
|
|
mimetype: "text/plain",
|
|
encoding: AnyCodable("utf8"),
|
|
content: "hello")
|
|
|
|
#expect(file.encoding.value as? String == "utf8")
|
|
}
|
|
|
|
private func roundTripGatewayFrame(_ json: String) throws -> GatewayFrame {
|
|
let decoded = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
|
let encoded = try JSONEncoder().encode(decoded)
|
|
return try JSONDecoder().decode(GatewayFrame.self, from: encoded)
|
|
}
|
|
|
|
@Test
|
|
func `optional fields stay additive around required fields`() {
|
|
let params = PluginApprovalRequestParams(
|
|
title: "Install plugin",
|
|
description: "Review requested")
|
|
|
|
#expect(params.pluginid == nil)
|
|
#expect(params.approvalreviewerdeviceids == nil)
|
|
}
|
|
|
|
@Test
|
|
func `optional fields stay additive before trailing required fields`() {
|
|
let params = MessageActionParams(
|
|
channel: "slack",
|
|
action: "member-info",
|
|
params: [:],
|
|
idempotencykey: "test")
|
|
|
|
#expect(params.accountid == nil)
|
|
#expect(params.requesteraccountid == nil)
|
|
}
|
|
|
|
@Test
|
|
func `strict literal model optional fields default to nil`() {
|
|
let result = PluginsSessionActionSuccessResult()
|
|
|
|
#expect(result.ok)
|
|
#expect(result.result == nil)
|
|
}
|
|
|
|
@Test
|
|
func `session group results decode older gateway payloads`() throws {
|
|
let list = try JSONDecoder().decode(
|
|
SessionsGroupsListResult.self,
|
|
from: Data(#"{"groups":[]}"#.utf8))
|
|
let mutation = try JSONDecoder().decode(
|
|
SessionsGroupsMutationResult.self,
|
|
from: Data(#"{"ok":true,"groups":[]}"#.utf8))
|
|
|
|
#expect(list.sectionorder == nil)
|
|
#expect(mutation.sectionorder == nil)
|
|
}
|
|
|
|
@Test
|
|
func `device pair setup results decode older gateway payloads`() throws {
|
|
let result = try JSONDecoder().decode(
|
|
DevicePairSetupCodeResult.self,
|
|
from: Data(
|
|
#"{"setupCode":"opaque-code","gatewayUrl":"wss://gateway.example","auth":"token","urlSource":"gateway.remote.url"}"#
|
|
.utf8))
|
|
|
|
#expect(result.setupid == nil)
|
|
#expect(result.expiresatms == nil)
|
|
#expect(result.setupcode == "opaque-code")
|
|
}
|
|
|
|
@Test
|
|
func `generated models ignore additive gateway fields`() throws {
|
|
let result = try JSONDecoder().decode(
|
|
SessionsGroupsListResult.self,
|
|
from: Data(#"{"groups":[],"futureField":{"ignored":true}}"#.utf8))
|
|
|
|
#expect(result.groups.isEmpty)
|
|
#expect(result.sectionorder == nil)
|
|
}
|
|
|
|
@Test
|
|
func `session compaction checkpoint preserves canonical token version casing`() throws {
|
|
let checkpoint = SessionCompactionCheckpoint(
|
|
checkpointid: "checkpoint-1",
|
|
sessionkey: "main",
|
|
sessionid: "session-1",
|
|
createdat: 1,
|
|
reason: AnyCodable("manual"),
|
|
tokensVersion: 1,
|
|
precompaction: [:],
|
|
postcompaction: [:])
|
|
|
|
#expect(checkpoint.tokensVersion == 1)
|
|
|
|
let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(checkpoint))
|
|
let encodedJSON = try #require(encoded as? [String: Any])
|
|
#expect(encodedJSON.keys.contains("tokensVersion"))
|
|
#expect(!encodedJSON.keys.contains("tokensversion"))
|
|
|
|
let decoded = try JSONDecoder().decode(
|
|
SessionCompactionCheckpoint.self,
|
|
from: Data(
|
|
#"{"checkpointId":"checkpoint-2","sessionKey":"main","sessionId":"session-2","createdAt":2,"reason":"manual","tokensVersion":1,"preCompaction":{},"postCompaction":{}}"#
|
|
.utf8))
|
|
|
|
#expect(decoded.tokensVersion == 1)
|
|
}
|
|
|
|
@Test
|
|
func `request frames round trip current payloads`() throws {
|
|
let frame = try self.roundTripGatewayFrame(
|
|
#"{"type":"req","id":"req-1","method":"sessions.list","params":{"limit":20}}"#)
|
|
|
|
guard case let .req(request) = frame else {
|
|
Issue.record("Expected a request frame")
|
|
return
|
|
}
|
|
let params = try #require(request.params?.value as? [String: AnyCodable])
|
|
|
|
#expect(request.id == "req-1")
|
|
#expect(request.method == "sessions.list")
|
|
#expect(params["limit"] == AnyCodable(20))
|
|
}
|
|
|
|
@Test
|
|
func `response frames round trip current payloads`() throws {
|
|
let frame = try self.roundTripGatewayFrame(
|
|
#"{"type":"res","id":"req-1","ok":true,"payload":{"sessions":[]}}"#)
|
|
|
|
guard case let .res(response) = frame else {
|
|
Issue.record("Expected a response frame")
|
|
return
|
|
}
|
|
let payload = try #require(response.payload?.value as? [String: AnyCodable])
|
|
|
|
#expect(response.id == "req-1")
|
|
#expect(response.ok)
|
|
#expect(payload["sessions"] == AnyCodable([Any]()))
|
|
}
|
|
|
|
@Test
|
|
func `event frames round trip current payloads`() throws {
|
|
let frame = try self.roundTripGatewayFrame(
|
|
#"{"type":"event","event":"tick","payload":{"ts":123},"seq":7,"stateVersion":{"presence":2,"health":3}}"#)
|
|
|
|
guard case let .event(event) = frame else {
|
|
Issue.record("Expected an event frame")
|
|
return
|
|
}
|
|
let payload = try #require(event.payload?.value as? [String: AnyCodable])
|
|
|
|
#expect(event.event == "tick")
|
|
#expect(event.seq == 7)
|
|
#expect(event.stateversion?.presence == 2)
|
|
#expect(event.stateversion?.health == 3)
|
|
#expect(payload["ts"] == AnyCodable(123))
|
|
}
|
|
|
|
@Test
|
|
func `chat send canonical initializer stays unambiguous`() {
|
|
let params = ChatSendParams(
|
|
sessionkey: "main",
|
|
message: "hello",
|
|
idempotencykey: "test")
|
|
let legacyParams = ChatSendParams(
|
|
sessionkey: "main",
|
|
message: "hello",
|
|
fastmode: true,
|
|
idempotencykey: "test")
|
|
|
|
#expect(params.agentid == nil)
|
|
#expect(params.fastmodevalue == nil)
|
|
#expect(legacyParams.fastmode == true)
|
|
}
|
|
|
|
@Test
|
|
func `agent update model keeps legacy source compatibility and nullable wire semantics`() throws {
|
|
let legacyParams = AgentsUpdateParams(agentid: "work", model: "openai/gpt-5.6")
|
|
let omittedParams = AgentsUpdateParams(agentid: "work")
|
|
let clearedParams = AgentsUpdateParams(agentid: "work", modelvalue: AnyCodable(NSNull()))
|
|
|
|
#expect(legacyParams.model == "openai/gpt-5.6")
|
|
#expect(omittedParams.modelvalue == nil)
|
|
|
|
let legacyJSON = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(legacyParams))
|
|
as? [String: Any])
|
|
let omittedJSON = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(omittedParams))
|
|
as? [String: Any])
|
|
let clearedJSON = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(clearedParams))
|
|
as? [String: Any])
|
|
|
|
#expect(legacyJSON["model"] as? String == "openai/gpt-5.6")
|
|
#expect(omittedJSON["model"] == nil)
|
|
#expect(clearedJSON["model"] is NSNull)
|
|
|
|
let decodedOmitted = try JSONDecoder().decode(
|
|
AgentsUpdateParams.self,
|
|
from: Data(#"{"agentId":"work"}"#.utf8))
|
|
let decodedCleared = try JSONDecoder().decode(
|
|
AgentsUpdateParams.self,
|
|
from: Data(#"{"agentId":"work","model":null}"#.utf8))
|
|
let reencodedCleared = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(decodedCleared))
|
|
as? [String: Any])
|
|
|
|
#expect(decodedOmitted.modelvalue == nil)
|
|
#expect(decodedCleared.modelvalue?.value is NSNull)
|
|
#expect(reencodedCleared["model"] is NSNull)
|
|
}
|
|
}
|