Skip to content

Commit b9d3962

Browse files
stephentoubCopilot
andauthored
Fix Python from_dict() round-trip for optional fields with schema defaults (#1313)
* Fix Python from_dict() round-trip for optional fields with schema defaults Fixes #1139, #1140, #1141. The Python codegen was embedding JSON-Schema `default` values into `obj.get(key, default)` for optional fields. But the generated dataclass field is always `T | None = None` and `to_dict()` omits the field when `None`, so `from_dict(to_dict(x))` silently mutated unset fields into the schema default: - `SessionTaskCompleteData.summary`: `None` -> `""` - `PermissionPromptRequest.action`: `None` -> `MemoryAction.STORE` - `PermissionRequest.action`: `None` -> `MemoryAction.STORE` Drop `defaultLiteral` from both `emitPyClass` and `emitPyFlatDiscriminatedUnion` so `from_dict()` always uses `obj.get(key)` (matching the dataclass default). Regenerate `session_events.py`. Flip the two codegen-level test assertions that previously locked in the buggy output and add negative assertions. Replace `test_schema_defaults_are_applied_for_missing_optional_fields` (which asserted the bug as expected behavior) with regression tests covering missing-key parsing, explicit-null parsing, and full `from_dict(to_dict(x))` round-trips for all three affected classes. Other languages (Go, .NET, Rust, TypeScript) are unaffected; their generators never read `propSchema.default` for deserialization fallbacks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix prettier formatting in python-codegen.test.ts The CI prettier check failed on test/python-codegen.test.ts after the assertion update. Apply prettier --write to bring the file back into compliance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate Python session events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 971ef11 commit b9d3962

4 files changed

Lines changed: 55 additions & 39 deletions

File tree

nodejs/test/python-codegen.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ describe("python session event codegen", () => {
7474
);
7575
expect(code).toContain("def to_timedelta_int(x: timedelta) -> int:");
7676
expect(code).toContain(
77-
'action = from_union([from_none, lambda x: parse_enum(SessionSyntheticDataAction, x)], obj.get("action", "store"))'
78-
);
79-
expect(code).toContain(
80-
'summary = from_union([from_none, from_str], obj.get("summary", ""))'
77+
'action = from_union([from_none, lambda x: parse_enum(SessionSyntheticDataAction, x)], obj.get("action"))'
8178
);
79+
expect(code).toContain('summary = from_union([from_none, from_str], obj.get("summary"))');
80+
expect(code).not.toContain('obj.get("action", "store")');
81+
expect(code).not.toContain('obj.get("summary", "")');
8282
expect(code).toContain("uri: str");
8383
expect(code).toContain("pattern: str");
8484
expect(code).toContain("payload: str");

python/copilot/generated/session_events.py

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

python/test_event_forward_compatibility.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
ElicitationCompletedAction,
1818
ElicitationRequestedMode,
1919
ElicitationRequestedSchema,
20+
PermissionPromptRequest,
21+
PermissionPromptRequestKind,
2022
PermissionRequest,
23+
PermissionRequestKind,
2124
PermissionRequestMemoryAction,
2225
SessionEventType,
2326
SessionTaskCompleteData,
@@ -137,10 +140,49 @@ def test_data_shim_preserves_raw_mapping_values(self):
137140
constructed = Data(arguments={"tool_call_id": "call-1"})
138141
assert constructed.to_dict() == {"arguments": {"tool_call_id": "call-1"}}
139142

140-
def test_schema_defaults_are_applied_for_missing_optional_fields(self):
141-
"""Generated event models should honor primitive schema defaults during parsing."""
143+
def test_missing_optional_fields_remain_none_after_parsing(self):
144+
"""Generated event models should leave missing optional fields as None.
145+
146+
Regression test for github/copilot-sdk issues #1139, #1140, and #1141:
147+
the Python codegen previously baked JSON Schema `default` values into
148+
``obj.get(key, default)`` for optional fields, so ``from_dict()`` returned
149+
the schema default instead of ``None`` and broke ``from_dict(to_dict(x))``
150+
round-trips for instances where the field was ``None``.
151+
"""
152+
# #1141: PermissionRequest.action defaults to None when missing.
142153
request = PermissionRequest.from_dict({"kind": "memory", "fact": "remember this"})
143-
assert request.action == PermissionRequestMemoryAction.STORE
154+
assert request.action is None
155+
assert PermissionRequestMemoryAction.STORE.value == "store" # sanity
156+
157+
# #1140: PermissionPromptRequest.action defaults to None when missing.
158+
prompt_request = PermissionPromptRequest.from_dict({"kind": "memory"})
159+
assert prompt_request.action is None
144160

161+
# #1139: SessionTaskCompleteData.summary defaults to None when missing.
145162
task_complete = SessionTaskCompleteData.from_dict({"success": True})
146-
assert task_complete.summary == ""
163+
assert task_complete.summary is None
164+
165+
# Explicit JSON null should also map to None.
166+
task_complete_null = SessionTaskCompleteData.from_dict({"success": True, "summary": None})
167+
assert task_complete_null.summary is None
168+
169+
def test_optional_fields_round_trip_none(self):
170+
"""``from_dict(to_dict(x))`` should equal ``x`` when optional fields are None.
171+
172+
Regression test for github/copilot-sdk issues #1139, #1140, and #1141.
173+
"""
174+
# #1139: SessionTaskCompleteData round-trip with summary=None.
175+
task = SessionTaskCompleteData(success=None, summary=None)
176+
assert SessionTaskCompleteData.from_dict(task.to_dict()) == task
177+
178+
# #1140: PermissionPromptRequest round-trip with action=None.
179+
prompt = PermissionPromptRequest(kind=PermissionPromptRequestKind.MEMORY)
180+
assert prompt.action is None
181+
assert "action" not in prompt.to_dict()
182+
assert PermissionPromptRequest.from_dict(prompt.to_dict()) == prompt
183+
184+
# #1141: PermissionRequest round-trip with action=None.
185+
permission = PermissionRequest(kind=PermissionRequestKind.MEMORY)
186+
assert permission.action is None
187+
assert "action" not in permission.to_dict()
188+
assert PermissionRequest.from_dict(permission.to_dict()) == permission

scripts/codegen/python.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -918,22 +918,6 @@ function isPyBase64StringSchema(schema: JSONSchema7): boolean {
918918
return schema.format === "byte" || (schema as Record<string, unknown>).contentEncoding === "base64";
919919
}
920920

921-
function toPythonLiteral(value: unknown): string | undefined {
922-
if (typeof value === "string") {
923-
return JSON.stringify(value);
924-
}
925-
if (typeof value === "number") {
926-
return Number.isFinite(value) ? String(value) : undefined;
927-
}
928-
if (typeof value === "boolean") {
929-
return value ? "True" : "False";
930-
}
931-
if (value === null) {
932-
return "None";
933-
}
934-
return undefined;
935-
}
936-
937921
function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] {
938922
const definitionCollections = collectDefinitionCollections(schema as Record<string, unknown>);
939923
return getSessionEventVariantSchemas(schema, definitionCollections)
@@ -1492,9 +1476,6 @@ function emitPyClass(
14921476
fieldName: toSnakeCase(propName),
14931477
isRequired,
14941478
resolved,
1495-
defaultLiteral: isRequired ? undefined : toPythonLiteral(
1496-
propSchema.default ?? resolveSchema(propSchema, ctx.definitions)?.default
1497-
),
14981479
};
14991480
});
15001481

