From 78fec9ffef5c65af0a1f3d31dd34da1087c46fdd Mon Sep 17 00:00:00 2001 From: syf2211 Date: Fri, 3 Jul 2026 12:06:12 +0000 Subject: [PATCH 1/2] fix(python): preserve original JSON keys in Data shim round-trips Fixes #1138 The Data compatibility shim converted JSON keys to snake_case for attribute access but could not reconstruct abbreviation-heavy camelCase keys (userURL, sessionID, OAuthToken) on to_dict(). Store the original JSON key per field during from_dict() and prefer it when serializing back. Includes regression tests for the abbreviation key cases described in the issue. --- python/copilot/generated/session_events.py | 13 +++++++++++-- python/test_event_forward_compatibility.py | 11 +++++++++++ scripts/codegen/python.ts | 15 +++++++++++---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 00f6bb35cd..b6479d2171 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -290,16 +290,25 @@ class Data: def __init__(self, **kwargs: Any): self._values = {key: _compat_from_json_value(value) for key, value in kwargs.items()} + self._json_keys: dict[str, str] = {} for key, value in self._values.items(): setattr(self, key, value) @staticmethod def from_dict(obj: Any) -> "Data": assert isinstance(obj, dict) - return Data(**{_compat_to_python_key(key): _compat_from_json_value(value) for key, value in obj.items()}) + data = Data() + data._values = {} + data._json_keys = {} + for key, value in obj.items(): + py_key = _compat_to_python_key(key) + data._values[py_key] = _compat_from_json_value(value) + data._json_keys[py_key] = key + setattr(data, py_key, data._values[py_key]) + return data def to_dict(self) -> dict: - return {_compat_to_json_key(key): _compat_to_json_value(value) for key, value in self._values.items() if value is not None} + return {(self._json_keys.get(key) or _compat_to_json_key(key)): _compat_to_json_value(value) for key, value in self._values.items() if value is not None} # Experimental: this type is part of an experimental API and may change or be removed. diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 13fa4f09e5..eeb4c5ec95 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -138,6 +138,17 @@ def test_data_shim_preserves_raw_mapping_values(self): constructed = Data(arguments={"tool_call_id": "call-1"}) assert constructed.to_dict() == {"arguments": {"tool_call_id": "call-1"}} + def test_data_shim_preserves_abbreviation_json_keys_on_round_trip(self): + """Data.from_dict(x).to_dict() should preserve JSON keys with abbreviations. + + Regression test for github/copilot-sdk#1138: keys like userURL, sessionID, + and OAuthToken were rewritten on round-trip because _compat_to_json_key could + not reconstruct the original camelCase abbreviation casing. + """ + for key in ["userURL", "sessionID", "XMLPayload", "serverIP", "OAuthToken"]: + incoming = {key: 42} + assert Data.from_dict(incoming).to_dict() == incoming + def test_missing_optional_fields_remain_none_after_parsing(self): """Generated event models should leave missing optional fields as None. diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index e0c4e21412..cdb9dbf49f 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -2673,19 +2673,26 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(``); out.push(` def __init__(self, **kwargs: Any):`); out.push(` self._values = {key: _compat_from_json_value(value) for key, value in kwargs.items()}`); + out.push(` self._json_keys: dict[str, str] = {}`); out.push(` for key, value in self._values.items():`); out.push(` setattr(self, key, value)`); out.push(``); out.push(` @staticmethod`); out.push(` def from_dict(obj: Any) -> "Data":`); out.push(` assert isinstance(obj, dict)`); - out.push( - ` return Data(**{_compat_to_python_key(key): _compat_from_json_value(value) for key, value in obj.items()})` - ); + out.push(` data = Data()`); + out.push(` data._values = {}`); + out.push(` data._json_keys = {}`); + out.push(` for key, value in obj.items():`); + out.push(` py_key = _compat_to_python_key(key)`); + out.push(` data._values[py_key] = _compat_from_json_value(value)`); + out.push(` data._json_keys[py_key] = key`); + out.push(` setattr(data, py_key, data._values[py_key])`); + out.push(` return data`); out.push(``); out.push(` def to_dict(self) -> dict:`); out.push( - ` return {_compat_to_json_key(key): _compat_to_json_value(value) for key, value in self._values.items() if value is not None}` + ` return {(self._json_keys.get(key) or _compat_to_json_key(key)): _compat_to_json_value(value) for key, value in self._values.items() if value is not None}` ); out.push(``); out.push(``); From 3ab72052ce0b43d2d7932acae408e745fc72cce3 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Tue, 7 Jul 2026 12:45:52 +0200 Subject: [PATCH 2/2] fix(python): preserve colliding Data JSON keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/copilot/generated/session_events.py | 8 +++++++- python/test_event_forward_compatibility.py | 5 +++++ scripts/codegen/python.ts | 10 +++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index b6479d2171..668c7d2a5f 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -291,6 +291,7 @@ class Data: def __init__(self, **kwargs: Any): self._values = {key: _compat_from_json_value(value) for key, value in kwargs.items()} self._json_keys: dict[str, str] = {} + self._json_values: dict[str, Any] | None = None for key, value in self._values.items(): setattr(self, key, value) @@ -300,14 +301,19 @@ def from_dict(obj: Any) -> "Data": data = Data() data._values = {} data._json_keys = {} + data._json_values = {} for key, value in obj.items(): py_key = _compat_to_python_key(key) - data._values[py_key] = _compat_from_json_value(value) + json_value = _compat_from_json_value(value) + data._values[py_key] = json_value data._json_keys[py_key] = key + data._json_values[key] = json_value setattr(data, py_key, data._values[py_key]) return data def to_dict(self) -> dict: + if self._json_values is not None: + return {key: _compat_to_json_value(value) for key, value in self._json_values.items() if value is not None} return {(self._json_keys.get(key) or _compat_to_json_key(key)): _compat_to_json_value(value) for key, value in self._values.items() if value is not None} diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index eeb4c5ec95..7f8c29b6e8 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -149,6 +149,11 @@ def test_data_shim_preserves_abbreviation_json_keys_on_round_trip(self): incoming = {key: 42} assert Data.from_dict(incoming).to_dict() == incoming + def test_data_shim_preserves_colliding_json_keys_on_round_trip(self): + """Data.from_dict(x).to_dict() should preserve keys with the same Python name.""" + colliding_keys = {"userURL": 42, "userUrl": 43} + assert Data.from_dict(colliding_keys).to_dict() == colliding_keys + def test_missing_optional_fields_remain_none_after_parsing(self): """Generated event models should leave missing optional fields as None. diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index cdb9dbf49f..49905b3878 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -2674,6 +2674,7 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(` def __init__(self, **kwargs: Any):`); out.push(` self._values = {key: _compat_from_json_value(value) for key, value in kwargs.items()}`); out.push(` self._json_keys: dict[str, str] = {}`); + out.push(` self._json_values: dict[str, Any] | None = None`); out.push(` for key, value in self._values.items():`); out.push(` setattr(self, key, value)`); out.push(``); @@ -2683,14 +2684,21 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(` data = Data()`); out.push(` data._values = {}`); out.push(` data._json_keys = {}`); + out.push(` data._json_values = {}`); out.push(` for key, value in obj.items():`); out.push(` py_key = _compat_to_python_key(key)`); - out.push(` data._values[py_key] = _compat_from_json_value(value)`); + out.push(` json_value = _compat_from_json_value(value)`); + out.push(` data._values[py_key] = json_value`); out.push(` data._json_keys[py_key] = key`); + out.push(` data._json_values[key] = json_value`); out.push(` setattr(data, py_key, data._values[py_key])`); out.push(` return data`); out.push(``); out.push(` def to_dict(self) -> dict:`); + out.push(` if self._json_values is not None:`); + out.push( + ` return {key: _compat_to_json_value(value) for key, value in self._json_values.items() if value is not None}` + ); out.push( ` return {(self._json_keys.get(key) or _compat_to_json_key(key)): _compat_to_json_value(value) for key, value in self._values.items() if value is not None}` );