feat(02-01): add Bus package with discriminated-union Codable + fixtures

- New `packages/Bus` SPM package (Swift 6 strict concurrency, WebKit linked).
- `BUS_PROTOCOL_VERSION = "2.0.0"` + shared `HudState` / `TurnTerminator` /
  `BusCoder` in `Protocol.swift`.
- `BusOutbound` (8 cases) + `BusInbound` (2 cases) with hand-written
  `Codable` using a `type` discriminator and exhaustive switches — SE-0295
  synthesized `{"case": {...}}` shape is rejected by design.
- `BusReply` plist-serializable reply + `BusError` error surface.
- 14 JSON fixtures + 24 XCTest round-trip tests asserting byte-equal
  re-encoding, shape invariants, UUID-lowercasing, and negative-path decode
  failures. All green.
- `JarvisLogChannel` gains `case bus` (OBS-06 extension per RESEARCH-07);
  `LoggingTests.test_fiveChannelsAreDefined` updated to match.
This commit is contained in:
james.maes committed 2026-04-23 17:23:02 -05:00
1 parent 37619d0c19
commit cf61b6bac8
24 files changed
+636 -2

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "Bus",
platforms: [.macOS(.v13)],
products: [
.library(name: "Bus", targets: ["Bus"]),
],
dependencies: [
.package(path: "../Logging"),
],
targets: [
.target(
name: "Bus",
dependencies: [
.product(name: "JarvisLogging", package: "Logging"),
],
swiftSettings: [.swiftLanguageMode(.v6)],
linkerSettings: [
.linkedFramework("WebKit"),
]
),
.testTarget(
name: "BusTests",
dependencies: ["Bus"],
resources: [.process("Fixtures")],
swiftSettings: [.swiftLanguageMode(.v6)]
),
]
)
+17
View File
@@ -0,0 +1,17 @@
import Foundation
/// Errors the bus raises across decode, send, and handshake paths.
///
/// Surfaces:
/// - `.decodeFailed` — inbound JSON failed to decode into a `BusInbound`.
/// - `.bridgeNotReady` — `send(_:)` called before the handshake armed.
/// - `.handshakeMismatch` — `helloAck` returned a version the Swift side
/// does not recognize; both sides recorded for alerting.
/// - `.handshakeTimeout` — the webview failed to send `helloAck` within the
/// 2-second window defined by `HandshakeTiming.timeout`.
public enum BusError: Error, Sendable, Equatable {
case decodeFailed(String)
case bridgeNotReady
case handshakeMismatch(swift: String, js: String)
case handshakeTimeout
}
+49
View File
@@ -0,0 +1,49 @@
import Foundation
/// Messages the webview sends to the Swift side.
///
/// Minimal P2 scope (per RESEARCH §Deferred Ideas) — only the handshake ack
/// and a UI-ready ping. P3+ adds user-input, tool-confirm, and other cases.
///
/// Hand-written `Codable` mirrors `BusOutbound` — same `type` discriminator,
/// same exhaustive-switch drift preventer.
public enum BusInbound: Equatable, Sendable {
case helloAck(version: String)
case uiReady
}
extension BusInbound: Codable {
private enum Discriminator: String, Codable {
case helloAck
case uiReady
}
private enum CodingKeys: String, CodingKey {
case type
case version
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let tag = try container.decode(Discriminator.self, forKey: .type)
switch tag {
case .helloAck:
let version = try container.decode(String.self, forKey: .version)
self = .helloAck(version: version)
case .uiReady:
self = .uiReady
}
// NO default — adding a case without a matching arm is a compile error.
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .helloAck(let version):
try container.encode(Discriminator.helloAck, forKey: .type)
try container.encode(version, forKey: .version)
case .uiReady:
try container.encode(Discriminator.uiReady, forKey: .type)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import Foundation
/// Messages the Swift side sends to the webview.
///
/// IMPORTANT: `Codable` conformance is declared in an **extension** (below),
/// not on the enum declaration. This is load-bearing: Swift's synthesized
/// `Codable` (SE-0295) would encode associated-value enums as
/// `{"hudState": {"_0": "idle"}}`, which does NOT match the TS mirror's
/// `{"type": "hudState", "state": "idle"}` shape. Hand-writing the
/// `init(from:)` and `encode(to:)` methods with exhaustive switches is the
/// schema-drift preventer at the language level — adding a case without
/// updating both methods is a compile error.
public enum BusOutbound: Equatable, Sendable {
case hello(version: String)
case hudState(HudState)
case tokenDelta(text: String)
case audioLevel(rms: Float)
case toolCallStart(id: UUID, name: String, argsPreview: String)
case toolCallEnd(id: UUID, ok: Bool, previewOrError: String)
case turnStarted(id: UUID)
case turnEnded(id: UUID, terminator: TurnTerminator)
}
extension BusOutbound: Codable {
/// Discriminator tag used as the `type` field in the wire format.
/// Declaring this as a `String`-raw-value enum means an unknown tag in
/// inbound JSON surfaces as `DecodingError.dataCorrupted` from the
/// `try c.decode(Discriminator.self, forKey: .type)` call below.
private enum Discriminator: String, Codable {
case hello
case hudState
case tokenDelta
case audioLevel
case toolCallStart
case toolCallEnd
case turnStarted
case turnEnded
}
/// All `CodingKeys` across every case. Swift's keyed container does not
/// care about unused keys per-case; the per-case `encode(to:)` only
/// writes the fields relevant to the variant.
private enum CodingKeys: String, CodingKey {
case type
case version
case state
case text
case rms
case id
case name
case argsPreview
case ok
case previewOrError
case terminator
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let tag = try container.decode(Discriminator.self, forKey: .type)
switch tag {
case .hello:
let version = try container.decode(String.self, forKey: .version)
self = .hello(version: version)
case .hudState:
let state = try container.decode(HudState.self, forKey: .state)
self = .hudState(state)
case .tokenDelta:
let text = try container.decode(String.self, forKey: .text)
self = .tokenDelta(text: text)
case .audioLevel:
let rms = try container.decode(Float.self, forKey: .rms)
self = .audioLevel(rms: rms)
case .toolCallStart:
let id = try Self.decodeUUID(container: container, forKey: .id)
let name = try container.decode(String.self, forKey: .name)
let argsPreview = try container.decode(String.self, forKey: .argsPreview)
self = .toolCallStart(id: id, name: name, argsPreview: argsPreview)
case .toolCallEnd:
let id = try Self.decodeUUID(container: container, forKey: .id)
let ok = try container.decode(Bool.self, forKey: .ok)
let previewOrError = try container.decode(String.self, forKey: .previewOrError)
self = .toolCallEnd(id: id, ok: ok, previewOrError: previewOrError)
case .turnStarted:
let id = try Self.decodeUUID(container: container, forKey: .id)
self = .turnStarted(id: id)
case .turnEnded:
let id = try Self.decodeUUID(container: container, forKey: .id)
let terminator = try container.decode(TurnTerminator.self, forKey: .terminator)
self = .turnEnded(id: id, terminator: terminator)
}
// NO default branch — adding a Discriminator case without also adding
// a matching switch arm here is a compile error. That is the entire
// reason this Codable is hand-written.
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .hello(let version):
try container.encode(Discriminator.hello, forKey: .type)
try container.encode(version, forKey: .version)
case .hudState(let state):
try container.encode(Discriminator.hudState, forKey: .type)
try container.encode(state, forKey: .state)
case .tokenDelta(let text):
try container.encode(Discriminator.tokenDelta, forKey: .type)
try container.encode(text, forKey: .text)
case .audioLevel(let rms):
try container.encode(Discriminator.audioLevel, forKey: .type)
try container.encode(rms, forKey: .rms)
case .toolCallStart(let id, let name, let argsPreview):
try container.encode(Discriminator.toolCallStart, forKey: .type)
try container.encode(id.uuidString.lowercased(), forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(argsPreview, forKey: .argsPreview)
case .toolCallEnd(let id, let ok, let previewOrError):
try container.encode(Discriminator.toolCallEnd, forKey: .type)
try container.encode(id.uuidString.lowercased(), forKey: .id)
try container.encode(ok, forKey: .ok)
try container.encode(previewOrError, forKey: .previewOrError)
case .turnStarted(let id):
try container.encode(Discriminator.turnStarted, forKey: .type)
try container.encode(id.uuidString.lowercased(), forKey: .id)
case .turnEnded(let id, let terminator):
try container.encode(Discriminator.turnEnded, forKey: .type)
try container.encode(id.uuidString.lowercased(), forKey: .id)
try container.encode(terminator, forKey: .terminator)
}
// Exhaustive at encode site — adding a case without encoding it is a
// compile error, mirroring the init(from:) drift preventer.
}
/// `UUID` is encoded as a lowercase string (Pitfall 6). Decoder accepts
/// either case since `UUID(uuidString:)` is case-insensitive, but we
/// route the parse through an explicit helper so bad payloads surface
/// as structured `DecodingError`s instead of silent `fatalError`s.
private static func decodeUUID(
container: KeyedDecodingContainer<CodingKeys>,
forKey key: CodingKeys
) throws -> UUID {
let raw = try container.decode(String.self, forKey: key)
guard let uuid = UUID(uuidString: raw) else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "invalid UUID string: \(raw)"
)
}
return uuid
}
}
+49
View File
@@ -0,0 +1,49 @@
import Foundation
/// Reply shape returned via `WKScriptMessageHandlerWithReply`'s `replyHandler`.
///
/// WebKit requires replies to be plist-serializable (`NSNumber`, `NSString`,
/// `NSArray`, `NSDictionary`, `NSNull`, nested) — arbitrary `Codable` types
/// are rejected at runtime. `asPlist()` produces the accepted dictionary form.
///
/// JS semantics (per HUD-03):
/// - `(value, nil)` → JS-side `Promise.resolve(value)`
/// - `(nil, errorString)` → JS-side `Promise.reject(new Error(errorString))`
public struct BusReply: Sendable, Equatable {
public let ok: Bool
/// JSON-encoded payload as a string, or `nil` for success with no body.
public let value: String?
public let error: String?
public init(ok: Bool, value: String? = nil, error: String? = nil) {
self.ok = ok
self.value = value
self.error = error
}
/// Canonical empty-success reply.
public static let success = BusReply(ok: true)
/// Canonical failure reply. `message` is what the JS `Error.message`
/// will surface after the promise rejects.
public static func failure(_ message: String) -> BusReply {
BusReply(ok: false, error: message)
}
/// Plist-serializable dictionary form accepted by
/// `WKScriptMessageHandlerWithReply.replyHandler`. Strings and booleans
/// bridge to `NSString` / `NSNumber`; nil slots become `NSNull`.
public func asPlist() -> [String: Any] {
if ok {
return [
"ok": true,
"value": (value as Any?) ?? NSNull(),
]
} else {
return [
"ok": false,
"error": error ?? "unknown",
]
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
/// The wire-format version for the Swift ↔ JS bus. Bumping this rejects any
/// webview bundle that was built against a different constant (HUD-05).
///
/// Versioning policy (per RESEARCH §HUD-05):
/// - `MAJOR` — structural schema change (field renamed, removed, re-typed).
/// - `MINOR` — additive case or field (webview still decodes older shape).
/// - `PATCH` — bugfix / fixture correction with no behavioural change.
///
/// Strict equality is enforced at runtime by the handshake. Additive-only
/// relaxation (match on `MAJOR` alone) is deferred to a later phase.
public let BUS_PROTOCOL_VERSION: String = "2.0.0"
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
public enum HudState: String, Codable, Sendable, CaseIterable {
case idle
case listening
case thinking
case speaking
case awaitingConfirmation
case reconfiguring
case booting
}
/// Terminal reason for a conversation turn. Encoded as its `rawValue` inside
/// `BusOutbound.turnEnded(id:terminator:)`.
public enum TurnTerminator: String, Codable, Sendable, CaseIterable {
case completed
case cancelled
case errored
case superseded
}
/// Shared JSON coder configuration. Single source of truth for the wire
/// format so `WebviewBridge`, `CodableRoundTripTests`, and any downstream
/// consumers all agree on encoding.
///
/// Notes:
/// - `outputFormatting` is intentionally empty — the wire form is minimal,
/// not pretty-printed or key-sorted. Round-trip tests normalize via
/// `JSONSerialization` to tolerate key-order differences.
/// - `UUID` values are encoded as *lowercase* strings at each call site
/// inside `BusOutbound.encode(to:)` because `JSONEncoder` encodes `UUID`
/// as uppercase by default, which the TS mirror does not emit
/// (Pitfall 6 in RESEARCH §pitfalls).
public enum BusCoder {
public static func makeEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = []
encoder.dateEncodingStrategy = .iso8601
return encoder
}
public static func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,258 @@
import XCTest
@testable import Bus
/// Round-trip parity: decode fixture → re-encode → normalize → compare.
///
/// Plan 02 (TS mirror) reads the same fixture files and Plan 04's parity
/// script asserts the two fixture directories stay in sync. Together with
/// the exhaustive switches in `BusOutbound` / `BusInbound`, these tests are
/// the build-time line of defense against schema drift.
final class CodableRoundTripTests: XCTestCase {
// MARK: - Helpers
private func loadFixture(_ name: String) throws -> Data {
guard let url = Bundle.module.url(forResource: name, withExtension: "json") else {
XCTFail("missing fixture: \(name).json")
throw CocoaError(.fileNoSuchFile)
}
return try Data(contentsOf: url)
}
/// Normalize JSON so key-order differences don't fail comparisons.
/// `NSDictionary.isEqual` is order-independent, so we decode both sides
/// to a generic JSON object graph first.
private func normalize(_ data: Data) throws -> NSObject {
let value = try JSONSerialization.jsonObject(with: data, options: [])
return value as! NSObject
}
private func assertOutboundRoundTrips(
fixture: String,
expect: BusOutbound,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let data = try loadFixture(fixture)
let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: data)
XCTAssertEqual(decoded, expect, "decoded value mismatch for \(fixture)", file: file, line: line)
let reencoded = try BusCoder.makeEncoder().encode(decoded)
let originalNorm = try normalize(data)
let reencodedNorm = try normalize(reencoded)
XCTAssertEqual(
originalNorm, reencodedNorm,
"round-trip mismatch for \(fixture).\nexpected: \(String(data: data, encoding: .utf8) ?? "<?>")\ngot: \(String(data: reencoded, encoding: .utf8) ?? "<?>")",
file: file, line: line
)
}
private func assertInboundRoundTrips(
fixture: String,
expect: BusInbound,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let data = try loadFixture(fixture)
let decoded = try BusCoder.makeDecoder().decode(BusInbound.self, from: data)
XCTAssertEqual(decoded, expect, "decoded value mismatch for \(fixture)", file: file, line: line)
let reencoded = try BusCoder.makeEncoder().encode(decoded)
let originalNorm = try normalize(data)
let reencodedNorm = try normalize(reencoded)
XCTAssertEqual(
originalNorm, reencodedNorm,
"round-trip mismatch for \(fixture)",
file: file, line: line
)
}
// MARK: - Outbound round-trips (8 variants; HudState expanded × 7)
func test_roundTrip_hello() throws {
try assertOutboundRoundTrips(
fixture: "hello",
expect: .hello(version: "2.0.0")
)
}
func test_roundTrip_hudState_idle() throws {
try assertOutboundRoundTrips(fixture: "hudState.idle", expect: .hudState(.idle))
}
func test_roundTrip_hudState_listening() throws {
try assertOutboundRoundTrips(fixture: "hudState.listening", expect: .hudState(.listening))
}
func test_roundTrip_hudState_thinking() throws {
try assertOutboundRoundTrips(fixture: "hudState.thinking", expect: .hudState(.thinking))
}
func test_roundTrip_hudState_speaking() throws {
try assertOutboundRoundTrips(fixture: "hudState.speaking", expect: .hudState(.speaking))
}
func test_roundTrip_hudState_awaitingConfirmation() throws {
try assertOutboundRoundTrips(
fixture: "hudState.awaitingConfirmation",
expect: .hudState(.awaitingConfirmation)
)
}
func test_roundTrip_hudState_reconfiguring() throws {
try assertOutboundRoundTrips(
fixture: "hudState.reconfiguring",
expect: .hudState(.reconfiguring)
)
}
func test_roundTrip_hudState_booting() throws {
try assertOutboundRoundTrips(fixture: "hudState.booting", expect: .hudState(.booting))
}
func test_roundTrip_tokenDelta() throws {
try assertOutboundRoundTrips(
fixture: "tokenDelta",
expect: .tokenDelta(text: "Hello, world!")
)
}
func test_roundTrip_audioLevel() throws {
try assertOutboundRoundTrips(
fixture: "audioLevel",
expect: .audioLevel(rms: 0.42)
)
}
func test_roundTrip_toolCallStart() throws {
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
try assertOutboundRoundTrips(
fixture: "toolCallStart",
expect: .toolCallStart(id: id, name: "get_time", argsPreview: "{}")
)
}
func test_roundTrip_toolCallEnd() throws {
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
try assertOutboundRoundTrips(
fixture: "toolCallEnd",
expect: .toolCallEnd(id: id, ok: true, previewOrError: "\"2026-04-23T16:00:00Z\"")
)
}
func test_roundTrip_turnStarted() throws {
let id = UUID(uuidString: "6ba7b810-9dad-11d1-80b4-00c04fd430c8")!
try assertOutboundRoundTrips(fixture: "turnStarted", expect: .turnStarted(id: id))
}
func test_roundTrip_turnEnded() throws {
let id = UUID(uuidString: "6ba7b810-9dad-11d1-80b4-00c04fd430c8")!
try assertOutboundRoundTrips(
fixture: "turnEnded",
expect: .turnEnded(id: id, terminator: .completed)
)
}
// MARK: - Inbound round-trips
func test_roundTrip_helloAck() throws {
try assertInboundRoundTrips(
fixture: "helloAck",
expect: .helloAck(version: "2.0.0")
)
}
// MARK: - Shape invariants (these prove hand-written Codable is load-bearing)
/// If SE-0295 synthesis leaked, we'd see `{"hudState":{"state":"idle"}}`.
/// The required wire shape is `{"type":"hudState","state":"idle"}`.
func test_hudStateEncodesWithTypeDiscriminatorNotSynthesizedShape() throws {
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.hudState(.idle))
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "hudState")
XCTAssertEqual(obj["state"] as? String, "idle")
XCTAssertNil(obj["hudState"], "synthesized SE-0295 Codable leaked: nested payload under case name")
}
/// Same invariant for `tokenDelta` — required wire shape is
/// `{"type":"tokenDelta","text":"hi"}`.
func test_tokenDeltaEncodesWithTypeDiscriminator() throws {
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.tokenDelta(text: "hi"))
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "tokenDelta")
XCTAssertEqual(obj["text"] as? String, "hi")
XCTAssertNil(obj["tokenDelta"])
}
/// The `helloAck` shape is `{"type":"helloAck","version":"2.0.0"}`.
func test_helloAckEncodesWithTypeDiscriminator() throws {
let encoded = try BusCoder.makeEncoder().encode(BusInbound.helloAck(version: "2.0.0"))
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
XCTAssertEqual(obj["type"] as? String, "helloAck")
XCTAssertEqual(obj["version"] as? String, "2.0.0")
}
// MARK: - UUID formatting (Pitfall 6: JSONEncoder defaults to uppercase)
func test_uuidIsEncodedAsLowercaseString() throws {
let id = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")!
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.turnStarted(id: id))
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
let raw = obj["id"] as? String
XCTAssertEqual(raw, "550e8400-e29b-41d4-a716-446655440000")
XCTAssertFalse(raw?.contains(where: { $0.isUppercase }) ?? true, "UUID must encode lowercase")
}
// MARK: - Negative tests (drift detection)
func test_decodeUnknownDiscriminatorFails() {
let payload = #"{"type":"unknown","state":"idle"}"#.data(using: .utf8)!
XCTAssertThrowsError(
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
) { error in
XCTAssertTrue(error is DecodingError, "expected DecodingError for unknown discriminator")
}
}
func test_decodeMissingDiscriminatorFails() {
let payload = #"{"version":"2.0.0"}"#.data(using: .utf8)!
XCTAssertThrowsError(
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
) { error in
XCTAssertTrue(error is DecodingError, "expected DecodingError for missing type key")
}
}
func test_decodeInvalidUUIDFails() {
let payload = #"{"type":"turnStarted","id":"not-a-uuid"}"#.data(using: .utf8)!
XCTAssertThrowsError(
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
) { error in
XCTAssertTrue(error is DecodingError, "expected DecodingError for malformed UUID")
}
}
func test_inboundDecodeUnknownDiscriminatorFails() {
let payload = #"{"type":"unknown"}"#.data(using: .utf8)!
XCTAssertThrowsError(
try BusCoder.makeDecoder().decode(BusInbound.self, from: payload)
) { error in
XCTAssertTrue(error is DecodingError, "expected DecodingError for unknown inbound discriminator")
}
}
// MARK: - HudState case coverage guard
func test_allHudStateCasesHaveFixtures() throws {
// Every HudState case must have a dedicated fixture. If this test
// fails after an enum case is added, add the fixture and update the
// switch below.
for state in HudState.allCases {
let fixtureName = "hudState.\(state.rawValue)"
try assertOutboundRoundTrips(fixture: fixtureName, expect: .hudState(state))
}
}
}
@@ -0,0 +1 @@
{"type":"audioLevel","rms":0.42}
@@ -0,0 +1 @@
{"type":"hello","version":"2.0.0"}
@@ -0,0 +1 @@
{"type":"helloAck","version":"2.0.0"}
@@ -0,0 +1 @@
{"type":"hudState","state":"awaitingConfirmation"}
@@ -0,0 +1 @@
{"type":"hudState","state":"booting"}
@@ -0,0 +1 @@
{"type":"hudState","state":"idle"}
@@ -0,0 +1 @@
{"type":"hudState","state":"listening"}
@@ -0,0 +1 @@
{"type":"hudState","state":"reconfiguring"}
@@ -0,0 +1 @@
{"type":"hudState","state":"speaking"}
@@ -0,0 +1 @@
{"type":"hudState","state":"thinking"}
@@ -0,0 +1 @@
{"type":"tokenDelta","text":"Hello, world!"}
@@ -0,0 +1 @@
{"type":"toolCallEnd","id":"550e8400-e29b-41d4-a716-446655440000","ok":true,"previewOrError":"\"2026-04-23T16:00:00Z\""}
@@ -0,0 +1 @@
{"type":"toolCallStart","id":"550e8400-e29b-41d4-a716-446655440000","name":"get_time","argsPreview":"{}"}
@@ -0,0 +1 @@
{"type":"turnEnded","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","terminator":"completed"}
@@ -0,0 +1 @@
{"type":"turnStarted","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8"}
@@ -5,4 +5,5 @@ public enum JarvisLogChannel: String, Sendable, CaseIterable {
case tools
case ui
case system
case bus
}
@@ -3,8 +3,10 @@ import Logging
@testable import JarvisLogging
final class LoggingTests: XCTestCase {
func test_fourChannelsAreDefined() {
let expected: Set<String> = ["agent", "tools", "ui", "system"]
func test_fiveChannelsAreDefined() {
// `bus` was added in Phase 2 for bus-specific logging (RESEARCH §OBS-06
// open question #7). Keep this set in sync with `JarvisLogChannel`.
let expected: Set<String> = ["agent", "tools", "ui", "system", "bus"]
let actual = Set(JarvisLogChannel.allCases.map(\.rawValue))
XCTAssertEqual(actual, expected)
}