Skip to content

python: boolean-discriminated unions never decode - sessions.list() raises for any non-empty result #2122

Description

@examon

Summary

The Python SDK generates union dispatchers that match the discriminator against Python string literals "true" / "false".
A JSON boolean decodes to Python True / False, which matches neither, so the dispatcher always falls through to case _: raise ValueError(...).

The API schema has exactly two boolean-discriminated unions, and both are broken in Python:

Union Discriminator Used by Effect in Python
SessionListEntry isRemote ({"type": "boolean", "const": false/true}) sessions.list result decode raises for any non-empty list
QueuedCommandResult handled (boolean const) session.commands.respondToQueuedCommand fails to decode, and serializes the string "true" instead of true

Every other discriminated union in the schema uses string consts, which is why this went unnoticed.

isRemote is required on both variants, so there is no payload for which the dispatcher succeeds. The type cannot even round-trip its own output.

Impact

client.rpc.sessions.list() raises on any non-empty result. It succeeds only when the session list is empty.

The QueuedCommandResult symptom is a wire-contract violation rather than a visible failure today. It is worth fixing regardless: both variants serialize to a non-empty string, so handled: "false" is indistinguishable from handled: "true" to any consumer that reads the field as a boolean.

Reproduction

pip install github-copilot-sdk
python repro.py

repro.py - no runtime or network needed, this is a pure decode:

from copilot.rpc import SessionList

# The wire shape `sessions.list` returns. Per the API schema,
# LocalSessionMetadataValue.isRemote is {"type": "boolean", "const": false}
# and it is a required property.
payload = {
    "sessions": [
        {
            "sessionId": "example-local",
            "startTime": "2026-07-26T10:00:00.000Z",
            "modifiedTime": "2026-07-26T10:05:00.000Z",
            "isRemote": False,
        }
    ]
}

print(SessionList.from_dict(payload))

Expected: a SessionList holding one LocalSessionMetadataValue.

Actual:

Traceback (most recent call last):
  File "repro.py", line 17, in <module>
    print(SessionList.from_dict(payload))
  File ".../copilot/generated/rpc.py", line 7857, in from_dict
    sessions = from_list(_load_SessionListEntry, obj.get("sessions"))
  File ".../copilot/generated/rpc.py", line 65, in from_list
    return [f(y) for y in x]
  File ".../copilot/generated/rpc.py", line 27771, in _load_SessionListEntry
    case _: raise ValueError(f"Unknown SessionListEntry isRemote: {kind!r}")
ValueError: Unknown SessionListEntry isRemote: False

A remote entry ("isRemote": True) fails the same way with Unknown SessionListEntry isRemote: True.

Second symptom: the discriminator is serialized as a string

>>> from copilot.rpc import QueuedCommandHandled
>>> QueuedCommandHandled.from_dict({"handled": True}).to_dict()
{'handled': 'true'}

The schema declares handled as {"type": "boolean", "const": true} with additionalProperties: false, so this payload violates the wire contract.

Generated code

def _load_SessionListEntry(obj: Any) -> "SessionListEntry":
    assert isinstance(obj, dict)
    kind = obj.get("isRemote")
    match kind:
        case "false": return LocalSessionMetadataValue.from_dict(obj)
        case "true": return RemoteSessionMetadataValue.from_dict(obj)
        case _: raise ValueError(f"Unknown SessionListEntry isRemote: {kind!r}")

match True: case "true": never matches - a literal pattern compares by equality, and a bool is never equal to a str.

Root cause

scripts/codegen/python.ts coerces the schema const to a JavaScript string when it captures a discriminator value (String(discProp.const)), and types the dispatch table as Array<{ value: string; typeName: string }>. The boolean type is lost before the emitters run, so they can only ever render a Python string literal - both in the match arms and in the variant's handled: ClassVar[str] = "true".

scripts/codegen/go.ts already models this correctly with type GoDiscriminatorValue = string | boolean, which is why Go decodes switch *raw.IsRemote { case false: ...; case true: ... } and is unaffected. Node.js, .NET and Rust are unaffected as well; Python is the only binding that loses the type.

Why CI does not catch it

python/e2e/test_rpc_server_e2e.py already calls client.rpc.sessions.list(...) and asserts over listed.sessions, but never requires the list to be non-empty. An empty list is the only payload that decodes, so the assertions pass vacuously.

Environment

  • github-copilot-sdk 1.0.8 (published wheel), Python 3.13, Linux x86-64
  • Also present on main at aa4e707f28c16f683965871c6adb47d2896d7aa5
  • Deterministic: fails on every run

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions