diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index d8d4cceca..8214dec4e 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5,6 +5,84 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; +/** + * The agent mode. Valid values: "interactive", "plan", "autopilot". + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SessionMode". + */ +export type SessionMode = "interactive" | "plan" | "autopilot"; +/** + * The user's response: accept (submitted), decline (rejected), or cancel (dismissed) + */ +export type UIElicitationResponseAction = "accept" | "decline" | "cancel"; +export type UIElicitationFieldValue = string | number | boolean | string[]; +export type PermissionDecision = + | { + /** + * The permission request was approved + */ + kind: "approved"; + } + | { + /** + * Denied because approval rules explicitly blocked it + */ + kind: "denied-by-rules"; + /** + * Rules that denied the request + */ + rules: unknown[]; + } + | { + /** + * Denied because no approval rule matched and user confirmation was unavailable + */ + kind: "denied-no-approval-rule-and-could-not-request-from-user"; + } + | { + /** + * Denied by the user during an interactive prompt + */ + kind: "denied-interactively-by-user"; + /** + * Optional feedback from the user explaining the denial + */ + feedback?: string; + } + | { + /** + * Denied by the organization's content exclusion policy + */ + kind: "denied-by-content-exclusion-policy"; + /** + * File path that triggered the exclusion + */ + path: string; + /** + * Human-readable explanation of why the path was excluded + */ + message: string; + } + | { + /** + * Denied by a permission request hook registered by an extension or plugin + */ + kind: "denied-by-permission-request-hook"; + /** + * Optional message from the hook explaining the denial + */ + message?: string; + /** + * Whether to interrupt the current agent turn + */ + interrupt?: boolean; + }; +/** + * Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". + */ +export type SessionLogLevel = "info" | "warning" | "error"; + export interface PingResult { /** * Echoed message (or default greeting) @@ -457,13 +535,6 @@ export interface CurrentModel { modelId?: string; } -export interface SessionModelGetCurrentRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface ModelSwitchToResult { /** * Currently active model identifier after the switch @@ -472,10 +543,6 @@ export interface ModelSwitchToResult { } export interface ModelSwitchToRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Model identifier to switch to */ @@ -524,23 +591,7 @@ export interface ModelCapabilitiesOverride { }; } -/** - * The agent mode. Valid values: "interactive", "plan", "autopilot". - */ -export type SessionMode = "interactive" | "plan" | "autopilot"; - -export interface SessionModeGetRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface ModeSetRequest { - /** - * Target session identifier - */ - sessionId: string; mode: SessionMode; } @@ -559,31 +610,13 @@ export interface PlanReadResult { path: string | null; } -export interface SessionPlanReadRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface PlanUpdateRequest { - /** - * Target session identifier - */ - sessionId: string; /** * The new content for the plan file */ content: string; } -export interface SessionPlanDeleteRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface WorkspaceListFilesResult { /** * Relative file paths in the workspace files directory @@ -591,13 +624,6 @@ export interface WorkspaceListFilesResult { files: string[]; } -export interface SessionWorkspaceListFilesRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface WorkspaceReadFileResult { /** * File content as a UTF-8 string @@ -606,10 +632,6 @@ export interface WorkspaceReadFileResult { } export interface WorkspaceReadFileRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Relative path within the workspace files directory */ @@ -617,10 +639,6 @@ export interface WorkspaceReadFileRequest { } export interface WorkspaceCreateFileRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Relative path within the workspace files directory */ @@ -641,10 +659,6 @@ export interface FleetStartResult { /** @experimental */ export interface FleetStartRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Optional user prompt to combine with fleet instructions */ @@ -672,14 +686,6 @@ export interface AgentList { }[]; } -/** @experimental */ -export interface SessionAgentListRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface AgentGetCurrentResult { /** @@ -701,14 +707,6 @@ export interface AgentGetCurrentResult { } | null; } -/** @experimental */ -export interface SessionAgentGetCurrentRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface AgentSelectResult { /** @@ -732,24 +730,12 @@ export interface AgentSelectResult { /** @experimental */ export interface AgentSelectRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Name of the custom agent to select */ name: string; } -/** @experimental */ -export interface SessionAgentDeselectRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface AgentReloadResult { /** @@ -771,14 +757,6 @@ export interface AgentReloadResult { }[]; } -/** @experimental */ -export interface SessionAgentReloadRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface SkillList { /** @@ -812,20 +790,8 @@ export interface SkillList { }[]; } -/** @experimental */ -export interface SessionSkillsListRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface SkillsEnableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Name of the skill to enable */ @@ -834,24 +800,12 @@ export interface SkillsEnableRequest { /** @experimental */ export interface SkillsDisableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Name of the skill to disable */ name: string; } -/** @experimental */ -export interface SessionSkillsReloadRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface McpServerList { /** @@ -877,20 +831,8 @@ export interface McpServerList { }[]; } -/** @experimental */ -export interface SessionMcpListRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface McpEnableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Name of the MCP server to enable */ @@ -899,24 +841,12 @@ export interface McpEnableRequest { /** @experimental */ export interface McpDisableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Name of the MCP server to disable */ serverName: string; } -/** @experimental */ -export interface SessionMcpReloadRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface PluginList { /** @@ -942,14 +872,6 @@ export interface PluginList { }[]; } -/** @experimental */ -export interface SessionPluginsListRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface ExtensionList { /** @@ -979,20 +901,8 @@ export interface ExtensionList { }[]; } -/** @experimental */ -export interface SessionExtensionsListRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface ExtensionsEnableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Source-qualified extension ID to enable */ @@ -1001,24 +911,12 @@ export interface ExtensionsEnableRequest { /** @experimental */ export interface ExtensionsDisableRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Source-qualified extension ID to disable */ id: string; } -/** @experimental */ -export interface SessionExtensionsReloadRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface HandleToolCallResult { /** * Whether the tool call result was handled successfully @@ -1027,10 +925,6 @@ export interface HandleToolCallResult { } export interface ToolsHandlePendingToolCallRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Request ID of the pending tool call */ @@ -1073,10 +967,6 @@ export interface CommandsHandlePendingCommandResult { } export interface CommandsHandlePendingCommandRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Request ID from the command invocation event */ @@ -1086,14 +976,11 @@ export interface CommandsHandlePendingCommandRequest { */ error?: string; } - -/** - * The user's response: accept (submitted), decline (rejected), or cancel (dismissed) - */ -export type UIElicitationResponseAction = "accept" | "decline" | "cancel"; -export type UIElicitationFieldValue = string | number | boolean | string[]; /** * The elicitation response (accept with form values, decline, or cancel) + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "UIElicitationResponse". */ export interface UIElicitationResponse { action: UIElicitationResponseAction; @@ -1107,10 +994,6 @@ export interface UIElicitationResponseContent { } export interface UIElicitationRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Message describing what information is needed from the user */ @@ -1206,10 +1089,6 @@ export interface UIElicitationResult { } export interface UIHandlePendingElicitationRequest { - /** - * Target session identifier - */ - sessionId: string; /** * The unique request ID from the elicitation.requested event */ @@ -1224,73 +1103,7 @@ export interface PermissionRequestResult { success: boolean; } -export type PermissionDecision = - | { - /** - * The permission request was approved - */ - kind: "approved"; - } -| { - /** - * Denied because approval rules explicitly blocked it - */ - kind: "denied-by-rules"; - /** - * Rules that denied the request - */ - rules: unknown[]; - } - | { - /** - * Denied because no approval rule matched and user confirmation was unavailable - */ - kind: "denied-no-approval-rule-and-could-not-request-from-user"; - } - | { - /** - * Denied by the user during an interactive prompt - */ - kind: "denied-interactively-by-user"; - /** - * Optional feedback from the user explaining the denial - */ - feedback?: string; - } - | { - /** - * Denied by the organization's content exclusion policy - */ - kind: "denied-by-content-exclusion-policy"; - /** - * File path that triggered the exclusion - */ - path: string; - /** - * Human-readable explanation of why the path was excluded - */ - message: string; - } - | { - /** - * Denied by a permission request hook registered by an extension or plugin - */ - kind: "denied-by-permission-request-hook"; - /** - * Optional message from the hook explaining the denial - */ - message?: string; - /** - * Whether to interrupt the current agent turn - */ - interrupt?: boolean; - }; - export interface PermissionDecisionRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Request ID of the pending permission request */ @@ -1305,15 +1118,7 @@ export interface LogResult { eventId: string; } -/** - * Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". - */ -export type SessionLogLevel = "info" | "warning" | "error"; export interface LogRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Human-readable message */ @@ -1337,10 +1142,6 @@ export interface ShellExecResult { } export interface ShellExecRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Shell command to execute */ @@ -1363,10 +1164,6 @@ export interface ShellKillResult { } export interface ShellKillRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Process identifier returned by shell.exec */ @@ -1422,14 +1219,6 @@ export interface HistoryCompactResult { }; } -/** @experimental */ -export interface SessionHistoryCompactRequest { - /** - * Target session identifier - */ - sessionId: string; -} - /** @experimental */ export interface HistoryTruncateResult { /** @@ -1440,10 +1229,6 @@ export interface HistoryTruncateResult { /** @experimental */ export interface HistoryTruncateRequest { - /** - * Target session identifier - */ - sessionId: string; /** * Event ID to truncate to. This event and all events after it are removed from the session. */ @@ -1544,14 +1329,6 @@ export interface UsageGetMetricsResult { lastCallOutputTokens: number; } -/** @experimental */ -export interface SessionUsageGetMetricsRequest { - /** - * Target session identifier - */ - sessionId: string; -} - export interface SessionFsReadFileResult { /** * File content as UTF-8 string diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 96da352e8..9e63b68ea 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -16,13 +16,20 @@ import { getApiSchemaPath, getRpcSchemaTypeName, getSessionEventsSchemaPath, + writeGeneratedFile, + collectDefinitionCollections, + postProcessSchema, + resolveRef, + resolveObjectSchema, + resolveSchema, + refTypeName, + isRpcMethod, isNodeFullyExperimental, isObjectSchema, isVoidSchema, - isRpcMethod, REPO_ROOT, - writeGeneratedFile, type ApiSchema, + type DefinitionCollections, type RpcMethod, } from "./utils.js"; @@ -301,6 +308,9 @@ interface EventVariant { let generatedEnums = new Map(); +/** Schema definitions available during session event generation (for $ref resolution). */ +let sessionDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; + function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string, explicitName?: string): string { const enumName = explicitName ?? `${parentClassName}${propName}`; const existing = generatedEnums.get(enumName); @@ -320,17 +330,27 @@ function getOrCreateEnum(parentClassName: string, propName: string, values: stri } function extractEventVariants(schema: JSONSchema7): EventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + const definitionCollections = collectDefinitionCollections(schema as Record); + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); return sessionEvent.anyOf .map((variant) => { - if (typeof variant !== "object" || !variant.properties) throw new Error("Invalid variant"); - const typeSchema = variant.properties.type as JSONSchema7; + const resolvedVariant = + resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? + resolveSchema(variant as JSONSchema7, definitionCollections) ?? + (variant as JSONSchema7); + if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) throw new Error("Invalid variant"); + const typeSchema = resolvedVariant.properties.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) throw new Error("Variant must have type.const"); const baseName = typeToClassName(typeName); - const dataSchema = variant.properties.data as JSONSchema7; + const dataSchema = + resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + (resolvedVariant.properties.data as JSONSchema7); return { typeName, className: `${baseName}Event`, @@ -505,6 +525,28 @@ function resolveSessionPropertyType( nestedClasses: Map, enumOutput: string[] ): string { + // Handle $ref by resolving against schema definitions + if (propSchema.$ref) { + const className = typeToClassName(refTypeName(propSchema.$ref, sessionDefinitions)); + const refSchema = resolveRef(propSchema.$ref, sessionDefinitions); + if (!refSchema) { + return isRequired ? className : `${className}?`; + } + + if (refSchema.enum && Array.isArray(refSchema.enum)) { + const enumName = getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput, refSchema.description); + return isRequired ? enumName : `${enumName}?`; + } + + if (refSchema.type === "object" && refSchema.properties) { + if (!nestedClasses.has(className)) { + nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput)); + } + return isRequired ? className : `${className}?`; + } + + return resolveSessionPropertyType(refSchema, parentClassName, propName, isRequired, knownTypes, nestedClasses, enumOutput); + } if (propSchema.anyOf) { const hasNull = propSchema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); const nonNull = propSchema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); @@ -536,28 +578,15 @@ function resolveSessionPropertyType( } if (propSchema.type === "array" && propSchema.items) { const items = propSchema.items as JSONSchema7; - // Array of discriminated union (anyOf with shared discriminator) - if (items.anyOf && Array.isArray(items.anyOf)) { - const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); - const discriminatorInfo = findDiscriminator(variants); - if (discriminatorInfo) { - const baseClassName = (items.title as string) ?? `${parentClassName}${propName}Item`; - const renamedBase = applyTypeRename(baseClassName); - const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, items.description); - nestedClasses.set(renamedBase, polymorphicCode); - return isRequired ? `${renamedBase}[]` : `${renamedBase}[]?`; - } - } - if (items.type === "object" && items.properties) { - const itemClassName = (items.title as string) ?? `${parentClassName}${propName}Item`; - nestedClasses.set(itemClassName, generateNestedClass(itemClassName, items, knownTypes, nestedClasses, enumOutput)); - return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; - } - if (items.enum && Array.isArray(items.enum)) { - const enumName = getOrCreateEnum(parentClassName, `${propName}Item`, items.enum as string[], enumOutput, items.description, items.title as string | undefined); - return isRequired ? `${enumName}[]` : `${enumName}[]?`; - } - const itemType = schemaTypeToCSharp(items, true, knownTypes); + const itemType = resolveSessionPropertyType( + items, + parentClassName, + `${propName}Item`, + true, + knownTypes, + nestedClasses, + enumOutput + ); return isRequired ? `${itemType}[]` : `${itemType}[]?`; } return schemaTypeToCSharp(propSchema, isRequired, knownTypes); @@ -596,14 +625,24 @@ function generateDataClass(variant: EventVariant, knownTypes: Map); const variants = extractEventVariants(schema); const knownTypes = new Map(); const nestedClasses = new Map(); const enumOutput: string[] = []; // Extract descriptions for base class properties from the first variant - const firstVariant = (schema.definitions?.SessionEvent as JSONSchema7)?.anyOf?.[0]; - const baseProps = typeof firstVariant === "object" && firstVariant?.properties ? firstVariant.properties : {}; + const sessionEventDefinition = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, sessionDefinitions) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, sessionDefinitions); + const firstVariant = + typeof sessionEventDefinition === "object" ? (sessionEventDefinition.anyOf?.[0] as JSONSchema7 | undefined) : undefined; + const resolvedFirstVariant = + resolveObjectSchema(firstVariant, sessionDefinitions) ?? + resolveSchema(firstVariant, sessionDefinitions) ?? + firstVariant; + const baseProps = + typeof resolvedFirstVariant === "object" && resolvedFirstVariant?.properties ? resolvedFirstVariant.properties : {}; const baseDesc = (name: string) => { const prop = baseProps[name]; return typeof prop === "object" ? (prop as JSONSchema7).description : undefined; @@ -692,7 +731,8 @@ export async function generateSessionEvents(schemaPath?: string): Promise console.log("C#: generating session-events..."); const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); const schema = cloneSchemaForCodegen(JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7); - const code = generateSessionEventsCode(schema); + const processed = postProcessSchema(schema); + const code = generateSessionEventsCode(processed); const outPath = await writeGeneratedFile("dotnet/src/Generated/SessionEvents.cs", code); console.log(` ✓ ${outPath}`); await formatCSharpFile(outPath); @@ -708,6 +748,9 @@ let experimentalRpcTypes = new Set(); let rpcKnownTypes = new Map(); let rpcEnumOutput: string[] = []; +/** Schema definitions available during RPC generation (for $ref resolution). */ +let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; + function singularPascal(s: string): string { const p = toPascalCase(s); if (p.endsWith("ies")) return `${p.slice(0, -3)}y`; @@ -716,12 +759,25 @@ function singularPascal(s: string): string { return p; } +function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { + return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; +} + function resultTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.result, `${typeToClassName(method.rpcMethod)}Result`); + return getRpcSchemaTypeName(getMethodResultSchema(method), `${typeToClassName(method.rpcMethod)}Result`); } function paramsTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.params, `${typeToClassName(method.rpcMethod)}Request`); + return getRpcSchemaTypeName(resolveMethodParamsSchema(method), `${typeToClassName(method.rpcMethod)}Request`); +} + +function resolveMethodParamsSchema(method: RpcMethod): JSONSchema7 | undefined { + return ( + resolveObjectSchema(method.params, rpcDefinitions) ?? + resolveSchema(method.params, rpcDefinitions) ?? + method.params ?? + undefined + ); } function stableStringify(value: unknown): string { @@ -736,6 +792,27 @@ function stableStringify(value: unknown): string { } function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle $ref by resolving against schema definitions and generating the referenced class + if (schema.$ref) { + const typeName = typeToClassName(refTypeName(schema.$ref, rpcDefinitions)); + const refSchema = resolveRef(schema.$ref, rpcDefinitions); + if (!refSchema) { + return isRequired ? typeName : `${typeName}?`; + } + + if (refSchema.enum && Array.isArray(refSchema.enum)) { + const enumName = getOrCreateEnum(typeName, "", refSchema.enum as string[], rpcEnumOutput, refSchema.description); + return isRequired ? enumName : `${enumName}?`; + } + + if (refSchema.type === "object" && refSchema.properties) { + const cls = emitRpcClass(typeName, refSchema, "public", classes); + if (cls) classes.push(cls); + return isRequired ? typeName : `${typeName}?`; + } + + return resolveRpcType(refSchema, isRequired, parentClassName, propName, classes); + } // Handle anyOf: [T, null] → T? (nullable typed property) if (schema.anyOf) { const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); @@ -764,39 +841,32 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam if (schema.type === "array" && schema.items) { const items = schema.items as JSONSchema7; if (items.type === "object" && items.properties) { - const itemClass = (items.title as string) ?? singularPascal(propName); + const itemClass = (items.title as string) ?? `${parentClassName}${singularPascal(propName)}`; classes.push(emitRpcClass(itemClass, items, "public", classes)); return isRequired ? `IList<${itemClass}>` : `IList<${itemClass}>?`; } - if (items.enum && Array.isArray(items.enum)) { - const itemEnum = getOrCreateEnum( - parentClassName, - `${propName}Item`, - items.enum as string[], - rpcEnumOutput, - items.description, - items.title as string | undefined, - ); - return isRequired ? `IList<${itemEnum}>` : `IList<${itemEnum}>?`; - } - const itemType = schemaTypeToCSharp(items, true, rpcKnownTypes); + const itemType = resolveRpcType(items, true, parentClassName, `${propName}Item`, classes); return isRequired ? `IList<${itemType}>` : `IList<${itemType}>?`; } if (schema.type === "object" && schema.additionalProperties && typeof schema.additionalProperties === "object") { const vs = schema.additionalProperties as JSONSchema7; - if (vs.type === "object" && vs.properties) { - const valClass = (vs.title as string) ?? `${parentClassName}${propName}Value`; - classes.push(emitRpcClass(valClass, vs, "public", classes)); - return isRequired ? `IDictionary` : `IDictionary?`; - } - const valueType = schemaTypeToCSharp(vs, true, rpcKnownTypes); + const valueType = resolveRpcType(vs, true, parentClassName, `${propName}Value`, classes); return isRequired ? `IDictionary` : `IDictionary?`; } return schemaTypeToCSharp(schema, isRequired, rpcKnownTypes); } -function emitRpcClass(className: string, schema: JSONSchema7, visibility: "public" | "internal", extraClasses: string[]): string { - const schemaKey = stableStringify(schema); +function emitRpcClass( + className: string, + schema: JSONSchema7, + visibility: "public" | "internal", + extraClasses: string[] +): string { + const effectiveSchema = + resolveObjectSchema(schema, rpcDefinitions) ?? + resolveSchema(schema, rpcDefinitions) ?? + schema; + const schemaKey = stableStringify(effectiveSchema); const existingSchema = emittedRpcClassSchemas.get(className); if (existingSchema) { if (existingSchema !== schemaKey) { @@ -809,15 +879,15 @@ function emitRpcClass(className: string, schema: JSONSchema7, visibility: "publi emittedRpcClassSchemas.set(className, schemaKey); - const requiredSet = new Set(schema.required || []); + const requiredSet = new Set(effectiveSchema.required || []); const lines: string[] = []; - lines.push(...xmlDocComment(schema.description || `RPC data type for ${className.replace(/(Request|Result|Params)$/, "")} operations.`, "")); + lines.push(...xmlDocComment(schema.description || effectiveSchema.description || `RPC data type for ${className.replace(/(Request|Result|Params)$/, "")} operations.`, "")); if (experimentalRpcTypes.has(className)) { lines.push(`[Experimental(Diagnostics.Experimental)]`); } lines.push(`${visibility} sealed class ${className}`, `{`); - const props = Object.entries(schema.properties || {}); + const props = Object.entries(effectiveSchema.properties || {}); for (let i = 0; i < props.length; i++) { const [propName, propSchema] = props[i]; if (typeof propSchema !== "object") continue; @@ -952,19 +1022,21 @@ function emitServerInstanceMethod( groupExperimental: boolean ): void { const methodName = toPascalCase(name); - let resultClassName = !isVoidSchema(method.result) ? resultTypeName(method) : ""; - if (!isVoidSchema(method.result) && method.stability === "experimental") { + const resultSchema = getMethodResultSchema(method); + let resultClassName = !isVoidSchema(resultSchema) ? resultTypeName(method) : ""; + if (!isVoidSchema(resultSchema) && method.stability === "experimental") { experimentalRpcTypes.add(resultClassName); } - if (isObjectSchema(method.result)) { - const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (isObjectSchema(resultSchema)) { + const resultClass = emitRpcClass(resultClassName, resultSchema!, "public", classes); if (resultClass) classes.push(resultClass); - } else if (!isVoidSchema(method.result)) { - resultClassName = emitNonObjectResultType(resultClassName, method.result, classes); + } else if (!isVoidSchema(resultSchema)) { + resultClassName = emitNonObjectResultType(resultClassName, resultSchema!, classes); } - const paramEntries = method.params?.properties ? Object.entries(method.params.properties) : []; - const requiredSet = new Set(method.params?.required || []); + const effectiveParams = resolveMethodParamsSchema(method); + const paramEntries = effectiveParams?.properties ? Object.entries(effectiveParams.properties) : []; + const requiredSet = new Set(effectiveParams?.required || []); let requestClassName: string | null = null; if (paramEntries.length > 0) { @@ -972,7 +1044,7 @@ function emitServerInstanceMethod( if (method.stability === "experimental") { experimentalRpcTypes.add(requestClassName); } - const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); + const reqClass = emitRpcClass(requestClassName, effectiveParams!, "internal", classes); if (reqClass) classes.push(reqClass); } @@ -989,32 +1061,26 @@ function emitServerInstanceMethod( if (typeof pSchema !== "object") continue; const isReq = requiredSet.has(pName); const jsonSchema = pSchema as JSONSchema7; - let csType: string; - // If the property has an enum, resolve to the generated enum type by title - if (jsonSchema.enum && Array.isArray(jsonSchema.enum) && requestClassName) { - const enumTitle = (jsonSchema.title as string) ?? `${requestClassName}${toPascalCase(pName)}`; - const match = generatedEnums.get(enumTitle); - csType = match ? (isReq ? match.enumName : `${match.enumName}?`) : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); - } else { - csType = schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); - } + const csType = requestClassName + ? resolveRpcType(jsonSchema, isReq, requestClassName, toPascalCase(pName), classes) + : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); } sigParams.push("CancellationToken cancellationToken = default"); - const taskType = !isVoidSchema(method.result) ? `Task<${resultClassName}>` : "Task"; + const taskType = !isVoidSchema(resultSchema) ? `Task<${resultClassName}>` : "Task"; lines.push(`${indent}public async ${taskType} ${methodName}Async(${sigParams.join(", ")})`); lines.push(`${indent}{`); if (requestClassName && bodyAssignments.length > 0) { lines.push(`${indent} var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); - if (!isVoidSchema(method.result)) { + if (!isVoidSchema(resultSchema)) { lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`); } else { lines.push(`${indent} await CopilotClient.InvokeRpcAsync(_rpc, "${method.rpcMethod}", [request], cancellationToken);`); } } else { - if (!isVoidSchema(method.result)) { + if (!isVoidSchema(resultSchema)) { lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [], cancellationToken);`); } else { lines.push(`${indent} await CopilotClient.InvokeRpcAsync(_rpc, "${method.rpcMethod}", [], cancellationToken);`); @@ -1052,19 +1118,21 @@ function emitSessionRpcClasses(node: Record, classes: string[]) function emitSessionMethod(key: string, method: RpcMethod, lines: string[], classes: string[], indent: string, groupExperimental: boolean): void { const methodName = toPascalCase(key); - let resultClassName = !isVoidSchema(method.result) ? resultTypeName(method) : ""; - if (!isVoidSchema(method.result) && method.stability === "experimental") { + const resultSchema = getMethodResultSchema(method); + let resultClassName = !isVoidSchema(resultSchema) ? resultTypeName(method) : ""; + if (!isVoidSchema(resultSchema) && method.stability === "experimental") { experimentalRpcTypes.add(resultClassName); } - if (isObjectSchema(method.result)) { - const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (isObjectSchema(resultSchema)) { + const resultClass = emitRpcClass(resultClassName, resultSchema!, "public", classes); if (resultClass) classes.push(resultClass); - } else if (!isVoidSchema(method.result)) { - resultClassName = emitNonObjectResultType(resultClassName, method.result, classes); + } else if (!isVoidSchema(resultSchema)) { + resultClassName = emitNonObjectResultType(resultClassName, resultSchema!, classes); } - const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); - const requiredSet = new Set(method.params?.required || []); + const effectiveParams = resolveMethodParamsSchema(method); + const paramEntries = (effectiveParams?.properties ? Object.entries(effectiveParams.properties) : []).filter(([k]) => k !== "sessionId"); + const requiredSet = new Set(effectiveParams?.required || []); // Sort so required params come before optional (C# requires defaults at end) paramEntries.sort((a, b) => { @@ -1077,8 +1145,8 @@ function emitSessionMethod(key: string, method: RpcMethod, lines: string[], clas if (method.stability === "experimental") { experimentalRpcTypes.add(requestClassName); } - if (method.params) { - const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); + if (effectiveParams?.properties && Object.keys(effectiveParams.properties).length > 0) { + const reqClass = emitRpcClass(requestClassName, effectiveParams, "internal", classes); if (reqClass) classes.push(reqClass); } @@ -1098,10 +1166,10 @@ function emitSessionMethod(key: string, method: RpcMethod, lines: string[], clas } sigParams.push("CancellationToken cancellationToken = default"); - const taskType = !isVoidSchema(method.result) ? `Task<${resultClassName}>` : "Task"; + const taskType = !isVoidSchema(resultSchema) ? `Task<${resultClassName}>` : "Task"; lines.push(`${indent}public async ${taskType} ${methodName}Async(${sigParams.join(", ")})`); lines.push(`${indent}{`, `${indent} var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); - if (!isVoidSchema(method.result)) { + if (!isVoidSchema(resultSchema)) { lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, `${indent}}`); } else { lines.push(`${indent} await CopilotClient.InvokeRpcAsync(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, `${indent}}`); @@ -1152,17 +1220,19 @@ function emitClientSessionApiRegistration(clientSchema: Record, for (const { methods } of groups) { for (const method of methods) { - if (!isVoidSchema(method.result)) { - if (isObjectSchema(method.result)) { - const resultClass = emitRpcClass(resultTypeName(method), method.result, "public", classes); + const resultSchema = getMethodResultSchema(method); + if (!isVoidSchema(resultSchema)) { + if (isObjectSchema(resultSchema)) { + const resultClass = emitRpcClass(resultTypeName(method), resultSchema!, "public", classes); if (resultClass) classes.push(resultClass); } else { - emitNonObjectResultType(resultTypeName(method), method.result, classes); + emitNonObjectResultType(resultTypeName(method), resultSchema!, classes); } } - if (method.params?.properties && Object.keys(method.params.properties).length > 0) { - const paramsClass = emitRpcClass(paramsTypeName(method), method.params, "public", classes); + const effectiveParams = resolveMethodParamsSchema(method); + if (effectiveParams?.properties && Object.keys(effectiveParams.properties).length > 0) { + const paramsClass = emitRpcClass(paramsTypeName(method), effectiveParams, "public", classes); if (paramsClass) classes.push(paramsClass); } } @@ -1178,8 +1248,10 @@ function emitClientSessionApiRegistration(clientSchema: Record, lines.push(`public interface ${interfaceName}`); lines.push(`{`); for (const method of methods) { - const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; - const taskType = !isVoidSchema(method.result) ? `Task<${resultTypeName(method)}>` : "Task"; + const effectiveParams = resolveMethodParamsSchema(method); + const hasParams = !!effectiveParams?.properties && Object.keys(effectiveParams.properties).length > 0; + const resultSchema = getMethodResultSchema(method); + const taskType = !isVoidSchema(resultSchema) ? `Task<${resultTypeName(method)}>` : "Task"; lines.push(` /// Handles "${method.rpcMethod}".`); if (method.stability === "experimental" && !groupExperimental) { lines.push(` [Experimental(Diagnostics.Experimental)]`); @@ -1220,9 +1292,11 @@ function emitClientSessionApiRegistration(clientSchema: Record, for (const method of methods) { const handlerProperty = toPascalCase(groupName); const handlerMethod = clientHandlerMethodName(method.rpcMethod); - const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const effectiveParams = resolveMethodParamsSchema(method); + const hasParams = !!effectiveParams?.properties && Object.keys(effectiveParams.properties).length > 0; + const resultSchema = getMethodResultSchema(method); const paramsClass = paramsTypeName(method); - const taskType = !isVoidSchema(method.result) ? `Task<${resultTypeName(method)}>` : "Task"; + const taskType = !isVoidSchema(resultSchema) ? `Task<${resultTypeName(method)}>` : "Task"; const registrationVar = `register${typeToClassName(method.rpcMethod)}Method`; if (hasParams) { @@ -1230,7 +1304,7 @@ function emitClientSessionApiRegistration(clientSchema: Record, lines.push(` {`); lines.push(` var handler = getHandlers(request.SessionId).${handlerProperty};`); lines.push(` if (handler is null) throw new InvalidOperationException($"No ${groupName} handler registered for session: {request.SessionId}");`); - if (!isVoidSchema(method.result)) { + if (!isVoidSchema(resultSchema)) { lines.push(` return await handler.${handlerMethod}(request, cancellationToken);`); } else { lines.push(` await handler.${handlerMethod}(request, cancellationToken);`); @@ -1259,6 +1333,7 @@ function generateRpcCode(schema: ApiSchema): string { rpcKnownTypes.clear(); rpcEnumOutput = []; generatedEnums.clear(); // Clear shared enum deduplication map + rpcDefinitions = collectDefinitionCollections(schema as Record); const classes: string[] = []; let serverRpcParts: string[] = []; diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 980fb3b8e..dd87f037b 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -17,12 +17,20 @@ import { getRpcSchemaTypeName, getSessionEventsSchemaPath, hoistTitledSchemas, + hasSchemaPayload, isNodeFullyExperimental, isVoidSchema, isRpcMethod, postProcessSchema, writeGeneratedFile, + collectDefinitionCollections, + resolveObjectSchema, + resolveSchema, + withSharedDefinitions, + refTypeName, + resolveRef, type ApiSchema, + type DefinitionCollections, type RpcMethod, } from "./utils.js"; @@ -173,12 +181,24 @@ function extractFieldNames(qtCode: string): Map> { return result; } -function goResultTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.result, toPascalCase(method.rpcMethod) + "Result"); -} +function extractQuicktypeImports(qtCode: string): { code: string; imports: string[] } { + const collectedImports: string[] = []; + let code = qtCode.replace(/^import \(\n([\s\S]*?)^\)\n+/m, (_match, block: string) => { + for (const line of block.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + collectedImports.push(trimmed); + } + } + return ""; + }); -function goParamsTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.params, toPascalCase(method.rpcMethod) + "Request"); + code = code.replace(/^import ("[^"]+")\n+/m, (_match, singleImport: string) => { + collectedImports.push(singleImport.trim()); + return ""; + }); + + return { code, imports: collectedImports }; } async function formatGoFile(filePath: string): Promise { @@ -202,6 +222,55 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } +let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; + +function withRootTitle(schema: JSONSchema7, title: string): JSONSchema7 { + return { ...schema, title }; +} + +function goRequestFallbackName(method: RpcMethod): string { + return toPascalCase(method.rpcMethod) + "Request"; +} + +function schemaSourceForNamedDefinition( + schema: JSONSchema7 | null | undefined, + resolvedSchema: JSONSchema7 | undefined +): JSONSchema7 { + if (schema?.$ref && resolvedSchema) { + return resolvedSchema; + } + return schema ?? resolvedSchema ?? { type: "object" }; +} + +function isNamedGoObjectSchema(schema: JSONSchema7 | undefined): schema is JSONSchema7 { + return !!schema && schema.type === "object" && (schema.properties !== undefined || schema.additionalProperties === false); +} + +function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { + return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; +} + +function getMethodParamsSchema(method: RpcMethod): JSONSchema7 | undefined { + return ( + resolveObjectSchema(method.params, rpcDefinitions) ?? + resolveSchema(method.params, rpcDefinitions) ?? + method.params ?? + undefined + ); +} + +function goResultTypeName(method: RpcMethod): string { + return getRpcSchemaTypeName(getMethodResultSchema(method), toPascalCase(method.rpcMethod) + "Result"); +} + +function goParamsTypeName(method: RpcMethod): string { + const fallback = goRequestFallbackName(method); + if (method.rpcMethod.startsWith("session.") && method.params?.$ref) { + return fallback; + } + return getRpcSchemaTypeName(getMethodParamsSchema(method), fallback); +} + // ── Session Events (custom codegen — per-event-type data structs) ─────────── interface GoEventVariant { @@ -216,19 +285,30 @@ interface GoCodegenCtx { enums: string[]; enumsByName: Map; // enumName → enumName (dedup by type name, not values) generatedNames: Set; + definitions?: DefinitionCollections; } function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + const definitionCollections = collectDefinitionCollections(schema as Record); + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); return (sessionEvent.anyOf as JSONSchema7[]) .map((variant) => { - if (typeof variant !== "object" || !variant.properties) throw new Error("Invalid variant"); - const typeSchema = variant.properties.type as JSONSchema7; + const resolvedVariant = + resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? + resolveSchema(variant as JSONSchema7, definitionCollections) ?? + (variant as JSONSchema7); + if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) throw new Error("Invalid variant"); + const typeSchema = resolvedVariant.properties.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) throw new Error("Variant must have type.const"); - const dataSchema = (variant.properties.data as JSONSchema7) || {}; + const dataSchema = + resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + ((resolvedVariant.properties.data as JSONSchema7) || {}); return { typeName, dataClassName: `${toPascalCase(typeName)}Data`, @@ -320,6 +400,25 @@ function resolveGoPropertyType( ): string { const nestedName = parentTypeName + toGoFieldName(jsonPropName); + // Handle $ref — resolve the reference and generate the referenced type + if (propSchema.$ref && typeof propSchema.$ref === "string") { + const typeName = toGoFieldName(refTypeName(propSchema.$ref, ctx.definitions)); + const resolved = resolveRef(propSchema.$ref, ctx.definitions); + if (resolved) { + if (resolved.enum) { + const enumType = getOrCreateGoEnum(typeName, resolved.enum as string[], ctx, resolved.description); + return isRequired ? enumType : `*${enumType}`; + } + if (isNamedGoObjectSchema(resolved)) { + emitGoStruct(typeName, resolved, ctx); + return isRequired ? typeName : `*${typeName}`; + } + return resolveGoPropertyType(resolved, parentTypeName, jsonPropName, isRequired, ctx); + } + // Fallback: use the type name directly + return isRequired ? typeName : `*${typeName}`; + } + // Handle anyOf if (propSchema.anyOf) { const nonNull = (propSchema.anyOf as JSONSchema7[]).filter((s) => s.type !== "null"); @@ -580,6 +679,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { enums: [], enumsByName: new Map(), generatedNames: new Set(), + definitions: collectDefinitionCollections(schema as Record), }; // Generate per-event data structs @@ -858,50 +958,73 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype - prefix types to avoid conflicts - const combinedSchema: JSONSchema7 = { - $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, - }; + // Build a combined schema for quicktype — prefix types to avoid conflicts. + // Include shared definitions from the API schema for $ref resolution. + rpcDefinitions = collectDefinitionCollections(schema as Record); + const combinedSchema = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + }, + rpcDefinitions + ); for (const method of allMethods) { - if (isVoidSchema(method.result)) { + const resultSchema = getMethodResultSchema(method); + if (isVoidSchema(resultSchema)) { // Emit an empty struct for void results (forward-compatible with adding fields later) - combinedSchema.definitions![goResultTypeName(method)] = { type: "object", properties: {}, additionalProperties: false }; - } else { - combinedSchema.definitions![goResultTypeName(method)] = method.result; + combinedSchema.definitions![goResultTypeName(method)] = { + title: goResultTypeName(method), + type: "object", + properties: {}, + additionalProperties: false, + }; + } else if (method.result) { + combinedSchema.definitions![goResultTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.result, resultSchema), + goResultTypeName(method) + ); } - if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + const resolvedParams = getMethodParamsSchema(method); + if (method.params && hasSchemaPayload(resolvedParams)) { // For session methods, filter out sessionId from params type - if (method.rpcMethod.startsWith("session.")) { + if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { const filtered: JSONSchema7 = { - ...method.params, + ...resolvedParams, properties: Object.fromEntries( - Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + Object.entries(resolvedParams.properties).filter(([k]) => k !== "sessionId") ), - required: method.params.required?.filter((r) => r !== "sessionId"), + required: resolvedParams.required?.filter((r) => r !== "sessionId"), }; - if (Object.keys(filtered.properties!).length > 0) { - combinedSchema.definitions![goParamsTypeName(method)] = filtered; + if (hasSchemaPayload(filtered)) { + combinedSchema.definitions![goParamsTypeName(method)] = withRootTitle( + filtered, + goParamsTypeName(method) + ); } } else { - combinedSchema.definitions![goParamsTypeName(method)] = method.params; + combinedSchema.definitions![goParamsTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.params, resolvedParams), + goParamsTypeName(method) + ); } } } const { rootDefinitions, sharedDefinitions } = hoistTitledSchemas(combinedSchema.definitions! as Record); + const allDefinitions = { ...rootDefinitions, ...sharedDefinitions }; + const allDefinitionCollections: DefinitionCollections = { + definitions: { ...(combinedSchema.$defs ?? {}), ...allDefinitions }, + $defs: { ...allDefinitions, ...(combinedSchema.$defs ?? {}) }, + }; // Generate types via quicktype const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); for (const [name, def] of Object.entries(rootDefinitions)) { - await schemaInput.addSource({ - name, - schema: JSON.stringify({ - ...def, - definitions: sharedDefinitions, - }), - }); + const schemaWithDefs = withSharedDefinitions( + typeof def === "object" ? (def as JSONSchema7) : {}, + allDefinitionCollections + ); + await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) }); } const inputData = new InputData(); @@ -913,21 +1036,10 @@ async function generateRpc(schemaPath?: string): Promise { rendererOptions: { package: "copilot", "just-types": "true" }, }); - // Post-process quicktype output: fix enum constant names + // Post-process quicktype output: hoist quicktype's imports into the file-level import block let qtCode = qtResult.lines.filter((l) => !l.startsWith("package ")).join("\n"); - // Extract any imports quicktype emitted (e.g., "time") and hoist them - const qtImports: string[] = []; - qtCode = qtCode.replace(/^import\s+"([^"]+)"\s*$/gm, (_match, imp) => { - qtImports.push(`"${imp}"`); - return ""; - }); - qtCode = qtCode.replace(/^import\s+\(([^)]*)\)\s*$/gm, (_match, block) => { - for (const line of block.split("\n")) { - const trimmed = line.trim(); - if (trimmed) qtImports.push(trimmed); - } - return ""; - }); + const quicktypeImports = extractQuicktypeImports(qtCode); + qtCode = quicktypeImports.code; qtCode = postProcessEnumConstants(qtCode); qtCode = collapsePlaceholderGoStructs(qtCode); // Strip trailing whitespace from quicktype output (gofmt requirement) @@ -935,7 +1047,7 @@ async function generateRpc(schemaPath?: string): Promise { // Extract actual type names generated by quicktype (may differ from toPascalCase) const actualTypeNames = new Map(); - const typeRe = /^type\s+(\w+)\s+/gm; + const typeRe = /^type\s+(\w+)\b/gm; let sm; while ((sm = typeRe.exec(qtCode)) !== null) { actualTypeNames.set(sm[1].toLowerCase(), sm[1]); @@ -974,14 +1086,15 @@ async function generateRpc(schemaPath?: string): Promise { lines.push(`package rpc`); lines.push(``); const imports = [`"context"`, `"encoding/json"`]; + for (const imp of quicktypeImports.imports) { + if (!imports.includes(imp)) { + imports.push(imp); + } + } if (schema.clientSession) { imports.push(`"errors"`, `"fmt"`); } imports.push(`"github.com/github/copilot-sdk/go/internal/jsonrpc2"`); - // Add any imports hoisted from quicktype output - for (const qi of qtImports) { - if (!imports.includes(qi)) imports.push(qi); - } lines.push(`import (`); for (const imp of imports) { @@ -1090,10 +1203,11 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc const methodName = toPascalCase(name); const resultType = resolveType(goResultTypeName(method)); - const paramProps = method.params?.properties || {}; - const requiredParams = new Set(method.params?.required || []); + const effectiveParams = getMethodParamsSchema(method); + const paramProps = effectiveParams?.properties || {}; + const requiredParams = new Set(effectiveParams?.required || []); const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); - const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const hasParams = isSession ? nonSessionParams.length > 0 : hasSchemaPayload(effectiveParams); const paramsType = hasParams ? resolveType(goParamsTypeName(method)) : ""; // For wrapper-level methods, access fields through a.common; for service type aliases, use a directly diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index c1a80aa06..62b53e1e6 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -22,7 +22,14 @@ import { isNodeFullyExperimental, postProcessSchema, writeGeneratedFile, + collectDefinitionCollections, + hasSchemaPayload, + refTypeName, + resolveObjectSchema, + resolveSchema, + withSharedDefinitions, type ApiSchema, + type DefinitionCollections, type RpcMethod, } from "./utils.js"; @@ -210,12 +217,53 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } +let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; + +function withRootTitle(schema: JSONSchema7, title: string): JSONSchema7 { + return { ...schema, title }; +} + +function pythonRequestFallbackName(method: RpcMethod): string { + return toPascalCase(method.rpcMethod) + "Request"; +} + +function schemaSourceForNamedDefinition( + schema: JSONSchema7 | null | undefined, + resolvedSchema: JSONSchema7 | undefined +): JSONSchema7 { + if (schema?.$ref && resolvedSchema) { + return resolvedSchema; + } + return schema ?? resolvedSchema ?? { type: "object" }; +} + +function isNamedPyObjectSchema(schema: JSONSchema7 | undefined): schema is JSONSchema7 { + return !!schema && schema.type === "object" && (schema.properties !== undefined || schema.additionalProperties === false); +} + +function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { + return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; +} + +function getMethodParamsSchema(method: RpcMethod): JSONSchema7 | undefined { + return ( + resolveObjectSchema(method.params, rpcDefinitions) ?? + resolveSchema(method.params, rpcDefinitions) ?? + method.params ?? + undefined + ); +} + function pythonResultTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.result, toPascalCase(method.rpcMethod) + "Result"); + return getRpcSchemaTypeName(getMethodResultSchema(method), toPascalCase(method.rpcMethod) + "Result"); } function pythonParamsTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.params, toPascalCase(method.rpcMethod) + "Request"); + const fallback = pythonRequestFallbackName(method); + if (method.rpcMethod.startsWith("session.") && method.params?.$ref) { + return fallback; + } + return getRpcSchemaTypeName(getMethodParamsSchema(method), fallback); } // ── Session Events ────────────────────────────────────────────────────────── @@ -241,6 +289,7 @@ interface PyCodegenCtx { generatedNames: Set; usesTimedelta: boolean; usesIntegerTimedelta: boolean; + definitions: DefinitionCollections; } function toEnumMemberName(value: string): string { @@ -372,24 +421,34 @@ function toPythonLiteral(value: unknown): string | undefined { } function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + const definitionCollections = collectDefinitionCollections(schema as Record); + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); if (!sessionEvent?.anyOf) { throw new Error("Schema must have SessionEvent definition with anyOf"); } return (sessionEvent.anyOf as JSONSchema7[]) .map((variant) => { - if (typeof variant !== "object" || !variant.properties) { + const resolvedVariant = + resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? + resolveSchema(variant as JSONSchema7, definitionCollections) ?? + (variant as JSONSchema7); + if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) { throw new Error("Invalid event variant"); } - const typeSchema = variant.properties.type as JSONSchema7; + const typeSchema = resolvedVariant.properties.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) { throw new Error("Event variant must define type.const"); } - const dataSchema = (variant.properties.data as JSONSchema7) || {}; + const dataSchema = + resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? + ((resolvedVariant.properties.data as JSONSchema7) || {}); return { typeName, dataClassName: `${toPascalCase(typeName)}Data`, @@ -479,6 +538,35 @@ function resolvePyPropertyType( ): PyResolvedType { const nestedName = parentTypeName + toPascalCase(jsonPropName); + if (propSchema.$ref && typeof propSchema.$ref === "string") { + const typeName = toPascalCase(refTypeName(propSchema.$ref, ctx.definitions)); + const resolved = resolveSchema(propSchema, ctx.definitions); + if (resolved && resolved !== propSchema) { + if (resolved.enum && Array.isArray(resolved.enum) && resolved.enum.every((value) => typeof value === "string")) { + const enumType = getOrCreatePyEnum(typeName, resolved.enum as string[], ctx, resolved.description); + const enumResolved: PyResolvedType = { + annotation: enumType, + fromExpr: (expr) => `parse_enum(${enumType}, ${expr})`, + toExpr: (expr) => `to_enum(${enumType}, ${expr})`, + }; + return isRequired ? enumResolved : pyOptionalResolvedType(enumResolved); + } + + const resolvedObject = resolveObjectSchema(propSchema, ctx.definitions); + if (isNamedPyObjectSchema(resolvedObject)) { + emitPyClass(typeName, resolvedObject, ctx, resolvedObject.description); + const objectResolved: PyResolvedType = { + annotation: typeName, + fromExpr: (expr) => `${typeName}.from_dict(${expr})`, + toExpr: (expr) => `to_class(${typeName}, ${expr})`, + }; + return isRequired ? objectResolved : pyOptionalResolvedType(objectResolved); + } + + return resolvePyPropertyType(resolved, parentTypeName, jsonPropName, isRequired, ctx); + } + } + if (propSchema.allOf && propSchema.allOf.length === 1 && typeof propSchema.allOf[0] === "object") { return resolvePyPropertyType( propSchema.allOf[0] as JSONSchema7, @@ -490,7 +578,14 @@ function resolvePyPropertyType( } if (propSchema.anyOf) { - const variants = (propSchema.anyOf as JSONSchema7[]).filter((item) => typeof item === "object"); + const variants = (propSchema.anyOf as JSONSchema7[]) + .filter((item) => typeof item === "object") + .map( + (item) => + resolveObjectSchema(item as JSONSchema7, ctx.definitions) ?? + resolveSchema(item as JSONSchema7, ctx.definitions) ?? + (item as JSONSchema7) + ); const nonNull = variants.filter((item) => item.type !== "null"); const hasNull = variants.length !== nonNull.length; @@ -634,6 +729,12 @@ function resolvePyPropertyType( if (items.anyOf) { const itemVariants = (items.anyOf as JSONSchema7[]) .filter((variant) => typeof variant === "object") + .map( + (variant) => + resolveObjectSchema(variant as JSONSchema7, ctx.definitions) ?? + resolveSchema(variant as JSONSchema7, ctx.definitions) ?? + (variant as JSONSchema7) + ) .filter((variant) => variant.type !== "null"); const discriminator = findPyDiscriminator(itemVariants); if (discriminator) { @@ -941,6 +1042,7 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { generatedNames: new Set(), usesTimedelta: false, usesIntegerTimedelta: false, + definitions: collectDefinitionCollections(schema as Record), }; for (const variant of variants) { @@ -1273,46 +1375,63 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype - const combinedSchema: JSONSchema7 = { - $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, - }; + // Build a combined schema for quicktype, including shared definitions from the API schema + rpcDefinitions = collectDefinitionCollections(schema as Record); + const combinedSchema = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + }, + rpcDefinitions + ); for (const method of allMethods) { - if (!isVoidSchema(method.result)) { - combinedSchema.definitions![pythonResultTypeName(method)] = method.result; + const resultSchema = getMethodResultSchema(method); + if (!isVoidSchema(resultSchema)) { + combinedSchema.definitions![pythonResultTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.result, resultSchema), + pythonResultTypeName(method) + ); } - if (method.params?.properties && Object.keys(method.params.properties).length > 0) { - if (method.rpcMethod.startsWith("session.")) { + const resolvedParams = getMethodParamsSchema(method); + if (method.params && hasSchemaPayload(resolvedParams)) { + if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { const filtered: JSONSchema7 = { - ...method.params, + ...resolvedParams, properties: Object.fromEntries( - Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + Object.entries(resolvedParams.properties).filter(([k]) => k !== "sessionId") ), - required: method.params.required?.filter((r) => r !== "sessionId"), + required: resolvedParams.required?.filter((r) => r !== "sessionId"), }; - if (Object.keys(filtered.properties!).length > 0) { - combinedSchema.definitions![pythonParamsTypeName(method)] = filtered; + if (hasSchemaPayload(filtered)) { + combinedSchema.definitions![pythonParamsTypeName(method)] = withRootTitle( + filtered, + pythonParamsTypeName(method) + ); } } else { - combinedSchema.definitions![pythonParamsTypeName(method)] = method.params; + combinedSchema.definitions![pythonParamsTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.params, resolvedParams), + pythonParamsTypeName(method) + ); } } } const { rootDefinitions, sharedDefinitions } = hoistTitledSchemas(combinedSchema.definitions! as Record); + const allDefinitions = { ...rootDefinitions, ...sharedDefinitions }; + const allDefinitionCollections: DefinitionCollections = { + definitions: { ...(combinedSchema.$defs ?? {}), ...allDefinitions }, + $defs: { ...allDefinitions, ...(combinedSchema.$defs ?? {}) }, + }; // Generate types via quicktype const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); for (const [name, def] of Object.entries(rootDefinitions)) { - await schemaInput.addSource({ - name, - schema: JSON.stringify({ - ...def, - definitions: sharedDefinitions, - }), - }); + const schemaWithDefs = withSharedDefinitions( + typeof def === "object" ? (def as JSONSchema7) : {}, + allDefinitionCollections + ); + await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) }); } const inputData = new InputData(); @@ -1502,13 +1621,15 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: boolean, resolveType: (name: string) => string, groupExperimental = false): void { const methodName = toSnakeCase(name); - const hasResult = !isVoidSchema(method.result); + const resultSchema = getMethodResultSchema(method); + const hasResult = !isVoidSchema(resultSchema); const resultType = hasResult ? resolveType(pythonResultTypeName(method)) : "None"; - const resultIsObject = isObjectSchema(method.result); + const resultIsObject = isObjectSchema(resultSchema); - const paramProps = method.params?.properties || {}; + const effectiveParams = getMethodParamsSchema(method); + const paramProps = effectiveParams?.properties || {}; const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); - const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const hasParams = isSession ? nonSessionParams.length > 0 : hasSchemaPayload(effectiveParams); const paramsType = resolveType(pythonParamsTypeName(method)); // Build signature with typed params + optional timeout @@ -1625,7 +1746,8 @@ function emitClientSessionHandlerMethod( groupExperimental = false ): void { const paramsType = resolveType(pythonParamsTypeName(method)); - const resultType = !isVoidSchema(method.result) ? resolveType(pythonResultTypeName(method)) : "None"; + const resultSchema = getMethodResultSchema(method); + const resultType = !isVoidSchema(resultSchema) ? resolveType(pythonResultTypeName(method)) : "None"; lines.push(` async def ${toSnakeCase(name)}(self, params: ${paramsType}) -> ${resultType}:`); if (method.stability === "experimental" && !groupExperimental) { lines.push(` """.. warning:: This API is experimental and may change or be removed in future versions."""`); @@ -1642,7 +1764,8 @@ function emitClientSessionRegistrationMethod( ): void { const handlerVariableName = `handle_${toSnakeCase(groupName)}_${toSnakeCase(methodName)}`; const paramsType = resolveType(pythonParamsTypeName(method)); - const resultType = !isVoidSchema(method.result) ? resolveType(pythonResultTypeName(method)) : null; + const resultSchema = getMethodResultSchema(method); + const resultType = !isVoidSchema(resultSchema) ? resolveType(pythonResultTypeName(method)) : null; const handlerField = toSnakeCase(groupName); const handlerMethod = toSnakeCase(methodName); @@ -1654,7 +1777,7 @@ function emitClientSessionRegistrationMethod( ); if (resultType) { lines.push(` result = await handler.${handlerMethod}(request)`); - if (isObjectSchema(method.result)) { + if (isObjectSchema(resultSchema)) { lines.push(` return result.to_dict()`); } else { lines.push(` return result.value if hasattr(result, 'value') else result`); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 7dfd5631f..c18108573 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -13,13 +13,20 @@ import { getApiSchemaPath, getRpcSchemaTypeName, getSessionEventsSchemaPath, - isNodeFullyExperimental, + normalizeSchemaTitles, + postProcessSchema, + writeGeneratedFile, + collectDefinitionCollections, + hasSchemaPayload, + resolveObjectSchema, + resolveSchema, + withSharedDefinitions, isRpcMethod, + isNodeFullyExperimental, isVoidSchema, - postProcessSchema, stripNonAnnotationTitles, - writeGeneratedFile, type ApiSchema, + type DefinitionCollections, type RpcMethod, } from "./utils.js"; @@ -125,6 +132,111 @@ function collectRpcMethods(node: Record): RpcMethod[] { return results; } +function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { + const root = structuredClone(schema) as JSONSchema7 & { + definitions?: Record; + $defs?: Record; + }; + const definitions = { ...(root.definitions ?? {}) }; + const draftDefinitionAliases = new Map(); + + for (const [key, value] of Object.entries(root.$defs ?? {})) { + let alias = key; + if (alias in definitions) { + alias = `$defs_${key}`; + while (alias in definitions) { + alias = `$defs_${alias}`; + } + } + draftDefinitionAliases.set(key, alias); + definitions[alias] = value; + } + + root.definitions = definitions; + delete root.$defs; + + const rewrite = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(rewrite); + } + if (!value || typeof value !== "object") { + return value; + } + + const rewritten = Object.fromEntries( + Object.entries(value as Record).map(([key, child]) => [key, rewrite(child)]) + ) as Record; + + if (typeof rewritten.$ref === "string" && rewritten.$ref.startsWith("#/$defs/")) { + const definitionName = rewritten.$ref.slice("#/$defs/".length); + rewritten.$ref = `#/definitions/${draftDefinitionAliases.get(definitionName) ?? definitionName}`; + } + + return rewritten; + }; + + return rewrite(root) as JSONSchema7; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function replaceDuplicateTitledSchemasWithRefs( + value: unknown, + definitions: Record, + isRoot = false +): unknown { + if (Array.isArray(value)) { + return value.map((item) => replaceDuplicateTitledSchemasWithRefs(item, definitions)); + } + if (!value || typeof value !== "object") { + return value; + } + + const rewritten = Object.fromEntries( + Object.entries(value as Record).map(([key, child]) => [ + key, + replaceDuplicateTitledSchemasWithRefs(child, definitions), + ]) + ) as Record; + + if (!isRoot && typeof rewritten.title === "string") { + const sharedSchema = definitions[rewritten.title]; + if ( + sharedSchema && + typeof sharedSchema === "object" && + stableStringify(normalizeSchemaTitles(rewritten as JSONSchema7)) === + stableStringify(normalizeSchemaTitles(sharedSchema as JSONSchema7)) + ) { + return { $ref: `#/definitions/${rewritten.title}` }; + } + } + + return rewritten; +} + +function reuseSharedTitledSchemas(schema: JSONSchema7): JSONSchema7 { + const definitions = { ...((schema.definitions ?? {}) as Record) }; + + return { + ...schema, + definitions: Object.fromEntries( + Object.entries(definitions).map(([name, definition]) => [ + name, + replaceDuplicateTitledSchemasWithRefs(definition, definitions, true), + ]) + ), + }; +} + // ── Session Events ────────────────────────────────────────────────────────── async function generateSessionEvents(schemaPath?: string): Promise { @@ -133,8 +245,14 @@ async function generateSessionEvents(schemaPath?: string): Promise { const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; const processed = postProcessSchema(stripNonAnnotationTitles(schema)); - - const ts = await compile(processed, "SessionEvent", { + const definitionCollections = collectDefinitionCollections(processed as Record); + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections) ?? + processed; + const schemaForCompile = withSharedDefinitions(sessionEvent, definitionCollections); + + const ts = await compile(normalizeSchemaForTypeScript(schemaForCompile), "SessionEvent", { bannerComment: `/** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: session-events.schema.json @@ -149,12 +267,52 @@ async function generateSessionEvents(schemaPath?: string): Promise { // ── RPC Types ─────────────────────────────────────────────────────────────── +let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; + +function withRootTitle(schema: JSONSchema7, title: string): JSONSchema7 { + return { ...schema, title }; +} + +function rpcRequestFallbackName(method: RpcMethod): string { + return method.rpcMethod.split(".").map(toPascalCase).join("") + "Request"; +} + +function schemaSourceForNamedDefinition( + schema: JSONSchema7 | null | undefined, + resolvedSchema: JSONSchema7 | undefined +): JSONSchema7 { + if (schema?.$ref && resolvedSchema) { + return resolvedSchema; + } + return schema ?? resolvedSchema ?? { type: "object" }; +} + +function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { + return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; +} + +function getMethodParamsSchema(method: RpcMethod): JSONSchema7 | undefined { + return ( + resolveObjectSchema(method.params, rpcDefinitions) ?? + resolveSchema(method.params, rpcDefinitions) ?? + method.params ?? + undefined + ); +} + function resultTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.result, method.rpcMethod.split(".").map(toPascalCase).join("") + "Result"); + return getRpcSchemaTypeName( + getMethodResultSchema(method), + method.rpcMethod.split(".").map(toPascalCase).join("") + "Result" + ); } function paramsTypeName(method: RpcMethod): string { - return getRpcSchemaTypeName(method.params, method.rpcMethod.split(".").map(toPascalCase).join("") + "Request"); + const fallback = rpcRequestFallbackName(method); + if (method.rpcMethod.startsWith("session.") && method.params?.$ref) { + return fallback; + } + return getRpcSchemaTypeName(getMethodParamsSchema(method), fallback); } async function generateRpc(schemaPath?: string): Promise { @@ -176,32 +334,94 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); const seenBlocks = new Map(); + // 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); + const combinedSchema = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + }, + rpcDefinitions + ); + + // Track which type names come from experimental methods for JSDoc annotations. + const experimentalTypes = new Set(); + for (const method of [...allMethods, ...clientSessionMethods]) { - if (!isVoidSchema(method.result)) { - const compiled = await compile(stripNonAnnotationTitles(method.result), resultTypeName(method), { - bannerComment: "", - additionalProperties: false, - }); + const resultSchema = getMethodResultSchema(method); + if (!isVoidSchema(resultSchema)) { + combinedSchema.definitions![resultTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.result, resultSchema), + resultTypeName(method) + ); if (method.stability === "experimental") { - lines.push("/** @experimental */"); + experimentalTypes.add(resultTypeName(method)); } - appendUniqueExportBlocks(lines, compiled, seenBlocks); - lines.push(""); } - if (method.params?.properties && Object.keys(method.params.properties).length > 0) { - const paramsCompiled = await compile(stripNonAnnotationTitles(method.params), paramsTypeName(method), { - bannerComment: "", - additionalProperties: false, - }); - if (method.stability === "experimental") { - lines.push("/** @experimental */"); + const resolvedParams = getMethodParamsSchema(method); + if (method.params && hasSchemaPayload(resolvedParams)) { + if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { + const filtered: JSONSchema7 = { + ...resolvedParams, + properties: Object.fromEntries( + Object.entries(resolvedParams.properties).filter(([k]) => k !== "sessionId") + ), + required: resolvedParams.required?.filter((r) => r !== "sessionId"), + }; + if (hasSchemaPayload(filtered)) { + combinedSchema.definitions![paramsTypeName(method)] = withRootTitle( + filtered, + paramsTypeName(method) + ); + if (method.stability === "experimental") { + experimentalTypes.add(paramsTypeName(method)); + } + } + } else { + combinedSchema.definitions![paramsTypeName(method)] = withRootTitle( + schemaSourceForNamedDefinition(method.params, resolvedParams), + paramsTypeName(method) + ); + if (method.stability === "experimental") { + experimentalTypes.add(paramsTypeName(method)); + } } - appendUniqueExportBlocks(lines, paramsCompiled, seenBlocks); - lines.push(""); } } + const schemaForCompile = reuseSharedTitledSchemas(stripNonAnnotationTitles(combinedSchema)); + + const compiled = await compile(normalizeSchemaForTypeScript(schemaForCompile), "_RpcSchemaRoot", { + bannerComment: "", + additionalProperties: false, + unreachableDefinitions: true, + }); + + // Strip the placeholder root type and keep only the definition-generated types + const strippedTs = compiled + .replace( + /\/\*\*\n \* This (?:interface|type) was referenced by `_RpcSchemaRoot`'s JSON-Schema\n \* via the `definition` "[^"]+"\.\n \*\/\n/g, + "\n" + ) + .replace(/export interface _RpcSchemaRoot\s*\{[^}]*\}\s*/g, "") + .replace(/export type _RpcSchemaRoot = [^;]+;\s*/g, "") + .trim(); + + if (strippedTs) { + // Add @experimental JSDoc annotations for types from experimental methods + let annotatedTs = strippedTs; + for (const expType of experimentalTypes) { + annotatedTs = annotatedTs.replace( + new RegExp(`(^|\\n)(export (?:interface|type) ${expType}\\b)`, "m"), + `$1/** @experimental */\n$2` + ); + } + lines.push(annotatedTs); + lines.push(""); + } + // Generate factory functions if (schema.server) { lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); @@ -237,11 +457,14 @@ function emitGroup(node: Record, indent: string, isSession: boo for (const [key, value] of Object.entries(node)) { if (isRpcMethod(value)) { const { rpcMethod, params } = value; - const resultType = !isVoidSchema(value.result) ? resultTypeName(value) : "void"; + const resultType = !isVoidSchema(getMethodResultSchema(value)) ? resultTypeName(value) : "void"; const paramsType = paramsTypeName(value); + const effectiveParams = getMethodParamsSchema(value); - const paramEntries = params?.properties ? Object.entries(params.properties).filter(([k]) => k !== "sessionId") : []; - const hasParams = params?.properties && Object.keys(params.properties).length > 0; + const paramEntries = effectiveParams?.properties + ? Object.entries(effectiveParams.properties).filter(([k]) => k !== "sessionId") + : []; + const hasParams = hasSchemaPayload(effectiveParams); const hasNonSessionParams = paramEntries.length > 0; const sigParams: string[] = []; @@ -325,9 +548,9 @@ function emitClientSessionApiRegistration(clientSchema: Record) lines.push(`export interface ${interfaceName} {`); for (const method of methods) { const name = handlerMethodName(method.rpcMethod); - const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); const pType = hasParams ? paramsTypeName(method) : ""; - const rType = !isVoidSchema(method.result) ? resultTypeName(method) : "void"; + const rType = !isVoidSchema(getMethodResultSchema(method)) ? resultTypeName(method) : "void"; if (hasParams) { lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); @@ -365,7 +588,7 @@ function emitClientSessionApiRegistration(clientSchema: Record) for (const method of methods) { const name = handlerMethodName(method.rpcMethod); const pType = paramsTypeName(method); - const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 225e678b7..1931e8ac6 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -21,6 +21,20 @@ const __dirname = path.dirname(__filename); /** Root of the copilot-sdk repo */ export const REPO_ROOT = path.resolve(__dirname, "../.."); +/** Event types to exclude from generation (internal/legacy types) */ +export const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); + +export interface DefinitionCollections { + definitions?: Record; + $defs?: Record; +} + +export interface JSONSchema7WithDefs extends JSONSchema7, DefinitionCollections {} + +export type SchemaWithSharedDefinitions = T & { + definitions: Record; + $defs: Record; +}; // ── Schema paths ──────────────────────────────────────────────────────────── export async function getSessionEventsSchemaPath(): Promise { @@ -51,7 +65,7 @@ export async function getApiSchemaPath(cliArg?: string): Promise { export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { if (typeof schema !== "object" || schema === null) return schema; - const processed: JSONSchema7 = { ...schema }; + const processed = { ...schema } as JSONSchema7WithDefs; if ("const" in processed && typeof processed.const === "boolean") { processed.enum = [processed.const]; @@ -84,13 +98,28 @@ export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { } } - if (processed.definitions) { - const newDefs: Record = {}; - for (const [key, value] of Object.entries(processed.definitions)) { + const { definitions, $defs } = collectDefinitionCollections(processed as Record); + let newDefs: Record | undefined; + if (Object.keys(definitions).length > 0) { + newDefs = {}; + for (const [key, value] of Object.entries(definitions)) { newDefs[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; } processed.definitions = newDefs; } + let newDraftDefs: Record | undefined; + if (Object.keys($defs).length > 0) { + newDraftDefs = {}; + for (const [key, value] of Object.entries($defs)) { + newDraftDefs[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; + } + processed.$defs = newDraftDefs; + } + if (processed.definitions && !processed.$defs) { + processed.$defs = { ...(newDefs ?? processed.definitions) }; + } else if (processed.$defs && !processed.definitions) { + processed.definitions = { ...processed.$defs }; + } if (typeof processed.additionalProperties === "object") { processed.additionalProperties = postProcessSchema(processed.additionalProperties as JSONSchema7); @@ -282,6 +311,8 @@ function sortJsonValue(value: unknown): unknown { } export interface ApiSchema { + definitions?: Record; + $defs?: Record; server?: Record; session?: Record; clientSession?: Record; @@ -291,6 +322,135 @@ export function isRpcMethod(node: unknown): node is RpcMethod { return typeof node === "object" && node !== null && "rpcMethod" in node; } +function normalizeSchemaDefinitionTitles(definition: JSONSchema7Definition): JSONSchema7Definition { + return typeof definition === "object" && definition !== null + ? normalizeSchemaTitles(definition as JSONSchema7) + : definition; +} + +export function normalizeSchemaTitles(schema: JSONSchema7): JSONSchema7 { + if (typeof schema !== "object" || schema === null) return schema; + + const normalized = { ...schema } as JSONSchema7WithDefs & Record; + delete normalized.title; + delete normalized.titleSource; + + if (normalized.properties) { + const newProps: Record = {}; + for (const [key, value] of Object.entries(normalized.properties)) { + newProps[key] = normalizeSchemaDefinitionTitles(value); + } + normalized.properties = newProps; + } + + if (normalized.items) { + if (typeof normalized.items === "object" && !Array.isArray(normalized.items)) { + normalized.items = normalizeSchemaTitles(normalized.items as JSONSchema7); + } else if (Array.isArray(normalized.items)) { + normalized.items = normalized.items.map((item) => normalizeSchemaDefinitionTitles(item)) as JSONSchema7Definition[]; + } + } + + for (const combiner of ["anyOf", "allOf", "oneOf"] as const) { + if (normalized[combiner]) { + normalized[combiner] = normalized[combiner]!.map((item) => normalizeSchemaDefinitionTitles(item)) as JSONSchema7Definition[]; + } + } + + if (normalized.additionalProperties && typeof normalized.additionalProperties === "object") { + normalized.additionalProperties = normalizeSchemaTitles(normalized.additionalProperties as JSONSchema7); + } + + if (normalized.propertyNames && typeof normalized.propertyNames === "object" && !Array.isArray(normalized.propertyNames)) { + normalized.propertyNames = normalizeSchemaTitles(normalized.propertyNames as JSONSchema7); + } + + if (normalized.contains && typeof normalized.contains === "object" && !Array.isArray(normalized.contains)) { + normalized.contains = normalizeSchemaTitles(normalized.contains as JSONSchema7); + } + + if (normalized.not && typeof normalized.not === "object" && !Array.isArray(normalized.not)) { + normalized.not = normalizeSchemaTitles(normalized.not as JSONSchema7); + } + + if (normalized.if && typeof normalized.if === "object" && !Array.isArray(normalized.if)) { + normalized.if = normalizeSchemaTitles(normalized.if as JSONSchema7); + } + if (normalized.then && typeof normalized.then === "object" && !Array.isArray(normalized.then)) { + normalized.then = normalizeSchemaTitles(normalized.then as JSONSchema7); + } + if (normalized.else && typeof normalized.else === "object" && !Array.isArray(normalized.else)) { + normalized.else = normalizeSchemaTitles(normalized.else as JSONSchema7); + } + + if (normalized.patternProperties) { + const newPatternProps: Record = {}; + for (const [key, value] of Object.entries(normalized.patternProperties)) { + newPatternProps[key] = normalizeSchemaDefinitionTitles(value); + } + normalized.patternProperties = newPatternProps; + } + + const { definitions, $defs } = collectDefinitionCollections(normalized as Record); + if (Object.keys(definitions).length > 0) { + const newDefs: Record = {}; + for (const [key, value] of Object.entries(definitions)) { + newDefs[key] = normalizeSchemaDefinitionTitles(value); + } + normalized.definitions = newDefs; + } + if (Object.keys($defs).length > 0) { + const newDraftDefs: Record = {}; + for (const [key, value] of Object.entries($defs)) { + newDraftDefs[key] = normalizeSchemaDefinitionTitles(value); + } + normalized.$defs = newDraftDefs; + } + + return normalized; +} + +function normalizeApiNode(node: Record | undefined): Record | undefined { + if (!node) return undefined; + + const normalizedNode: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (isRpcMethod(value)) { + const method = value as RpcMethod; + normalizedNode[key] = { + ...method, + params: method.params ? normalizeSchemaTitles(method.params) : method.params, + result: method.result ? normalizeSchemaTitles(method.result) : method.result, + }; + } else if (typeof value === "object" && value !== null) { + normalizedNode[key] = normalizeApiNode(value as Record); + } else { + normalizedNode[key] = value; + } + } + + return normalizedNode; +} + +export function normalizeApiSchema(schema: ApiSchema): ApiSchema { + return { + ...schema, + definitions: schema.definitions + ? Object.fromEntries( + Object.entries(schema.definitions).map(([key, value]) => [key, normalizeSchemaDefinitionTitles(value)]) + ) + : schema.definitions, + $defs: schema.$defs + ? Object.fromEntries( + Object.entries(schema.$defs).map(([key, value]) => [key, normalizeSchemaDefinitionTitles(value)]) + ) + : schema.$defs, + server: normalizeApiNode(schema.server), + session: normalizeApiNode(schema.session), + clientSession: normalizeApiNode(schema.clientSession), + }; +} + /** Returns true when every leaf RPC method inside `node` is marked experimental. */ export function isNodeFullyExperimental(node: Record): boolean { const methods: RpcMethod[] = []; @@ -305,3 +465,163 @@ export function isNodeFullyExperimental(node: Record): boolean })(node); return methods.length > 0 && methods.every(m => m.stability === "experimental"); } + +// ── $ref resolution ───────────────────────────────────────────────────────── + +/** Extract the generated type name from a `$ref` path (e.g. "#/definitions/Model" → "Model"). */ +export function refTypeName(ref: string, definitions?: DefinitionCollections): string { + const baseName = ref.split("/").pop()!; + const match = ref.match(/^#\/(definitions|\$defs)\/(.+)$/); + if (!match || match[1] !== "$defs" || !definitions) return baseName; + + const key = match[2]; + const legacyDefinition = definitions.definitions?.[key]; + const draftDefinition = definitions.$defs?.[key]; + if ( + legacyDefinition !== undefined && + draftDefinition !== undefined && + stableStringify(legacyDefinition) !== stableStringify(draftDefinition) + ) { + return `Draft${baseName}`; + } + + return baseName; +} + +/** Resolve a `$ref` path against a definitions map, returning the referenced schema. */ +export function resolveRef( + ref: string, + definitions: DefinitionCollections | undefined +): JSONSchema7 | undefined { + const match = ref.match(/^#\/(definitions|\$defs)\/(.+)$/); + if (!match || !definitions) return undefined; + const [, namespace, key] = match; + const primary = namespace === "$defs" ? definitions.$defs : definitions.definitions; + const fallback = namespace === "$defs" ? definitions.definitions : definitions.$defs; + const def = primary?.[key] ?? fallback?.[key]; + return typeof def === "object" ? (def as JSONSchema7) : undefined; +} + +export function resolveSchema( + schema: JSONSchema7 | null | undefined, + definitions: DefinitionCollections | undefined +): JSONSchema7 | undefined { + let current = schema ?? undefined; + const seenRefs = new Set(); + while (current?.$ref) { + if (seenRefs.has(current.$ref)) break; + seenRefs.add(current.$ref); + const resolved = resolveRef(current.$ref, definitions); + if (!resolved) break; + current = resolved; + } + return current; +} + +export function resolveObjectSchema( + schema: JSONSchema7 | null | undefined, + definitions: DefinitionCollections | undefined +): JSONSchema7 | undefined { + const resolved = resolveSchema(schema, definitions) ?? schema ?? undefined; + if (!resolved) return undefined; + if (resolved.properties || resolved.additionalProperties || resolved.type === "object") return resolved; + + if (resolved.allOf) { + const mergedProperties: Record = {}; + const mergedRequired = new Set(); + const merged: JSONSchema7 = { + type: "object", + description: resolved.description, + }; + let hasObjectShape = false; + + for (const item of resolved.allOf) { + if (typeof item !== "object") continue; + const objectSchema = resolveObjectSchema(item as JSONSchema7, definitions); + if (!objectSchema) continue; + + if (objectSchema.properties) { + Object.assign(mergedProperties, objectSchema.properties); + hasObjectShape = true; + } + if (objectSchema.required) { + for (const name of objectSchema.required) { + mergedRequired.add(name); + } + } + if (objectSchema.additionalProperties !== undefined) { + merged.additionalProperties = objectSchema.additionalProperties; + hasObjectShape = true; + } + if (!merged.description && objectSchema.description) { + merged.description = objectSchema.description; + } + } + + if (!hasObjectShape) return resolved; + if (Object.keys(mergedProperties).length > 0) { + merged.properties = mergedProperties; + } + if (mergedRequired.size > 0) { + merged.required = [...mergedRequired]; + } + return merged; + } + + const singleBranch = (resolved.anyOf ?? resolved.oneOf) + ?.filter((item): item is JSONSchema7 => typeof item === "object" && (item as JSONSchema7).type !== "null"); + if (singleBranch && singleBranch.length === 1) { + return resolveObjectSchema(singleBranch[0], definitions); + } + + return resolved; +} + +export function hasSchemaPayload(schema: JSONSchema7 | null | undefined): boolean { + if (!schema) return false; + if (schema.properties) return Object.keys(schema.properties).length > 0; + if (schema.additionalProperties) return true; + if (schema.items) return true; + if (schema.anyOf || schema.oneOf || schema.allOf) return true; + if (schema.enum && schema.enum.length > 0) return true; + if (schema.const !== undefined) return true; + if (schema.$ref) return true; + if (Array.isArray(schema.type)) return schema.type.length > 0 && !(schema.type.length === 1 && schema.type[0] === "object"); + return schema.type !== undefined && schema.type !== "object"; +} + +export function collectDefinitionCollections( + schema: Record +): Required { + return { + definitions: { ...((schema.definitions ?? {}) as Record) }, + $defs: { ...((schema.$defs ?? {}) as Record) }, + }; +} + +/** Collect the shared definitions from a schema (handles both `definitions` and `$defs`). */ +export function collectDefinitions( + schema: Record +): Record { + const { definitions, $defs } = collectDefinitionCollections(schema); + return { ...$defs, ...definitions }; +} + +export function withSharedDefinitions( + schema: T, + definitions: DefinitionCollections +): SchemaWithSharedDefinitions { + const legacyDefinitions = { ...(definitions.definitions ?? {}) }; + const draft2019Definitions = { ...(definitions.$defs ?? {}) }; + + const sharedLegacyDefinitions = + Object.keys(legacyDefinitions).length > 0 ? legacyDefinitions : { ...draft2019Definitions }; + const sharedDraftDefinitions = + Object.keys(draft2019Definitions).length > 0 ? draft2019Definitions : { ...legacyDefinitions }; + + return { + ...schema, + definitions: sharedLegacyDefinitions, + $defs: sharedDraftDefinitions, + }; +}