Files
nucleic/Tests/NucleicProtocolTests/WireMessageTests.swift
T

306 lines
16 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),
.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),
]
for msg in msgs { #expect(try roundTrip(msg) == msg) }
}
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: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000, efforts: ["auto"], 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") == ["auto"])
#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 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)
}
@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),
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?.peakUtilization == 78)
#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 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)
}
}