diff --git a/.github/workflows/update-copilot-dependency.yml b/.github/workflows/update-copilot-dependency.yml index 2870ad27f7..7374dab863 100644 --- a/.github/workflows/update-copilot-dependency.yml +++ b/.github/workflows/update-copilot-dependency.yml @@ -91,7 +91,7 @@ jobs: env: VERSION: ${{ inputs.version }} working-directory: ./java/scripts/codegen - run: npm install "@github/copilot@$VERSION" + run: npm install --save-exact "@github/copilot@$VERSION" - name: Update Java POM CLI version property env: diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 2c74ab4bde..22fb609a95 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -2347,7 +2347,8 @@ internal sealed class DiscoveredExtensionsDisableRequest [Experimental(Diagnostics.Experimental)] [JsonPolymorphic( TypeDiscriminatorPropertyName = "kind", - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] + IgnoreUnrecognizedTypeDiscriminators = false, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] [JsonDerivedType(typeof(CatalogSearchResultSucceeded), "succeeded")] [JsonDerivedType(typeof(CatalogSearchResultNegotiationRefused), "negotiation-refused")] [JsonDerivedType(typeof(CatalogSearchResultUnsupportedKind), "unsupported-kind")] @@ -2362,6 +2363,7 @@ internal sealed class DiscoveredExtensionsDisableRequest public partial class CatalogSearchResult { /// The type discriminator. + [JsonRequired] [JsonPropertyName("kind")] public virtual string Kind { get; set; } = string.Empty; } @@ -2372,12 +2374,14 @@ public partial class CatalogSearchResult [Experimental(Diagnostics.Experimental)] [JsonPolymorphic( TypeDiscriminatorPropertyName = "kind", - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] + IgnoreUnrecognizedTypeDiscriminators = false, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] [JsonDerivedType(typeof(CatalogCandidateMcpServer), "mcp-server")] [JsonDerivedType(typeof(CatalogCandidateAiSkill), "ai-skill")] public partial class CatalogCandidate { /// The type discriminator. + [JsonRequired] [JsonPropertyName("kind")] public virtual string Kind { get; set; } = string.Empty; } @@ -2407,12 +2411,14 @@ public sealed class CatalogMcpServerCandidateProvenance [Experimental(Diagnostics.Experimental)] [JsonPolymorphic( TypeDiscriminatorPropertyName = "kind", - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] + IgnoreUnrecognizedTypeDiscriminators = false, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] [JsonDerivedType(typeof(CatalogCandidateSourceUrl), "url")] [JsonDerivedType(typeof(CatalogCandidateSourceEmbedded), "embedded")] public partial class CatalogCandidateSource { /// The type discriminator. + [JsonRequired] [JsonPropertyName("kind")] public virtual string Kind { get; set; } = string.Empty; } diff --git a/dotnet/test/Unit/DiscriminatedUnionConformanceTests.cs b/dotnet/test/Unit/DiscriminatedUnionConformanceTests.cs new file mode 100644 index 0000000000..d4332d9371 --- /dev/null +++ b/dotnet/test/Unit/DiscriminatedUnionConformanceTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using GitHub.Copilot.Rpc; +using Xunit; + +#pragma warning disable GHCP001 // The catalogue search schema is experimental. + +namespace GitHub.Copilot.Test.Unit; + +public class DiscriminatedUnionConformanceTests +{ + private const string OpaqueMcpHandle = "opaque:mcp/01-do-not-parse"; + private const string OpaqueSkillHandle = "opaque:skill/02-do-not-parse"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + [Fact] + public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles() + { + const string json = """ + { + "kind": "succeeded", + "rawCard": { "secret": "must-not-survive" }, + "searchId": "search-01", + "candidates": [ + { + "kind": "mcp-server", + "handle": "opaque:mcp/01-do-not-parse", + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "rawCard": { "secret": "must-not-survive" }, + "source": { "kind": "url", "url": "https://catalog.example/mcp.json", "rawCard": { "secret": "must-not-survive" } }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json" + } + }, + { + "kind": "ai-skill", + "handle": "opaque:skill/02-do-not-parse", + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/ai-skill", + "installability": "not-installable-kind", + "displayName": "Example skill", + "rawCard": { "secret": "must-not-survive" }, + "source": { "kind": "embedded", "rawCard": { "secret": "must-not-survive" } }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/ai-skill" + } + } + ], + "truncated": false, + "negotiated": { + "runtimeProtocolVersion": 1, + "grantedCapabilities": ["mcp-server-card", "ai-skill-discovery"] + } + } + """; + + var result = Assert.IsType( + JsonSerializer.Deserialize(json, SerializerOptions)); + var mcp = Assert.IsType(result.Candidates[0]); + var skill = Assert.IsType(result.Candidates[1]); + Assert.Equal(OpaqueMcpHandle, mcp.Handle); + Assert.Equal(OpaqueSkillHandle, skill.Handle); + Assert.IsType(mcp.Source); + Assert.IsType(skill.Source); + + using var encoded = JsonDocument.Parse(JsonSerializer.Serialize( + result, SerializerOptions)); + Assert.False(encoded.RootElement.TryGetProperty("rawCard", out _)); + foreach (var candidate in encoded.RootElement.GetProperty("candidates").EnumerateArray()) + { + Assert.False(candidate.TryGetProperty("card", out _)); + Assert.False(candidate.TryGetProperty("cardData", out _)); + Assert.False(candidate.TryGetProperty("rawCard", out _)); + Assert.False(candidate.GetProperty("source").TryGetProperty("rawCard", out _)); + } + } + + [Fact] + public void CatalogSearchResult_PreservesRefusalsAndFailures() + { + var authentication = JsonSerializer.Deserialize( + """{"kind":"authentication-required","reason":"no-credential","message":"Sign in is required."}""", + SerializerOptions); + Assert.IsType(authentication); + + var network = Assert.IsType( + JsonSerializer.Deserialize( + """{"kind":"network-failure","reason":"timeout","retryAfterSeconds":30,"message":"The catalogue timed out."}""", + SerializerOptions)); + Assert.Equal(30, network.RetryAfterSeconds); + } + + [Fact] + public void ClosedUnions_RejectUnknownAndMissingDiscriminators() + { + string[] invalidPayloads = + [ + """{"kind":"future-result","rawCard":{"secret":"must-not-survive"}}""", + """{"rawCard":{"secret":"must-not-survive"}}""", + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{"kind":"future-candidate","rawCard":{"secret":"must-not-survive"}}], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{"rawCard":{"secret":"must-not-survive"}}], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{ + "kind":"mcp-server", + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "source":{"kind":"future-source","rawCard":{"secret":"must-not-survive"}}, + "provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"} + }], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{ + "kind":"mcp-server", + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "source":{"rawCard":{"secret":"must-not-survive"}}, + "provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"} + }], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + ]; + + foreach (string json in invalidPayloads) + { + Exception? exception = Record.Exception(() => + JsonSerializer.Deserialize(json, SerializerOptions)); + Assert.True(exception is JsonException, $"Invalid closed union payload was accepted: {json}"); + } + } +} diff --git a/go/rpc/discriminated_union_conformance_test.go b/go/rpc/discriminated_union_conformance_test.go new file mode 100644 index 0000000000..d05ba81754 --- /dev/null +++ b/go/rpc/discriminated_union_conformance_test.go @@ -0,0 +1,181 @@ +package rpc + +import ( + "encoding/json" + "testing" +) + +const ( + opaqueMCPHandle = "opaque:mcp/01-do-not-parse" + opaqueSkillHandle = "opaque:skill/02-do-not-parse" +) + +func TestClosedDiscriminatedUnionPreservesKnownNestedVariants(t *testing.T) { + result, err := unmarshalCatalogSearchResult([]byte(`{ + "kind":"succeeded", + "rawCard":{"secret":"must-not-survive"}, + "searchId":"search-01", + "candidates":[ + { + "kind":"mcp-server", + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "rawCard":{"secret":"must-not-survive"}, + "source":{"kind":"url","url":"https://catalog.example/mcp.json","rawCard":{"secret":"must-not-survive"}}, + "provenance":{ + "authority":"catalog.example", + "observedAt":"2026-09-02T11:00:00Z", + "mediaType":"application/mcp-server-card+json" + } + }, + { + "kind":"ai-skill", + "handle":"opaque:skill/02-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/ai-skill", + "installability":"not-installable-kind", + "displayName":"Example skill", + "rawCard":{"secret":"must-not-survive"}, + "source":{"kind":"embedded","rawCard":{"secret":"must-not-survive"}}, + "provenance":{ + "authority":"catalog.example", + "observedAt":"2026-09-02T11:00:00Z", + "mediaType":"application/ai-skill" + } + } + ], + "truncated":false, + "negotiated":{ + "runtimeProtocolVersion":1, + "grantedCapabilities":["mcp-server-card","ai-skill-discovery"] + } + }`)) + if err != nil { + t.Fatalf("unmarshal catalogue success: %v", err) + } + + success, ok := result.(*CatalogSearchSucceeded) + if !ok { + t.Fatalf("catalogue result = %T, want *CatalogSearchSucceeded", result) + } + mcp, ok := success.Candidates[0].(*CatalogMCPServerCandidate) + if !ok { + t.Fatalf("first candidate = %T, want *CatalogMCPServerCandidate", success.Candidates[0]) + } + skill, ok := success.Candidates[1].(*CatalogAiSkillCandidate) + if !ok { + t.Fatalf("second candidate = %T, want *CatalogAiSkillCandidate", success.Candidates[1]) + } + if mcp.Handle != opaqueMCPHandle || skill.Handle != opaqueSkillHandle { + t.Fatalf("opaque handles changed: %q, %q", mcp.Handle, skill.Handle) + } + if _, ok := mcp.Source.(*CatalogCandidateSourceURL); !ok { + t.Fatalf("MCP source = %T, want *CatalogCandidateSourceURL", mcp.Source) + } + if _, ok := skill.Source.(*CatalogCandidateSourceEmbedded); !ok { + t.Fatalf("skill source = %T, want *CatalogCandidateSourceEmbedded", skill.Source) + } + + encoded, err := json.Marshal(success) + if err != nil { + t.Fatalf("marshal catalogue success: %v", err) + } + var wire map[string]any + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatalf("decode catalogue wire result: %v", err) + } + if _, exists := wire["rawCard"]; exists { + t.Fatalf("result leaked rawCard: %s", encoded) + } + for _, candidate := range wire["candidates"].([]any) { + fields := candidate.(map[string]any) + for _, forbidden := range []string{"card", "cardData", "rawCard"} { + if _, exists := fields[forbidden]; exists { + t.Fatalf("candidate leaked %q: %s", forbidden, encoded) + } + } + if _, exists := fields["source"].(map[string]any)["rawCard"]; exists { + t.Fatalf("candidate source leaked rawCard: %s", encoded) + } + } +} + +func TestClosedDiscriminatedUnionPreservesRefusalsAndFailures(t *testing.T) { + tests := []struct { + name string + payload string + assert func(*testing.T, CatalogSearchResult) + }{ + { + name: "authentication required", + payload: `{"kind":"authentication-required","reason":"no-credential","message":"Sign in is required."}`, + assert: func(t *testing.T, result CatalogSearchResult) { + if _, ok := result.(*CatalogAuthenticationRequiredError); !ok { + t.Fatalf("result = %T, want *CatalogAuthenticationRequiredError", result) + } + }, + }, + { + name: "network failure", + payload: `{"kind":"network-failure","reason":"timeout","retryAfterSeconds":30,"message":"The catalogue timed out."}`, + assert: func(t *testing.T, result CatalogSearchResult) { + failure, ok := result.(*CatalogNetworkFailureError) + if !ok { + t.Fatalf("result = %T, want *CatalogNetworkFailureError", result) + } + if failure.RetryAfterSeconds == nil || *failure.RetryAfterSeconds != 30 { + t.Fatalf("retryAfterSeconds = %v, want 30", failure.RetryAfterSeconds) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := unmarshalCatalogSearchResult([]byte(test.payload)) + if err != nil { + t.Fatalf("unmarshal catalogue result: %v", err) + } + test.assert(t, result) + }) + } +} + +func TestClosedDiscriminatedUnionRejectsUnknownAndMissingDiscriminators(t *testing.T) { + validCandidatePrefix := `{ + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "provenance":{ + "authority":"catalog.example", + "observedAt":"2026-09-02T11:00:00Z", + "mediaType":"application/mcp-server-card+json" + },` + searchPrefix := `{"kind":"succeeded","searchId":"search-invalid","candidates":[` + searchSuffix := `],"truncated":false,"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}}` + tests := map[string]string{ + "unknown outer discriminator": `{"kind":"future-result","rawCard":{"secret":"must-not-survive"}}`, + "missing outer discriminator": `{"rawCard":{"secret":"must-not-survive"}}`, + "unknown candidate discriminator": searchPrefix + validCandidatePrefix + + `"kind":"future-candidate","source":{"kind":"url","url":"https://catalog.example/mcp.json"},"rawCard":{"secret":"must-not-survive"}}` + searchSuffix, + "missing candidate discriminator": searchPrefix + validCandidatePrefix + + `"source":{"kind":"url","url":"https://catalog.example/mcp.json"},"rawCard":{"secret":"must-not-survive"}}` + searchSuffix, + "unknown nested discriminator": searchPrefix + validCandidatePrefix + + `"kind":"mcp-server","source":{"kind":"future-source","rawCard":{"secret":"must-not-survive"}}}` + searchSuffix, + "missing nested discriminator": searchPrefix + validCandidatePrefix + + `"kind":"mcp-server","source":{"rawCard":{"secret":"must-not-survive"}}}` + searchSuffix, + } + + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + if _, err := unmarshalCatalogSearchResult([]byte(payload)); err == nil { + t.Fatal("invalid closed union payload must be rejected") + } + }) + } +} diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index 13d190be22..2d921c41dc 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -696,9 +696,8 @@ func unmarshalCatalogCandidate(data []byte) (CatalogCandidate, error) { return nil, err } return &d, nil - default: - return &RawCatalogCandidateData{Discriminator: raw.Kind, Raw: data}, nil } + return nil, errors.New("data did not match any union variant for CatalogCandidate") } func (r RawCatalogCandidateData) MarshalJSON() ([]byte, error) { @@ -737,9 +736,8 @@ func unmarshalCatalogCandidateSource(data []byte) (CatalogCandidateSource, error return nil, err } return &d, nil - default: - return &RawCatalogCandidateSourceData{Discriminator: raw.Kind, Raw: data}, nil } + return nil, errors.New("data did not match any union variant for CatalogCandidateSource") } func (r RawCatalogCandidateSourceData) MarshalJSON() ([]byte, error) { @@ -944,9 +942,8 @@ func unmarshalCatalogSearchResult(data []byte) (CatalogSearchResult, error) { return nil, err } return &d, nil - default: - return &RawCatalogSearchResultData{Discriminator: raw.Kind, Raw: data}, nil } + return nil, errors.New("data did not match any union variant for CatalogSearchResult") } func (r RawCatalogSearchResultData) MarshalJSON() ([]byte, error) { diff --git a/java/scripts/codegen/java.ts b/java/scripts/codegen/java.ts index 785049afa1..71b191d956 100644 --- a/java/scripts/codegen/java.ts +++ b/java/scripts/codegen/java.ts @@ -11,6 +11,10 @@ import fs from "fs/promises"; import type { JSONSchema7 } from "json-schema"; import path from "path"; import { fileURLToPath } from "url"; +import { + analyseDiscriminatedUnionVariants, + analyseNestedClosedUnionResult, +} from "../../../scripts/codegen/schema-unions.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -252,6 +256,7 @@ interface JavaTypeResult { // Set before each schema generation pass; used by schemaTypeToJava and helpers. let currentDefinitions: Record = {}; const pendingStandaloneTypes = new Map(); +const promotedNestedUnionTypes = new Set(); const generatedSessionEventTypeNames = new Set(); // Cross-schema definitions: keyed by schema filename (e.g. "session-events.schema.json"), @@ -300,14 +305,14 @@ function resolveMethodParamsSchema(method: RpcMethodNode): JSONSchema7 | undefin if (!params || typeof params !== "object") return undefined; if (params.properties) return params; if (!Array.isArray(params.anyOf)) return undefined; - const objectVariants = resolveAnyOfVariants(params.anyOf as JSONSchema7[]).filter((variant) => !!variant.properties); + const objectVariants = resolveUnionVariants(params.anyOf as JSONSchema7[]).filter((variant) => !!variant.properties); return hasOmissionSentinel(params) && objectVariants.length === 1 ? objectVariants[0] : undefined; } function resolveMethodParamsUnionSchema(method: RpcMethodNode): JSONSchema7 | undefined { const params = resolveRef(method.params ?? undefined); if (!params || typeof params !== "object" || !Array.isArray(params.anyOf)) return undefined; - const variants = resolveAnyOfVariants(params.anyOf as JSONSchema7[]); + const variants = resolveUnionVariants(params.anyOf as JSONSchema7[]); return variants.length > 1 && findDiscriminator(variants) ? params : undefined; } @@ -332,41 +337,31 @@ interface DiscriminatorInfo { * A discriminator is a property with a `const` value that uniquely identifies each variant. */ function findDiscriminator(variants: JSONSchema7[]): DiscriminatorInfo | null { - if (variants.length === 0) return null; - const firstVariant = variants[0]; - if (!firstVariant.properties) return null; - - for (const [propName, propSchema] of Object.entries(firstVariant.properties).sort(([a], [b]) => a.localeCompare(b))) { - if (typeof propSchema !== "object") continue; - const schema = propSchema as JSONSchema7; - if (schema.const === undefined) continue; - - const mapping = new Map(); - let isValidDiscriminator = true; - - for (const variant of variants) { - if (!variant.properties) { isValidDiscriminator = false; break; } - const variantProp = variant.properties[propName]; - if (typeof variantProp !== "object") { isValidDiscriminator = false; break; } - const variantSchema = variantProp as JSONSchema7; - if (variantSchema.const === undefined) { isValidDiscriminator = false; break; } - const key = String(variantSchema.const); - if (mapping.has(key)) { isValidDiscriminator = false; break; } - mapping.set(key, { value: variantSchema.const, schema: variant }); - } - - if (isValidDiscriminator && mapping.size === variants.length) { - return { property: propName, mapping }; - } + const analysis = analyseDiscriminatedUnionVariants(variants); + if ( + !analysis || + analysis.variants.some((variant) => variant.discriminatorValues.length !== 1) || + analysis.mapping.some((entry) => entry.variants.length !== 1) + ) { + return null; } - return null; + + return { + property: analysis.property, + mapping: new Map( + analysis.mapping.map((entry) => [ + String(entry.value), + { value: entry.value, schema: entry.variants[0].schema }, + ]) + ), + }; } /** * Resolve anyOf variants, handling $ref to definitions. */ -function resolveAnyOfVariants(anyOf: JSONSchema7[]): JSONSchema7[] { - return anyOf +function resolveUnionVariants(variants: JSONSchema7[]): JSONSchema7[] { + return variants .map((v) => { if (v.$ref) { const name = v.$ref.replace(/^#\/definitions\//, ""); @@ -377,6 +372,60 @@ function resolveAnyOfVariants(anyOf: JSONSchema7[]): JSONSchema7[] { .filter((v) => v.type !== "null"); } +export function collectNestedDiscriminatedUnionTypeNames( + root: unknown, + definitions: Record +): Set { + const promotedTypes = new Set(); + const visitedDefinitions = new Set(); + const visit = (node: unknown, isRoot = false): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + if (!node || typeof node !== "object") return; + + const schema = node as JSONSchema7; + if (schema.$ref?.startsWith("#/definitions/")) { + const name = schema.$ref.slice("#/definitions/".length); + if (visitedDefinitions.has(name)) return; + visitedDefinitions.add(name); + const definition = definitions[name]; + if (definition) { + const union = definition.anyOf ?? definition.oneOf; + if ( + union + && Array.isArray(union) + && analyseDiscriminatedUnionVariants( + union as JSONSchema7[], + (variant) => { + if (!variant.$ref?.startsWith("#/definitions/")) return variant; + return definitions[variant.$ref.slice("#/definitions/".length)]; + } + ) + && !isRoot + ) { + promotedTypes.add(name); + } + visit(definition); + } + return; + } + + for (const value of Object.values(node as Record)) { + visit(value); + } + }; + visit(root, true); + return promotedTypes; +} + +function collectPromotedNestedUnionTypes(root: unknown): void { + for (const name of collectNestedDiscriminatedUnionTypeNames(root, currentDefinitions)) { + promotedNestedUnionTypes.add(name); + } +} + /** * Generate a polymorphic base class and variant subclasses for a discriminated union result type. */ @@ -386,8 +435,8 @@ async function generatePolymorphicResultClass( packageName: string, packageDir: string ): Promise { - const anyOf = schema.anyOf as JSONSchema7[]; - const variants = resolveAnyOfVariants(anyOf); + const union = (schema.anyOf ?? schema.oneOf) as JSONSchema7[]; + const variants = resolveUnionVariants(union); const discriminator = findDiscriminator(variants); if (!discriminator) { @@ -408,7 +457,7 @@ async function generatePolymorphicResultClass( variantInfos.push({ discriminatorValue: discValue, variantClassName, schema: variantSchema }); } - // Generate the abstract base class + // Generate the polymorphic base class const baseLines: string[] = []; baseLines.push(COPYRIGHT); baseLines.push(""); @@ -608,6 +657,18 @@ function schemaTypeToJava( const name = schema.$ref.replace(/^#\/definitions\//, ""); const resolved = currentDefinitions[name]; if (resolved) { + const resolvedUnion = resolved.anyOf ?? resolved.oneOf; + if ( + promotedNestedUnionTypes.has(name) + && resolvedUnion + && Array.isArray(resolvedUnion) + ) { + const variants = resolveUnionVariants(resolvedUnion as JSONSchema7[]); + if (variants.length > 1 && findDiscriminator(variants)) { + pendingStandaloneTypes.set(name, resolved); + return { javaType: name, imports }; + } + } // Enum or object types → register for standalone generation, return ref name if ((resolved.type === "string" && resolved.enum) || (resolved.type === "object" && resolved.properties)) { @@ -622,17 +683,25 @@ function schemaTypeToJava( return { javaType: name, imports }; } - if (schema.anyOf) { - const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); - const nonNull = schema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + const union = schema.anyOf ?? schema.oneOf; + if (union) { + const hasNull = union.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = union.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); if (nonNull.length === 1) { const result = schemaTypeToJava(nonNull[0] as JSONSchema7, required && !hasNull, context, propName, nestedTypes); return result; } - // Multi-branch anyOf: fall through to Object, matching the C# generator's - // behavior. Java has no union types, so Object is the correct erasure for - // anyOf[string, object] and similar multi-variant schemas. - console.warn(`[codegen] ${context}.${propName}: anyOf with ${nonNull.length} non-null branches — falling back to Object`); + const variants = resolveUnionVariants(nonNull as JSONSchema7[]); + if ( + variants.length > 1 + && findDiscriminator(variants) + && schema.title + && promotedNestedUnionTypes.has(schema.title) + ) { + pendingStandaloneTypes.set(schema.title, schema); + return { javaType: schema.title, imports }; + } + console.warn(`[codegen] ${context}.${propName}: union with ${nonNull.length} non-null branches — falling back to Object`); return { javaType: "Object", imports }; } @@ -1183,12 +1252,15 @@ async function generatePendingStandaloneTypes( await generateStandaloneEnum(name, schema, packageName, packageDir, headerComment); } else if (schema.type === "object" && schema.properties) { await generateStandaloneRecord(name, schema, packageName, packageDir, headerComment); - } else if (schema.anyOf && Array.isArray(schema.anyOf)) { - const variants = resolveAnyOfVariants(schema.anyOf as JSONSchema7[]); + } else if ( + (schema.anyOf && Array.isArray(schema.anyOf)) + || (schema.oneOf && Array.isArray(schema.oneOf)) + ) { + const variants = resolveUnionVariants((schema.anyOf ?? schema.oneOf) as JSONSchema7[]); if (variants.length > 1 && findDiscriminator(variants)) { await generatePolymorphicResultClass(name, schema, packageName, packageDir); } else { - console.warn(`[codegen] Cannot generate standalone type for ${name}: anyOf without discriminator`); + console.warn(`[codegen] Cannot generate standalone type for ${name}: union without discriminator`); } } else { console.warn(`[codegen] Cannot generate standalone type for ${name}: type=${schema.type}`); @@ -1391,7 +1463,16 @@ async function generateRpcTypes(schemaPath: string): Promise { // Set module-level definitions for $ref resolution currentDefinitions = (schema.definitions ?? {}) as Record; pendingStandaloneTypes.clear(); + promotedNestedUnionTypes.clear(); crossSchemaDefinitions.clear(); + for (const section of [schema.server, schema.session, schema.clientSession, schema.clientGlobal]) { + if (!section) continue; + for (const [, method] of collectRpcMethods(section)) { + if (analyseNestedClosedUnionResult(method.result, currentDefinitions)) { + collectPromotedNestedUnionTypes(method.result); + } + } + } // Load cross-schema definitions (session-events) so that cross-schema $ref values // like "session-events.schema.json#/definitions/Foo" can be resolved. @@ -1477,7 +1558,7 @@ async function generateRpcTypes(schemaPath: string): Promise { pendingStandaloneTypes.set(resultRefName, resultSchema); } else if (resultRefName && resultSchema.anyOf && Array.isArray(resultSchema.anyOf)) { // anyOf discriminated union → generate polymorphic hierarchy - const variants = resolveAnyOfVariants(resultSchema.anyOf as JSONSchema7[]); + const variants = resolveUnionVariants(resultSchema.anyOf as JSONSchema7[]); if (variants.length > 1 && findDiscriminator(variants)) { if (!generatedClasses.has(resultRefName)) { generatedClasses.set(resultRefName, true); @@ -1645,7 +1726,7 @@ function wrapperResultClassName(method: RpcMethodNode): string { } // anyOf discriminated union → use the definition name if (resolved.anyOf && Array.isArray(resolved.anyOf)) { - const variants = resolveAnyOfVariants(resolved.anyOf as JSONSchema7[]); + const variants = resolveUnionVariants(resolved.anyOf as JSONSchema7[]); if (variants.length > 1 && findDiscriminator(variants)) { return refName; } @@ -2392,7 +2473,9 @@ async function main(): Promise { console.log("\n✅ Java code generation complete!"); } -main().catch((err) => { - console.error("❌ Code generation failed:", err); - process.exit(1); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch((err) => { + console.error("❌ Code generation failed:", err); + process.exit(1); + }); +} diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index 5e10cd839b..0d01b57ac0 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.83-5", + "@github/copilot": "1.0.83-5", "json-schema": "^0.4.0", "tsx": "^4.23.13" } diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index 5ef4484fb7..59e1aea0d2 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.83-5", + "@github/copilot": "1.0.83-5", "json-schema": "^0.4.0", "tsx": "^4.23.13" } diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index aa40b25189..01374dec39 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -796,6 +796,7 @@ did not produce the multi-release output. Re-build on JDK 25+ and verify the ${project.parent.basedir}/scripts/codegen install + --save-exact @github/copilot@${copilot.schema.version} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidate.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidate.java new file mode 100644 index 0000000000..59e70f4935 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidate.java @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * An inert AI skill catalog result. AI skills are discovery-only and cannot be represented as installable through this surface. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class CatalogAiSkillCandidate extends CatalogCandidate { + + @JsonProperty("kind") + private final String kind = "ai-skill"; + + @Override + public String getKind() { return kind; } + + /** Opaque, runtime-instance scoped, TTL-bound, single-use handle for this candidate. Carries no readable information and is rejected when stale, replayed, or presented to a different runtime instance. Never logged. */ + @JsonProperty("handle") + private String handle; + + /** ISO 8601 timestamp after which the handle is stale and will be rejected. */ + @JsonProperty("handleExpiresAt") + private String handleExpiresAt; + + /** Media type of the underlying AI skill card */ + @JsonProperty("mediaType") + private String mediaType; + + /** AI skills are discovery-only and cannot be installed through this surface */ + @JsonProperty("installability") + private String installability; + + /** Display name taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("displayName") + private String displayName; + + /** Description taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("description") + private String description; + + /** Publisher taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("publisher") + private String publisher; + + /** Where the card came from: exactly one of a URL or embedded data, encoded as a tagged union so neither both nor neither can be represented. */ + @JsonProperty("source") + private CatalogCandidateSource source; + + /** Where the catalog reference was observed, without the card itself or any content digest. */ + @JsonProperty("provenance") + private CatalogAiSkillCandidateProvenance provenance; + + public String getHandle() { return handle; } + public void setHandle(String handle) { this.handle = handle; } + + public String getHandleExpiresAt() { return handleExpiresAt; } + public void setHandleExpiresAt(String handleExpiresAt) { this.handleExpiresAt = handleExpiresAt; } + + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + + public String getInstallability() { return installability; } + public void setInstallability(String installability) { this.installability = installability; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getPublisher() { return publisher; } + public void setPublisher(String publisher) { this.publisher = publisher; } + + public CatalogCandidateSource getSource() { return source; } + public void setSource(CatalogCandidateSource source) { this.source = source; } + + public CatalogAiSkillCandidateProvenance getProvenance() { return provenance; } + public void setProvenance(CatalogAiSkillCandidateProvenance provenance) { this.provenance = provenance; } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidateProvenance.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidateProvenance.java new file mode 100644 index 0000000000..0a2eff24e8 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogAiSkillCandidateProvenance.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Where and when an AI skill catalog reference was observed. Discovery provenance deliberately carries no content digest because search does not establish the exact validated content a later plan will bind. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record CatalogAiSkillCandidateProvenance( + /** Host of the catalog authority that advertised the reference, without path, query, or credentials. Inert untrusted data. */ + @JsonProperty("authority") String authority, + /** ISO 8601 timestamp at which the runtime observed the catalog reference. This is not a retrieval or validation timestamp. */ + @JsonProperty("observedAt") String observedAt, + /** Media type advertised for the referenced AI skill card */ + @JsonProperty("mediaType") String mediaType +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidate.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidate.java new file mode 100644 index 0000000000..7fc982cc21 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidate.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import javax.annotation.processing.Generated; + +/** + * One inert catalog result, represented as an MCP server or discovery-only AI skill variant so kind, media type, provenance, and installability cannot contradict each other. + * + * @since 1.0.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = CatalogMcpServerCandidate.class, name = "mcp-server"), + @JsonSubTypes.Type(value = CatalogAiSkillCandidate.class, name = "ai-skill") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public abstract class CatalogCandidate { + + /** + * Returns the discriminator value for this variant. + * + * @return the kind discriminator + */ + public abstract String getKind(); +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSource.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSource.java new file mode 100644 index 0000000000..90d9667d0d --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSource.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import javax.annotation.processing.Generated; + +/** + * Where a candidate's card came from. Exactly one of a URL or embedded data: the union has no variant carrying both, and no variant carrying neither, so the rule holds structurally rather than by validation. + * + * @since 1.0.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = CatalogCandidateSourceUrl.class, name = "url"), + @JsonSubTypes.Type(value = CatalogCandidateSourceEmbedded.class, name = "embedded") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public abstract class CatalogCandidateSource { + + /** + * Returns the discriminator value for this variant. + * + * @return the kind discriminator + */ + public abstract String getKind(); +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceEmbedded.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceEmbedded.java new file mode 100644 index 0000000000..6814a0c4f2 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceEmbedded.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Candidate whose card reference arrived inline. The document and its content-derived properties stay behind the runtime boundary. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class CatalogCandidateSourceEmbedded extends CatalogCandidateSource { + + @JsonProperty("kind") + private final String kind = "embedded"; + + @Override + public String getKind() { return kind; } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceUrl.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceUrl.java new file mode 100644 index 0000000000..1c9b6d8905 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogCandidateSourceUrl.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Candidate whose card is retrieved from a URL through the runtime's hardened fetch boundary. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class CatalogCandidateSourceUrl extends CatalogCandidateSource { + + @JsonProperty("kind") + private final String kind = "url"; + + @Override + public String getKind() { return kind; } + + /** Card URL as advertised. Inert untrusted data: the runtime retrieves it only through its own hardened boundary, and it is never logged. */ + @JsonProperty("url") + private String url; + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidate.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidate.java new file mode 100644 index 0000000000..8183ca422a --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidate.java @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * An inert MCP server catalog result. Every free-text field is untrusted external data and must never be treated as an instruction, and the handle is the only way to refer to the candidate in a later operation. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class CatalogMcpServerCandidate extends CatalogCandidate { + + @JsonProperty("kind") + private final String kind = "mcp-server"; + + @Override + public String getKind() { return kind; } + + /** Opaque, runtime-instance scoped, TTL-bound, single-use handle for this candidate. Carries no readable information and is rejected when stale, replayed, or presented to a different runtime instance. Never logged. */ + @JsonProperty("handle") + private String handle; + + /** ISO 8601 timestamp after which the handle is stale and will be rejected. */ + @JsonProperty("handleExpiresAt") + private String handleExpiresAt; + + /** JSON MCP media type of the underlying card. */ + @JsonProperty("mediaType") + private McpServerCardMediaType mediaType; + + /** Whether this MCP server can be planned for installation, and if policy prevents it. */ + @JsonProperty("installability") + private CatalogMcpServerInstallability installability; + + /** Display name taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("displayName") + private String displayName; + + /** Description taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("description") + private String description; + + /** Publisher taken verbatim from the card. Inert untrusted text. */ + @JsonProperty("publisher") + private String publisher; + + /** Where the card came from: exactly one of a URL or embedded data, encoded as a tagged union so neither both nor neither can be represented. */ + @JsonProperty("source") + private CatalogCandidateSource source; + + /** Where the catalog reference was observed, without the card itself or any content digest. */ + @JsonProperty("provenance") + private CatalogMcpServerCandidateProvenance provenance; + + public String getHandle() { return handle; } + public void setHandle(String handle) { this.handle = handle; } + + public String getHandleExpiresAt() { return handleExpiresAt; } + public void setHandleExpiresAt(String handleExpiresAt) { this.handleExpiresAt = handleExpiresAt; } + + public McpServerCardMediaType getMediaType() { return mediaType; } + public void setMediaType(McpServerCardMediaType mediaType) { this.mediaType = mediaType; } + + public CatalogMcpServerInstallability getInstallability() { return installability; } + public void setInstallability(CatalogMcpServerInstallability installability) { this.installability = installability; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getPublisher() { return publisher; } + public void setPublisher(String publisher) { this.publisher = publisher; } + + public CatalogCandidateSource getSource() { return source; } + public void setSource(CatalogCandidateSource source) { this.source = source; } + + public CatalogMcpServerCandidateProvenance getProvenance() { return provenance; } + public void setProvenance(CatalogMcpServerCandidateProvenance provenance) { this.provenance = provenance; } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidateProvenance.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidateProvenance.java new file mode 100644 index 0000000000..3ef13704ff --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerCandidateProvenance.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Where and when an MCP server catalog reference was observed. Discovery provenance deliberately carries no content digest because search does not establish the exact validated content a later plan will bind. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record CatalogMcpServerCandidateProvenance( + /** Host of the catalog authority that advertised the reference, without path, query, or credentials. Inert untrusted data. */ + @JsonProperty("authority") String authority, + /** ISO 8601 timestamp at which the runtime observed the catalog reference. This is not a retrieval or validation timestamp. */ + @JsonProperty("observedAt") String observedAt, + /** JSON MCP media type advertised for the referenced card. */ + @JsonProperty("mediaType") McpServerCardMediaType mediaType +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerInstallability.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerInstallability.java new file mode 100644 index 0000000000..4478a81bd3 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogMcpServerInstallability.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Whether an MCP server candidate can be planned for installation + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum CatalogMcpServerInstallability { + /** The {@code installable} variant. */ + INSTALLABLE("installable"), + /** The {@code not-installable-policy} variant. */ + NOT_INSTALLABLE_POLICY("not-installable-policy"); + + private final String value; + CatalogMcpServerInstallability(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static CatalogMcpServerInstallability fromValue(String value) { + for (CatalogMcpServerInstallability v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown CatalogMcpServerInstallability value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchSucceeded.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchSucceeded.java index 8ecd11788a..b49f78faff 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchSucceeded.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchSucceeded.java @@ -35,7 +35,7 @@ public final class CatalogSearchSucceeded extends CatalogSearchResult { /** Matching candidates, never more than the requested limit. All text is inert untrusted data. */ @JsonProperty("candidates") - private List candidates; + private List candidates; /** Whether further matches existed beyond the requested limit. */ @JsonProperty("truncated") @@ -48,8 +48,8 @@ public final class CatalogSearchSucceeded extends CatalogSearchResult { public String getSearchId() { return searchId; } public void setSearchId(String searchId) { this.searchId = searchId; } - public List getCandidates() { return candidates; } - public void setCandidates(List candidates) { this.candidates = candidates; } + public List getCandidates() { return candidates; } + public void setCandidates(List candidates) { this.candidates = candidates; } public Boolean getTruncated() { return truncated; } public void setTruncated(Boolean truncated) { this.truncated = truncated; } diff --git a/java/sdk/src/test/java/com/github/copilot/generated/DiscriminatedUnionConformanceTest.java b/java/sdk/src/test/java/com/github/copilot/generated/DiscriminatedUnionConformanceTest.java new file mode 100644 index 0000000000..a7cfe90dda --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/generated/DiscriminatedUnionConformanceTest.java @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.generated; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.CatalogAiSkillCandidate; +import com.github.copilot.generated.rpc.CatalogAuthenticationRequiredError; +import com.github.copilot.generated.rpc.CatalogCandidateSourceEmbedded; +import com.github.copilot.generated.rpc.CatalogCandidateSourceUrl; +import com.github.copilot.generated.rpc.CatalogMcpServerCandidate; +import com.github.copilot.generated.rpc.CatalogNetworkFailureError; +import com.github.copilot.generated.rpc.CatalogSearchResult; +import com.github.copilot.generated.rpc.CatalogSearchSucceeded; + +class DiscriminatedUnionConformanceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String OPAQUE_MCP_HANDLE = "opaque:mcp/01-do-not-parse"; + private static final String OPAQUE_SKILL_HANDLE = "opaque:skill/02-do-not-parse"; + + @Test + void preservesTypedCandidatesAndOpaqueHandles() throws Exception { + var result = MAPPER.readValue( + """ + { + "kind": "succeeded", + "rawCard": { "secret": "must-not-survive" }, + "searchId": "search-01", + "candidates": [ + { + "kind": "mcp-server", + "handle": "opaque:mcp/01-do-not-parse", + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "rawCard": { "secret": "must-not-survive" }, + "source": { "kind": "url", "url": "https://catalog.example/mcp.json", "rawCard": { "secret": "must-not-survive" } }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json" + } + }, + { + "kind": "ai-skill", + "handle": "opaque:skill/02-do-not-parse", + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/ai-skill", + "installability": "not-installable-kind", + "displayName": "Example skill", + "rawCard": { "secret": "must-not-survive" }, + "source": { "kind": "embedded", "rawCard": { "secret": "must-not-survive" } }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/ai-skill" + } + } + ], + "truncated": false, + "negotiated": { + "runtimeProtocolVersion": 1, + "grantedCapabilities": ["mcp-server-card", "ai-skill-discovery"] + } + } + """, + CatalogSearchResult.class); + + var success = assertInstanceOf(CatalogSearchSucceeded.class, result); + var mcp = assertInstanceOf(CatalogMcpServerCandidate.class, success.getCandidates().get(0)); + var skill = assertInstanceOf(CatalogAiSkillCandidate.class, success.getCandidates().get(1)); + assertEquals(OPAQUE_MCP_HANDLE, mcp.getHandle()); + assertEquals(OPAQUE_SKILL_HANDLE, skill.getHandle()); + assertInstanceOf(CatalogCandidateSourceUrl.class, mcp.getSource()); + assertInstanceOf(CatalogCandidateSourceEmbedded.class, skill.getSource()); + + JsonNode encoded = MAPPER.valueToTree(success); + assertFalse(encoded.has("rawCard")); + for (JsonNode candidate : encoded.get("candidates")) { + assertFalse(candidate.has("card")); + assertFalse(candidate.has("cardData")); + assertFalse(candidate.has("rawCard")); + assertFalse(candidate.get("source").has("rawCard")); + } + } + + @Test + void preservesRefusalsAndFailures() throws Exception { + var authentication = MAPPER.readValue(""" + {"kind":"authentication-required","reason":"no-credential","message":"Sign in is required."} + """, CatalogSearchResult.class); + assertInstanceOf(CatalogAuthenticationRequiredError.class, authentication); + + var network = assertInstanceOf(CatalogNetworkFailureError.class, + MAPPER.readValue( + """ + {"kind":"network-failure","reason":"timeout","retryAfterSeconds":30,"message":"The catalogue timed out."} + """, + CatalogSearchResult.class)); + assertEquals(30L, network.getRetryAfterSeconds()); + } + + @Test + void rejectsUnknownAndMissingDiscriminators() { + var invalidPayloads = new String[]{""" + {"kind":"future-result","rawCard":{"secret":"must-not-survive"}} + """, """ + {"rawCard":{"secret":"must-not-survive"}} + """, """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{"kind":"future-candidate","rawCard":{"secret":"must-not-survive"}}], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{"rawCard":{"secret":"must-not-survive"}}], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{ + "kind":"mcp-server", + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "source":{"kind":"future-source","rawCard":{"secret":"must-not-survive"}}, + "provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"} + }], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """, + """ + { + "kind":"succeeded", + "searchId":"search-invalid", + "candidates":[{ + "kind":"mcp-server", + "handle":"opaque:mcp/01-do-not-parse", + "handleExpiresAt":"2026-09-02T12:00:00Z", + "mediaType":"application/mcp-server-card+json", + "installability":"installable", + "displayName":"Example MCP", + "source":{"rawCard":{"secret":"must-not-survive"}}, + "provenance":{"authority":"catalog.example","observedAt":"2026-09-02T11:00:00Z","mediaType":"application/mcp-server-card+json"} + }], + "truncated":false, + "negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]} + } + """}; + + for (var payload : invalidPayloads) { + assertThrows(JsonProcessingException.class, () -> MAPPER.readValue(payload, CatalogSearchResult.class)); + } + } +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index f4978de1ff..e68046951b 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -23710,6 +23710,334 @@ export interface SessionFsSqliteExistsRequest { sessionId: string; } +type RpcResultProjection = + | { kind: "ref"; name: string } + | { kind: "array"; items: RpcResultProjection | null } + | { kind: "object"; closed: boolean; properties: Record } + | { kind: "union"; discriminator: string; variants: Record }; + +const RPC_RESULT_PROJECTIONS: Record = { + "catalog.search": { + "kind": "ref", + "name": "CatalogSearchResult" + } +}; + +const RPC_RESULT_PROJECTION_DEFINITIONS: Record = { + "CatalogCandidateSourceUrl": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "url": null + } + }, + "CatalogCandidateSourceEmbedded": { + "kind": "object", + "closed": true, + "properties": { + "kind": null + } + }, + "CatalogCandidateSource": { + "kind": "union", + "discriminator": "kind", + "variants": { + "string:\"url\"": { + "kind": "ref", + "name": "CatalogCandidateSourceUrl" + }, + "string:\"embedded\"": { + "kind": "ref", + "name": "CatalogCandidateSourceEmbedded" + } + } + }, + "CatalogMcpServerCandidateProvenance": { + "kind": "object", + "closed": true, + "properties": { + "authority": null, + "observedAt": null, + "mediaType": null + } + }, + "CatalogMcpServerCandidate": { + "kind": "object", + "closed": true, + "properties": { + "handle": null, + "handleExpiresAt": null, + "kind": null, + "mediaType": null, + "installability": null, + "displayName": null, + "description": null, + "publisher": null, + "source": { + "kind": "ref", + "name": "CatalogCandidateSource" + }, + "provenance": { + "kind": "ref", + "name": "CatalogMcpServerCandidateProvenance" + } + } + }, + "CatalogAiSkillCandidateProvenance": { + "kind": "object", + "closed": true, + "properties": { + "authority": null, + "observedAt": null, + "mediaType": null + } + }, + "CatalogAiSkillCandidate": { + "kind": "object", + "closed": true, + "properties": { + "handle": null, + "handleExpiresAt": null, + "kind": null, + "mediaType": null, + "installability": null, + "displayName": null, + "description": null, + "publisher": null, + "source": { + "kind": "ref", + "name": "CatalogCandidateSource" + }, + "provenance": { + "kind": "ref", + "name": "CatalogAiSkillCandidateProvenance" + } + } + }, + "CatalogCandidate": { + "kind": "union", + "discriminator": "kind", + "variants": { + "string:\"mcp-server\"": { + "kind": "ref", + "name": "CatalogMcpServerCandidate" + }, + "string:\"ai-skill\"": { + "kind": "ref", + "name": "CatalogAiSkillCandidate" + } + } + }, + "CatalogNegotiatedContract": { + "kind": "object", + "closed": true, + "properties": { + "runtimeProtocolVersion": null, + "grantedCapabilities": null + } + }, + "CatalogSearchSucceeded": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "searchId": null, + "candidates": { + "kind": "array", + "items": { + "kind": "ref", + "name": "CatalogCandidate" + } + }, + "truncated": null, + "negotiated": { + "kind": "ref", + "name": "CatalogNegotiatedContract" + } + } + }, + "CatalogNegotiationRefusedError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "runtimeProtocolVersion": null, + "minimumSupportedProtocolVersion": null, + "supportedCapabilities": null, + "unsupportedCapabilities": null, + "message": null + } + }, + "CatalogUnsupportedKindError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "requestedKinds": null, + "supportedKinds": null, + "message": null + } + }, + "CatalogInvalidRequestError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "field": null, + "message": null + } + }, + "CatalogAuthenticationRequiredError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "message": null + } + }, + "CatalogPolicyRejectedError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "source": null, + "message": null + } + }, + "CatalogNetworkFailureError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "statusCode": null, + "retryAfterSeconds": null, + "message": null + } + }, + "CatalogUnsafeRetrievalError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "message": null + } + }, + "CatalogMalformedCardError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "mediaType": null, + "message": null + } + }, + "CatalogContractViolationError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "message": null + } + }, + "CatalogUnavailableError": { + "kind": "object", + "closed": true, + "properties": { + "kind": null, + "reason": null, + "message": null + } + }, + "CatalogSearchResult": { + "kind": "union", + "discriminator": "kind", + "variants": { + "string:\"succeeded\"": { + "kind": "ref", + "name": "CatalogSearchSucceeded" + }, + "string:\"negotiation-refused\"": { + "kind": "ref", + "name": "CatalogNegotiationRefusedError" + }, + "string:\"unsupported-kind\"": { + "kind": "ref", + "name": "CatalogUnsupportedKindError" + }, + "string:\"invalid-request\"": { + "kind": "ref", + "name": "CatalogInvalidRequestError" + }, + "string:\"authentication-required\"": { + "kind": "ref", + "name": "CatalogAuthenticationRequiredError" + }, + "string:\"policy-rejected\"": { + "kind": "ref", + "name": "CatalogPolicyRejectedError" + }, + "string:\"network-failure\"": { + "kind": "ref", + "name": "CatalogNetworkFailureError" + }, + "string:\"unsafe-retrieval\"": { + "kind": "ref", + "name": "CatalogUnsafeRetrievalError" + }, + "string:\"malformed-card\"": { + "kind": "ref", + "name": "CatalogMalformedCardError" + }, + "string:\"contract-violation\"": { + "kind": "ref", + "name": "CatalogContractViolationError" + }, + "string:\"unavailable\"": { + "kind": "ref", + "name": "CatalogUnavailableError" + } + } + } +}; + +function projectRpcResult(value: unknown, projection: RpcResultProjection, path = "$"): unknown { + if (projection.kind === "ref") { + const definition = RPC_RESULT_PROJECTION_DEFINITIONS[projection.name]; + if (!definition) throw new TypeError(`Missing RPC result projection for ${projection.name}`); + return projectRpcResult(value, definition, path); + } + if (projection.kind === "array") { + if (!Array.isArray(value)) throw new TypeError(`Invalid RPC result at ${path}: expected an array`); + return projection.items ? value.map((item, index) => projectRpcResult(item, projection.items!, `${path}[${index}]`)) : value; + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`Invalid RPC result at ${path}: expected an object`); + } + const record = value as Record; + if (projection.kind === "union") { + const discriminator = record[projection.discriminator]; + const key = `${typeof discriminator}:${JSON.stringify(discriminator)}`; + const variant = projection.variants[key]; + if (!variant) { + throw new TypeError(`Invalid RPC result at ${path}: unknown or missing ${projection.discriminator} discriminator`); + } + return projectRpcResult(value, variant, path); + } + const result: Record = projection.closed ? {} : { ...record }; + for (const [name, child] of Object.entries(projection.properties)) { + if (!Object.hasOwn(record, name)) continue; + result[name] = child ? projectRpcResult(record[name], child, `${path}.${name}`) : record[name]; + } + return result; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -23935,7 +24263,10 @@ export function createServerRpc(connection: MessageConnection) { * @returns Outcome of a catalog.search call: either bounded inert candidates, or one typed refusal. Never a partial success. */ search: async (params: CatalogSearchRequest): Promise => - connection.sendRequest("catalog.search", params), + projectRpcResult( + await connection.sendRequest("catalog.search", params), + RPC_RESULT_PROJECTIONS["catalog.search"], + ) as CatalogSearchResult, }, /** @experimental */ plugins: { diff --git a/nodejs/test/discriminated-union-codegen.test.ts b/nodejs/test/discriminated-union-codegen.test.ts new file mode 100644 index 0000000000..fbda41991e --- /dev/null +++ b/nodejs/test/discriminated-union-codegen.test.ts @@ -0,0 +1,165 @@ +import type { JSONSchema7 } from "json-schema"; +import { describe, expect, it } from "vitest"; + +import { collectNestedDiscriminatedUnionTypeNames } from "../../java/scripts/codegen/java.ts"; +import { + analyseDiscriminatedUnion, + analyseNestedClosedUnionResult, + schemaDiscriminatorValueKey, +} from "../../scripts/codegen/schema-unions.ts"; +import { createRpcResultProjectionBundle } from "../../scripts/codegen/typescript.ts"; +import type { DefinitionCollections } from "../../scripts/codegen/utils.ts"; + +const definitions: Record = { + SyntheticResult: { + anyOf: [ + { $ref: "#/definitions/SyntheticSucceeded" }, + { $ref: "#/definitions/SyntheticFailed" }, + ], + }, + SyntheticSucceeded: { + type: "object", + additionalProperties: false, + required: ["kind", "choices"], + properties: { + kind: { const: "succeeded" }, + choices: { + type: "array", + items: { $ref: "#/definitions/SyntheticChoice" }, + }, + }, + }, + SyntheticFailed: { + type: "object", + additionalProperties: false, + required: ["kind", "message"], + properties: { + kind: { const: "failed" }, + message: { type: "string" }, + }, + }, + SyntheticChoice: { + anyOf: [{ $ref: "#/definitions/SyntheticAlpha" }, { $ref: "#/definitions/SyntheticBeta" }], + }, + SyntheticAlpha: { + type: "object", + additionalProperties: false, + required: ["kind", "source"], + properties: { + kind: { const: "alpha" }, + source: { $ref: "#/definitions/SyntheticSource" }, + }, + }, + SyntheticBeta: { + type: "object", + additionalProperties: false, + required: ["kind", "source"], + properties: { + kind: { const: "beta" }, + source: { $ref: "#/definitions/SyntheticSource" }, + }, + }, + SyntheticSource: { + oneOf: [ + { $ref: "#/definitions/SyntheticInlineSource" }, + { $ref: "#/definitions/SyntheticUrlSource" }, + ], + }, + SyntheticInlineSource: { + type: "object", + additionalProperties: false, + required: ["kind"], + properties: { + kind: { const: "inline" }, + }, + }, + SyntheticUrlSource: { + type: "object", + additionalProperties: false, + required: ["kind", "url"], + properties: { + kind: { const: "url" }, + url: { type: "string" }, + }, + }, +}; + +const collections: DefinitionCollections = { definitions, $defs: {} }; +const resolveVariant = (schema: JSONSchema7): JSONSchema7 | undefined => { + const name = schema.$ref?.match(/^#\/definitions\/([^/]+)$/)?.[1]; + return name ? definitions[name] : schema; +}; + +describe("schema-driven discriminated union codegen", () => { + it("classifies a closed synthetic union without relying on domain names", () => { + const analysis = analyseDiscriminatedUnion(definitions.SyntheticChoice, resolveVariant); + + expect(analysis?.property).toBe("kind"); + expect(analysis?.unknownVariantPolicy).toBe("reject"); + expect(analysis?.mapping.map(({ value }) => value)).toEqual(["alpha", "beta"]); + }); + + it("preserves fallback only when a variant explicitly permits extra properties", () => { + const openDefinitions = structuredClone(definitions); + openDefinitions.SyntheticBeta.additionalProperties = true; + const analysis = analyseDiscriminatedUnion(openDefinitions.SyntheticChoice, (schema) => { + const name = schema.$ref?.match(/^#\/definitions\/([^/]+)$/)?.[1]; + return name ? openDefinitions[name] : schema; + }); + + expect(analysis?.unknownVariantPolicy).toBe("preserve"); + }); + + it("builds nested runtime projections for an equivalent synthetic result", () => { + const projection = createRpcResultProjectionBundle( + { $ref: "#/definitions/SyntheticResult" }, + collections + ); + + expect(projection?.root).toEqual({ + kind: "ref", + name: "SyntheticResult", + }); + expect(projection?.definitions.SyntheticChoice).toMatchObject({ + kind: "union", + discriminator: "kind", + variants: { + [schemaDiscriminatorValueKey("alpha")]: { + kind: "ref", + name: "SyntheticAlpha", + }, + [schemaDiscriminatorValueKey("beta")]: { + kind: "ref", + name: "SyntheticBeta", + }, + }, + }); + expect(projection?.definitions.SyntheticSource).toMatchObject({ + kind: "union", + discriminator: "kind", + }); + }); + + it("promotes every nested synthetic union for Java generation", () => { + expect( + analyseNestedClosedUnionResult( + { $ref: "#/definitions/SyntheticResult" }, + definitions + )?.unionDefinitionNames + ).toEqual( + new Set(["SyntheticResult", "SyntheticChoice", "SyntheticSource"]) + ); + expect( + collectNestedDiscriminatedUnionTypeNames( + { $ref: "#/definitions/SyntheticResult" }, + definitions + ) + ).toEqual(new Set(["SyntheticChoice", "SyntheticSource"])); + expect( + collectNestedDiscriminatedUnionTypeNames( + { $ref: "#/definitions/SyntheticChoice" }, + definitions + ) + ).toEqual(new Set(["SyntheticSource"])); + }); +}); diff --git a/nodejs/test/discriminated-union-conformance.test.ts b/nodejs/test/discriminated-union-conformance.test.ts new file mode 100644 index 0000000000..ce4330b825 --- /dev/null +++ b/nodejs/test/discriminated-union-conformance.test.ts @@ -0,0 +1,203 @@ +import { PassThrough } from "node:stream"; + +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import { describe, expect, it, onTestFinished } from "vitest"; + +import { + type CatalogSearchRequest, + type CatalogSearchResult, + createServerRpc, +} from "../src/generated/rpc.js"; + +const OPAQUE_MCP_HANDLE = "opaque:mcp/01-do-not-parse"; +const OPAQUE_SKILL_HANDLE = "opaque:skill/02-do-not-parse"; + +function successResult(): CatalogSearchResult { + return { + kind: "succeeded", + searchId: "search-01", + candidates: [ + { + kind: "mcp-server", + handle: OPAQUE_MCP_HANDLE, + handleExpiresAt: "2026-09-02T12:00:00Z", + mediaType: "application/mcp-server-card+json", + installability: "installable", + displayName: "Example MCP", + source: { kind: "url", url: "https://catalog.example/mcp.json" }, + provenance: { + authority: "catalog.example", + observedAt: "2026-09-02T11:00:00Z", + mediaType: "application/mcp-server-card+json", + }, + }, + { + kind: "ai-skill", + handle: OPAQUE_SKILL_HANDLE, + handleExpiresAt: "2026-09-02T12:00:00Z", + mediaType: "application/ai-skill", + installability: "not-installable-kind", + displayName: "Example skill", + source: { kind: "embedded" }, + provenance: { + authority: "catalog.example", + observedAt: "2026-09-02T11:00:00Z", + mediaType: "application/ai-skill", + }, + }, + ], + truncated: false, + negotiated: { + runtimeProtocolVersion: 1, + grantedCapabilities: ["mcp-server-card", "ai-skill-discovery"], + }, + }; +} + +function successWireResult(): unknown { + const result = successResult(); + if (result.kind !== "succeeded") throw new Error("Expected a successful search."); + return { + ...result, + rawCard: { secret: "must-not-survive" }, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + card: { secret: "must-not-survive" }, + cardData: { secret: "must-not-survive" }, + rawCard: { secret: "must-not-survive" }, + source: { + ...candidate.source, + rawCard: { secret: "must-not-survive" }, + }, + })), + }; +} + +function invalidWireResult(kind: string): unknown { + const result = structuredClone(successWireResult()) as { + kind?: string; + candidates: Array<{ kind?: string; source: { kind?: string } }>; + }; + switch (kind) { + case "unknown-result": + result.kind = "future-result"; + return result; + case "missing-result": + delete result.kind; + return result; + case "unknown-candidate": + result.candidates[0].kind = "future-candidate"; + return result; + case "missing-candidate": + delete result.candidates[0].kind; + return result; + case "unknown-source": + result.candidates[0].source.kind = "future-source"; + return result; + case "missing-source": + delete result.candidates[0].source.kind; + return result; + default: + throw new Error(`Unknown invalid result fixture: ${kind}`); + } +} + +describe("closed discriminated union conformance", () => { + it("transports typed candidates, refusals, and failures unchanged", async () => { + const clientToServer = new PassThrough(); + const serverToClient = new PassThrough(); + const client = createMessageConnection( + new StreamMessageReader(serverToClient), + new StreamMessageWriter(clientToServer) + ); + const server = createMessageConnection( + new StreamMessageReader(clientToServer), + new StreamMessageWriter(serverToClient) + ); + onTestFinished(() => { + client.dispose(); + server.dispose(); + }); + + server.onRequest("catalog.search", (params: CatalogSearchRequest) => { + if (params.query === "authentication") { + return { + kind: "authentication-required", + reason: "no-credential", + message: "Sign in is required.", + } satisfies CatalogSearchResult; + } + if (params.query === "network") { + return { + kind: "network-failure", + reason: "timeout", + retryAfterSeconds: 30, + message: "The catalogue timed out.", + } satisfies CatalogSearchResult; + } + if (params.query.startsWith("invalid:")) { + return invalidWireResult(params.query.slice("invalid:".length)); + } + return successWireResult(); + }); + client.listen(); + server.listen(); + + const rpc = createServerRpc(client); + const request = { + contract: { protocolVersion: 1, requiredCapabilities: [] }, + query: "success", + }; + const success = await rpc.catalog.search(request); + expect(success.kind).toBe("succeeded"); + if (success.kind !== "succeeded") throw new Error("Expected a successful search."); + + expect(success.candidates.map((candidate) => candidate.kind)).toEqual([ + "mcp-server", + "ai-skill", + ]); + expect(success.candidates.map((candidate) => candidate.handle)).toEqual([ + OPAQUE_MCP_HANDLE, + OPAQUE_SKILL_HANDLE, + ]); + const encodedCandidates = JSON.parse(JSON.stringify(success)).candidates as Array< + Record + >; + expect(success).not.toHaveProperty("rawCard"); + for (const candidate of encodedCandidates) { + expect(candidate).not.toHaveProperty("card"); + expect(candidate).not.toHaveProperty("cardData"); + expect(candidate).not.toHaveProperty("rawCard"); + expect(candidate.source).not.toHaveProperty("rawCard"); + } + + await expect( + rpc.catalog.search({ ...request, query: "authentication" }) + ).resolves.toMatchObject({ + kind: "authentication-required", + reason: "no-credential", + }); + await expect(rpc.catalog.search({ ...request, query: "network" })).resolves.toMatchObject({ + kind: "network-failure", + reason: "timeout", + retryAfterSeconds: 30, + }); + + for (const invalid of [ + "unknown-result", + "missing-result", + "unknown-candidate", + "missing-candidate", + "unknown-source", + "missing-source", + ]) { + await expect( + rpc.catalog.search({ ...request, query: `invalid:${invalid}` }) + ).rejects.toThrow(/unknown or missing kind discriminator/); + } + }); +}); diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index ac4cc5441d..52a07e576e 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -16618,47 +16618,6 @@ def to_dict(self) -> dict: result["observedAt"] = from_str(self.observed_at) return result -@dataclass -class CatalogCandidateProvenance: - """Where the catalog reference was observed, without the card itself or any content digest. - - Where and when an MCP server catalog reference was observed. Discovery provenance - deliberately carries no content digest because search does not establish the exact - validated content a later plan will bind. - - Where and when an AI skill catalog reference was observed. Discovery provenance - deliberately carries no content digest because search does not establish the exact - validated content a later plan will bind. - """ - authority: str - """Host of the catalog authority that advertised the reference, without path, query, or - credentials. Inert untrusted data. - """ - media_type: CatalogMediaType - """JSON MCP media type advertised for the referenced card. - - Media type advertised for the referenced AI skill card - """ - observed_at: str - """ISO 8601 timestamp at which the runtime observed the catalog reference. This is not a - retrieval or validation timestamp. - """ - - @staticmethod - def from_dict(obj: Any) -> 'CatalogCandidateProvenance': - assert isinstance(obj, dict) - authority = from_str(obj.get("authority")) - media_type = CatalogMediaType(obj.get("mediaType")) - observed_at = from_str(obj.get("observedAt")) - return CatalogCandidateProvenance(authority, media_type, observed_at) - - def to_dict(self) -> dict: - result: dict = {} - result["authority"] = from_str(self.authority) - result["mediaType"] = to_enum(CatalogMediaType, self.media_type) - result["observedAt"] = from_str(self.observed_at) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CatalogMCPServerCandidateProvenance: @@ -26193,6 +26152,50 @@ def to_dict(self) -> dict: result["validatedAt"] = from_str(self.validated_at) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CatalogSearchSucceeded: + """A completed catalog search: inert candidate summaries, each carrying a single-use handle.""" + + candidates: list[CatalogCandidate] + """Matching candidates, never more than the requested limit. All text is inert untrusted + data. + """ + kind: ClassVar[str] = "succeeded" + """Discriminator: the search completed""" + + negotiated: CatalogNegotiatedContract + """Protocol version and capabilities the runtime honoured.""" + + search_id: str + """Pseudonymous identifier for this search, issued by the runtime or by the catalog + authority it queried and never by the caller, so it cannot be forged or replayed to + attribute an install to a search that never happened. Always present on a success, so a + result set can be tied to the installs it leads to. It identifies a search rather than a + person: it is derived from no user, account, device, or query data, and must never be + joined with user identity to re-identify anyone. + """ + truncated: bool + """Whether further matches existed beyond the requested limit.""" + + @staticmethod + def from_dict(obj: Any) -> 'CatalogSearchSucceeded': + assert isinstance(obj, dict) + candidates = from_list(_load_CatalogCandidate, obj.get("candidates")) + negotiated = CatalogNegotiatedContract.from_dict(obj.get("negotiated")) + search_id = from_str(obj.get("searchId")) + truncated = from_bool(obj.get("truncated")) + return CatalogSearchSucceeded(candidates, negotiated, search_id, truncated) + + def to_dict(self) -> dict: + result: dict = {} + result["candidates"] = from_list(lambda x: (x).to_dict(), self.candidates) + result["kind"] = self.kind + result["negotiated"] = to_class(CatalogNegotiatedContract, self.negotiated) + result["searchId"] = from_str(self.search_id) + result["truncated"] = from_bool(self.truncated) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SlashCommandInfo: @@ -26358,7 +26361,7 @@ class CatalogAISkillCandidate: installability: Installability """AI skills are discovery-only and cannot be installed through this surface""" - kind: CatalogAISkillCandidateKind + kind: ClassVar[str] = "ai-skill" """Discriminator: this candidate describes an AI skill""" media_type: MediaType @@ -26384,13 +26387,12 @@ def from_dict(obj: Any) -> 'CatalogAISkillCandidate': handle = from_str(obj.get("handle")) handle_expires_at = from_str(obj.get("handleExpiresAt")) installability = Installability(obj.get("installability")) - kind = CatalogAISkillCandidateKind(obj.get("kind")) media_type = MediaType(obj.get("mediaType")) provenance = CatalogAISkillCandidateProvenance.from_dict(obj.get("provenance")) source = _load_CatalogCandidateSource(obj.get("source")) description = from_union([from_str, from_none], obj.get("description")) publisher = from_union([from_str, from_none], obj.get("publisher")) - return CatalogAISkillCandidate(display_name, handle, handle_expires_at, installability, kind, media_type, provenance, source, description, publisher) + return CatalogAISkillCandidate(display_name, handle, handle_expires_at, installability, media_type, provenance, source, description, publisher) def to_dict(self) -> dict: result: dict = {} @@ -26398,7 +26400,7 @@ def to_dict(self) -> dict: result["handle"] = from_str(self.handle) result["handleExpiresAt"] = from_str(self.handle_expires_at) result["installability"] = to_enum(Installability, self.installability) - result["kind"] = to_enum(CatalogAISkillCandidateKind, self.kind) + result["kind"] = self.kind result["mediaType"] = to_enum(MediaType, self.media_type) result["provenance"] = to_class(CatalogAISkillCandidateProvenance, self.provenance) result["source"] = (self.source).to_dict() @@ -26408,89 +26410,6 @@ def to_dict(self) -> dict: result["publisher"] = from_union([from_str, from_none], self.publisher) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class CatalogCandidate: - """One inert catalog result, represented as an MCP server or discovery-only AI skill variant - so kind, media type, provenance, and installability cannot contradict each other. - - An inert MCP server catalog result. Every free-text field is untrusted external data and - must never be treated as an instruction, and the handle is the only way to refer to the - candidate in a later operation. - - An inert AI skill catalog result. AI skills are discovery-only and cannot be represented - as installable through this surface. - """ - display_name: str - """Display name taken verbatim from the card. Inert untrusted text.""" - - handle: str - """Opaque, runtime-instance scoped, TTL-bound, single-use handle for this candidate. Carries - no readable information and is rejected when stale, replayed, or presented to a different - runtime instance. Never logged. - """ - handle_expires_at: str - """ISO 8601 timestamp after which the handle is stale and will be rejected.""" - - installability: CatalogCandidateInstallability - """Whether this MCP server can be planned for installation, and if policy prevents it. - - AI skills are discovery-only and cannot be installed through this surface - """ - kind: CatalogCandidateKind - """Discriminator: this candidate describes an MCP server - - Discriminator: this candidate describes an AI skill - """ - media_type: CatalogMediaType - """JSON MCP media type of the underlying card. - - Media type of the underlying AI skill card - """ - provenance: CatalogCandidateProvenance - """Where the catalog reference was observed, without the card itself or any content digest.""" - - source: CatalogCandidateSource - """Where the card came from: exactly one of a URL or embedded data, encoded as a tagged - union so neither both nor neither can be represented. - """ - description: str | None = None - """Description taken verbatim from the card. Inert untrusted text.""" - - publisher: str | None = None - """Publisher taken verbatim from the card. Inert untrusted text.""" - - @staticmethod - def from_dict(obj: Any) -> 'CatalogCandidate': - assert isinstance(obj, dict) - display_name = from_str(obj.get("displayName")) - handle = from_str(obj.get("handle")) - handle_expires_at = from_str(obj.get("handleExpiresAt")) - installability = CatalogCandidateInstallability(obj.get("installability")) - kind = CatalogCandidateKind(obj.get("kind")) - media_type = CatalogMediaType(obj.get("mediaType")) - provenance = CatalogCandidateProvenance.from_dict(obj.get("provenance")) - source = _load_CatalogCandidateSource(obj.get("source")) - description = from_union([from_str, from_none], obj.get("description")) - publisher = from_union([from_str, from_none], obj.get("publisher")) - return CatalogCandidate(display_name, handle, handle_expires_at, installability, kind, media_type, provenance, source, description, publisher) - - def to_dict(self) -> dict: - result: dict = {} - result["displayName"] = from_str(self.display_name) - result["handle"] = from_str(self.handle) - result["handleExpiresAt"] = from_str(self.handle_expires_at) - result["installability"] = to_enum(CatalogCandidateInstallability, self.installability) - result["kind"] = to_enum(CatalogCandidateKind, self.kind) - result["mediaType"] = to_enum(CatalogMediaType, self.media_type) - result["provenance"] = to_class(CatalogCandidateProvenance, self.provenance) - result["source"] = (self.source).to_dict() - if self.description is not None: - result["description"] = from_union([from_str, from_none], self.description) - if self.publisher is not None: - result["publisher"] = from_union([from_str, from_none], self.publisher) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CatalogMCPServerCandidate: @@ -26512,7 +26431,7 @@ class CatalogMCPServerCandidate: installability: CatalogMCPServerInstallabilityEnum """Whether this MCP server can be planned for installation, and if policy prevents it.""" - kind: CatalogMCPServerCandidateKind + kind: ClassVar[str] = "mcp-server" """Discriminator: this candidate describes an MCP server""" media_type: MCPServerCardMediaType @@ -26538,13 +26457,12 @@ def from_dict(obj: Any) -> 'CatalogMCPServerCandidate': handle = from_str(obj.get("handle")) handle_expires_at = from_str(obj.get("handleExpiresAt")) installability = CatalogMCPServerInstallabilityEnum(obj.get("installability")) - kind = CatalogMCPServerCandidateKind(obj.get("kind")) media_type = MCPServerCardMediaType(obj.get("mediaType")) provenance = CatalogMCPServerCandidateProvenance.from_dict(obj.get("provenance")) source = _load_CatalogCandidateSource(obj.get("source")) description = from_union([from_str, from_none], obj.get("description")) publisher = from_union([from_str, from_none], obj.get("publisher")) - return CatalogMCPServerCandidate(display_name, handle, handle_expires_at, installability, kind, media_type, provenance, source, description, publisher) + return CatalogMCPServerCandidate(display_name, handle, handle_expires_at, installability, media_type, provenance, source, description, publisher) def to_dict(self) -> dict: result: dict = {} @@ -26552,7 +26470,7 @@ def to_dict(self) -> dict: result["handle"] = from_str(self.handle) result["handleExpiresAt"] = from_str(self.handle_expires_at) result["installability"] = to_enum(CatalogMCPServerInstallabilityEnum, self.installability) - result["kind"] = to_enum(CatalogMCPServerCandidateKind, self.kind) + result["kind"] = self.kind result["mediaType"] = to_enum(MCPServerCardMediaType, self.media_type) result["provenance"] = to_class(CatalogMCPServerCandidateProvenance, self.provenance) result["source"] = (self.source).to_dict() @@ -30795,50 +30713,6 @@ def to_dict(self) -> dict: result["session"] = from_union([lambda x: to_class(CanvasSessionContext, x), from_none], self.session) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class CatalogSearchSucceeded: - """A completed catalog search: inert candidate summaries, each carrying a single-use handle.""" - - candidates: list[CatalogCandidate] - """Matching candidates, never more than the requested limit. All text is inert untrusted - data. - """ - kind: ClassVar[str] = "succeeded" - """Discriminator: the search completed""" - - negotiated: CatalogNegotiatedContract - """Protocol version and capabilities the runtime honoured.""" - - search_id: str - """Pseudonymous identifier for this search, issued by the runtime or by the catalog - authority it queried and never by the caller, so it cannot be forged or replayed to - attribute an install to a search that never happened. Always present on a success, so a - result set can be tied to the installs it leads to. It identifies a search rather than a - person: it is derived from no user, account, device, or query data, and must never be - joined with user identity to re-identify anyone. - """ - truncated: bool - """Whether further matches existed beyond the requested limit.""" - - @staticmethod - def from_dict(obj: Any) -> 'CatalogSearchSucceeded': - assert isinstance(obj, dict) - candidates = from_list(CatalogCandidate.from_dict, obj.get("candidates")) - negotiated = CatalogNegotiatedContract.from_dict(obj.get("negotiated")) - search_id = from_str(obj.get("searchId")) - truncated = from_bool(obj.get("truncated")) - return CatalogSearchSucceeded(candidates, negotiated, search_id, truncated) - - def to_dict(self) -> dict: - result: dict = {} - result["candidates"] = from_list(lambda x: to_class(CatalogCandidate, x), self.candidates) - result["kind"] = self.kind - result["negotiated"] = to_class(CatalogNegotiatedContract, self.negotiated) - result["searchId"] = from_str(self.search_id) - result["truncated"] = from_bool(self.truncated) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class HandlePendingToolCallRequest: @@ -37796,7 +37670,7 @@ def from_dict(obj: Any) -> 'RPC': catalog_ai_skill_candidate_provenance = CatalogAISkillCandidateProvenance.from_dict(obj.get("CatalogAiSkillCandidateProvenance")) catalog_authentication_required_error = CatalogAuthenticationRequiredError.from_dict(obj.get("CatalogAuthenticationRequiredError")) catalog_authentication_required_reason = CatalogAuthenticationRequiredReason(obj.get("CatalogAuthenticationRequiredReason")) - catalog_candidate = CatalogCandidate.from_dict(obj.get("CatalogCandidate")) + catalog_candidate = _load_CatalogCandidate(obj.get("CatalogCandidate")) catalog_candidate_kind = CatalogCandidateKind(obj.get("CatalogCandidateKind")) catalog_candidate_source = _load_CatalogCandidateSource(obj.get("CatalogCandidateSource")) catalog_candidate_source_embedded = CatalogCandidateSourceEmbedded.from_dict(obj.get("CatalogCandidateSourceEmbedded")) @@ -39016,7 +38890,7 @@ def to_dict(self) -> dict: result["CatalogAiSkillCandidateProvenance"] = to_class(CatalogAISkillCandidateProvenance, self.catalog_ai_skill_candidate_provenance) result["CatalogAuthenticationRequiredError"] = to_class(CatalogAuthenticationRequiredError, self.catalog_authentication_required_error) result["CatalogAuthenticationRequiredReason"] = to_enum(CatalogAuthenticationRequiredReason, self.catalog_authentication_required_reason) - result["CatalogCandidate"] = to_class(CatalogCandidate, self.catalog_candidate) + result["CatalogCandidate"] = (self.catalog_candidate).to_dict() result["CatalogCandidateKind"] = to_enum(CatalogCandidateKind, self.catalog_candidate_kind) result["CatalogCandidateSource"] = (self.catalog_candidate_source).to_dict() result["CatalogCandidateSourceEmbedded"] = to_class(CatalogCandidateSourceEmbedded, self.catalog_candidate_source_embedded) @@ -40182,6 +40056,17 @@ def _load_AuthInfo(obj: Any) -> "AuthInfo": case "api-key": return APIKeyAuthInfo.from_dict(obj) case _: raise ValueError(f"Unknown AuthInfo type: {kind!r}") +# One inert catalog result, represented as an MCP server or discovery-only AI skill variant so kind, media type, provenance, and installability cannot contradict each other. +CatalogCandidate = CatalogMCPServerCandidate | CatalogAISkillCandidate + +def _load_CatalogCandidate(obj: Any) -> "CatalogCandidate": + assert isinstance(obj, dict) + kind = obj.get("kind") + match kind: + case "mcp-server": return CatalogMCPServerCandidate.from_dict(obj) + case "ai-skill": return CatalogAISkillCandidate.from_dict(obj) + raise ValueError(f"Unknown CatalogCandidate kind: {kind!r}") + # Where a candidate's card came from. Exactly one of a URL or embedded data: the union has no variant carrying both, and no variant carrying neither, so the rule holds structurally rather than by validation. CatalogCandidateSource = CatalogCandidateSourceURL | CatalogCandidateSourceEmbedded @@ -40191,7 +40076,7 @@ def _load_CatalogCandidateSource(obj: Any) -> "CatalogCandidateSource": match kind: case "url": return CatalogCandidateSourceURL.from_dict(obj) case "embedded": return CatalogCandidateSourceEmbedded.from_dict(obj) - case _: raise ValueError(f"Unknown CatalogCandidateSource kind: {kind!r}") + raise ValueError(f"Unknown CatalogCandidateSource kind: {kind!r}") # Outcome of a catalog.search call: either bounded inert candidates, or one typed refusal. Never a partial success. CatalogSearchResult = CatalogSearchSucceeded | CatalogNegotiationRefusedError | CatalogUnsupportedKindError | CatalogInvalidRequestError | CatalogAuthenticationRequiredError | CatalogPolicyRejectedError | CatalogNetworkFailureError | CatalogUnsafeRetrievalError | CatalogMalformedCardError | CatalogContractViolationError | CatalogUnavailableError @@ -40211,7 +40096,7 @@ def _load_CatalogSearchResult(obj: Any) -> "CatalogSearchResult": case "malformed-card": return CatalogMalformedCardError.from_dict(obj) case "contract-violation": return CatalogContractViolationError.from_dict(obj) case "unavailable": return CatalogUnavailableError.from_dict(obj) - case _: raise ValueError(f"Unknown CatalogSearchResult kind: {kind!r}") + raise ValueError(f"Unknown CatalogSearchResult kind: {kind!r}") # A content block within a tool result, which may be text, terminal output, image, audio, or a resource ExternalToolTextResultForLlmContent = ExternalToolTextResultForLlmContentText | ExternalToolTextResultForLlmContentTerminal | ExternalToolTextResultForLlmContentShellExit | ExternalToolTextResultForLlmContentImage | ExternalToolTextResultForLlmContentAudio | ExternalToolTextResultForLlmContentResourceLink | ExternalToolTextResultForLlmContentResource @@ -43548,7 +43433,6 @@ async def handle_git_hub_token_get_token(params: dict) -> dict | None: "CatalogCandidate", "CatalogCandidateInstallability", "CatalogCandidateKind", - "CatalogCandidateProvenance", "CatalogCandidateSource", "CatalogCandidateSourceEmbedded", "CatalogCandidateSourceKind", diff --git a/python/test_rpc_generated.py b/python/test_rpc_generated.py index a23173727a..dc350e1204 100644 --- a/python/test_rpc_generated.py +++ b/python/test_rpc_generated.py @@ -1,12 +1,22 @@ """Tests for generated RPC method behavior.""" import json +from typing import Any from unittest.mock import AsyncMock import pytest from copilot.rpc import ( BuiltinToolInputSchemaType, + CatalogAISkillCandidate, + CatalogAuthenticationRequiredError, + CatalogCandidateSourceEmbedded, + CatalogCandidateSourceURL, + CatalogClientContract, + CatalogMCPServerCandidate, + CatalogNetworkFailureError, + CatalogSearchRequest, + CatalogSearchSucceeded, CommandsApi, CommandsInvokeRequest, CommandsRespondToQueuedCommandRequest, @@ -16,12 +26,16 @@ RemoteControlStatusOff, RemoteControlStatusResult, RemoteSessionMetadataValue, + ServerCatalogApi, SessionList, SlashCommandTextResult, TaskAgentInfo, UIElicitationSchemaType, ) +OPAQUE_MCP_HANDLE = "opaque:mcp/01-do-not-parse" +OPAQUE_SKILL_HANDLE = "opaque:skill/02-do-not-parse" + @pytest.mark.asyncio async def test_commands_invoke_deserializes_slash_command_result(): @@ -131,3 +145,202 @@ def test_queued_command_result_serializes_boolean_discriminator( assert request.to_dict()["result"]["handled"] is expected_handled assert isinstance(round_tripped.result, type(variant)) + + +@pytest.mark.asyncio +async def test_closed_union_preserves_typed_nested_variants_and_opaque_handles(): + client = AsyncMock() + client.request = AsyncMock( + return_value={ + "kind": "succeeded", + "rawCard": {"secret": "must-not-survive"}, + "searchId": "search-01", + "candidates": [ + { + "kind": "mcp-server", + "handle": OPAQUE_MCP_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "rawCard": {"secret": "must-not-survive"}, + "source": { + "kind": "url", + "url": "https://catalog.example/mcp.json", + "rawCard": {"secret": "must-not-survive"}, + }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json", + }, + }, + { + "kind": "ai-skill", + "handle": OPAQUE_SKILL_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/ai-skill", + "installability": "not-installable-kind", + "displayName": "Example skill", + "rawCard": {"secret": "must-not-survive"}, + "source": { + "kind": "embedded", + "rawCard": {"secret": "must-not-survive"}, + }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/ai-skill", + }, + }, + ], + "truncated": False, + "negotiated": { + "runtimeProtocolVersion": 1, + "grantedCapabilities": [ + "mcp-server-card", + "ai-skill-discovery", + ], + }, + } + ) + api = ServerCatalogApi(client) + + result = await api.search( + CatalogSearchRequest( + contract=CatalogClientContract( + protocol_version=1, + required_capabilities=[], + ), + query="example", + ) + ) + + assert isinstance(result, CatalogSearchSucceeded) + mcp, skill = result.candidates + assert isinstance(mcp, CatalogMCPServerCandidate) + assert isinstance(skill, CatalogAISkillCandidate) + assert mcp.handle == OPAQUE_MCP_HANDLE + assert skill.handle == OPAQUE_SKILL_HANDLE + assert isinstance(mcp.source, CatalogCandidateSourceURL) + assert isinstance(skill.source, CatalogCandidateSourceEmbedded) + encoded = result.to_dict() + assert "rawCard" not in encoded + for candidate in encoded["candidates"]: + assert {"card", "cardData", "rawCard"}.isdisjoint(candidate) + assert "rawCard" not in candidate["source"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "expected_type"), + [ + ( + { + "kind": "authentication-required", + "reason": "no-credential", + "message": "Sign in is required.", + }, + CatalogAuthenticationRequiredError, + ), + ( + { + "kind": "network-failure", + "reason": "timeout", + "retryAfterSeconds": 30, + "message": "The catalogue timed out.", + }, + CatalogNetworkFailureError, + ), + ], +) +async def test_closed_union_preserves_refusals_and_failures(payload, expected_type): + client = AsyncMock() + client.request = AsyncMock(return_value=payload) + api = ServerCatalogApi(client) + + result = await api.search( + CatalogSearchRequest( + contract=CatalogClientContract( + protocol_version=1, + required_capabilities=[], + ), + query="example", + ) + ) + + assert isinstance(result, expected_type) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "case", + [ + "unknown-result", + "missing-result", + "unknown-candidate", + "missing-candidate", + "unknown-source", + "missing-source", + ], +) +async def test_closed_union_rejects_unknown_and_missing_discriminators(case): + candidate: dict[str, Any] = { + "kind": "mcp-server", + "handle": OPAQUE_MCP_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "source": { + "kind": "url", + "url": "https://catalog.example/mcp.json", + }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json", + }, + } + payload: dict[str, Any] = { + "kind": "succeeded", + "searchId": "search-invalid", + "candidates": [candidate], + "truncated": False, + "negotiated": { + "runtimeProtocolVersion": 1, + "grantedCapabilities": [], + }, + } + if case == "unknown-result": + payload = {"kind": "future-result", "rawCard": {"secret": "must-not-survive"}} + elif case == "missing-result": + payload = {"rawCard": {"secret": "must-not-survive"}} + elif case == "unknown-candidate": + candidate["kind"] = "future-candidate" + candidate["rawCard"] = {"secret": "must-not-survive"} + elif case == "missing-candidate": + del candidate["kind"] + candidate["rawCard"] = {"secret": "must-not-survive"} + elif case == "unknown-source": + candidate["source"] = { + "kind": "future-source", + "rawCard": {"secret": "must-not-survive"}, + } + elif case == "missing-source": + candidate["source"] = {"rawCard": {"secret": "must-not-survive"}} + + client = AsyncMock() + client.request = AsyncMock(return_value=payload) + api = ServerCatalogApi(client) + + with pytest.raises(ValueError, match="Unknown .* kind"): + await api.search( + CatalogSearchRequest( + contract=CatalogClientContract( + protocol_version=1, + required_capabilities=[], + ), + query="example", + ) + ) diff --git a/rust/tests/discriminated_union_conformance_test.rs b/rust/tests/discriminated_union_conformance_test.rs new file mode 100644 index 0000000000..1d05f9fb64 --- /dev/null +++ b/rust/tests/discriminated_union_conformance_test.rs @@ -0,0 +1,179 @@ +#![allow(clippy::unwrap_used)] + +use github_copilot_sdk::rpc::{CatalogCandidate, CatalogCandidateSource, CatalogSearchResult}; + +const OPAQUE_MCP_HANDLE: &str = "opaque:mcp/01-do-not-parse"; +const OPAQUE_SKILL_HANDLE: &str = "opaque:skill/02-do-not-parse"; + +#[test] +fn closed_union_preserves_known_nested_variants() { + let result: CatalogSearchResult = serde_json::from_value(serde_json::json!({ + "kind": "succeeded", + "rawCard": {"secret": "must-not-survive"}, + "searchId": "search-01", + "candidates": [ + { + "kind": "mcp-server", + "handle": OPAQUE_MCP_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "rawCard": {"secret": "must-not-survive"}, + "source": { + "kind": "url", + "url": "https://catalog.example/mcp.json", + "rawCard": {"secret": "must-not-survive"} + }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json" + } + }, + { + "kind": "ai-skill", + "handle": OPAQUE_SKILL_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/ai-skill", + "installability": "not-installable-kind", + "displayName": "Example skill", + "rawCard": {"secret": "must-not-survive"}, + "source": { + "kind": "embedded", + "rawCard": {"secret": "must-not-survive"} + }, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/ai-skill" + } + } + ], + "truncated": false, + "negotiated": { + "runtimeProtocolVersion": 1, + "grantedCapabilities": ["mcp-server-card", "ai-skill-discovery"] + } + })) + .unwrap(); + + let CatalogSearchResult::Succeeded(success) = &result else { + panic!("expected a successful catalogue search"); + }; + let CatalogCandidate::McpServer(mcp) = &success.candidates[0] else { + panic!("expected an MCP server candidate"); + }; + let CatalogCandidate::AiSkill(skill) = &success.candidates[1] else { + panic!("expected an AI skill candidate"); + }; + assert_eq!(mcp.handle, OPAQUE_MCP_HANDLE); + assert_eq!(skill.handle, OPAQUE_SKILL_HANDLE); + assert!(matches!(mcp.source, CatalogCandidateSource::Url(_))); + assert!(matches!(skill.source, CatalogCandidateSource::Embedded(_))); + + let wire = serde_json::to_value(&result).unwrap(); + assert!(wire.get("rawCard").is_none()); + for candidate in wire["candidates"].as_array().unwrap() { + let fields = candidate.as_object().unwrap(); + for forbidden in ["card", "cardData", "rawCard"] { + assert!( + !fields.contains_key(forbidden), + "candidate leaked {forbidden}" + ); + } + assert!(candidate["source"].get("rawCard").is_none()); + } +} + +#[test] +fn closed_union_preserves_refusals_and_failures() { + let authentication: CatalogSearchResult = serde_json::from_value(serde_json::json!({ + "kind": "authentication-required", + "reason": "no-credential", + "message": "Sign in is required." + })) + .unwrap(); + assert!(matches!( + authentication, + CatalogSearchResult::AuthenticationRequired(_) + )); + + let network: CatalogSearchResult = serde_json::from_value(serde_json::json!({ + "kind": "network-failure", + "reason": "timeout", + "retryAfterSeconds": 30, + "message": "The catalogue timed out." + })) + .unwrap(); + let CatalogSearchResult::NetworkFailure(failure) = network else { + panic!("expected a network failure"); + }; + assert_eq!(failure.retry_after_seconds, Some(30)); +} + +#[test] +fn closed_union_rejects_unknown_and_missing_discriminators() { + let invalid_payloads = [ + serde_json::json!({"kind": "future-result", "rawCard": {"secret": "must-not-survive"}}), + serde_json::json!({"rawCard": {"secret": "must-not-survive"}}), + serde_json::json!({ + "kind": "succeeded", + "searchId": "search-invalid", + "candidates": [{"kind": "future-candidate", "rawCard": {"secret": "must-not-survive"}}], + "truncated": false, + "negotiated": {"runtimeProtocolVersion": 1, "grantedCapabilities": []} + }), + serde_json::json!({ + "kind": "succeeded", + "searchId": "search-invalid", + "candidates": [{"rawCard": {"secret": "must-not-survive"}}], + "truncated": false, + "negotiated": {"runtimeProtocolVersion": 1, "grantedCapabilities": []} + }), + serde_json::json!({ + "kind": "succeeded", + "searchId": "search-invalid", + "candidates": [{ + "kind": "mcp-server", + "handle": OPAQUE_MCP_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "source": {"kind": "future-source", "rawCard": {"secret": "must-not-survive"}}, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json" + } + }], + "truncated": false, + "negotiated": {"runtimeProtocolVersion": 1, "grantedCapabilities": []} + }), + serde_json::json!({ + "kind": "succeeded", + "searchId": "search-invalid", + "candidates": [{ + "kind": "mcp-server", + "handle": OPAQUE_MCP_HANDLE, + "handleExpiresAt": "2026-09-02T12:00:00Z", + "mediaType": "application/mcp-server-card+json", + "installability": "installable", + "displayName": "Example MCP", + "source": {"rawCard": {"secret": "must-not-survive"}}, + "provenance": { + "authority": "catalog.example", + "observedAt": "2026-09-02T11:00:00Z", + "mediaType": "application/mcp-server-card+json" + } + }], + "truncated": false, + "negotiated": {"runtimeProtocolVersion": 1, "grantedCapabilities": []} + }), + ]; + + for payload in invalid_payloads { + assert!(serde_json::from_value::(payload).is_err()); + } +} diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 00afec6000..76f90306f1 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -12,6 +12,11 @@ import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; import type { JSONSchema7 } from "json-schema"; +import { + analyseDiscriminatedUnionVariants, + analyseNestedClosedUnionResult, + type UnknownVariantPolicy, +} from "./schema-unions.js"; import { cloneSchemaForCodegen, fixNullableRequiredRefsInApiSchema, @@ -796,6 +801,7 @@ type PropertyTypeResolver = ( interface DiscriminatedUnionGenerationOptions { sealLeafTypes?: boolean; + unknownVariantPolicy?: UnknownVariantPolicy; } function isBooleanDiscriminator(discriminatorInfo: DiscriminatorInfo): boolean { @@ -916,9 +922,16 @@ function generatePolymorphicClasses( lines.push(...xmlDocCommentWithFallback(description, `Polymorphic base type discriminated by ${escapeXml(discriminatorProperty)}.`, "")); if (experimental) pushExperimentalAttribute(lines); + const unknownDerivedTypeHandling = + options.unknownVariantPolicy === "reject" + ? "FailSerialization" + : "FallBackToBaseType"; lines.push(`[JsonPolymorphic(`); lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); - lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); + if (options.unknownVariantPolicy === "reject") { + lines.push(` IgnoreUnrecognizedTypeDiscriminators = false,`); + } + lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.${unknownDerivedTypeHandling})]`); for (const { value } of discriminatorInfo.mapping.values()) { const constValue = String(value); @@ -929,6 +942,9 @@ function generatePolymorphicClasses( lines.push(`public partial class ${renamedBase}`); lines.push(`{`); lines.push(` /// The type discriminator.`); + if (options.unknownVariantPolicy === "reject") { + lines.push(` [JsonRequired]`); + } lines.push(` [JsonPropertyName("${discriminatorProperty}")]`); lines.push(` public virtual string ${toPascalCase(discriminatorProperty)} { get; set; } = string.Empty;`); for (const propName of baseProperties) { @@ -1585,6 +1601,7 @@ let rpcKnownTypes = new Map(); let rpcEnumOutput: string[] = []; let externalRpcValueTypes = new Set(); let rpcRootJsonSerializableTypes = new Set(); +let rpcRejectUnknownUnionTypeNames = new Set(); /** Schema definitions available during RPC generation (for $ref resolution). */ let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; @@ -1719,7 +1736,22 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam } return result; }; - const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, rpcKnownTypes, nestedMap, rpcEnumOutput, schema.description, rpcPropertyResolver, isSchemaExperimental(schema) || experimentalRpcTypes.has(baseClassName)); + const polymorphicCode = generateDiscriminatedUnionClass( + baseClassName, + discriminatorInfo, + variants, + rpcKnownTypes, + nestedMap, + rpcEnumOutput, + schema.description, + rpcPropertyResolver, + isSchemaExperimental(schema) || experimentalRpcTypes.has(baseClassName), + { + unknownVariantPolicy: rpcRejectUnknownUnionTypeNames.has(baseClassName) + ? analyseDiscriminatedUnionVariants(variants)?.unknownVariantPolicy + : undefined, + } + ); classes.push(polymorphicCode); for (const nested of nestedMap.values()) classes.push(nested); } @@ -2625,6 +2657,26 @@ function generateRpcCode( ...collectRpcMethods(schema.clientSession || {}), ...collectRpcMethods(schema.clientGlobal || {}), ]; + const schemaDefinitions = { + ...Object.fromEntries( + Object.entries(rpcDefinitions.$defs ?? {}).filter( + ([, value]) => typeof value === "object" && value !== null + ) + ) as Record, + ...Object.fromEntries( + Object.entries(rpcDefinitions.definitions ?? {}).filter( + ([, value]) => typeof value === "object" && value !== null + ) + ) as Record, + }; + rpcRejectUnknownUnionTypeNames = new Set(); + for (const method of allMethods) { + const analysis = analyseNestedClosedUnionResult(method.result, schemaDefinitions); + if (!analysis) continue; + for (const name of analysis.unionDefinitionNames) { + rpcRejectUnknownUnionTypeNames.add(typeToClassName(name)); + } + } for (const name of collectRpcMethodReferencedDefinitionNames( allMethods.filter((method) => method.stability !== "experimental"), rpcDefinitions diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index f609009320..021e41ad89 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -13,6 +13,11 @@ import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; import wordwrap from "wordwrap"; +import { + analyseDiscriminatedUnion, + analyseNestedClosedUnionResult, + type UnknownVariantPolicy, +} from "./schema-unions.js"; import { addManagedApprovalRequiredToPermissionRequests, cloneSchemaForCodegen, @@ -530,6 +535,7 @@ interface GoDiscriminatorInfo { valueKind: GoDiscriminatorValueKind; mapping: Map; variants: GoDiscriminatedUnionVariant[]; + unknownVariantPolicy: UnknownVariantPolicy; } interface GoRequiredFieldDiscriminatorInfo { @@ -567,6 +573,7 @@ interface GoCodegenCtx { definitions?: DefinitionCollections; wrapComments?: boolean; discriminatedUnionRawVariantSuffix?: string; + rejectUnknownUnionTypeNames?: Set; skipDefinitionTypeNames?: Set; encodingBlocks?: Set; unionVariantMarshalers?: Set; @@ -685,7 +692,13 @@ function findGoDiscriminator( } } if (valid && mapping.size > 0 && unionVariants.length === variants.length) { - return { property: propName, valueKind: firstDiscriminatorValues.kind, mapping, variants: unionVariants }; + return { + property: propName, + valueKind: firstDiscriminatorValues.kind, + mapping, + variants: unionVariants, + unknownVariantPolicy: "preserve", + }; } } return null; @@ -1111,6 +1124,7 @@ function registerGoExternalUnionUnmarshalers( definitions: externalDefinitions, wrapComments: ctx.wrapComments, discriminatedUnionRawVariantSuffix: ctx.discriminatedUnionRawVariantSuffix, + rejectUnknownUnionTypeNames: ctx.rejectUnknownUnionTypeNames, packageName: ctx.packageName, }; @@ -1817,7 +1831,10 @@ function emitGoFlatDiscriminatedUnion( const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); const rawDataName = `Raw${typeName}${ctx.discriminatedUnionRawVariantSuffix ?? "Data"}`; - const hasRawVariant = discriminator.valueKind === "string"; + // Preserve existing wrapper types for source compatibility; closed unions never decode into them. + const emitsRawVariant = discriminator.valueKind === "string"; + const acceptsRawVariant = + emitsRawVariant && discriminator.unknownVariantPolicy === "preserve"; const markerName = toGoUnexportedIdentifier(typeName); ctx.discriminatedUnions.set(typeName, { typeName, unmarshalFuncName }); @@ -1894,25 +1911,25 @@ function emitGoFlatDiscriminatedUnion( unmarshalLines.push(`\t\t\treturn &d, nil`); unmarshalLines.push(`\t\t}`); } - if (hasRawVariant) { + if (acceptsRawVariant) { unmarshalLines.push(`\t\treturn &${rawDataName}{Discriminator: ${rawDiscExpr}, Raw: data}, nil`); } else { unmarshalLines.push(`\t\treturn nil, errors.New("data did not match any union variant for ${typeName}")`); } } } - if (hasRawVariant) { + if (acceptsRawVariant) { unmarshalLines.push(`\tdefault:`); unmarshalLines.push(`\t\treturn &${rawDataName}{Discriminator: ${rawDiscExpr}, Raw: data}, nil`); } unmarshalLines.push(`\t}`); - if (discriminator.valueKind === "boolean") { + if (!acceptsRawVariant) { unmarshalLines.push(`\treturn nil, errors.New("data did not match any union variant for ${typeName}")`); } unmarshalLines.push(`}`); pushGoEncodingBlock(unmarshalLines, ctx); - if (hasRawVariant) { + if (emitsRawVariant) { lines.push(`type ${rawDataName} struct {`); lines.push(`\tDiscriminator ${discGoType}`); lines.push(`\tRaw json.RawMessage`); @@ -2800,6 +2817,12 @@ function planGoUnion(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx, i const description = (schema as JSONSchema7).description; const discriminator = findGoDiscriminator(members, ctx, typeName); if (discriminator) { + if (ctx.rejectUnknownUnionTypeNames?.has(typeName)) { + discriminator.unknownVariantPolicy = + analyseDiscriminatedUnion(schema, (variant) => + resolveGoUnionMember(variant, ctx.definitions) + )?.unknownVariantPolicy ?? "preserve"; + } return { kind: "discriminated", typeName, schema, description, discriminator }; } @@ -3090,7 +3113,11 @@ function goGeneratedEncodingFileCode(schemaFileName: string, packageName: string return wrapComments ? wrapGeneratedGoComments(code) : code; } -function generateGoRpcTypeCode(definitions: Record, definitionCollections: DefinitionCollections): GoGeneratedTypeCode { +function generateGoRpcTypeCode( + definitions: Record, + definitionCollections: DefinitionCollections, + rejectUnknownUnionTypeNames: Set +): GoGeneratedTypeCode { const ctx: GoCodegenCtx = { structs: [], encoding: [], @@ -3099,6 +3126,7 @@ function generateGoRpcTypeCode(definitions: Record, definit discriminatedUnions: new Map(), generatedNames: new Set(), definitions: definitionCollections, + rejectUnknownUnionTypeNames, packageName: "rpc", }; ctx.skipDefinitionTypeNames = collectGoDiscriminatedUnionVariantDefinitionTypeNames(definitions, ctx); @@ -3829,6 +3857,14 @@ async function generateRpc(schemaPath?: string): Promise { Object.entries(rpcDefinitions.definitions ?? {}).filter(([, value]) => typeof value === "object" && value !== null) ) as Record, }; + const rejectUnknownUnionTypeNames = new Set(); + for (const method of allMethods) { + const analysis = analyseNestedClosedUnionResult(method.result, allDefinitions); + if (!analysis) continue; + for (const name of analysis.unionDefinitionNames) { + rejectUnknownUnionTypeNames.add(goDefinitionName(name)); + } + } for (const method of allMethods) { const resultSchema = getMethodResultSchema(method); @@ -3885,7 +3921,11 @@ async function generateRpc(schemaPath?: string): Promise { rpcDefinitions = allDefinitionCollections; // Strip trailing whitespace from generated output (gofmt requirement) - const generatedRpcCode = generateGoRpcTypeCode(allDefinitions, allDefinitionCollections); + const generatedRpcCode = generateGoRpcTypeCode( + allDefinitions, + allDefinitionCollections, + rejectUnknownUnionTypeNames + ); let generatedTypeCode = stripTrailingGoWhitespace(generatedRpcCode.typeCode); const generatedEncodingCode = stripTrailingGoWhitespace(generatedRpcCode.encodingCode); diff --git a/scripts/codegen/package.json b/scripts/codegen/package.json index 8e65352916..fb47410338 100644 --- a/scripts/codegen/package.json +++ b/scripts/codegen/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tsx typescript.ts && tsx csharp.ts && tsx python.ts && tsx go.ts && tsx rust.ts", + "conformance": "tsx schema-conformance.ts", + "generate": "npm run conformance && tsx typescript.ts && tsx csharp.ts && tsx python.ts && tsx go.ts && tsx rust.ts", "generate:ts": "tsx typescript.ts", "generate:csharp": "tsx csharp.ts", "generate:python": "tsx python.ts", diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index b3bfcf8bc9..4357791b7a 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -10,6 +10,7 @@ import fs from "fs/promises"; import path from "path"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { fileURLToPath } from "url"; +import { analyseNestedClosedUnionResult } from "./schema-unions.js"; import { addManagedApprovalRequiredToPermissionRequests, cloneSchemaForCodegen, @@ -353,7 +354,8 @@ interface ResolvedRefBasedUnion { function postProcessRefBasedDiscriminatedUnionsForPython( code: string, definitions: Record, - definitionCollections: DefinitionCollections + definitionCollections: DefinitionCollections, + explicitFailureUnionNames: ReadonlySet ): { code: string; unions: ResolvedRefBasedUnion[] } { interface UnionInfo { aliasName: string; @@ -413,6 +415,7 @@ function postProcessRefBasedDiscriminatedUnionsForPython( const acronymCandidates = (name: string): string[] => { const substitutions: Array<[RegExp, string]> = [ [/Api/g, "API"], + [/Ai/g, "AI"], [/Mcp/g, "MCP"], [/Url/g, "URL"], [/Json/g, "JSON"], @@ -507,9 +510,15 @@ function postProcessRefBasedDiscriminatedUnionsForPython( for (const m of actualDispatch) { dispatcherLines.push(` case ${pyDiscriminatorValueExpr(m.value)}: return ${m.typeName}.from_dict(obj)`); } - dispatcherLines.push( - ` case _: raise ValueError(f"Unknown ${actualAliasName} ${union.discriminatorProp}: {kind!r}")` - ); + if (explicitFailureUnionNames.has(union.aliasName)) { + dispatcherLines.push( + ` raise ValueError(f"Unknown ${actualAliasName} ${union.discriminatorProp}: {kind!r}")` + ); + } else { + dispatcherLines.push( + ` case _: raise ValueError(f"Unknown ${actualAliasName} ${union.discriminatorProp}: {kind!r}")` + ); + } code = `${code.trimEnd()}\n\n\n${aliasLine}\n\n\n${dispatcherLines.join("\n")}\n`; } @@ -3057,6 +3066,14 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema } const allDefinitions = combinedSchema.definitions! as Record; + const explicitFailureUnionNames = new Set(); + for (const method of allMethods) { + const analysis = analyseNestedClosedUnionResult(method.result, allDefinitions); + if (!analysis) continue; + for (const name of analysis.unionDefinitionNames) { + explicitFailureUnionNames.add(name); + } + } preservePythonRpcStringDateFields(allDefinitions); const allDefinitionCollections: DefinitionCollections = { definitions: { ...(combinedSchema.$defs ?? {}), ...allDefinitions }, @@ -3137,7 +3154,8 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema const { code: typesCodeAfterUnions, unions: refBasedUnions } = postProcessRefBasedDiscriminatedUnionsForPython( typesCode, allDefinitions, - allDefinitionCollections + allDefinitionCollections, + explicitFailureUnionNames ); typesCode = typesCodeAfterUnions; typesCode = modernizePython(typesCode); diff --git a/scripts/codegen/schema-conformance.ts b/scripts/codegen/schema-conformance.ts new file mode 100644 index 0000000000..e2d9e3b6be --- /dev/null +++ b/scripts/codegen/schema-conformance.ts @@ -0,0 +1,178 @@ +import fs from "fs/promises"; +import type { JSONSchema7 } from "json-schema"; +import path from "path"; + +import { COPILOT_CLI_VERSION } from "../../nodejs/src/cliVersion.js"; +import { + analyseDiscriminatedUnion, + analyseNestedClosedUnionResult, +} from "./schema-unions.js"; +import { getApiSchemaPath, REPO_ROOT } from "./utils.js"; + +const FORBIDDEN_CANDIDATE_FIELDS = ["card", "cardData", "rawCard"]; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`Schema conformance failed: ${message}`); +} + +function referencedDefinitionNames(schema: JSONSchema7): string[] { + return (((schema.anyOf ?? schema.oneOf) as JSONSchema7[]) ?? []).map( + (variant) => variant.$ref?.split("/").at(-1) ?? "", + ); +} + +function collectRpcMethods( + node: unknown, +): Array<{ rpcMethod: string; result?: JSONSchema7 }> { + if (!node || typeof node !== "object") return []; + if ( + "rpcMethod" in node && + typeof (node as { rpcMethod?: unknown }).rpcMethod === "string" + ) { + return [node as { rpcMethod: string; result?: JSONSchema7 }]; + } + return Object.values(node).flatMap(collectRpcMethods); +} + +const schemaPath = await getApiSchemaPath(); +const packageRoot = path.dirname(path.dirname(schemaPath)); +const javaCodegenPackageJson = JSON.parse( + await fs.readFile( + path.join(REPO_ROOT, "java/scripts/codegen/package.json"), + "utf8", + ), +) as { dependencies?: Record }; +const javaCodegenPackageVersion = + javaCodegenPackageJson.dependencies?.["@github/copilot"]; +const packageJson = JSON.parse( + await fs.readFile(path.join(packageRoot, "package.json"), "utf8"), +) as { version?: string }; +const schema = JSON.parse(await fs.readFile(schemaPath, "utf8")) as { + definitions: Record; + server?: { + catalog?: { + search?: { + rpcMethod?: string; + params?: { $ref?: string }; + result?: { $ref?: string }; + }; + }; + }; +}; + +assert( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(COPILOT_CLI_VERSION), + "nodejs/src/cliVersion.ts must pin an exact Copilot CLI version", +); +assert( + packageJson.version === COPILOT_CLI_VERSION, + `expected Copilot CLI ${COPILOT_CLI_VERSION}, received ${packageJson.version ?? "unknown"}`, +); +assert( + javaCodegenPackageVersion === COPILOT_CLI_VERSION, + `java/scripts/codegen must pin @github/copilot exactly to ${COPILOT_CLI_VERSION}`, +); +assert( + schema.server?.catalog?.search?.rpcMethod === "catalog.search", + "catalog.search is missing", +); +assert( + schema.server.catalog.search.params?.$ref === + "#/definitions/CatalogSearchRequest", + "catalog.search request is not typed", +); +assert( + schema.server.catalog.search.result?.$ref === + "#/definitions/CatalogSearchResult", + "catalog.search result is not typed", +); + +const candidateVariants = referencedDefinitionNames( + schema.definitions.CatalogCandidate, +); +assert( + candidateVariants.join(",") === + "CatalogMcpServerCandidate,CatalogAiSkillCandidate", + `unexpected candidate variants: ${candidateVariants.join(", ")}`, +); +const sourceVariants = referencedDefinitionNames( + schema.definitions.CatalogCandidateSource, +); +assert( + sourceVariants.join(",") === + "CatalogCandidateSourceUrl,CatalogCandidateSourceEmbedded", + `unexpected candidate source variants: ${sourceVariants.join(", ")}`, +); + +const resolveVariant = (variant: JSONSchema7): JSONSchema7 | undefined => { + const name = variant.$ref?.match(/^#\/definitions\/([^/]+)$/)?.[1]; + return name ? schema.definitions[name] : variant; +}; +for (const name of [ + "CatalogCandidate", + "CatalogCandidateSource", + "CatalogSearchResult", +]) { + const analysis = analyseDiscriminatedUnion( + schema.definitions[name], + resolveVariant, + ); + assert(analysis !== undefined, `${name} must remain a discriminated union`); + assert( + analysis.unknownVariantPolicy === "reject", + `${name} variants must remain closed to unknown payload fields`, + ); +} + +const selectedNestedUnionMethods = collectRpcMethods(schema) + .map((method) => ({ + method, + analysis: analyseNestedClosedUnionResult( + method.result, + schema.definitions, + ), + })) + .filter((entry) => entry.analysis !== undefined); +assert( + selectedNestedUnionMethods.map(({ method }) => method.rpcMethod).join(",") === + "catalog.search", + `unexpected nested-union result methods: ${selectedNestedUnionMethods + .map(({ method }) => method.rpcMethod) + .join(", ")}`, +); +assert( + [...selectedNestedUnionMethods[0].analysis!.unionDefinitionNames] + .sort() + .join(",") === + "CatalogCandidate,CatalogCandidateSource,CatalogSearchResult", + "nested-union policy graph must remain limited to the proven catalogue result unions", +); + +for (const name of candidateVariants) { + const properties = schema.definitions[name]?.properties; + assert( + properties?.handle?.type === "string", + `${name}.handle must remain an opaque string`, + ); + for (const field of FORBIDDEN_CANDIDATE_FIELDS) { + assert( + !(field in properties), + `${name} exposes forbidden raw card field ${field}`, + ); + } +} + +const resultVariants = new Set( + referencedDefinitionNames(schema.definitions.CatalogSearchResult), +); +for (const name of [ + "CatalogSearchSucceeded", + "CatalogAuthenticationRequiredError", + "CatalogNetworkFailureError", + "CatalogContractViolationError", + "CatalogUnavailableError", +]) { + assert(resultVariants.has(name), `CatalogSearchResult is missing ${name}`); +} + +console.log(`Schema conformance: Copilot CLI ${packageJson.version}`); diff --git a/scripts/codegen/schema-unions.ts b/scripts/codegen/schema-unions.ts new file mode 100644 index 0000000000..142d1f09e8 --- /dev/null +++ b/scripts/codegen/schema-unions.ts @@ -0,0 +1,249 @@ +import type { JSONSchema7 } from "json-schema"; + +export type SchemaDiscriminatorValue = string | number | boolean | null; +export type UnknownVariantPolicy = "preserve" | "reject"; + +export interface SchemaDiscriminatedUnionVariant { + source: JSONSchema7; + schema: JSONSchema7; + discriminatorValues: SchemaDiscriminatorValue[]; +} + +export interface SchemaDiscriminatorMapping { + value: SchemaDiscriminatorValue; + variants: SchemaDiscriminatedUnionVariant[]; +} + +export interface SchemaDiscriminatedUnion { + property: string; + variants: SchemaDiscriminatedUnionVariant[]; + mapping: SchemaDiscriminatorMapping[]; + unknownVariantPolicy: UnknownVariantPolicy; +} + +export interface NestedClosedUnionResult { + rootDefinitionName?: string; + unionDefinitionNames: Set; +} + +export type SchemaVariantResolver = ( + schema: JSONSchema7, +) => JSONSchema7 | undefined; + +function isDiscriminatorValue( + value: unknown, +): value is SchemaDiscriminatorValue { + return ( + value === null || ["string", "number", "boolean"].includes(typeof value) + ); +} + +function discriminatorValues( + schema: JSONSchema7, +): SchemaDiscriminatorValue[] | undefined { + if (isDiscriminatorValue(schema.const)) { + return [schema.const]; + } + if ( + Array.isArray(schema.enum) && + schema.enum.length > 0 && + schema.enum.every(isDiscriminatorValue) + ) { + return [...new Set(schema.enum)]; + } + return undefined; +} + +export function schemaDiscriminatorValueKey( + value: SchemaDiscriminatorValue, +): string { + return `${typeof value}:${JSON.stringify(value)}`; +} + +/** + * Derive discriminator and unknown-value handling from JSON Schema alone. + * + * A union is closed when every resolved variant rejects additional properties. + * Language emitters use that policy to reject unknown or missing discriminators + * while retaining their idiomatic generated representation. + */ +export function analyseDiscriminatedUnionVariants( + sources: JSONSchema7[], + resolveVariant: SchemaVariantResolver = (schema) => schema, +): SchemaDiscriminatedUnion | undefined { + if (sources.length < 2) return undefined; + + const resolved = sources.map((source) => resolveVariant(source)); + if (resolved.some((schema) => !schema?.properties)) return undefined; + + const schemas = resolved as JSONSchema7[]; + for (const property of Object.keys(schemas[0].properties ?? {}).sort()) { + const variants: SchemaDiscriminatedUnionVariant[] = []; + const mapping = new Map< + string, + { + value: SchemaDiscriminatorValue; + variants: SchemaDiscriminatedUnionVariant[]; + } + >(); + let valid = true; + + for (let index = 0; index < schemas.length; index++) { + const schema = schemas[index]; + const propertySchema = schema.properties?.[property]; + if ( + !(schema.required ?? []).includes(property) || + !propertySchema || + typeof propertySchema !== "object" + ) { + valid = false; + break; + } + + const values = discriminatorValues(propertySchema as JSONSchema7); + if (!values) { + valid = false; + break; + } + + const variant = { + source: sources[index], + schema, + discriminatorValues: values, + }; + variants.push(variant); + for (const value of values) { + const key = schemaDiscriminatorValueKey(value); + const entry = mapping.get(key) ?? { value, variants: [] }; + entry.variants.push(variant); + mapping.set(key, entry); + } + } + + if (valid && variants.length === schemas.length && mapping.size > 0) { + return { + property, + variants, + mapping: [...mapping.values()], + unknownVariantPolicy: schemas.every( + (schema) => schema.additionalProperties === false, + ) + ? "reject" + : "preserve", + }; + } + } + + return undefined; +} + +export function analyseDiscriminatedUnion( + schema: JSONSchema7, + resolveVariant: SchemaVariantResolver = (variant) => variant, +): SchemaDiscriminatedUnion | undefined { + const variants = schema.anyOf ?? schema.oneOf; + if (!Array.isArray(variants)) return undefined; + return analyseDiscriminatedUnionVariants( + variants as JSONSchema7[], + resolveVariant, + ); +} + +function localDefinitionName(schema: JSONSchema7): string | undefined { + return schema.$ref?.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/)?.[1]; +} + +function resolveLocalSchema( + schema: JSONSchema7, + definitions: Record, +): JSONSchema7 | undefined { + const name = localDefinitionName(schema); + return name ? definitions[name] : schema; +} + +/** + * Select the narrow nested-union shape that requires promoted list elements: + * a closed discriminated result whose variant directly owns an array of another + * closed discriminated union. Nested closed unions below those list elements + * are included in the same policy graph. + */ +export function analyseNestedClosedUnionResult( + root: JSONSchema7 | null | undefined, + definitions: Record, +): NestedClosedUnionResult | undefined { + if (!root) return undefined; + const resolveVariant = (schema: JSONSchema7): JSONSchema7 | undefined => + resolveLocalSchema(schema, definitions); + const resolvedRoot = resolveVariant(root); + if (!resolvedRoot) return undefined; + const rootUnion = analyseDiscriminatedUnion(resolvedRoot, resolveVariant); + if (rootUnion?.unknownVariantPolicy !== "reject") return undefined; + + const nestedArrayItems: JSONSchema7[] = []; + for (const variant of rootUnion.variants) { + for (const property of Object.values(variant.schema.properties ?? {})) { + if (!property || typeof property !== "object") continue; + const resolvedProperty = resolveVariant(property as JSONSchema7); + if ( + resolvedProperty?.type !== "array" || + !resolvedProperty.items || + Array.isArray(resolvedProperty.items) + ) { + continue; + } + const items = resolvedProperty.items as JSONSchema7; + const resolvedItems = resolveVariant(items); + if ( + resolvedItems && + analyseDiscriminatedUnion(resolvedItems, resolveVariant) + ?.unknownVariantPolicy === "reject" + ) { + nestedArrayItems.push(items); + } + } + } + if (nestedArrayItems.length === 0) return undefined; + + const unionDefinitionNames = new Set(); + const rootDefinitionName = localDefinitionName(root); + if (rootDefinitionName) unionDefinitionNames.add(rootDefinitionName); + const visitedDefinitions = new Set(); + const visit = (schema: JSONSchema7): void => { + const definitionName = localDefinitionName(schema); + if (definitionName) { + if (visitedDefinitions.has(definitionName)) return; + visitedDefinitions.add(definitionName); + const definition = definitions[definitionName]; + if (!definition) return; + if ( + analyseDiscriminatedUnion(definition, resolveVariant) + ?.unknownVariantPolicy === "reject" + ) { + unionDefinitionNames.add(definitionName); + } + visit(definition); + return; + } + + for (const property of Object.values(schema.properties ?? {})) { + if (property && typeof property === "object") { + visit(property as JSONSchema7); + } + } + if (schema.items && !Array.isArray(schema.items)) { + visit(schema.items as JSONSchema7); + } + for (const branch of [ + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ...(schema.allOf ?? []), + ]) { + if (branch && typeof branch === "object") { + visit(branch as JSONSchema7); + } + } + }; + for (const items of nestedArrayItems) visit(items); + + return { rootDefinitionName, unionDefinitionNames }; +} diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f5e8acb146..75313755e1 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -11,6 +11,11 @@ import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import path from "path"; import { fileURLToPath } from "url"; +import { + analyseDiscriminatedUnion, + analyseNestedClosedUnionResult, + schemaDiscriminatorValueKey, +} from "./schema-unions.js"; import { getApiSchemaPath, fixNullableRequiredRefsInApiSchema, @@ -606,6 +611,200 @@ async function generateSessionEvents(schemaPath?: string): Promise { // ── RPC Types ─────────────────────────────────────────────────────────────── let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; +let rpcResultProjections = new Map(); +let rpcResultProjectionDefinitions = new Map(); + +export type RpcResultProjection = + | { kind: "ref"; name: string } + | { kind: "array"; items: RpcResultProjection | null } + | { + kind: "object"; + closed: boolean; + properties: Record; + } + | { + kind: "union"; + discriminator: string; + variants: Record; + }; + +interface RpcResultProjectionBuild { + projection: RpcResultProjection | null; + containsClosedUnion: boolean; +} + +export interface RpcResultProjectionBundle { + root: RpcResultProjection; + definitions: Record; +} + +function localDefinitionName(ref: string): string | undefined { + return ref.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/)?.[1]; +} + +function buildRpcResultProjection( + schema: JSONSchema7, + definitions: DefinitionCollections, + projectionDefinitions: Map, + resolvingReferences = new Set() +): RpcResultProjectionBuild { + if (schema.$ref) { + const definitionName = localDefinitionName(schema.$ref); + if (!definitionName) { + return { projection: null, containsClosedUnion: false }; + } + const cached = projectionDefinitions.get(definitionName); + if (cached) { + return cached.projection + ? { + projection: { kind: "ref", name: definitionName }, + containsClosedUnion: cached.containsClosedUnion, + } + : cached; + } + if (resolvingReferences.has(definitionName)) { + return { + projection: { kind: "ref", name: definitionName }, + containsClosedUnion: false, + }; + } + const resolved = resolveSchema(schema, definitions); + if (!resolved) return { projection: null, containsClosedUnion: false }; + const nestedReferences = new Set(resolvingReferences); + nestedReferences.add(definitionName); + const built = buildRpcResultProjection( + resolved, + definitions, + projectionDefinitions, + nestedReferences + ); + projectionDefinitions.set(definitionName, built); + return built.projection + ? { + projection: { kind: "ref", name: definitionName }, + containsClosedUnion: built.containsClosedUnion, + } + : built; + } + + const discriminatedUnion = analyseDiscriminatedUnion( + schema, + (variant) => + resolveObjectSchema(variant, definitions) ?? + resolveSchema(variant, definitions) ?? + variant + ); + if ( + discriminatedUnion?.unknownVariantPolicy === "reject" && + discriminatedUnion.mapping.every((entry) => entry.variants.length === 1) + ) { + const variants: Record = {}; + for (const entry of discriminatedUnion.mapping) { + const variantProjection = buildRpcResultProjection( + entry.variants[0].source, + definitions, + projectionDefinitions, + new Set(resolvingReferences) + ).projection; + if (!variantProjection) { + return { projection: null, containsClosedUnion: false }; + } + variants[schemaDiscriminatorValueKey(entry.value)] = variantProjection; + } + return { + projection: { + kind: "union", + discriminator: discriminatedUnion.property, + variants, + }, + containsClosedUnion: true, + }; + } + + const unionMembers = schema.anyOf ?? schema.oneOf; + if (Array.isArray(unionMembers)) { + const nonNullMembers = (unionMembers as JSONSchema7[]).filter( + (member) => member.type !== "null" + ); + if (nonNullMembers.length === 1) { + return buildRpcResultProjection( + nonNullMembers[0], + definitions, + projectionDefinitions, + resolvingReferences + ); + } + } + + if (schema.type === "array" && schema.items && !Array.isArray(schema.items)) { + const item = buildRpcResultProjection( + schema.items as JSONSchema7, + definitions, + projectionDefinitions, + new Set(resolvingReferences) + ); + return { + projection: + item.projection || item.containsClosedUnion + ? { kind: "array", items: item.projection } + : null, + containsClosedUnion: item.containsClosedUnion, + }; + } + + const objectSchema = resolveObjectSchema(schema, definitions); + if ( + objectSchema && + (objectSchema.type === "object" || objectSchema.properties) && + !objectSchema.anyOf && + !objectSchema.oneOf + ) { + const properties: Record = {}; + let containsClosedUnion = false; + for (const [name, property] of Object.entries(objectSchema.properties ?? {})) { + if (!property || typeof property !== "object") { + properties[name] = null; + continue; + } + const child = buildRpcResultProjection( + property as JSONSchema7, + definitions, + projectionDefinitions, + new Set(resolvingReferences) + ); + properties[name] = child.projection; + containsClosedUnion ||= child.containsClosedUnion; + } + const closed = objectSchema.additionalProperties === false; + return { + projection: + closed || Object.values(properties).some((projection) => projection !== null) + ? { kind: "object", closed, properties } + : null, + containsClosedUnion, + }; + } + + return { projection: null, containsClosedUnion: false }; +} + +export function createRpcResultProjectionBundle( + schema: JSONSchema7 | null | undefined, + definitions: DefinitionCollections +): RpcResultProjectionBundle | undefined { + if (!schema) return undefined; + const projectionDefinitions = new Map(); + const result = buildRpcResultProjection(schema, definitions, projectionDefinitions); + if (!result.containsClosedUnion || !result.projection) return undefined; + return { + root: result.projection, + definitions: Object.fromEntries( + [...projectionDefinitions] + .filter(([, built]) => built.projection) + .map(([name, built]) => [name, built.projection!]) + ), + }; +} function withRootTitle(schema: JSONSchema7, title: string): JSONSchema7 { return { ...schema, title }; @@ -741,6 +940,21 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; // Build a single combined schema with shared definitions and all method types. // This ensures $ref-referenced types are generated exactly once. rpcDefinitions = collectDefinitionCollections(schema as Record); + rpcResultProjections = new Map(); + rpcResultProjectionDefinitions = new Map(); + const schemaDefinitions = { + ...(rpcDefinitions.$defs as Record), + ...(rpcDefinitions.definitions as Record), + }; + for (const method of rpcMethods) { + if (!analyseNestedClosedUnionResult(method.result, schemaDefinitions)) continue; + const projection = createRpcResultProjectionBundle(method.result, rpcDefinitions); + if (!projection) continue; + rpcResultProjections.set(method.rpcMethod, projection.root); + for (const [name, definition] of Object.entries(projection.definitions)) { + rpcResultProjectionDefinitions.set(name, definition); + } + } const combinedSchema = withSharedDefinitions( { $schema: "http://json-schema.org/draft-07/schema#", @@ -884,6 +1098,80 @@ function hasInternalMethods(node: Record): boolean { return false; } + if (rpcResultProjections.size > 0) { + lines.push(`type RpcResultProjection =`); + lines.push(` | { kind: "ref"; name: string }`); + lines.push(` | { kind: "array"; items: RpcResultProjection | null }`); + lines.push( + ` | { kind: "object"; closed: boolean; properties: Record }` + ); + lines.push( + ` | { kind: "union"; discriminator: string; variants: Record };` + ); + lines.push(""); + lines.push( + `const RPC_RESULT_PROJECTIONS: Record = ${JSON.stringify(Object.fromEntries(rpcResultProjections), null, 4)};` + ); + lines.push(""); + lines.push( + `const RPC_RESULT_PROJECTION_DEFINITIONS: Record = ${JSON.stringify(Object.fromEntries(rpcResultProjectionDefinitions), null, 4)};` + ); + lines.push(""); + lines.push( + `function projectRpcResult(value: unknown, projection: RpcResultProjection, path = "$"): unknown {` + ); + lines.push(` if (projection.kind === "ref") {`); + lines.push( + ` const definition = RPC_RESULT_PROJECTION_DEFINITIONS[projection.name];` + ); + lines.push( + ` if (!definition) throw new TypeError(\`Missing RPC result projection for \${projection.name}\`);` + ); + lines.push(` return projectRpcResult(value, definition, path);`); + lines.push(` }`); + lines.push(` if (projection.kind === "array") {`); + lines.push( + ` if (!Array.isArray(value)) throw new TypeError(\`Invalid RPC result at \${path}: expected an array\`);` + ); + lines.push( + ` return projection.items ? value.map((item, index) => projectRpcResult(item, projection.items!, \`\${path}[\${index}]\`)) : value;` + ); + lines.push(` }`); + lines.push( + ` if (value === null || typeof value !== "object" || Array.isArray(value)) {` + ); + lines.push( + ` throw new TypeError(\`Invalid RPC result at \${path}: expected an object\`);` + ); + lines.push(` }`); + lines.push(` const record = value as Record;`); + lines.push(` if (projection.kind === "union") {`); + lines.push(` const discriminator = record[projection.discriminator];`); + lines.push( + ` const key = \`\${typeof discriminator}:\${JSON.stringify(discriminator)}\`;` + ); + lines.push(` const variant = projection.variants[key];`); + lines.push(` if (!variant) {`); + lines.push( + ` throw new TypeError(\`Invalid RPC result at \${path}: unknown or missing \${projection.discriminator} discriminator\`);` + ); + lines.push(` }`); + lines.push(` return projectRpcResult(value, variant, path);`); + lines.push(` }`); + lines.push( + ` const result: Record = projection.closed ? {} : { ...record };` + ); + lines.push(` for (const [name, child] of Object.entries(projection.properties)) {`); + lines.push(` if (!Object.hasOwn(record, name)) continue;`); + lines.push( + ` result[name] = child ? projectRpcResult(record[name], child, \`\${path}.\${name}\`) : record[name];` + ); + lines.push(` }`); + lines.push(` return result;`); + lines.push(`}`); + lines.push(""); + } + if (schema.server) { lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); lines.push(`export function createServerRpc(connection: MessageConnection) {`); @@ -1016,7 +1304,15 @@ function emitGroup( includeExperimental: (value as RpcMethod).stability === "experimental" && !parentExperimental, }); lines.push(`${indent}${key}: async (${sigParams.join(", ")}): Promise<${resultType}> =>`); - lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); + const resultProjection = rpcResultProjections.get(rpcMethod); + if (resultProjection) { + lines.push(`${indent} projectRpcResult(`); + lines.push(`${indent} await connection.sendRequest("${rpcMethod}", ${bodyArg}),`); + lines.push(`${indent} RPC_RESULT_PROJECTIONS[${JSON.stringify(rpcMethod)}],`); + lines.push(`${indent} ) as ${resultType},`); + } else { + lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); + } } else if (typeof value === "object" && value !== null) { const groupExperimental = isNodeFullyExperimental(value as Record); const groupDeprecated = isNodeFullyDeprecated(value as Record);