diff --git a/packages/Bus/Package.swift b/packages/Bus/Package.swift new file mode 100644 index 0000000..0e3464c --- /dev/null +++ b/packages/Bus/Package.swift @@ -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)] + ), + ] +) diff --git a/packages/Bus/Sources/Bus/BusError.swift b/packages/Bus/Sources/Bus/BusError.swift new file mode 100644 index 0000000..358c56d --- /dev/null +++ b/packages/Bus/Sources/Bus/BusError.swift @@ -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 +} diff --git a/packages/Bus/Sources/Bus/BusInbound.swift b/packages/Bus/Sources/Bus/BusInbound.swift new file mode 100644 index 0000000..c1774f3 --- /dev/null +++ b/packages/Bus/Sources/Bus/BusInbound.swift @@ -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) + } + } +} diff --git a/packages/Bus/Sources/Bus/BusOutbound.swift b/packages/Bus/Sources/Bus/BusOutbound.swift new file mode 100644 index 0000000..47c2f96 --- /dev/null +++ b/packages/Bus/Sources/Bus/BusOutbound.swift @@ -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, + 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 + } +} diff --git a/packages/Bus/Sources/Bus/BusReply.swift b/packages/Bus/Sources/Bus/BusReply.swift new file mode 100644 index 0000000..818cf79 --- /dev/null +++ b/packages/Bus/Sources/Bus/BusReply.swift @@ -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", + ] + } + } +} diff --git a/packages/Bus/Sources/Bus/Protocol.swift b/packages/Bus/Sources/Bus/Protocol.swift new file mode 100644 index 0000000..cb34ad8 --- /dev/null +++ b/packages/Bus/Sources/Bus/Protocol.swift @@ -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 + } +} diff --git a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift new file mode 100644 index 0000000..6f5dd2b --- /dev/null +++ b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift @@ -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)) + } + } +} diff --git a/packages/Bus/Tests/BusTests/Fixtures/audioLevel.json b/packages/Bus/Tests/BusTests/Fixtures/audioLevel.json new file mode 100644 index 0000000..ce9372a --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/audioLevel.json @@ -0,0 +1 @@ +{"type":"audioLevel","rms":0.42} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hello.json b/packages/Bus/Tests/BusTests/Fixtures/hello.json new file mode 100644 index 0000000..b7ba1b9 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hello.json @@ -0,0 +1 @@ +{"type":"hello","version":"2.0.0"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/helloAck.json b/packages/Bus/Tests/BusTests/Fixtures/helloAck.json new file mode 100644 index 0000000..201fee2 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/helloAck.json @@ -0,0 +1 @@ +{"type":"helloAck","version":"2.0.0"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.awaitingConfirmation.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.awaitingConfirmation.json new file mode 100644 index 0000000..96b4714 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.awaitingConfirmation.json @@ -0,0 +1 @@ +{"type":"hudState","state":"awaitingConfirmation"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.booting.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.booting.json new file mode 100644 index 0000000..b42bbe9 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.booting.json @@ -0,0 +1 @@ +{"type":"hudState","state":"booting"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.idle.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.idle.json new file mode 100644 index 0000000..7fa2f3d --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.idle.json @@ -0,0 +1 @@ +{"type":"hudState","state":"idle"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.listening.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.listening.json new file mode 100644 index 0000000..d6fa780 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.listening.json @@ -0,0 +1 @@ +{"type":"hudState","state":"listening"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.reconfiguring.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.reconfiguring.json new file mode 100644 index 0000000..a6ce9a1 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.reconfiguring.json @@ -0,0 +1 @@ +{"type":"hudState","state":"reconfiguring"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.speaking.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.speaking.json new file mode 100644 index 0000000..c99e087 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.speaking.json @@ -0,0 +1 @@ +{"type":"hudState","state":"speaking"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/hudState.thinking.json b/packages/Bus/Tests/BusTests/Fixtures/hudState.thinking.json new file mode 100644 index 0000000..915fe68 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/hudState.thinking.json @@ -0,0 +1 @@ +{"type":"hudState","state":"thinking"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/tokenDelta.json b/packages/Bus/Tests/BusTests/Fixtures/tokenDelta.json new file mode 100644 index 0000000..ffac94e --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/tokenDelta.json @@ -0,0 +1 @@ +{"type":"tokenDelta","text":"Hello, world!"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/toolCallEnd.json b/packages/Bus/Tests/BusTests/Fixtures/toolCallEnd.json new file mode 100644 index 0000000..b5f1b45 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/toolCallEnd.json @@ -0,0 +1 @@ +{"type":"toolCallEnd","id":"550e8400-e29b-41d4-a716-446655440000","ok":true,"previewOrError":"\"2026-04-23T16:00:00Z\""} diff --git a/packages/Bus/Tests/BusTests/Fixtures/toolCallStart.json b/packages/Bus/Tests/BusTests/Fixtures/toolCallStart.json new file mode 100644 index 0000000..de1cf9e --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/toolCallStart.json @@ -0,0 +1 @@ +{"type":"toolCallStart","id":"550e8400-e29b-41d4-a716-446655440000","name":"get_time","argsPreview":"{}"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/turnEnded.json b/packages/Bus/Tests/BusTests/Fixtures/turnEnded.json new file mode 100644 index 0000000..3e3c027 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/turnEnded.json @@ -0,0 +1 @@ +{"type":"turnEnded","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","terminator":"completed"} diff --git a/packages/Bus/Tests/BusTests/Fixtures/turnStarted.json b/packages/Bus/Tests/BusTests/Fixtures/turnStarted.json new file mode 100644 index 0000000..e2f74a0 --- /dev/null +++ b/packages/Bus/Tests/BusTests/Fixtures/turnStarted.json @@ -0,0 +1 @@ +{"type":"turnStarted","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8"} diff --git a/packages/Logging/Sources/JarvisLogging/JarvisLogChannel.swift b/packages/Logging/Sources/JarvisLogging/JarvisLogChannel.swift index c6e2beb..bb123cb 100644 --- a/packages/Logging/Sources/JarvisLogging/JarvisLogChannel.swift +++ b/packages/Logging/Sources/JarvisLogging/JarvisLogChannel.swift @@ -5,4 +5,5 @@ public enum JarvisLogChannel: String, Sendable, CaseIterable { case tools case ui case system + case bus } diff --git a/packages/Logging/Tests/JarvisLoggingTests/LoggingTests.swift b/packages/Logging/Tests/JarvisLoggingTests/LoggingTests.swift index db4d399..11c6610 100644 --- a/packages/Logging/Tests/JarvisLoggingTests/LoggingTests.swift +++ b/packages/Logging/Tests/JarvisLoggingTests/LoggingTests.swift @@ -3,8 +3,10 @@ import Logging @testable import JarvisLogging final class LoggingTests: XCTestCase { - func test_fourChannelsAreDefined() { - let expected: Set = ["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 = ["agent", "tools", "ui", "system", "bus"] let actual = Set(JarvisLogChannel.allCases.map(\.rawValue)) XCTAssertEqual(actual, expected) }