Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable {

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

// NSNumber bridges promiscuously to Bool/Int/Double — pattern matching
// alone can't distinguish a Bool-backed NSNumber from an Int-backed one.
// Inspect objCType to dispatch faithfully to the underlying type.
// ('c' is also Int8's encoding, but JSONSerialization only ever produces
// 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.)
if let number = value as? NSNumber, type(of: value) != Bool.self {
let objCType = number.objCType[0]
switch objCType {
case 0x63 /* 'c' */, 0x42 /* 'B' */:
try container.encode(number.boolValue)
return
case 0x66 /* 'f' */, 0x64 /* 'd' */:
try container.encode(number.doubleValue)
return
default:
try container.encode(number.int64Value)
return
}
Comment on lines +52 to +62
}

switch value {
case is NSNull:
try container.encodeNil()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// AnyCodableTests.swift — Tests for AnyCodable encode/decode correctness.
//
// Focused on the NSNumber-bridging bug: when a [String: Any] dictionary is
// produced by JSONSerialization (which boxes numeric/bool values as NSNumber),
// AnyCodable must re-encode each value to its original JSON type rather than
// the first matching Swift pattern-match arm.

import XCTest
@testable import AgentHostProtocol

final class AnyCodableTests: XCTestCase {

func testAnyCodableEncodePreservesIntFromNSNumber() throws {
let object = try JSONSerialization.jsonObject(
with: #"{"x":1}"#.data(using: .utf8)!
)
let wrapped = AnyCodable(object)
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1}"#)
}

func testAnyCodableEncodePreservesBoolFromNSNumber() throws {
let object = try JSONSerialization.jsonObject(
with: #"{"x":true}"#.data(using: .utf8)!
)
let wrapped = AnyCodable(object)
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":true}"#)
}

func testAnyCodableEncodePreservesDoubleFromNSNumber() throws {
let object = try JSONSerialization.jsonObject(
with: #"{"x":1.5}"#.data(using: .utf8)!
)
let wrapped = AnyCodable(object)
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#)
}

func testAnyCodableEncodePreservesNativeSwiftBool() throws {
// A native Swift Bool (NOT NSNumber-backed) must encode as `true`, not `1`.
// Exercises the `type(of:) != Bool.self` guard, which routes a native Swift
// Bool past the objCType dispatch to the `case let bool as Bool` arm.
let wrapped = AnyCodable(["x": true] as [String: Any])
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":true}"#)
}

func testAnyCodableEncodePreservesFloatBackedNSNumber() throws {
// A Float-backed NSNumber (objCType 'f') must encode as a decimal, not an
// integer. This exercises the 'f' dispatch arm; the JSONSerialization path
// boxes JSON numbers as 'd'/'q' and never produces it.
let wrapped = AnyCodable(["x": NSNumber(value: Float(1.5))] as [String: Any])
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#)
}
Comment on lines +49 to +56
}
4 changes: 4 additions & 0 deletions clients/swift/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ the tag matches the version pinned in [`VERSION`](VERSION).

### Fixed

- `AnyCodable.encode(to:)` now dispatches `NSNumber`-backed values by
`objCType` before the Swift pattern-match arms, preventing `Int` from being
corrupted to `Bool` and `Double` from being corrupted to `Int` when the
wrapped value originated from `JSONSerialization`.
- Encode-fidelity: an unknown `StateAction` variant no longer re-encodes to
`{}` (dropping its `type` discriminant and extra fields); the raw payload is
preserved on decode and re-emitted verbatim.
Expand Down
37 changes: 36 additions & 1 deletion scripts/generate-swift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,12 @@ function anyCodableContent(): string {
import Foundation

/// A type-erased \`Codable\` value for handling \`unknown\` and \`Record<string, unknown>\` types.
public struct AnyCodable: Codable, Sendable, Equatable {
///
/// Marked \`@unchecked Sendable\` because the stored \`Any\` is only ever set to
/// immutable, \`Sendable\`-safe types during decoding (Bool, Int, Double, String,
/// NSNull, and recursive \`[Any]\`/\`[String: Any]\` of those). The value is \`let\`,
/// so it cannot be mutated after initialization.
public struct AnyCodable: Codable, @unchecked Sendable, Equatable {
public let value: Any

public init(_ value: Any) {
Expand Down Expand Up @@ -1630,6 +1635,27 @@ public struct AnyCodable: Codable, Sendable, Equatable {

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

// NSNumber bridges promiscuously to Bool/Int/Double — pattern matching
// alone can't distinguish a Bool-backed NSNumber from an Int-backed one.
// Inspect objCType to dispatch faithfully to the underlying type.
// ('c' is also Int8's encoding, but JSONSerialization only ever produces
// 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.)
if let number = value as? NSNumber, type(of: value) != Bool.self {
let objCType = number.objCType[0]
switch objCType {
case 0x63 /* 'c' */, 0x42 /* 'B' */:
try container.encode(number.boolValue)
return
case 0x66 /* 'f' */, 0x64 /* 'd' */:
try container.encode(number.doubleValue)
return
default:
try container.encode(number.int64Value)
return
}
Comment on lines +1646 to +1656
}

switch value {
case is NSNull:
try container.encodeNil()
Expand Down Expand Up @@ -1666,6 +1692,15 @@ public struct AnyCodable: Codable, Sendable, Equatable {
return lhs == rhs
case let (lhs as String, rhs as String):
return lhs == rhs
case let (lhs as [Any], rhs as [Any]):
guard lhs.count == rhs.count else { return false }
return zip(lhs, rhs).allSatisfy { AnyCodable($0) == AnyCodable($1) }
case let (lhs as [String: Any], rhs as [String: Any]):
guard lhs.count == rhs.count else { return false }
return lhs.allSatisfy { key, val in
guard let other = rhs[key] else { return false }
return AnyCodable(val) == AnyCodable(other)
}
default:
return false
}
Expand Down