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
Summary
The Python SDK generates union dispatchers that
matchthe discriminator against Python string literals"true"/"false".A JSON boolean decodes to Python
True/False, which matches neither, so the dispatcher always falls through tocase _: raise ValueError(...).The API schema has exactly two boolean-discriminated unions, and both are broken in Python:
SessionListEntryisRemote({"type": "boolean", "const": false/true})sessions.listresultQueuedCommandResulthandled(boolean const)session.commands.respondToQueuedCommand"true"instead oftrueEvery other discriminated union in the schema uses string consts, which is why this went unnoticed.
isRemoteisrequiredon 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
QueuedCommandResultsymptom is a wire-contract violation rather than a visible failure today. It is worth fixing regardless: both variants serialize to a non-empty string, sohandled: "false"is indistinguishable fromhandled: "true"to any consumer that reads the field as a boolean.Reproduction
repro.py- no runtime or network needed, this is a pure decode:Expected: a
SessionListholding oneLocalSessionMetadataValue.Actual:
A remote entry (
"isRemote": True) fails the same way withUnknown SessionListEntry isRemote: True.Second symptom: the discriminator is serialized as a string
The schema declares
handledas{"type": "boolean", "const": true}withadditionalProperties: false, so this payload violates the wire contract.Generated code
match True: case "true":never matches - a literal pattern compares by equality, and aboolis never equal to astr.Root cause
scripts/codegen/python.tscoerces the schemaconstto a JavaScript string when it captures a discriminator value (String(discProp.const)), and types the dispatch table asArray<{ 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 thematcharms and in the variant'shandled: ClassVar[str] = "true".scripts/codegen/go.tsalready models this correctly withtype GoDiscriminatorValue = string | boolean, which is why Go decodesswitch *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.pyalready callsclient.rpc.sessions.list(...)and asserts overlisted.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-sdk1.0.8 (published wheel), Python 3.13, Linux x86-64mainataa4e707f28c16f683965871c6adb47d2896d7aa5