Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions nodejs/test/python-codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ describe("python session event codegen", () => {
);
expect(code).toContain("def to_timedelta_int(x: timedelta) -> int:");
expect(code).toContain(
'action = from_union([from_none, lambda x: parse_enum(SessionSyntheticDataAction, x)], obj.get("action", "store"))'
'action = from_union([from_none, lambda x: parse_enum(SessionSyntheticDataAction, x)], obj.get("action"))'
);
expect(code).toContain(
'summary = from_union([from_none, from_str], obj.get("summary", ""))'
'summary = from_union([from_none, from_str], obj.get("summary"))'
);
expect(code).not.toContain('obj.get("action", "store")');
expect(code).not.toContain('obj.get("summary", "")');
expect(code).toContain("uri: str");
expect(code).toContain("pattern: str");
expect(code).toContain("payload: str");
Expand Down
6 changes: 3 additions & 3 deletions python/copilot/generated/session_events.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 46 additions & 4 deletions python/test_event_forward_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
ElicitationCompletedAction,
ElicitationRequestedMode,
ElicitationRequestedSchema,
PermissionPromptRequest,
PermissionPromptRequestKind,
PermissionRequest,
PermissionRequestKind,
PermissionRequestMemoryAction,
SessionEventType,
SessionTaskCompleteData,
Expand Down Expand Up @@ -137,10 +140,49 @@ 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_schema_defaults_are_applied_for_missing_optional_fields(self):
"""Generated event models should honor primitive schema defaults during parsing."""
def test_missing_optional_fields_remain_none_after_parsing(self):
"""Generated event models should leave missing optional fields as None.

Regression test for github/copilot-sdk issues #1139, #1140, and #1141:
the Python codegen previously baked JSON Schema `default` values into
``obj.get(key, default)`` for optional fields, so ``from_dict()`` returned
the schema default instead of ``None`` and broke ``from_dict(to_dict(x))``
round-trips for instances where the field was ``None``.
"""
# #1141: PermissionRequest.action defaults to None when missing.
request = PermissionRequest.from_dict({"kind": "memory", "fact": "remember this"})
assert request.action == PermissionRequestMemoryAction.STORE
assert request.action is None
assert PermissionRequestMemoryAction.STORE.value == "store" # sanity

# #1140: PermissionPromptRequest.action defaults to None when missing.
prompt_request = PermissionPromptRequest.from_dict({"kind": "memory"})
assert prompt_request.action is None

# #1139: SessionTaskCompleteData.summary defaults to None when missing.
task_complete = SessionTaskCompleteData.from_dict({"success": True})
assert task_complete.summary == ""
assert task_complete.summary is None

# Explicit JSON null should also map to None.
task_complete_null = SessionTaskCompleteData.from_dict({"success": True, "summary": None})
assert task_complete_null.summary is None

def test_optional_fields_round_trip_none(self):
"""``from_dict(to_dict(x))`` should equal ``x`` when optional fields are None.

Regression test for github/copilot-sdk issues #1139, #1140, and #1141.
"""
# #1139: SessionTaskCompleteData round-trip with summary=None.
task = SessionTaskCompleteData(success=None, summary=None)
assert SessionTaskCompleteData.from_dict(task.to_dict()) == task

# #1140: PermissionPromptRequest round-trip with action=None.
prompt = PermissionPromptRequest(kind=PermissionPromptRequestKind.MEMORY)
assert prompt.action is None
assert "action" not in prompt.to_dict()
assert PermissionPromptRequest.from_dict(prompt.to_dict()) == prompt

# #1141: PermissionRequest round-trip with action=None.
permission = PermissionRequest(kind=PermissionRequestKind.MEMORY)
assert permission.action is None
assert "action" not in permission.to_dict()
assert PermissionRequest.from_dict(permission.to_dict()) == permission
30 changes: 2 additions & 28 deletions scripts/codegen/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,22 +865,6 @@ function isPyBase64StringSchema(schema: JSONSchema7): boolean {
return schema.format === "byte" || (schema as Record<string, unknown>).contentEncoding === "base64";
}

function toPythonLiteral(value: unknown): string | undefined {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
if (typeof value === "boolean") {
return value ? "True" : "False";
}
if (value === null) {
return "None";
}
return undefined;
}

function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] {
const definitionCollections = collectDefinitionCollections(schema as Record<string, unknown>);
return getSessionEventVariantSchemas(schema, definitionCollections)
Expand Down Expand Up @@ -1430,9 +1414,6 @@ function emitPyClass(
fieldName: toSnakeCase(propName),
isRequired,
resolved,
defaultLiteral: isRequired ? undefined : toPythonLiteral(
propSchema.default ?? resolveSchema(propSchema, ctx.definitions)?.default
),
};
});

Expand Down Expand Up @@ -1474,9 +1455,7 @@ function emitPyClass(
lines.push(` def from_dict(obj: Any) -> "${typeName}":`);
lines.push(` assert isinstance(obj, dict)`);
for (const field of fieldInfos) {
const sourceExpr = field.defaultLiteral
? `obj.get(${JSON.stringify(field.jsonName)}, ${field.defaultLiteral})`
: `obj.get(${JSON.stringify(field.jsonName)})`;
const sourceExpr = `obj.get(${JSON.stringify(field.jsonName)})`;
lines.push(
` ${field.fieldName} = ${field.resolved.fromExpr(sourceExpr)}`
);
Expand Down Expand Up @@ -1592,9 +1571,6 @@ function emitPyFlatDiscriminatedUnion(
fieldName: toSnakeCase(propName),
isRequired: requiredInAll,
resolved,
defaultLiteral: requiredInAll ? undefined : toPythonLiteral(
propSchema.default ?? resolveSchema(propSchema, ctx.definitions)?.default
),
};
});

Expand All @@ -1620,9 +1596,7 @@ function emitPyFlatDiscriminatedUnion(
lines.push(` def from_dict(obj: Any) -> "${typeName}":`);
lines.push(` assert isinstance(obj, dict)`);
for (const field of fieldInfos) {
const sourceExpr = field.defaultLiteral
? `obj.get(${JSON.stringify(field.jsonName)}, ${field.defaultLiteral})`
: `obj.get(${JSON.stringify(field.jsonName)})`;
const sourceExpr = `obj.get(${JSON.stringify(field.jsonName)})`;
lines.push(
` ${field.fieldName} = ${field.resolved.fromExpr(sourceExpr)}`
);
Expand Down
Loading