963 lines
51 KiB
Swift
963 lines
51 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import NucleicProtocol
|
|
|
|
@Suite struct WireMessageTests {
|
|
private func roundTrip<T: Codable & Equatable>(_ value: T) throws -> T {
|
|
try CBORDecoder().decode(T.self, from: CBOREncoder().encode(value))
|
|
}
|
|
|
|
private let summary = SessionSummary(
|
|
sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
|
|
backend: .claudeCode, status: .awaitingApproval, disposition: .completed,
|
|
title: "auth-refactor", branch: "nucleic/auth", lastSeq: 120,
|
|
diffStat: DiffStat(filesChanged: 3, added: 312, removed: 40),
|
|
pendingApprovalCount: 1, favorite: true, archived: false,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
@Test func releaseChannelCompatibility() {
|
|
// Same channel always matches.
|
|
for ch: ReleaseChannel in [.local, .canary, .beta, .releaseCandidate, .release] {
|
|
#expect(ch.isCompatible(with: ch))
|
|
}
|
|
// The one exception: canary and local are interchangeable, in both directions.
|
|
#expect(ReleaseChannel.canary.isCompatible(with: .local))
|
|
#expect(ReleaseChannel.local.isCompatible(with: .canary))
|
|
// Everything else is incompatible.
|
|
#expect(!ReleaseChannel.beta.isCompatible(with: .canary))
|
|
#expect(!ReleaseChannel.canary.isCompatible(with: .beta))
|
|
#expect(!ReleaseChannel.release.isCompatible(with: .local))
|
|
#expect(!ReleaseChannel.local.isCompatible(with: .beta))
|
|
#expect(!ReleaseChannel.releaseCandidate.isCompatible(with: .release))
|
|
}
|
|
|
|
@Test func helloAndWelcomeCarryChannel() throws {
|
|
let hello = Hello(deviceID: "dev", deviceLabel: "iPhone", channel: .canary)
|
|
#expect(try roundTrip(hello).channel == .canary)
|
|
let welcome = Welcome(
|
|
grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []),
|
|
channel: .beta)
|
|
#expect(try roundTrip(welcome).channel == .beta)
|
|
// A peer that predates the field decodes as `nil` (gate skipped).
|
|
#expect(try roundTrip(Hello(deviceID: "d", deviceLabel: "i")).channel == nil)
|
|
}
|
|
|
|
@Test func clientMessagesRoundTrip() throws {
|
|
let approval = ApprovalID(rawValue: "a1")
|
|
let sid = SessionID(rawValue: "s1")
|
|
let msgs: [ClientMsg] = [
|
|
.hello(Hello(deviceID: "dev", deviceLabel: "Andrew's iPhone")),
|
|
.listSessions,
|
|
.subscribe(Subscribe(sessionID: sid, sinceSeq: 42, verbosity: .full)),
|
|
.subscribe(Subscribe(sessionID: sid, sinceSeq: nil, verbosity: .coalesced)),
|
|
.unsubscribe(sid),
|
|
.approvalRespond(approval, .allow(updatedInput: ["command": "ls"])),
|
|
.approvalRespond(approval, .deny(reason: "no")),
|
|
.approvalRespond(approval, .allowAlways(.toolName)),
|
|
.sendInput(sid, AgentInput(text: "keep going")),
|
|
.interrupt(sid),
|
|
.ping,
|
|
// Control scope:
|
|
.listDashboard,
|
|
.startChat(StartChatRequest(projectID: ProjectID(rawValue: "p1"), message: "go", auto: true)),
|
|
.captureTodo(CaptureTodoRequest(text: "fix the thing", projectID: ProjectID(rawValue: "p1"))),
|
|
.dispatchTodo(TodoID(rawValue: "t1"), ProjectID(rawValue: "p1")),
|
|
.setTodoStatus(TodoID(rawValue: "t1"), .done),
|
|
.deleteTodo(TodoID(rawValue: "t1")),
|
|
.renameSession(sid, "new title"),
|
|
.setFavorite(sid, true),
|
|
.setArchived(sid, false),
|
|
.markSessionDone(sid),
|
|
.deleteSession(sid),
|
|
.integrate(sid, .squash),
|
|
.discard(sid),
|
|
// Mid-session controls — exercise both the value and the nil (reset) variants.
|
|
.setSessionModel(sid, "claude-sonnet-4-6"),
|
|
.setSessionModel(sid, nil),
|
|
.setSessionEffort(sid, "xhigh"),
|
|
.setSessionEffort(sid, nil),
|
|
.setSessionAuto(sid, true),
|
|
.setSessionAutoShip(sid, false),
|
|
.setSessionShipBranch(sid, "main"),
|
|
.setSessionShipBranch(sid, nil),
|
|
.cancelQueuedMessage(sid, UUID(uuidString: "11111111-1111-1111-1111-111111111111")!),
|
|
.fetchDiff(sid),
|
|
.listPeers, // mesh P4
|
|
.addressUpdate(PeerAddresses(lanHint: "10.0.0.4:51000", tailnet: "100.64.0.9")), // mesh P4
|
|
]
|
|
for msg in msgs { #expect(try roundTrip(msg) == msg) }
|
|
}
|
|
|
|
// MARK: Mesh P4 — peer model, deviceKind, listPeers/peerList
|
|
|
|
@Test func helloCarriesDeviceKindAndClientCaps() throws {
|
|
let hello = Hello(
|
|
deviceID: "mac-b", deviceLabel: "Work Mac",
|
|
scopeClaim: .control, deviceKind: PeerKind.mac.rawValue,
|
|
clientCaps: WireClientCapabilities(mesh: 1, canHost: true, canRunAgents: true))
|
|
let decoded = try roundTrip(hello)
|
|
#expect(decoded.deviceKind == "mac")
|
|
#expect(decoded.kind == .mac)
|
|
#expect(decoded.clientCaps?.mesh == 1)
|
|
#expect(decoded.clientCaps?.canHost == true)
|
|
}
|
|
|
|
@Test func helloWithoutMeshFieldsDefaultsToIPhone() throws {
|
|
// A pre-mesh iPhone omits deviceKind/clientCaps.
|
|
let decoded = try roundTrip(Hello(deviceID: "phone", deviceLabel: "iPhone"))
|
|
#expect(decoded.deviceKind == nil)
|
|
#expect(decoded.kind == .iphone)
|
|
#expect(decoded.clientCaps == nil)
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanListPeers() throws {
|
|
let caps = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [], canListPeers: true)
|
|
#expect(try roundTrip(caps).canListPeers == true)
|
|
}
|
|
|
|
/// A host that predates `canListPeers` decodes it as false (client won't send `listPeers`).
|
|
@Test func capabilitiesTolerateMissingCanListPeers() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let data = try CBOREncoder().encode(LegacyCaps())
|
|
let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
|
|
#expect(decoded.canListPeers == false)
|
|
#expect(decoded.canFetchDiff == false)
|
|
}
|
|
|
|
// MARK: Remote pairing-code mint ("add a device to this mesh")
|
|
|
|
@Test func capabilitiesCarryCanMintPairingCode() throws {
|
|
let caps = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [], canMintPairingCode: true)
|
|
#expect(try roundTrip(caps).canMintPairingCode == true)
|
|
}
|
|
|
|
/// A host that predates `canMintPairingCode` decodes it as false (the phone hides the
|
|
/// "add a device" button and never sends `requestPairingCode`).
|
|
@Test func capabilitiesTolerateMissingCanMintPairingCode() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let data = try CBOREncoder().encode(LegacyCaps())
|
|
let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
|
|
#expect(decoded.canMintPairingCode == false)
|
|
}
|
|
|
|
@Test func pairingCodeVerbsRoundTrip() throws {
|
|
#expect(try roundTrip(ClientMsg.requestPairingCode) == .requestPairingCode)
|
|
#expect(try roundTrip(ClientMsg.cancelPairingCode) == .cancelPairingCode)
|
|
// The host reply carries the join string on success and nil when it couldn't mint.
|
|
#expect(try roundTrip(HostMsg.pairingCode("nucleic://pair?d=abc")) == .pairingCode("nucleic://pair?d=abc"))
|
|
#expect(try roundTrip(HostMsg.pairingCode(nil)) == .pairingCode(nil))
|
|
}
|
|
|
|
@Test func macPairApprovalForwardingRoundTrips() throws {
|
|
// Host forwards the confirm to a control client, which answers, and the host withdraws it.
|
|
let req = WireMacPairRequest(deviceID: "mac-c", label: "Studio")
|
|
#expect(try roundTrip(req) == req)
|
|
#expect(try roundTrip(HostMsg.macPairRequested(req)) == .macPairRequested(req))
|
|
#expect(try roundTrip(HostMsg.macPairResolved("mac-c", true)) == .macPairResolved("mac-c", true))
|
|
#expect(try roundTrip(ClientMsg.respondMacPair("mac-c", false)) == .respondMacPair("mac-c", false))
|
|
}
|
|
|
|
@Test func peerListRoundTrips() throws {
|
|
let peers = [
|
|
PeerSummary(deviceID: "mac-b", label: "Work Mac", kind: .mac,
|
|
capabilities: .hostAndAgents, online: true,
|
|
lastSeenAt: Date(timeIntervalSince1970: 1_700_000_000)),
|
|
PeerSummary(deviceID: "phone", label: "iPhone", kind: .iphone),
|
|
]
|
|
let decoded = try roundTrip(HostMsg.peerList(peers))
|
|
#expect(decoded == .peerList(peers))
|
|
}
|
|
|
|
@Test func peerKindIsForwardCompatible() throws {
|
|
// An unknown future kind decodes to its raw value, not a throw.
|
|
let future = PeerSummary(deviceID: "x", label: "Watch", kind: PeerKind(rawValue: "watch"))
|
|
#expect(try roundTrip(future).kind.rawValue == "watch")
|
|
}
|
|
|
|
// MARK: Mesh "join" — roster gossip (MeshMember / MeshTombstone / MeshRosterPush)
|
|
|
|
private let member = MeshMember(
|
|
deviceID: "mac-b", label: "Work Mac", kind: .mac, capabilities: .hostAndAgents,
|
|
staticPublicKey: Data(repeating: 0xAB, count: 32),
|
|
addresses: PeerAddresses(lanHint: "192.168.1.20:52341", tailnet: "100.64.0.7"),
|
|
pairedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
lastSeenAt: Date(timeIntervalSince1970: 1_700_000_500))
|
|
|
|
@Test func meshRosterRoundTripsBothDirections() throws {
|
|
let push = MeshRosterPush(
|
|
members: [
|
|
member,
|
|
MeshMember(deviceID: "phone", label: "iPhone", kind: .iphone,
|
|
staticPublicKey: Data(repeating: 0x01, count: 32),
|
|
pairedAt: Date(timeIntervalSince1970: 1_700_000_100)),
|
|
],
|
|
tombstones: [MeshTombstone(deviceID: "old-mac", revokedAt: Date(timeIntervalSince1970: 1_699_000_000))])
|
|
// Gossip flows both ways: a Mac is host *and* client.
|
|
#expect(try roundTrip(ClientMsg.meshRoster(push)) == .meshRoster(push))
|
|
#expect(try roundTrip(HostMsg.meshRoster(push)) == .meshRoster(push))
|
|
#expect(try roundTrip(member) == member)
|
|
}
|
|
|
|
/// A `MeshMember` from a build that predates a field (e.g. `pairedAt`) decodes with a floor
|
|
/// default rather than throwing — the same decode-defaulted tolerance as `PeerSummary`.
|
|
@Test func meshMemberToleratesMissingFields() throws {
|
|
struct LegacyMember: Encodable {
|
|
let deviceID = "mac-b"
|
|
let label = "Work Mac"
|
|
let staticPublicKey = Data(repeating: 0xAB, count: 32)
|
|
}
|
|
let decoded = try CBORDecoder().decode(MeshMember.self, from: CBOREncoder().encode(LegacyMember()))
|
|
#expect(decoded.deviceID == "mac-b")
|
|
#expect(decoded.kind == .iphone) // default when kind is absent
|
|
#expect(decoded.addresses == nil)
|
|
#expect(decoded.pairedAt == Date(timeIntervalSince1970: 0))
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanSyncRoster() throws {
|
|
let caps = WireCapabilities(
|
|
canModifyToolInput: true, allowAlwaysScopes: [], canSyncRoster: true)
|
|
#expect(try roundTrip(caps).canSyncRoster == true)
|
|
// A client advertises it in its Hello caps too.
|
|
let clientCaps = WireClientCapabilities(mesh: 2, canHost: true, canSyncRoster: true)
|
|
#expect(try roundTrip(clientCaps).canSyncRoster == true)
|
|
}
|
|
|
|
/// A host/client that predates `canSyncRoster` decodes it as false → falls back to the
|
|
/// display-only `listPeers` path, never sending/expecting `meshRoster`.
|
|
@Test func capabilitiesTolerateMissingCanSyncRoster() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let decoded = try CBORDecoder().decode(WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
|
|
#expect(decoded.canSyncRoster == false)
|
|
struct LegacyClientCaps: Encodable { let mesh = 1 }
|
|
let cc = try CBORDecoder().decode(
|
|
WireClientCapabilities.self, from: CBOREncoder().encode(LegacyClientCaps()))
|
|
#expect(cc.canSyncRoster == false)
|
|
}
|
|
|
|
// MARK: Account-level synced settings (SyncedSettings)
|
|
|
|
@Test func syncedSettingsRoundTrips() throws {
|
|
#expect(try roundTrip(SyncedSettings(resolveApprovalsViaCloud: true)).resolveApprovalsViaCloud == true)
|
|
#expect(try roundTrip(SyncedSettings(resolveApprovalsViaCloud: false)).resolveApprovalsViaCloud == false)
|
|
}
|
|
|
|
/// A peer that predates the field decodes it as the resting default (feature off).
|
|
@Test func syncedSettingsTolerateMissingField() throws {
|
|
struct Empty: Codable {}
|
|
let decoded = try CBORDecoder().decode(SyncedSettings.self, from: CBOREncoder().encode(Empty()))
|
|
#expect(decoded == .default)
|
|
#expect(decoded.resolveApprovalsViaCloud == false)
|
|
}
|
|
|
|
@Test func settingsMessagesRoundTripBothDirections() throws {
|
|
let on = SyncedSettings(resolveApprovalsViaCloud: true)
|
|
#expect(try roundTrip(ClientMsg.updateSettings(on)) == .updateSettings(on))
|
|
#expect(try roundTrip(HostMsg.settings(on)) == .settings(on))
|
|
}
|
|
|
|
@Test func welcomeCarriesSettings() throws {
|
|
let welcome = Welcome(
|
|
grantedScope: .approve, host: HostInfo(hostID: "h", hostName: "Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []),
|
|
settings: SyncedSettings(resolveApprovalsViaCloud: true))
|
|
#expect(try roundTrip(welcome).settings?.resolveApprovalsViaCloud == true)
|
|
// A host that predates the field omits it → the device keeps its own default.
|
|
let bare = Welcome(
|
|
grantedScope: .approve, host: HostInfo(hostID: "h", hostName: "Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []))
|
|
#expect(try roundTrip(bare).settings == nil)
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanSyncSettings() throws {
|
|
let caps = WireCapabilities(
|
|
canModifyToolInput: true, allowAlwaysScopes: [], canSyncSettings: true)
|
|
#expect(try roundTrip(caps).canSyncSettings == true)
|
|
}
|
|
|
|
/// A host that predates `canSyncSettings` decodes it as false → the client never sends
|
|
/// `updateSettings` and never expects `HostMsg.settings`.
|
|
@Test func capabilitiesTolerateMissingCanSyncSettings() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let decoded = try CBORDecoder().decode(WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
|
|
#expect(decoded.canSyncSettings == false)
|
|
}
|
|
|
|
// MARK: Covalence direct (SYNC §3.3) — candidate exchange + capability gating
|
|
|
|
private var punchOffer: DirectPunchOffer {
|
|
DirectPunchOffer(
|
|
candidates: [
|
|
DirectCandidate(kind: .ipv6, ip: "2001:db8::7", port: 61_000),
|
|
DirectCandidate(kind: .reflexive, ip: "203.0.113.9", port: 42_113),
|
|
DirectCandidate(kind: .local, ip: "192.168.1.20", port: 61_001),
|
|
],
|
|
natClass: .cone,
|
|
token: Data(repeating: 0xAB, count: 16))
|
|
}
|
|
|
|
@Test func directMessagesRoundTripBothDirections() throws {
|
|
#expect(try roundTrip(ClientMsg.directOffer(punchOffer)) == .directOffer(punchOffer))
|
|
let select = DirectSelect(ip: "203.0.113.9", port: 42_113)
|
|
#expect(try roundTrip(ClientMsg.directSelect(select)) == .directSelect(select))
|
|
#expect(try roundTrip(HostMsg.directAnswer(punchOffer)) == .directAnswer(punchOffer))
|
|
#expect(try roundTrip(HostMsg.directGo) == .directGo)
|
|
#expect(try roundTrip(HostMsg.directDecline("both symmetric")) == .directDecline("both symmetric"))
|
|
}
|
|
|
|
/// Transcript revert/undo fan-out (a truncate-to-tip): the `HostMsg` and its payload must
|
|
/// survive the wire so a subscribed device shrinks its transcript to the new tip. `throughSeq
|
|
/// == 0` (the whole transcript cleared) round-trips too.
|
|
@Test func transcriptRevertedRoundTrips() throws {
|
|
let reverted = TranscriptReverted(sessionID: SessionID(rawValue: "s1"), throughSeq: 42)
|
|
#expect(try roundTrip(reverted) == reverted)
|
|
#expect(try roundTrip(HostMsg.transcriptReverted(reverted)) == .transcriptReverted(reverted))
|
|
let cleared = TranscriptReverted(sessionID: SessionID(rawValue: "s1"), throughSeq: 0)
|
|
#expect(try roundTrip(HostMsg.transcriptReverted(cleared)) == .transcriptReverted(cleared))
|
|
// The owner's post-revert epoch rides along; absent (an old host) it decodes nil.
|
|
let stamped = TranscriptReverted(
|
|
sessionID: SessionID(rawValue: "s1"), throughSeq: 42, revertEpoch: 3)
|
|
#expect(try roundTrip(stamped) == stamped)
|
|
#expect(try roundTrip(reverted).revertEpoch == nil)
|
|
}
|
|
|
|
/// An old client decoding a host message with a tag it doesn't know must degrade to
|
|
/// `.unknown`, not throw — that fallback is what makes the direct replies safe to add
|
|
/// (SYNC §9). Exercised with a future tag, which an old decoder treats exactly like it
|
|
/// treats today's direct tags.
|
|
@Test func unknownHostTagsDegradeNotThrow() throws {
|
|
struct TagOnly: Codable { let t: String }
|
|
let decoded = try CBORDecoder().decode(
|
|
HostMsg.self, from: CBOREncoder().encode(TagOnly(t: "directWarp")))
|
|
#expect(decoded == .unknown("directWarp"))
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanDirectConnectBothWays() throws {
|
|
let host = WireCapabilities(
|
|
canModifyToolInput: true, allowAlwaysScopes: [], canDirectConnect: true)
|
|
#expect(try roundTrip(host).canDirectConnect == true)
|
|
let client = WireClientCapabilities(mesh: 1, canDirectConnect: true)
|
|
#expect(try roundTrip(client).canDirectConnect == true)
|
|
}
|
|
|
|
/// Peers that predate the capability decode it as false — neither side ever sends a
|
|
/// direct verb at them (old hosts *error* on unknown ClientMsg tags, so this gate is
|
|
/// what keeps the upgrade invisible to them).
|
|
@Test func capabilitiesTolerateMissingCanDirectConnect() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let host = try CBORDecoder().decode(WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
|
|
#expect(host.canDirectConnect == false)
|
|
struct LegacyClientCaps: Encodable { let mesh = 1 }
|
|
let client = try CBORDecoder().decode(
|
|
WireClientCapabilities.self, from: CBOREncoder().encode(LegacyClientCaps()))
|
|
#expect(client.canDirectConnect == false)
|
|
}
|
|
|
|
// MARK: Mesh P4 remainder — PeerAddresses, Hello/Welcome addresses, addressUpdate
|
|
|
|
private let addresses = PeerAddresses(
|
|
lanHint: "192.168.1.20:52341", tailnet: "100.64.0.7",
|
|
relayRoomID: nil, updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
@Test func peerAddressesRoundTrip() throws {
|
|
let back = try roundTrip(addresses)
|
|
#expect(back == addresses)
|
|
#expect(!back.isEmpty)
|
|
#expect(PeerAddresses().isEmpty)
|
|
}
|
|
|
|
@Test func helloCarriesAddresses() throws {
|
|
let hello = Hello(
|
|
deviceID: "mac-b", deviceLabel: "Work Mac",
|
|
deviceKind: PeerKind.mac.rawValue, addresses: addresses)
|
|
#expect(try roundTrip(hello).addresses == addresses)
|
|
// A client that predates the field omits it.
|
|
#expect(try roundTrip(Hello(deviceID: "d", deviceLabel: "i")).addresses == nil)
|
|
}
|
|
|
|
@Test func welcomeCarriesAddresses() throws {
|
|
let welcome = Welcome(
|
|
grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []),
|
|
addresses: addresses)
|
|
#expect(try roundTrip(welcome).addresses == addresses)
|
|
}
|
|
|
|
/// A hello/welcome from a build that predates `addresses` decodes with nil (never throws).
|
|
@Test func helloAndWelcomeTolerateMissingAddresses() throws {
|
|
struct LegacyHello: Encodable {
|
|
let protocolVersion = SyncProtocol.version
|
|
let deviceID = "d"
|
|
let deviceLabel = "iPhone"
|
|
let scopeClaim = DeviceScope.approve
|
|
}
|
|
let hello = try CBORDecoder().decode(Hello.self, from: CBOREncoder().encode(LegacyHello()))
|
|
#expect(hello.addresses == nil)
|
|
|
|
struct LegacyWelcome: Encodable {
|
|
let protocolVersion = SyncProtocol.version
|
|
let grantedScope = DeviceScope.control
|
|
let host = HostInfo(hostID: "h", hostName: "Mac")
|
|
let capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [])
|
|
}
|
|
let welcome = try CBORDecoder().decode(Welcome.self, from: CBOREncoder().encode(LegacyWelcome()))
|
|
#expect(welcome.addresses == nil)
|
|
}
|
|
|
|
@Test func addressUpdateRoundTrips() throws {
|
|
let msg = ClientMsg.addressUpdate(addresses)
|
|
#expect(try roundTrip(msg) == msg)
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanUpdateAddresses() throws {
|
|
let caps = WireCapabilities(
|
|
canModifyToolInput: true, allowAlwaysScopes: [], canUpdateAddresses: true)
|
|
#expect(try roundTrip(caps).canUpdateAddresses == true)
|
|
}
|
|
|
|
/// A host that predates `canUpdateAddresses` decodes it as false (client never sends it).
|
|
@Test func capabilitiesTolerateMissingCanUpdateAddresses() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let decoded = try CBORDecoder().decode(
|
|
WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
|
|
#expect(decoded.canUpdateAddresses == false)
|
|
}
|
|
|
|
// MARK: Mesh P5 — session transfer
|
|
|
|
private var transferOffer: TransferOffer {
|
|
TransferOffer(
|
|
transferID: "xfer-1",
|
|
record: SessionTransferRecord(
|
|
sessionID: SessionID(rawValue: "s1"), backend: .claudeCode,
|
|
backendSessionID: "native-abc", title: "auth refactor",
|
|
branch: "nucleic/auth", baseSHA: "deadbeef", model: "claude-opus-4-8",
|
|
effort: "high", lastSeq: 42, createdAt: Date(timeIntervalSince1970: 1_700_000_000)),
|
|
project: ProjectDescriptor(
|
|
projectID: ProjectID(rawValue: "p1"), name: "ProjA",
|
|
rootCommitSHA: "root123", normalizedRemote: "github.com/me/proj",
|
|
defaultBranch: "main"),
|
|
transcriptHeaderVersion: 1,
|
|
availableBaseSHAs: ["deadbeef", "cafef00d"],
|
|
knownItems: [
|
|
TransferItemDescriptor(kind: .transcript, byteCount: 4096, sha256: "abc", chunkCount: 1),
|
|
TransferItemDescriptor(kind: .nativeTranscript, byteCount: 8192, sha256: "def", chunkCount: 1),
|
|
])
|
|
}
|
|
|
|
@Test func transferClientMessagesRoundTrip() throws {
|
|
let chunk = TransferChunk(
|
|
transferID: "xfer-1", kind: .bundle, chunkIndex: 0, chunkCount: 2,
|
|
sha256: "bundlesha", data: Data([0x1, 0x2, 0x3]))
|
|
let msgs: [ClientMsg] = [
|
|
.transferOffer(transferOffer),
|
|
.transferChunk(chunk),
|
|
.transferCommit("xfer-1"),
|
|
.transferCancel("xfer-1"),
|
|
// "Transfer…" driven from a Mac that doesn't own the chat: the owner is told where to
|
|
// send it (here, back to the requester).
|
|
.requestSessionTransfer(SessionID(rawValue: "s1"), "device-b"),
|
|
]
|
|
for msg in msgs { #expect(try roundTrip(msg) == msg) }
|
|
}
|
|
|
|
/// The capability that gates `requestSessionTransfer` is field-additive: an older host omits it
|
|
/// and decodes as `false`, so a newer Mac keeps the menu hidden instead of sending a tag that
|
|
/// host would reject.
|
|
@Test func transferOnRequestCapabilityToleratesOlderHosts() throws {
|
|
let caps = WireCapabilities(
|
|
canModifyToolInput: false, allowAlwaysScopes: [], canTransferOnRequest: true)
|
|
#expect(try roundTrip(caps).canTransferOnRequest)
|
|
let legacy = try CBORDecoder().decode(
|
|
WireCapabilities.self,
|
|
from: CBOREncoder().encode(
|
|
WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])))
|
|
#expect(!legacy.canTransferOnRequest)
|
|
}
|
|
|
|
@Test func transferHostMessagesRoundTrip() throws {
|
|
let msgs: [HostMsg] = [
|
|
.transferAccept(TransferAccept(
|
|
transferID: "xfer-1", resolvedProjectID: ProjectID(rawValue: "p-local"),
|
|
haveSHAs: ["deadbeef"], resumeFrom: [.bundle: 3, .transcript: 0])),
|
|
.transferReject(TransferReject(transferID: "xfer-1", reason: .projectNotFound)),
|
|
.transferReady("xfer-1"),
|
|
.transferCommitted("xfer-1"),
|
|
.transferChunkAck(TransferChunkAck(transferID: "xfer-1", kind: .bundle, chunkIndex: 0)),
|
|
]
|
|
for msg in msgs { #expect(try roundTrip(msg) == msg) }
|
|
}
|
|
|
|
// MARK: Covalence — the mesh session work queue's wire additions (all field-additive)
|
|
|
|
@Test func startChatRequestCarriesCovalenceOriginWithLegacyTolerance() throws {
|
|
let request = StartChatRequest(
|
|
projectID: ProjectID(rawValue: "p1"), message: "go", requestID: "req-1",
|
|
covalenceOriginDeviceID: "origin-mac")
|
|
#expect(try roundTrip(ClientMsg.startChat(request)) == .startChat(request))
|
|
// A pre-Covalence sender omits the key — decodes nil (an ordinary, unmanaged chat).
|
|
let legacy = try CBORDecoder().decode(StartChatRequest.self, from: CBOREncoder().encode(
|
|
StartChatRequest(projectID: ProjectID(rawValue: "p1"), message: "go")))
|
|
#expect(legacy.covalenceOriginDeviceID == nil)
|
|
// A pre-rename sender using the old "carbonOriginDeviceID" key — still read via fallback.
|
|
struct LegacyStart: Encodable {
|
|
let projectID = "p1", message = "go"
|
|
let useWorktree = true
|
|
let carbonOriginDeviceID = "origin-legacy"
|
|
}
|
|
let carried = try CBORDecoder().decode(
|
|
StartChatRequest.self, from: CBOREncoder().encode(LegacyStart()))
|
|
#expect(carried.covalenceOriginDeviceID == "origin-legacy")
|
|
}
|
|
|
|
@Test func transferRecordCarriesCovalenceOriginWithLegacyTolerance() throws {
|
|
var offer = transferOffer
|
|
#expect(try roundTrip(ClientMsg.transferOffer(offer)) == .transferOffer(offer))
|
|
offer = TransferOffer(
|
|
transferID: offer.transferID, record: SessionTransferRecord(
|
|
sessionID: SessionID(rawValue: "s1"), backend: .claudeCode,
|
|
backendSessionID: nil, title: "t", branch: "nucleic/t", baseSHA: "b",
|
|
model: nil, effort: nil, lastSeq: 1,
|
|
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
covalenceOriginDeviceID: "origin-phone"),
|
|
project: offer.project, transcriptHeaderVersion: 1,
|
|
availableBaseSHAs: [], knownItems: [])
|
|
let carried = try roundTrip(ClientMsg.transferOffer(offer))
|
|
guard case .transferOffer(let decoded) = carried else { Issue.record("no offer"); return }
|
|
#expect(decoded.record.covalenceOriginDeviceID == "origin-phone")
|
|
// A pre-Covalence source's record (no key) decodes nil — the session arrives unmanaged.
|
|
struct LegacyRecord: Encodable {
|
|
let sessionID = "s1", backend = "claudeCode", title = "t", branch = "nucleic/t"
|
|
let baseSHA = "b"
|
|
let lastSeq: UInt64 = 1
|
|
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
|
|
}
|
|
let legacy = try CBORDecoder().decode(
|
|
SessionTransferRecord.self, from: CBOREncoder().encode(LegacyRecord()))
|
|
#expect(legacy.covalenceOriginDeviceID == nil)
|
|
// A pre-rename source using the old "carbonOriginDeviceID" key — read via fallback.
|
|
struct LegacyManagedRecord: Encodable {
|
|
let sessionID = "s1", backend = "claudeCode", title = "t", branch = "nucleic/t"
|
|
let baseSHA = "b"
|
|
let lastSeq: UInt64 = 1
|
|
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let carbonOriginDeviceID = "origin-legacy"
|
|
}
|
|
let carriedLegacy = try CBORDecoder().decode(
|
|
SessionTransferRecord.self, from: CBOREncoder().encode(LegacyManagedRecord()))
|
|
#expect(carriedLegacy.covalenceOriginDeviceID == "origin-legacy")
|
|
}
|
|
|
|
@Test func transferAcceptCarriesAlreadyHaveItemsWithLegacyTolerance() throws {
|
|
let accept = TransferAccept(
|
|
transferID: "xfer-1", resolvedProjectID: ProjectID(rawValue: "p-local"),
|
|
haveSHAs: [], resumeFrom: [:], alreadyHaveItems: [.transcript])
|
|
let carried = try roundTrip(HostMsg.transferAccept(accept))
|
|
guard case .transferAccept(let decoded) = carried else { Issue.record("no accept"); return }
|
|
#expect(decoded.alreadyHaveItems == [.transcript])
|
|
// A pre-mirror destination's accept (no key) decodes empty — the source streams all.
|
|
struct LegacyAccept: Encodable {
|
|
let transferID = "x", resolvedProjectID = "p"
|
|
let haveSHAs: [String] = []
|
|
}
|
|
let legacy = try CBORDecoder().decode(
|
|
TransferAccept.self, from: CBOREncoder().encode(LegacyAccept()))
|
|
#expect(legacy.alreadyHaveItems.isEmpty)
|
|
#expect(legacy.resumeFrom.isEmpty)
|
|
}
|
|
|
|
@Test func transferRejectReasonIsForwardCompatible() throws {
|
|
// An unknown future reason decodes to its raw value, not a throw.
|
|
let reject = TransferReject(
|
|
transferID: "x", reason: TransferRejectReason(rawValue: "someFutureReason"),
|
|
message: "who knows")
|
|
#expect(try roundTrip(HostMsg.transferReject(reject)) == .transferReject(reject))
|
|
}
|
|
|
|
@Test func capabilitiesCarryCanReceiveSessionTransfer() throws {
|
|
let caps = WireCapabilities(
|
|
canModifyToolInput: true, allowAlwaysScopes: [], canReceiveSessionTransfer: true)
|
|
#expect(try roundTrip(caps).canReceiveSessionTransfer == true)
|
|
}
|
|
|
|
/// A host that predates the transfer capability decodes it false — a source never offers.
|
|
@Test func capabilitiesTolerateMissingCanReceiveSessionTransfer() throws {
|
|
struct LegacyCaps: Encodable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
|
|
let decoded = try CBORDecoder().decode(
|
|
WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
|
|
#expect(decoded.canReceiveSessionTransfer == false)
|
|
}
|
|
|
|
/// A transfer reply from a newer host is still forward-compatible at the raw HostMsg layer
|
|
/// (`.unknown`), but the whole point of P5 is that SyncClient maps it to an Event — the
|
|
/// codec must at least round-trip it so an up-to-date source sees it.
|
|
@Test func transferReplyKeepsUnknownFallbackForOldClients() throws {
|
|
// An old client that predates transferReady sees it as .unknown, not a decode failure.
|
|
let data = try CBOREncoder().encode(["t": "transferReady", "transferID": "x"])
|
|
// (Old clients don't have the case; new clients decode it properly.)
|
|
let decoded = try CBORDecoder().decode(HostMsg.self, from: data)
|
|
#expect(decoded == .transferReady("x"))
|
|
}
|
|
|
|
@Test func projectDescriptorMatchesByIdentity() {
|
|
let a = ProjectDescriptor(projectID: ProjectID(rawValue: "p1"), name: "A",
|
|
rootCommitSHA: "root", normalizedRemote: nil, defaultBranch: "main")
|
|
let sameRoot = ProjectDescriptor(projectID: ProjectID(rawValue: "p2"), name: "A",
|
|
rootCommitSHA: "root", normalizedRemote: nil, defaultBranch: "main")
|
|
let sameRemote = ProjectDescriptor(projectID: ProjectID(rawValue: "p3"), name: "A",
|
|
rootCommitSHA: nil, normalizedRemote: "github.com/me/a", defaultBranch: "main")
|
|
let sameRemoteOther = ProjectDescriptor(projectID: ProjectID(rawValue: "p4"), name: "A",
|
|
rootCommitSHA: nil, normalizedRemote: "github.com/me/a", defaultBranch: "main")
|
|
let different = ProjectDescriptor(projectID: ProjectID(rawValue: "p5"), name: "B",
|
|
rootCommitSHA: "other", normalizedRemote: "github.com/me/b", defaultBranch: "main")
|
|
#expect(a.matches(sameRoot)) // same root commit
|
|
#expect(sameRemote.matches(sameRemoteOther)) // same normalized remote
|
|
#expect(a.matches(a)) // same UUID
|
|
#expect(!a.matches(different))
|
|
}
|
|
|
|
private let catalog = WireModelCatalog(
|
|
groups: [
|
|
[WireModelCatalog.Model(
|
|
sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
|
|
contextBadge: "256K", contextWindow: 256_000,
|
|
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
|
|
[WireModelCatalog.Model(
|
|
sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
|
|
contextBadge: nil, contextWindow: 400_000,
|
|
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
|
|
[WireModelCatalog.Model(
|
|
sku: "grok-build", displayName: "Grok Build", backend: .grok,
|
|
contextBadge: nil, contextWindow: 256_000,
|
|
efforts: ["low", "medium", "high"], effortNoun: "Reasoning")],
|
|
],
|
|
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
|
|
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
|
|
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
|
|
|
|
@Test func modelCatalogRoundTripsAndLooksUp() throws {
|
|
#expect(try roundTrip(catalog) == catalog)
|
|
// The phone reads everything it needs straight off the projection — no backend logic.
|
|
#expect(catalog.models(for: .grok).map(\.sku) == ["grok-build"])
|
|
#expect(catalog.efforts(forModel: "grok-build") == ["low", "medium", "high"])
|
|
#expect(catalog.efforts(forModel: "claude-opus-4-8").last == "max")
|
|
#expect(catalog.contextWindow("claude-opus-4-8") == 256_000)
|
|
#expect(catalog.effortNoun(forModel: "grok-build") == "Reasoning")
|
|
#expect(catalog.effortDisplayName("auto") == "Auto")
|
|
#expect(catalog.isOrchestra("orchestra"))
|
|
#expect(!catalog.isOrchestra("high"))
|
|
}
|
|
|
|
@Test func welcomeCarriesCatalog() throws {
|
|
let welcome = Welcome(
|
|
grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Andrew's Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session]),
|
|
modelCatalog: catalog)
|
|
#expect(try roundTrip(HostMsg.welcome(welcome)) == .welcome(welcome))
|
|
}
|
|
|
|
@Test func sessionSummaryNewFieldsRoundTrip() throws {
|
|
let rich = SessionSummary(
|
|
sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
|
|
backend: .claudeCode, status: .running, title: "t", branch: "b", lastSeq: 5,
|
|
diffStat: nil,
|
|
queuedMessages: [QueuedMessage(
|
|
id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
|
|
text: "look at this",
|
|
attachments: [QueuedAttachment(
|
|
id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!,
|
|
filename: "shot.png", relativePath: ".nucleic/attachments/ab-shot.png")])],
|
|
model: "claude-sonnet-4-6", effort: "xhigh", auto: true,
|
|
autoShip: true, shipBranch: "main", contextInputTokens: 84_000,
|
|
updatedAt: Date(timeIntervalSince1970: 1))
|
|
let back = try roundTrip(rich)
|
|
#expect(back.model == "claude-sonnet-4-6")
|
|
#expect(back.effort == "xhigh")
|
|
#expect(back.auto == true)
|
|
#expect(back.autoShip == true)
|
|
#expect(back.shipBranch == "main")
|
|
#expect(back.contextInputTokens == 84_000)
|
|
#expect(back.queuedMessages.count == 1)
|
|
#expect(back.queuedMessages.first?.text == "look at this")
|
|
#expect(back.queuedMessages.first?.attachments.first?.filename == "shot.png")
|
|
}
|
|
|
|
@Test func sessionUpdateCarriesCurrentBackendAndRekeysModelCatalog() throws {
|
|
let switched = SessionSummary(
|
|
sessionID: summary.sessionID, projectID: summary.projectID,
|
|
projectName: summary.projectName,
|
|
backend: .codex, status: .awaitingInput,
|
|
title: summary.title, branch: summary.branch, lastSeq: summary.lastSeq + 1,
|
|
diffStat: summary.diffStat,
|
|
model: "gpt-5.5", effort: "xhigh",
|
|
routedLevel: 3, routedPurpose: PromptPurpose.backendImpl.rawValue,
|
|
updatedAt: summary.updatedAt.addingTimeInterval(1))
|
|
guard case .sessionUpdated(let decoded) = try roundTrip(
|
|
HostMsg.sessionUpdated(switched))
|
|
else {
|
|
Issue.record("sessionUpdated changed wire kind")
|
|
return
|
|
}
|
|
|
|
#expect(decoded.backend == .codex)
|
|
#expect(decoded.model == "gpt-5.5")
|
|
#expect(catalog.models(for: decoded.backend).map(\.sku) == ["gpt-5.5"])
|
|
#expect(catalog.models(for: .claudeCode).map(\.sku)
|
|
!= catalog.models(for: decoded.backend).map(\.sku))
|
|
}
|
|
|
|
@Test func sessionSummaryToleratesMissingNewFields() throws {
|
|
// A summary encoded by a host that predates the model/effort/auto/autoShip fields:
|
|
// it must decode with safe defaults rather than throwing (SYNC §9 forward-compat).
|
|
struct LegacySummary: Encodable {
|
|
let sessionID = SessionID(rawValue: "s1")
|
|
let projectID = "p1"
|
|
let projectName = "ProjA"
|
|
let backend = BackendID.claudeCode
|
|
let status = SessionStatus.running
|
|
let title = "t"
|
|
let branch = "b"
|
|
let lastSeq: UInt64 = 5
|
|
let updatedAt = Date(timeIntervalSince1970: 1)
|
|
}
|
|
let data = try CBOREncoder().encode(LegacySummary())
|
|
let decoded = try CBORDecoder().decode(SessionSummary.self, from: data)
|
|
#expect(decoded.model == nil)
|
|
#expect(decoded.effort == nil)
|
|
#expect(decoded.auto == false)
|
|
#expect(decoded.autoShip == false)
|
|
#expect(decoded.shipBranch == nil)
|
|
#expect(decoded.contextInputTokens == nil)
|
|
#expect(decoded.queuedMessages.isEmpty)
|
|
// Pre-existing non-optional fields also fall back rather than throwing.
|
|
#expect(decoded.favorite == false)
|
|
#expect(decoded.pendingApprovalCount == 0)
|
|
// A summary that predates the moved-to field decodes as "not moved" (mesh P5).
|
|
#expect(decoded.movedTo == nil)
|
|
#expect(decoded.arrivedFrom == nil)
|
|
}
|
|
|
|
@Test func sessionSummaryCarriesMovedTo() throws {
|
|
// A live session leaves `movedTo` nil; a tombstoned one names where it went, so a phone
|
|
// renders "Moved to <deviceName>" (mesh P5). Round-trips through CBOR.
|
|
#expect(try roundTrip(summary).movedTo == nil)
|
|
let moved = SessionSummary(
|
|
sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
|
|
backend: .claudeCode, status: .awaitingInput, title: "auth-refactor",
|
|
branch: "nucleic/auth", lastSeq: 120, diffStat: nil, archived: true,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
movedTo: MovedDestination(deviceID: "abc123", deviceName: "Studio Mac"))
|
|
let back = try roundTrip(moved)
|
|
#expect(back.archived == true)
|
|
#expect(back.movedTo == MovedDestination(deviceID: "abc123", deviceName: "Studio Mac"))
|
|
#expect(back.movedTo?.deviceName == "Studio Mac")
|
|
}
|
|
|
|
@Test func sessionSummaryCarriesArrivedFrom() throws {
|
|
// A session created here leaves `arrivedFrom` nil; one transferred in names its origin so the
|
|
// destination renders "Arrived from <deviceName>" (mesh P5). Round-trips through CBOR.
|
|
#expect(try roundTrip(summary).arrivedFrom == nil)
|
|
let arrived = SessionSummary(
|
|
sessionID: SessionID(rawValue: "s2"), projectID: "p1", projectName: "ProjA",
|
|
backend: .claudeCode, status: .awaitingInput, title: "ported-work",
|
|
branch: "nucleic/ported", lastSeq: 3, diffStat: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
|
arrivedFrom: ArrivedFrom(deviceID: "laptop-1", deviceName: "Andrew's Laptop"))
|
|
let back = try roundTrip(arrived)
|
|
#expect(back.movedTo == nil)
|
|
#expect(back.arrivedFrom == ArrivedFrom(deviceID: "laptop-1", deviceName: "Andrew's Laptop"))
|
|
#expect(back.arrivedFrom?.deviceName == "Andrew's Laptop")
|
|
}
|
|
|
|
@Test func sessionSummaryQuiescenceDrivesRunnerSleep() {
|
|
// A cloud runner sums `!isQuiescent` across its sessions into the heartbeat's
|
|
// `activeSessions`; the pool sleeps a runner once every session it holds is quiescent.
|
|
func make(status: SessionStatus, disposition: TurnDisposition? = nil,
|
|
queued: [QueuedMessage] = [], archived: Bool = false) -> SessionSummary {
|
|
SessionSummary(
|
|
sessionID: SessionID(rawValue: "s"), projectID: "p", projectName: "P",
|
|
backend: .claudeCode, status: status, disposition: disposition,
|
|
title: "t", branch: "b", lastSeq: 1, diffStat: nil, archived: archived,
|
|
queuedMessages: queued, updatedAt: Date(timeIntervalSince1970: 1))
|
|
}
|
|
let queued = [QueuedMessage(text: "follow up")]
|
|
|
|
// "Done" = a finished run, or an interactive turn classified `.completed`.
|
|
#expect(make(status: .finished).isTurnDone)
|
|
#expect(make(status: .awaitingInput, disposition: .completed).isTurnDone)
|
|
#expect(!make(status: .awaitingInput, disposition: .awaitingInput).isTurnDone)
|
|
#expect(!make(status: .awaitingInput, disposition: .waitingBackground).isTurnDone)
|
|
#expect(!make(status: .running).isTurnDone)
|
|
#expect(!make(status: .awaitingApproval).isTurnDone)
|
|
|
|
// Quiescent = Done and holding no queued follow-up (or a moved-away tombstone).
|
|
#expect(make(status: .finished).isQuiescent)
|
|
#expect(make(status: .awaitingInput, disposition: .completed).isQuiescent)
|
|
#expect(make(status: .running, archived: true).isQuiescent) // inert tombstone
|
|
// A Done session still holding a queued message is NOT quiescent — its message is waiting
|
|
// to dispatch, so the runner reports it active and stays awake.
|
|
#expect(!make(status: .finished, queued: queued).isQuiescent)
|
|
#expect(!make(status: .awaitingInput, disposition: .completed, queued: queued).isQuiescent)
|
|
// Anything mid-turn or awaiting the user is active.
|
|
#expect(!make(status: .running).isQuiescent)
|
|
#expect(!make(status: .awaitingApproval).isQuiescent)
|
|
#expect(!make(status: .awaitingInput, disposition: .awaitingInput).isQuiescent)
|
|
}
|
|
|
|
@Test func dashboardRoundTrips() throws {
|
|
let snapshot = DashboardSnapshot(
|
|
counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5, tokens: 98_765),
|
|
activity: [ActivityDay(day: Date(timeIntervalSince1970: 1_700_000_000), count: 4, tokens: 12_345)],
|
|
projects: [WireProject(id: ProjectID(rawValue: "p1"), name: "ProjA", defaultBranch: "main", sessionCount: 3, activeCount: 1)],
|
|
todos: [WireTodo(
|
|
id: TodoID(rawValue: "t1"), text: "ship it", summary: "ship",
|
|
projectID: ProjectID(rawValue: "p1"), projectName: "ProjA", status: .open,
|
|
dispatchedSessionID: nil, triage: "critical", updatedAt: Date(timeIntervalSince1970: 1))],
|
|
usage: WireSubscriptionUsage(
|
|
fiveHour: WireUsageWindow(utilization: 42.5, resetsAt: Date(timeIntervalSince1970: 2)),
|
|
sevenDay: WireUsageWindow(utilization: 78, resetsAt: nil),
|
|
sevenDayFable: WireUsageWindow(utilization: 91, resetsAt: nil),
|
|
sevenDayOpus: nil,
|
|
sevenDaySonnet: WireUsageWindow(utilization: 12, resetsAt: nil)),
|
|
statusFeeds: [WireStatusFeed(
|
|
provider: "openai", providerName: "OpenAI",
|
|
incidents: [WireStatusIncident(
|
|
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
|
|
updatedAt: Date(timeIntervalSince1970: 3), state: "Monitoring",
|
|
isResolved: false, components: ["Codex", "APIs"])])])
|
|
let back = try roundTrip(HostMsg.dashboard(snapshot))
|
|
#expect(back == .dashboard(snapshot))
|
|
guard case .dashboard(let decoded) = back else { Issue.record("not a dashboard"); return }
|
|
#expect(decoded.todos.first?.triage == "critical")
|
|
#expect(decoded.usage?.fiveHour?.utilization == 42.5)
|
|
#expect(decoded.usage?.sevenDayFable?.utilization == 91)
|
|
#expect(decoded.usage?.peakUtilization == 91)
|
|
#expect(decoded.statusFeeds.first?.hasActiveIncident == true)
|
|
}
|
|
|
|
@Test func dashboardToleratesMissingUsageAndStatus() throws {
|
|
// A dashboard from a host that predates usage/status projection and todo triage —
|
|
// decodes with safe defaults rather than throwing (SYNC §9).
|
|
struct LegacyTodo: Encodable {
|
|
let id = TodoID(rawValue: "t1")
|
|
let text = "ship it"
|
|
let status = TodoStatus.open
|
|
let updatedAt = Date(timeIntervalSince1970: 1)
|
|
}
|
|
struct LegacySnapshot: Encodable {
|
|
let counts = DashboardCounts.empty
|
|
let activity: [ActivityDay] = []
|
|
let projects: [WireProject] = []
|
|
let todos = [LegacyTodo()]
|
|
}
|
|
let data = try CBOREncoder().encode(LegacySnapshot())
|
|
let decoded = try CBORDecoder().decode(DashboardSnapshot.self, from: data)
|
|
#expect(decoded.usage == nil)
|
|
#expect(decoded.statusFeeds.isEmpty)
|
|
#expect(decoded.todos.first?.triage == nil)
|
|
}
|
|
|
|
@Test func capabilitiesTolerateMissingCanFetchDiff() throws {
|
|
// A welcome from a host that predates on-demand diff fetch.
|
|
struct LegacyCapabilities: Encodable {
|
|
let canModifyToolInput = true
|
|
let allowAlwaysScopes = [AlwaysScope.session]
|
|
}
|
|
let data = try CBOREncoder().encode(LegacyCapabilities())
|
|
let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
|
|
#expect(decoded.canFetchDiff == false)
|
|
#expect(decoded.canModifyToolInput == true)
|
|
}
|
|
|
|
@Test func sessionDiffRoundTrips() throws {
|
|
let diff = WireSessionDiff(
|
|
sessionID: SessionID(rawValue: "s1"),
|
|
stat: DiffStat(filesChanged: 2, added: 10, removed: 3),
|
|
files: [
|
|
WireFileDiff(path: "a/b.swift", oldPath: nil, status: "modified", added: 8, removed: 3),
|
|
WireFileDiff(path: "c/new name.swift", oldPath: "c/old.swift", status: "renamed", added: 2, removed: 0),
|
|
],
|
|
patch: "diff --git a/a/b.swift b/a/b.swift\n+let x = 1\n-let x = 0\n",
|
|
truncated: true)
|
|
#expect(try roundTrip(HostMsg.sessionDiff(diff)) == .sessionDiff(diff))
|
|
// An unknown future file status stays a plain string on the wire.
|
|
guard case .sessionDiff(let back) = try roundTrip(HostMsg.sessionDiff(diff)) else {
|
|
Issue.record("not a sessionDiff"); return
|
|
}
|
|
#expect(back.files.map(\.status) == ["modified", "renamed"])
|
|
#expect(back.truncated == true)
|
|
}
|
|
|
|
@Test func hostMessagesRoundTrip() throws {
|
|
let snapshot = SessionSnapshot(
|
|
summary: summary,
|
|
recentEvents: [AgentEvent(
|
|
sessionID: summary.sessionID, seq: 119, at: Date(timeIntervalSince1970: 1),
|
|
backend: .claudeCode, nativeType: nil,
|
|
kind: .assistantText(TextChunk(messageID: "m", text: "hi", isPartial: false)))],
|
|
pendingApprovals: [ApprovalRequest(
|
|
id: ApprovalID(rawValue: "a1"), sessionID: summary.sessionID, toolCallID: "t",
|
|
toolName: "Bash", input: ["command": "rm -rf build/"], title: "Run command",
|
|
risk: .destructive, createdAt: Date(timeIntervalSince1970: 2))],
|
|
cursor: 120)
|
|
let msgs: [HostMsg] = [
|
|
.welcome(Welcome(
|
|
grantedScope: .approve,
|
|
host: HostInfo(hostID: "h", hostName: "Andrew's Mac"),
|
|
capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]))),
|
|
.sessionList([summary]),
|
|
.snapshot(snapshot),
|
|
.events(EventBatch(sessionID: summary.sessionID, events: snapshot.recentEvents)),
|
|
.approvalRequested(snapshot.pendingApprovals[0]),
|
|
.approvalResolved(ApprovalResolved(
|
|
id: ApprovalID(rawValue: "a1"), decision: .deny(reason: nil),
|
|
decidedBy: "iphone:dev", decidedAt: Date(timeIntervalSince1970: 3))),
|
|
.sessionUpdated(summary),
|
|
.error(WireError(code: .alreadyResolved, message: "lost the race", sessionID: summary.sessionID)),
|
|
.pong,
|
|
]
|
|
for msg in msgs { #expect(try roundTrip(msg) == msg) }
|
|
}
|
|
|
|
@Test func toolSummariesRoundTrip() throws {
|
|
let batch = ToolSummaryBatch(
|
|
sessionID: summary.sessionID,
|
|
summaries: [
|
|
ToolSummaryLine(toolCallID: "call-1", line: "Ran the test suite"),
|
|
ToolSummaryLine(toolCallID: "call-2", line: "Built the `nucleic-local` app"),
|
|
])
|
|
#expect(try roundTrip(HostMsg.toolSummaries(batch)) == .toolSummaries(batch))
|
|
}
|
|
|
|
/// A snapshot carries its seeded Bash summaries; and the empty case omits the key on the wire
|
|
/// (so an owner that predates the field is byte-identical), decoding back to an empty set
|
|
/// rather than failing — the mesh-session-sync back-compat guarantee.
|
|
@Test func snapshotToolSummariesAreWireOptional() throws {
|
|
let seeded = SessionSnapshot(
|
|
summary: summary, recentEvents: [], pendingApprovals: [], cursor: 5,
|
|
toolSummaries: [ToolSummaryLine(toolCallID: "b1", line: "Searched the codebase")])
|
|
#expect(try roundTrip(seeded).toolSummaries == seeded.toolSummaries)
|
|
|
|
// No summaries → the `toolSummaries` key is omitted entirely; the decoder's
|
|
// `decodeIfPresent ?? []` is what turns a legacy owner's absent key back into [].
|
|
let empty = SessionSnapshot(
|
|
summary: summary, recentEvents: [], pendingApprovals: [], cursor: 5)
|
|
#expect(try roundTrip(empty).toolSummaries.isEmpty)
|
|
}
|
|
|
|
@Test func unknownHostTagIsForwardCompatible() throws {
|
|
// Simulate a newer host sending a tag this client doesn't know.
|
|
let future: [String: String] = ["t": "futureThing"]
|
|
let data = try CBOREncoder().encode(future)
|
|
let decoded = try CBORDecoder().decode(HostMsg.self, from: data)
|
|
#expect(decoded == .unknown("futureThing"))
|
|
}
|
|
|
|
@Test func deviceScopeOrders() {
|
|
#expect(DeviceScope.view < DeviceScope.approve)
|
|
#expect(DeviceScope.approve < DeviceScope.control)
|
|
#expect(DeviceScope.approve >= DeviceScope.approve)
|
|
}
|
|
}
|