Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions clients/shared/Network/MessageTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
Expand Down Expand Up @@ -1602,6 +1606,47 @@ 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?
// 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
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
Expand Down Expand Up @@ -2279,6 +2324,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)
Expand Down Expand Up @@ -2733,6 +2780,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)
Expand Down
135 changes: 135 additions & 0 deletions clients/shared/Tests/MessageTypesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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.5
}
""".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.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(
"""
{
"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")
}
}