@@ -1536,9 +1517,7 @@ function emitPyClass(
15361517
lines.push(` def from_dict(obj: Any) -> "${typeName}":`);
15371518
lines.push(` assert isinstance(obj, dict)`);
15381519
for (const field of fieldInfos) {
1539-
const sourceExpr = field.defaultLiteral
1540-
? `obj.get(${JSON.stringify(field.jsonName)}, ${field.defaultLiteral})`
1541-
: `obj.get(${JSON.stringify(field.jsonName)})`;
1520+
const sourceExpr = `obj.get(${JSON.stringify(field.jsonName)})`;
15421521
lines.push(
15431522
` ${field.fieldName} = ${field.resolved.fromExpr(sourceExpr)}`
15441523
);
@@ -1655,9 +1634,6 @@ function emitPyFlatDiscriminatedUnion(
16551634
fieldName: toSnakeCase(propName),
16561635
isRequired: requiredInAll,
16571636
resolved,
1658-
defaultLiteral: requiredInAll ? undefined : toPythonLiteral(
1659-
propSchema.default ?? resolveSchema(propSchema, ctx.definitions)?.default
1660-
),
16611637
};
16621638
});
16631639

@@ -1683,9 +1659,7 @@ function emitPyFlatDiscriminatedUnion(
16831659
lines.push(` def from_dict(obj: Any) -> "${typeName}":`);
16841660
lines.push(` assert isinstance(obj, dict)`);
16851661
for (const field of fieldInfos) {
1686-
const sourceExpr = field.defaultLiteral
1687-
? `obj.get(${JSON.stringify(field.jsonName)}, ${field.defaultLiteral})`
1688-
: `obj.get(${JSON.stringify(field.jsonName)})`;
1662+
const sourceExpr = `obj.get(${JSON.stringify(field.jsonName)})`;
16891663
lines.push(
16901664
` ${field.fieldName} = ${field.resolved.fromExpr(sourceExpr)}`
16911665
);

0 commit comments

Comments
 (0)