Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift
Vyctor H. Brzezowski 3f006ba0fc fix(ui): complete mobile pairing after code redemption (#120933)
* 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>
2026-08-14 11:10:30 -07:00

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)
}
}