Skip to content

Commit 2b83654

Browse files
committed
Generalise closed union codegen
1 parent ef23568 commit 2b83654

36 files changed

Lines changed: 3379 additions & 462 deletions

.github/workflows/update-copilot-dependency.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ jobs:
9191
env:
9292
VERSION: ${{ inputs.version }}
9393
working-directory: ./java/scripts/codegen
94-
run: npm install "@github/copilot@$VERSION"
94+
run: npm install --save-exact "@github/copilot@$VERSION"
9595

9696
- name: Update Java POM CLI version property
9797
env:

dotnet/src/Generated/Rpc.cs

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

dotnet/test/Unit/CatalogueConformanceTests.cs renamed to dotnet/test/Unit/DiscriminatedUnionConformanceTests.cs

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
using GitHub.Copilot.Rpc;
44
using Xunit;
55

6-
#pragma warning disable GHCP001 // The catalogue search schema is experimental in CLI 1.0.83-2.
6+
#pragma warning disable GHCP001 // The catalogue search schema is experimental.
77

88
namespace GitHub.Copilot.Test.Unit;
99

10-
public class CatalogueConformanceTests
10+
public class DiscriminatedUnionConformanceTests
1111
{
1212
private const string OpaqueMcpHandle = "opaque:mcp/01-do-not-parse";
1313
private const string OpaqueSkillHandle = "opaque:skill/02-do-not-parse";
@@ -22,6 +22,7 @@ public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles()
2222
const string json = """
2323
{
2424
"kind": "succeeded",
25+
"rawCard": { "secret": "must-not-survive" },
2526
"searchId": "search-01",
2627
"candidates": [
2728
{
@@ -32,7 +33,7 @@ public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles()
3233
"installability": "installable",
3334
"displayName": "Example MCP",
3435
"rawCard": { "secret": "must-not-survive" },
35-
"source": { "kind": "url", "url": "https://catalog.example/mcp.json" },
36+
"source": { "kind": "url", "url": "https://catalog.example/mcp.json", "rawCard": { "secret": "must-not-survive" } },
3637
"provenance": {
3738
"authority": "catalog.example",
3839
"observedAt": "2026-09-02T11:00:00Z",
@@ -47,7 +48,7 @@ public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles()
4748
"installability": "not-installable-kind",
4849
"displayName": "Example skill",
4950
"rawCard": { "secret": "must-not-survive" },
50-
"source": { "kind": "embedded" },
51+
"source": { "kind": "embedded", "rawCard": { "secret": "must-not-survive" } },
5152
"provenance": {
5253
"authority": "catalog.example",
5354
"observedAt": "2026-09-02T11:00:00Z",
@@ -74,11 +75,13 @@ public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles()
7475

7576
using var encoded = JsonDocument.Parse(JsonSerializer.Serialize<CatalogSearchResult>(
7677
result, SerializerOptions));
78+
Assert.False(encoded.RootElement.TryGetProperty("rawCard", out _));
7779
foreach (var candidate in encoded.RootElement.GetProperty("candidates").EnumerateArray())
7880
{
7981
Assert.False(candidate.TryGetProperty("card", out _));
8082
Assert.False(candidate.TryGetProperty("cardData", out _));
8183
Assert.False(candidate.TryGetProperty("rawCard", out _));
84+
Assert.False(candidate.GetProperty("source").TryGetProperty("rawCard", out _));
8285
}
8386
}
8487

@@ -96,4 +99,75 @@ public void CatalogSearchResult_PreservesRefusalsAndFailures()
9699
SerializerOptions));
97100
Assert.Equal(30, network.RetryAfterSeconds);
98101
}
102+
103+
[Fact]
104+
public void ClosedUnions_RejectUnknownAndMissingDiscriminators()
105+
{
106+
string[] invalidPayloads =
107+
[
108+
"""{"kind":"future-result","rawCard":{"secret":"must-not-survive"}}""",
109+
"""{"rawCard":{"secret":"must-not-survive"}}""",
110+
"""
111+
{
112+
"kind":"succeeded",
113+
"searchId":"search-invalid",
114+
"candidates":[{"kind":"future-candidate","rawCard":{"secret":"must-not-survive"}}],
115+
"truncated":false,
116+
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
117+
}
118+
""",
119+
"""
120+
{
121+
"kind":"succeeded",
122+
"searchId":"search-invalid",
123+
"candidates":[{"rawCard":{"secret":"must-not-survive"}}],
124+
"truncated":false,
125+
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
126+
}
127+
""",
128+
"""
129+
{
130+
"kind":"succeeded",
131+
"searchId":"search-invalid",
132+
"candidates":[{
133+
"kind":"mcp-server",
134+
"handle":"opaque:mcp/01-do-not-parse",
135+
"handleExpiresAt":"2026-09-02T12:00:00Z",
136+
"mediaType":"application/mcp-server-card+json",
137+
"installability":"installable",
138+
"displayName":"Example MCP",
139+
"source":{"kind":"future-source","rawCard":{"secret":"must-not-survive"}},
140+
"provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"}
141+
}],
142+
"truncated":false,
143+
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
144+
}
145+
""",
146+
"""
147+
{
148+
"kind":"succeeded",
149+
"searchId":"search-invalid",
150+
"candidates":[{
151+
"kind":"mcp-server",
152+
"handle":"opaque:mcp/01-do-not-parse",
153+
"handleExpiresAt":"2026-09-02T12:00:00Z",
154+
"mediaType":"application/mcp-server-card+json",
155+
"installability":"installable",
156+
"displayName":"Example MCP",
157+
"source":{"rawCard":{"secret":"must-not-survive"}},
158+
"provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"}
159+
}],
160+
"truncated":false,
161+
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
162+
}
163+
""",
164+
];
165+
166+
foreach (string json in invalidPayloads)
167+
{
168+
Exception? exception = Record.Exception(() =>
169+
JsonSerializer.Deserialize<CatalogSearchResult>(json, SerializerOptions));
170+
Assert.True(exception is JsonException, $"Invalid closed union payload was accepted: {json}");
171+
}
172+
}
99173
}

go/rpc/catalogue_conformance_test.go renamed to go/rpc/discriminated_union_conformance_test.go

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ const (
1010
opaqueSkillHandle = "opaque:skill/02-do-not-parse"
1111
)
1212

13-
func TestCatalogSearchResultPreservesCandidateSemantics(t *testing.T) {
13+
func TestClosedDiscriminatedUnionPreservesKnownNestedVariants(t *testing.T) {
1414
result, err := unmarshalCatalogSearchResult([]byte(`{
1515
"kind":"succeeded",
16+
"rawCard":{"secret":"must-not-survive"},
1617
"searchId":"search-01",
1718
"candidates":[
1819
{
@@ -23,7 +24,7 @@ func TestCatalogSearchResultPreservesCandidateSemantics(t *testing.T) {
2324
"installability":"installable",
2425
"displayName":"Example MCP",
2526
"rawCard":{"secret":"must-not-survive"},
26-
"source":{"kind":"url","url":"https://catalog.example/mcp.json"},
27+
"source":{"kind":"url","url":"https://catalog.example/mcp.json","rawCard":{"secret":"must-not-survive"}},
2728
"provenance":{
2829
"authority":"catalog.example",
2930
"observedAt":"2026-09-02T11:00:00Z",
@@ -38,7 +39,7 @@ func TestCatalogSearchResultPreservesCandidateSemantics(t *testing.T) {
3839
"installability":"not-installable-kind",
3940
"displayName":"Example skill",
4041
"rawCard":{"secret":"must-not-survive"},
41-
"source":{"kind":"embedded"},
42+
"source":{"kind":"embedded","rawCard":{"secret":"must-not-survive"}},
4243
"provenance":{
4344
"authority":"catalog.example",
4445
"observedAt":"2026-09-02T11:00:00Z",
@@ -86,17 +87,23 @@ func TestCatalogSearchResultPreservesCandidateSemantics(t *testing.T) {
8687
if err := json.Unmarshal(encoded, &wire); err != nil {
8788
t.Fatalf("decode catalogue wire result: %v", err)
8889
}
90+
if _, exists := wire["rawCard"]; exists {
91+
t.Fatalf("result leaked rawCard: %s", encoded)
92+
}
8993
for _, candidate := range wire["candidates"].([]any) {
9094
fields := candidate.(map[string]any)
9195
for _, forbidden := range []string{"card", "cardData", "rawCard"} {
9296
if _, exists := fields[forbidden]; exists {
9397
t.Fatalf("candidate leaked %q: %s", forbidden, encoded)
9498
}
9599
}
100+
if _, exists := fields["source"].(map[string]any)["rawCard"]; exists {
101+
t.Fatalf("candidate source leaked rawCard: %s", encoded)
102+
}
96103
}
97104
}
98105

99-
func TestCatalogSearchResultPreservesRefusalsAndFailures(t *testing.T) {
106+
func TestClosedDiscriminatedUnionPreservesRefusalsAndFailures(t *testing.T) {
100107
tests := []struct {
101108
name string
102109
payload string
@@ -137,19 +144,38 @@ func TestCatalogSearchResultPreservesRefusalsAndFailures(t *testing.T) {
137144
}
138145
}
139146

140-
func TestCatalogSearchResultRejectsUnknownCandidateKinds(t *testing.T) {
141-
_, err := unmarshalCatalogSearchResult([]byte(`{
142-
"kind":"succeeded",
143-
"searchId":"search-unknown",
144-
"candidates":[{
145-
"kind":"future-kind",
146-
"handle":"opaque:future/03-do-not-parse",
147-
"rawCard":{"secret":"must-not-survive"}
148-
}],
149-
"truncated":false,
150-
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
151-
}`))
152-
if err == nil {
153-
t.Fatal("unknown catalogue candidate kind with rawCard must be rejected")
147+
func TestClosedDiscriminatedUnionRejectsUnknownAndMissingDiscriminators(t *testing.T) {
148+
validCandidatePrefix := `{
149+
"handle":"opaque:mcp/01-do-not-parse",
150+
"handleExpiresAt":"2026-09-02T12:00:00Z",
151+
"mediaType":"application/mcp-server-card+json",
152+
"installability":"installable",
153+
"displayName":"Example MCP",
154+
"provenance":{
155+
"authority":"catalog.example",
156+
"observedAt":"2026-09-02T11:00:00Z",
157+
"mediaType":"application/mcp-server-card+json"
158+
},`
159+
searchPrefix := `{"kind":"succeeded","searchId":"search-invalid","candidates":[`
160+
searchSuffix := `],"truncated":false,"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}}`
161+
tests := map[string]string{
162+
"unknown outer discriminator": `{"kind":"future-result","rawCard":{"secret":"must-not-survive"}}`,
163+
"missing outer discriminator": `{"rawCard":{"secret":"must-not-survive"}}`,
164+
"unknown candidate discriminator": searchPrefix + validCandidatePrefix +
165+
`"kind":"future-candidate","source":{"kind":"url","url":"https://catalog.example/mcp.json"},"rawCard":{"secret":"must-not-survive"}}` + searchSuffix,
166+
"missing candidate discriminator": searchPrefix + validCandidatePrefix +
167+
`"source":{"kind":"url","url":"https://catalog.example/mcp.json"},"rawCard":{"secret":"must-not-survive"}}` + searchSuffix,
168+
"unknown nested discriminator": searchPrefix + validCandidatePrefix +
169+
`"kind":"mcp-server","source":{"kind":"future-source","rawCard":{"secret":"must-not-survive"}}}` + searchSuffix,
170+
"missing nested discriminator": searchPrefix + validCandidatePrefix +
171+
`"kind":"mcp-server","source":{"rawCard":{"secret":"must-not-survive"}}}` + searchSuffix,
172+
}
173+
174+
for name, payload := range tests {
175+
t.Run(name, func(t *testing.T) {
176+
if _, err := unmarshalCatalogSearchResult([]byte(payload)); err == nil {
177+
t.Fatal("invalid closed union payload must be rejected")
178+
}
179+
})
154180
}
155181
}

go/rpc/zrpc.go

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

0 commit comments

Comments
 (0)