From 24daaa37fcd4d8ef25e23a4b54e0a1c7571e94c2 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 16:54:42 -0400 Subject: [PATCH 1/2] feat(clients/macos): decode host_browser_request and host_browser_cancel messages --- clients/shared/Network/MessageTypes.swift | 48 +++++++++ clients/shared/Tests/MessageTypesTests.swift | 102 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 clients/shared/Tests/MessageTypesTests.swift diff --git a/clients/shared/Network/MessageTypes.swift b/clients/shared/Network/MessageTypes.swift index 1b27d4fd664..2c943fd3db0 100644 --- a/clients/shared/Network/MessageTypes.swift +++ b/clients/shared/Network/MessageTypes.swift @@ -42,6 +42,10 @@ import Foundation // │ │ code generator cannot express it │ // │ HostCuResultPayload │ Posted back to daemon; hand-maintained │ // │ │ alongside HostCuRequest │ +// │ HostBrowserRequest │ Uses AnyCodable for `cdpParams`; client │ +// │ │ decodes only to keep SSE healthy │ +// │ HostBrowserCancelRequest │ Hand-maintained alongside │ +// │ │ HostBrowserRequest │ // │ SkillSearchResult │ Client-only result wrapper for search; │ // │ │ not a wire type │ // │ SkillOperationResult │ Client-only result wrapper for skill │ @@ -1602,6 +1606,42 @@ public struct HostCuCancelRequest: Decodable, Sendable { public let requestId: String } +// MARK: - Host Browser Proxy + +/// Request from the daemon to execute a Chrome DevTools Protocol (CDP) command on +/// the host browser. The desktop client decodes this so the SSE stream does not +/// fail-closed; the actual CDP execution lives in the Chrome extension and is not +/// handled directly by the macOS client. +public struct HostBrowserRequest: Decodable, Sendable { + public let type: String + public let requestId: String + public let conversationId: String + public let cdpMethod: String + public let cdpParams: [String: AnyCodable]? + public let cdpSessionId: String? + public let timeoutSeconds: Int? + + private enum CodingKeys: String, CodingKey { + case type + case requestId + case conversationId + case cdpMethod + case cdpParams + case cdpSessionId + // The daemon wire format for this field is snake_case while the + // sibling fields above are camelCase, so map it explicitly. + case timeoutSeconds = "timeout_seconds" + } +} + +/// Cancellation signal from the daemon telling the host browser to abort an +/// in-flight CDP command identified by `requestId`. As with `HostBrowserRequest` +/// the macOS client only decodes this to keep the SSE stream healthy. +public struct HostBrowserCancelRequest: Decodable, Sendable { + public let type: String + public let requestId: String +} + /// Payload posted back to the daemon with the result of a host CU action execution. public struct HostCuResultPayload: Codable, Sendable { public let requestId: String @@ -2279,6 +2319,8 @@ public enum ServerMessage: Decodable, Sendable { case hostFileCancel(HostFileCancelRequest) case hostCuRequest(HostCuRequest) case hostCuCancel(HostCuCancelRequest) + case hostBrowserRequest(HostBrowserRequest) + case hostBrowserCancel(HostBrowserCancelRequest) case permissionModeUpdate(PermissionModeUpdateMessage) case usageUpdate(UsageUpdate) case serviceGroupUpdateStarting(ServiceGroupUpdateStartingMessage) @@ -2733,6 +2775,12 @@ public enum ServerMessage: Decodable, Sendable { case "host_cu_cancel": let message = try HostCuCancelRequest(from: decoder) self = .hostCuCancel(message) + case "host_browser_request": + let message = try HostBrowserRequest(from: decoder) + self = .hostBrowserRequest(message) + case "host_browser_cancel": + let message = try HostBrowserCancelRequest(from: decoder) + self = .hostBrowserCancel(message) case "permission_mode_update": let message = try PermissionModeUpdateMessage(from: decoder) self = .permissionModeUpdate(message) diff --git a/clients/shared/Tests/MessageTypesTests.swift b/clients/shared/Tests/MessageTypesTests.swift new file mode 100644 index 00000000000..9dbfc7099a5 --- /dev/null +++ b/clients/shared/Tests/MessageTypesTests.swift @@ -0,0 +1,102 @@ +import XCTest + +@testable import VellumAssistantShared + +/// Unit tests for `ServerMessage` discriminated-union decoding. +/// +/// Phase 2 of the Host Browser Proxy work added `host_browser_request` and +/// `host_browser_cancel` cases. These tests assert the SSE decoder does not +/// fail-closed on those types and that the payload fields round-trip cleanly. +final class MessageTypesTests: XCTestCase { + private let decoder = JSONDecoder() + + // MARK: - host_browser_request + + func testDecodes_hostBrowserRequest_withAllFields() throws { + let json = Data( + """ + { + "type": "host_browser_request", + "requestId": "req-abc-123", + "conversationId": "conv-xyz-789", + "cdpMethod": "Page.navigate", + "cdpParams": { + "url": "https://example.com", + "transitionType": "typed" + }, + "cdpSessionId": "session-555", + "timeout_seconds": 45 + } + """.utf8 + ) + + let message = try decoder.decode(ServerMessage.self, from: json) + + guard case .hostBrowserRequest(let request) = message else { + XCTFail("Expected .hostBrowserRequest, got \(message)") + return + } + + XCTAssertEqual(request.type, "host_browser_request") + XCTAssertEqual(request.requestId, "req-abc-123") + XCTAssertEqual(request.conversationId, "conv-xyz-789") + XCTAssertEqual(request.cdpMethod, "Page.navigate") + XCTAssertEqual(request.cdpSessionId, "session-555") + XCTAssertEqual(request.timeoutSeconds, 45) + + let params = try XCTUnwrap(request.cdpParams) + XCTAssertEqual(params["url"]?.value as? String, "https://example.com") + XCTAssertEqual(params["transitionType"]?.value as? String, "typed") + } + + func testDecodes_hostBrowserRequest_withOptionalFieldsAbsent() throws { + let json = Data( + """ + { + "type": "host_browser_request", + "requestId": "req-min", + "conversationId": "conv-min", + "cdpMethod": "Browser.getVersion" + } + """.utf8 + ) + + let message = try decoder.decode(ServerMessage.self, from: json) + + guard case .hostBrowserRequest(let request) = message else { + XCTFail("Expected .hostBrowserRequest, got \(message)") + return + } + + XCTAssertEqual(request.type, "host_browser_request") + XCTAssertEqual(request.requestId, "req-min") + XCTAssertEqual(request.conversationId, "conv-min") + XCTAssertEqual(request.cdpMethod, "Browser.getVersion") + XCTAssertNil(request.cdpParams) + XCTAssertNil(request.cdpSessionId) + XCTAssertNil(request.timeoutSeconds) + } + + // MARK: - host_browser_cancel + + func testDecodes_hostBrowserCancel() throws { + let json = Data( + """ + { + "type": "host_browser_cancel", + "requestId": "req-abc-123" + } + """.utf8 + ) + + let message = try decoder.decode(ServerMessage.self, from: json) + + guard case .hostBrowserCancel(let cancel) = message else { + XCTFail("Expected .hostBrowserCancel, got \(message)") + return + } + + XCTAssertEqual(cancel.type, "host_browser_cancel") + XCTAssertEqual(cancel.requestId, "req-abc-123") + } +} From 353d6294e802e6e14372fa513aa0b3876a6aa9c4 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:17:53 -0400 Subject: [PATCH 2/2] fix: type HostBrowserRequest.timeoutSeconds as Double? Matches the daemon's number-typed wire contract and mirrors HostBashRequest.timeoutSeconds, so fractional timeouts like 0.01s don't throw a type-mismatch and drop the whole host_browser_request event. --- clients/shared/Network/MessageTypes.swift | 7 +++- clients/shared/Tests/MessageTypesTests.swift | 37 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/clients/shared/Network/MessageTypes.swift b/clients/shared/Network/MessageTypes.swift index 2c943fd3db0..47945d4eeb9 100644 --- a/clients/shared/Network/MessageTypes.swift +++ b/clients/shared/Network/MessageTypes.swift @@ -1619,7 +1619,12 @@ public struct HostBrowserRequest: Decodable, Sendable { public let cdpMethod: String public let cdpParams: [String: AnyCodable]? public let cdpSessionId: String? - public let timeoutSeconds: Int? + // Modeled as Double? to match the daemon's `timeout_seconds?: number` wire + // contract (which permits fractional values such as 0.01) and to mirror + // `HostBashRequest.timeoutSeconds`. Using Int? here would cause + // JSONDecoder to throw a type-mismatch on fractional timeouts and drop the + // entire host_browser_request event from the SSE stream. + public let timeoutSeconds: Double? private enum CodingKeys: String, CodingKey { case type diff --git a/clients/shared/Tests/MessageTypesTests.swift b/clients/shared/Tests/MessageTypesTests.swift index 9dbfc7099a5..7eeb0ec1cfd 100644 --- a/clients/shared/Tests/MessageTypesTests.swift +++ b/clients/shared/Tests/MessageTypesTests.swift @@ -25,7 +25,7 @@ final class MessageTypesTests: XCTestCase { "transitionType": "typed" }, "cdpSessionId": "session-555", - "timeout_seconds": 45 + "timeout_seconds": 45.5 } """.utf8 ) @@ -42,13 +42,46 @@ final class MessageTypesTests: XCTestCase { XCTAssertEqual(request.conversationId, "conv-xyz-789") XCTAssertEqual(request.cdpMethod, "Page.navigate") XCTAssertEqual(request.cdpSessionId, "session-555") - XCTAssertEqual(request.timeoutSeconds, 45) + XCTAssertEqual(request.timeoutSeconds, 45.5) let params = try XCTUnwrap(request.cdpParams) XCTAssertEqual(params["url"]?.value as? String, "https://example.com") XCTAssertEqual(params["transitionType"]?.value as? String, "typed") } + /// Regression test for the typing fix that changed `timeoutSeconds` from + /// `Int?` to `Double?`. The daemon's wire contract is `timeout_seconds?: + /// number`, which permits fractional values such as `0.01`. With the old + /// `Int?` typing, `JSONDecoder` would throw a type-mismatch on this + /// payload and the SSE decoder would drop the entire `host_browser_request` + /// event — exactly the failure mode this Phase 2 PR is meant to prevent. + func testDecodes_hostBrowserRequest_withFractionalTimeoutSeconds() throws { + let json = Data( + """ + { + "type": "host_browser_request", + "requestId": "req-frac", + "conversationId": "conv-frac", + "cdpMethod": "Page.navigate", + "timeout_seconds": 0.01 + } + """.utf8 + ) + + let message = try decoder.decode(ServerMessage.self, from: json) + + guard case .hostBrowserRequest(let request) = message else { + XCTFail("Expected .hostBrowserRequest, got \(message)") + return + } + + XCTAssertEqual(request.type, "host_browser_request") + XCTAssertEqual(request.requestId, "req-frac") + XCTAssertEqual(request.conversationId, "conv-frac") + XCTAssertEqual(request.cdpMethod, "Page.navigate") + XCTAssertEqual(request.timeoutSeconds, 0.01) + } + func testDecodes_hostBrowserRequest_withOptionalFieldsAbsent() throws { let json = Data( """