diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6d99ce49e..803252fe3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -831,11 +831,6 @@ export class CopilotClient { private setupClientGlobalHandlers(): void { const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; - // `hooks.invoke` is a client-global RPC method whose payload carries a - // `sessionId`; route each invocation to the matching session's dispatcher. - handlers.hooks = { - invoke: async (params) => await this.handleHooksInvoke(params), - }; if (this.requestHandler) { handlers.llmInference = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { @@ -2843,6 +2838,17 @@ export class CopilotClient { // — the runtime calls into a single handler for the whole connection. registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); + // `hooks.invoke` is an internal RPC method: the runtime calls it to + // invoke a hook callback on the client. Route each call to the matching + // session's dispatcher. Not part of the public ClientGlobalApiHandlers + // interface because HookInvokeRequest/HookType are internal types. + this.connection.onRequest( + "hooks.invoke", + async (params: { sessionId: string; hookType: string; input: unknown }) => { + return await this.handleHooksInvoke(params); + } + ); + this.connection.onClose(() => { this.state = "disconnected"; }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index db9039238..d80c9386b 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -21351,19 +21351,6 @@ export function registerClientSessionApiHandlers( }); } -/** Handler for `hooks` client global API methods. */ -/** @experimental */ -export interface HooksHandler { - /** - * Dispatches one SDK callback hook from the runtime to the connection that registered it. Internal transport plumbing: clients opt in through session initialization and the Rust hook processor owns ordering, policy, timeout, and callback routing. - * - * @param params Runtime-owned wire payload for a server-to-client hook callback invocation. - * - * @returns Optional output returned by an SDK callback hook. - */ - invoke(params: HookInvokeRequest): Promise; -} - /** Handler for `llmInference` client global API methods. */ /** @experimental */ export interface LlmInferenceHandler { @@ -21398,7 +21385,6 @@ export interface GitHubTelemetryHandler { /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { - hooks?: HooksHandler; llmInference?: LlmInferenceHandler; gitHubTelemetry?: GitHubTelemetryHandler; } @@ -21414,11 +21400,6 @@ export function registerClientGlobalApiHandlers( connection: MessageConnection, handlers: ClientGlobalApiHandlers, ): void { - connection.onRequest("hooks.invoke", async (params: HookInvokeRequest) => { - const handler = handlers.hooks; - if (!handler) throw new Error("No hooks client-global handler registered"); - return handler.invoke(params); - }); connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest) => { const handler = handlers.llmInference; if (!handler) throw new Error("No llmInference client-global handler registered"); diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 193dc0def..e619488b2 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -39,7 +39,6 @@ export type SessionEvent = | UserMessageEvent | PendingMessagesModifiedEvent | AssistantTurnStartEvent - | AssistantTurnRetryEvent | AssistantIntentEvent | AssistantServerToolProgressEvent | AssistantReasoningEvent @@ -53,7 +52,6 @@ export type SessionEvent = | AssistantIdleEvent | AssistantUsageEvent | ModelCallFailureEvent - | ModelCallStartEvent | AbortEvent | ToolUserRequestedEvent | ToolExecutionStartEvent @@ -3266,54 +3264,6 @@ export interface AssistantTurnStartData { */ turnId: string; } -/** - * Session event "assistant.turn_retry". Metadata for an additional model inference attempt within an existing assistant turn - */ -/** @internal */ -export interface AssistantTurnRetryEvent { - /** - * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. - */ - agentId?: string; - data: AssistantTurnRetryData; - /** - * Always true for events that are transient and not persisted to the session event log on disk. - */ - ephemeral: true; - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * Type discriminator. Always "assistant.turn_retry". - */ - type: "assistant.turn_retry"; -} -/** - * Metadata for an additional model inference attempt within an existing assistant turn - */ -export interface AssistantTurnRetryData { - /** - * Model identifier used for this retry, when known - */ - model?: string; - /** - * Provider or runtime classification that caused the retry, when known - */ - reason?: string; - /** - * Identifier of the turn whose model inference is being retried - */ - turnId: string; -} /** * Session event "assistant.intent". Agent intent description for current activity or plan */ @@ -4418,56 +4368,6 @@ export interface ModelCallFailureRequestFingerprint { */ toolResultMessageCount: number; } -/** - * Session event "model.call_start". Model API dispatch metadata for internal telemetry - */ -/** @internal */ -export interface ModelCallStartEvent { - /** - * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. - */ - agentId?: string; - data: ModelCallStartData; - /** - * Always true for events that are transient and not persisted to the session event log on disk. - */ - ephemeral: true; - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * Type discriminator. Always "model.call_start". - */ - type: "model.call_start"; -} -/** - * Model API dispatch metadata for internal telemetry - */ -export interface ModelCallStartData { - /** - * Model identifier used for this API call, when known - */ - model?: string; - /** - * Previous response or interaction identifier included in the model request, when present - * - * @internal - */ - previousResponseId?: string; - /** - * Identifier of the assistant turn that initiated the model call - */ - turnId: string; -} /** * Session event "abort". Turn abort information including the reason for termination */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 77149bc4b..6371642e2 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3139,16 +3139,47 @@ describe("CopilotClient", () => { expect(failureCalls).toEqual(["fail-tool"]); }); + it("registers hooks.invoke on the JSON-RPC connection and routes it to handleHooksInvoke", async () => { + const client = new CopilotClient(); + const handleHooksInvoke = vi + .spyOn(client as any, "handleHooksInvoke") + .mockResolvedValue({ output: { additionalContext: "ok" } }); + + const fakeConnection = { + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }; + + (client as any).connection = fakeConnection; + (client as any).attachConnectionHandlers(); + + const hooksRegistration = fakeConnection.onRequest.mock.calls.find( + ([method]: [string, unknown]) => method === "hooks.invoke" + ); + expect(hooksRegistration).toBeDefined(); + + const handler = hooksRegistration![1] as (params: { + sessionId: string; + hookType: string; + input: unknown; + }) => Promise<{ output?: unknown }>; + const payload = { + sessionId: "session-1", + hookType: "postToolUseFailure", + input: { toolName: "shell" }, + }; + + await expect(handler(payload)).resolves.toEqual({ + output: { additionalContext: "ok" }, + }); + expect(handleHooksInvoke).toHaveBeenCalledWith(payload); + }); + it("routes hooks.invoke JSON-RPC requests to the SessionHooks handler", async () => { - // Validates the full JSON-RPC entry point used by the CLI: - // clientGlobalHandlers.hooks.invoke({sessionId, hookType, input}) - // → CopilotSession._handleHooksInvoke(hookType, input) - // → SessionHooks.onPostToolUseFailure(normalizedInput, {sessionId}) - // - // This guards the wire-format contract that the bundled Copilot - // CLI relies on: the hookType string "postToolUseFailure" and the - // input shape `{toolName, toolArgs, error, timestamp, cwd}`. - // The SDK maps that to public `{..., timestamp: Date, workingDirectory}`. + // Validates the dispatch behavior for the internal `hooks.invoke` + // payload after the JSON-RPC connection hands it to the SDK. const client = new CopilotClient(); await client.start(); onTestFinished(() => stopClient(client)); @@ -3172,7 +3203,7 @@ describe("CopilotClient", () => { cwd: "/tmp", }; - const response = await (client as any).clientGlobalHandlers.hooks.invoke({ + const response = await (client as any).handleHooksInvoke({ sessionId: session.sessionId, hookType: "postToolUseFailure", input: failureInput, @@ -3249,7 +3280,7 @@ describe("CopilotClient", () => { }, }); - const response = await (client as any).clientGlobalHandlers.hooks.invoke({ + const response = await (client as any).handleHooksInvoke({ sessionId: session.sessionId, hookType: "agentStop", input: { diff --git a/nodejs/test/typescript-codegen.test.ts b/nodejs/test/typescript-codegen.test.ts index 248b60968..e3f3d2857 100644 --- a/nodejs/test/typescript-codegen.test.ts +++ b/nodejs/test/typescript-codegen.test.ts @@ -2,7 +2,12 @@ import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { describe, expect, it } from "vitest"; -import { normalizeSchemaForTypeScript } from "../../scripts/codegen/typescript.ts"; +import { + assertNoPublicInternalReferences, + filterPublicSessionEventVariants, + normalizeSchemaForTypeScript, +} from "../../scripts/codegen/typescript.ts"; +import type { DefinitionCollections } from "../../scripts/codegen/utils.ts"; describe("typescript schema codegen", () => { it("emits JSDoc comments for described enum values", async () => { @@ -44,3 +49,223 @@ describe("typescript schema codegen", () => { expect(code).toContain('inlineMode: /** Use a direct value. */ "direct" | "indirect";'); }); }); + +describe("filterPublicSessionEventVariants", () => { + const makeCollections = (defs: Record): DefinitionCollections => ({ + definitions: defs, + $defs: {}, + }); + + it("keeps public union arms", () => { + const defs = { + PublicEvent: { type: "object" as const, properties: { type: { const: "pub" } } }, + }; + const variants: JSONSchema7[] = [{ $ref: "#/definitions/PublicEvent" }]; + const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( + variants, + makeCollections(defs) + ); + expect(publicVariants).toHaveLength(1); + expect(excludedDefinitionNames.size).toBe(0); + }); + + it("excludes arms whose arm object is marked visibility:internal", () => { + const defs = { + InternalEvent: { + type: "object" as const, + visibility: "internal", + properties: { type: { const: "internal.evt" } }, + } as JSONSchema7 & { visibility: string }, + }; + const variants: JSONSchema7[] = [ + { $ref: "#/definitions/InternalEvent", visibility: "internal" } as JSONSchema7 & { + visibility: string; + }, + ]; + const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( + variants, + makeCollections(defs) + ); + expect(publicVariants).toHaveLength(0); + expect(excludedDefinitionNames.has("InternalEvent")).toBe(true); + }); + + it("excludes arms whose resolved definition is marked visibility:internal", () => { + const defs = { + InternalEvent: { + type: "object" as const, + visibility: "internal", + properties: { type: { const: "internal.evt" } }, + } as JSONSchema7 & { visibility: string }, + }; + // arm object itself is NOT marked, but the resolved definition is + const variants: JSONSchema7[] = [{ $ref: "#/definitions/InternalEvent" }]; + const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( + variants, + makeCollections(defs) + ); + expect(publicVariants).toHaveLength(0); + expect(excludedDefinitionNames.has("InternalEvent")).toBe(true); + }); + + it("excludes arms whose internal data sub-property is the only internal marker (legacy pattern)", () => { + // Event types that carry a `data: InternalData` field — the `data` property is what is + // internal, not the event wrapper type itself. + const defs = { + InternalData: { + type: "object" as const, + visibility: "internal", + } as JSONSchema7 & { visibility: string }, + WrapperEvent: { + type: "object" as const, + properties: { + type: { const: "wrapper.evt" }, + data: { $ref: "#/definitions/InternalData" }, + }, + }, + }; + const variants: JSONSchema7[] = [{ $ref: "#/definitions/WrapperEvent" }]; + const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( + variants, + makeCollections(defs) + ); + expect(publicVariants).toHaveLength(0); + expect(excludedDefinitionNames.has("WrapperEvent")).toBe(true); + expect(excludedDefinitionNames.has("InternalData")).toBe(true); + }); +}); + +describe("assertNoPublicInternalReferences", () => { + it("passes when all declarations are public and do not reference internal types", () => { + const ts = ` +export interface Foo { + bar: string; +} +export type Bar = "a" | "b"; +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); + + it("passes when the only reference is from an @internal-tagged declaration", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +/** @internal */ +export interface AlsoInternal { + h: Hidden; +} +export interface Public { + y: string; +} +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); + + it("passes when the reference is inside an @internal-tagged member of a public type", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +export interface Public { + /** + * Some field. + * @internal + */ + secret?: Hidden; + visible: string; +} +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); + + it("throws when a public declaration references an internal type directly", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +export type Event = PublicEvent | Hidden; +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).toThrow( + /Event \(public\) references internal type Hidden/ + ); + }); + + it("throws when a public interface member references an internal type", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +export interface Public { + value: Hidden; +} +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).toThrow( + /Public \(public\) references internal type Hidden/ + ); + }); + + it("does not count JSDoc comment text as a code reference", () => { + // The auto-generated JSDoc says 'via the definition "Hidden"' but that is not a + // real TypeScript type reference — it must not trigger the validator. + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +export interface Preceding { + y: string; +} +/** + * This interface was referenced by something. + * via the definition "Hidden". + */ +export interface Following { + z: string; +} +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); + + it("does not count inline object-shaped @internal members as public references", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +export interface Public { + /** + * Some field. + * @internal + */ + secret?: { + [k: string]: Hidden | undefined; + }; + visible: string; +} +`; + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); + + it("does not count function body references as public type references", () => { + const ts = ` +/** @internal */ +export interface Hidden { + x: number; +} +/** @internal */ +export function doInternal(connection: unknown): void { + connection.onRequest("x", async (params: Hidden) => { return params; }); +} +export function doPublic(connection: unknown): void { + connection.onRequest("x", async (params: Hidden) => { return params; }); +} +`; + // function body references are stripped — only signature matters + expect(() => assertNoPublicInternalReferences(ts, new Set(["Hidden"]))).not.toThrow(); + }); +}); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f82e67abe..30cefd3e9 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -57,6 +57,81 @@ function tsExperimentalJSDoc(indent = ""): string { return `${indent}${TS_EXPERIMENTAL_JSDOC}`; } +/** + * Validates that no public declaration in the generated TypeScript references an internal type. + * + * If the schema is valid (enforced by the runtime's `assert_no_public_internal_references` lint), + * this should never trigger. A failure here means the codegen itself produced a public reference + * to an internal type — which is a codegen bug that must be fixed, not silently worked around. + */ +export function assertNoPublicInternalReferences(generatedTs: string, internalTypes: Set): void { + if (internalTypes.size === 0) return; + + // Identify declarations tagged @internal anywhere in their JSDoc (multi-line or single-line). + const internalDeclarations = new Set(); + for (const m of generatedTs.matchAll( + /\/\*\*(?:[^*]|\*(?!\/))*@internal(?:[^*]|\*(?!\/))*\*\/\s*\nexport (?:interface|type|function|const) (\w+)\b/g + )) { + internalDeclarations.add(m[1]); + } + + // Split on export interface/type/function/const boundaries for attribution. + const declarationRe = /^export (interface|type|function|const) (\w+)\b/gm; + const starts: Array<{ index: number; kind: string; name: string }> = []; + for (let m = declarationRe.exec(generatedTs); m !== null; m = declarationRe.exec(generatedTs)) { + starts.push({ index: m.index, kind: m[1], name: m[2] }); + } + const blocks = starts.map((start, i) => ({ + kind: start.kind, + name: start.name, + text: generatedTs.slice(start.index, i + 1 < starts.length ? starts[i + 1].index : generatedTs.length), + })); + + const violations: string[] = []; + for (const intType of internalTypes) { + for (const block of blocks) { + if (block.name === intType) continue; + if (internalDeclarations.has(block.name)) continue; + + // Strip content that does not appear in the emitted .d.ts: + // 1. All JSDoc/block comments — prevents doc-comment text that happens to name a + // type (e.g. "via the definition X") from registering as a code reference. + // 2. Function bodies — declaration emit drops bodies, so a reference inside a + // function implementation is not a public type reference. + // 3. @internal-tagged member sections — TypeScript's stripInternal removes them + // from the .d.ts along with any types they reference. + let publicText = block.text + // Remove @internal-tagged member declarations before stripping comments so + // member-level internal references do not count as part of the public surface. + // Handles both simple members (`foo?: Hidden;`) and inline object-shaped members + // (`foo?: { ... };`) used by generated TypeScript interfaces. + .replace( + /^[ \t]*\/\*\*[\s\S]*?@internal[\s\S]*?\*\/\s*\n(?:[ \t]*[^\n{;]+;\n?|[ \t]*[^\n{]+\{\n[\s\S]*?^[ \t]*\};\n?)/gm, + "" + ) + // Remove all remaining block comments (JSDoc and otherwise). + .replace(/\/\*[\s\S]*?\*\//g, ""); + + if (block.kind === "function") { + // Remove function bodies (from the opening { to matching closing }). + publicText = publicText.replace(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, "{}"); + } + + if (new RegExp(`\\b${intType}\\b`).test(publicText)) { + violations.push(` ${block.name} (public) references internal type ${intType}`); + } + } + } + + if (violations.length > 0) { + throw new Error( + `Codegen produced public declarations that reference internal types.\n` + + `This is a codegen bug — fix the generator so internal types are not referenced by public output:\n` + + violations.join("\n") + ); + } +} + function sanitizeJsDocText(text: string): string { return text.trim().replace(/\*\//g, "* /"); } @@ -338,21 +413,37 @@ export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { // ── Session Events ────────────────────────────────────────────────────────── -async function generateSessionEvents(schemaPath?: string): Promise { - console.log("TypeScript: generating session-events..."); - - const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); - const schema = (await loadSchemaJson(resolvedPath)) as JSONSchema7; - const processed = propagateInternalVisibility(postProcessSchema(schema)); - const definitionCollections = collectDefinitionCollections(processed as Record); - const sessionEvent = - resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? - resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections) ?? - processed; +/** + * Filters a `SessionEvent` union schema to exclude internal arms. + * + * The schema marks internal union members with `visibility: "internal"` on the arm object itself + * AND on the resolved definition. An arm is excluded when either level is internal, or when the + * arm's resolved `data` property is internal (legacy pattern for event types that carry their + * payload in a `data` field). + * + * Returns the filtered arms and the set of definition names to exclude from compilation. + */ +export function filterPublicSessionEventVariants( + variants: JSONSchema7[], + definitionCollections: DefinitionCollections +): { publicVariants: JSONSchema7[]; excludedDefinitionNames: Set } { const excludedDefinitionNames = new Set(); - const publicVariants = (sessionEvent.anyOf ?? []).filter((variant) => { + const publicVariants = variants.filter((variant) => { const variantSchema = variant as JSONSchema7; const resolvedVariant = resolveSchema(variantSchema, definitionCollections) ?? variantSchema; + + // Exclude the arm if the arm object itself or its resolved definition is internal. + // The schema marks internal union members at both levels; checking only the resolved + // definition's `data` sub-property (the original logic) missed cases where the event + // type itself carries `visibility: "internal"`. + if (isSchemaInternal(variantSchema) || isSchemaInternal(resolvedVariant)) { + for (const ref of [variantSchema.$ref]) { + const match = ref?.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/); + if (match) excludedDefinitionNames.add(match[1]); + } + return false; + } + const dataSchema = resolvedVariant.properties?.data as JSONSchema7 | undefined; const resolvedData = dataSchema ? resolveSchema(dataSchema, definitionCollections) ?? dataSchema : undefined; if (!isSchemaInternal(resolvedData)) { @@ -365,6 +456,24 @@ async function generateSessionEvents(schemaPath?: string): Promise { } return false; }); + return { publicVariants, excludedDefinitionNames }; +} + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("TypeScript: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = (await loadSchemaJson(resolvedPath)) as JSONSchema7; + const processed = propagateInternalVisibility(postProcessSchema(schema)); + const definitionCollections = collectDefinitionCollections(processed as Record); + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections) ?? + processed; + const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( + sessionEvent.anyOf ?? [], + definitionCollections + ); const publicDefinitions = Object.fromEntries( Object.entries(definitionCollections.definitions).filter(([name]) => !excludedDefinitionNames.has(name)) ); @@ -398,6 +507,9 @@ async function generateSessionEvents(schemaPath?: string): Promise { // Add @internal JSDoc annotations for session-event types marked // `visibility: "internal"` in the schema. The tag drives `stripInternal` // so the whole type is dropped from the published .d.ts. + // Because internal union arms are excluded from the compiled output by the + // publicVariants filter above, no public declaration should reference these + // types; assertNoPublicInternalReferences enforces that invariant hard. const sessionInternalTypes = new Set(); for (const [name, def] of Object.entries(definitionCollections.definitions ?? {})) { if (def && typeof def === "object" && (def as Record).visibility === "internal") { @@ -415,6 +527,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { `$1/** @internal */\n$2` ); } + assertNoPublicInternalReferences(annotatedTs, sessionInternalTypes); const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", annotatedTs); console.log(` ✓ ${outPath}`); } @@ -675,13 +788,9 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `$1/** @deprecated */\n$2` ); } - // Add @internal JSDoc annotations for types from internal methods - for (const intType of internalTypes) { - annotatedTs = annotatedTs.replace( - new RegExp(`(^|\\n)(export (?:interface|type) ${intType}\\b)`, "m"), - `$1/** @internal */\n$2` - ); - } + // @internal tagging happens in a final pass over the assembled file: the client/server + // method signatures that reference these types are emitted later, so a per-chunk check + // would not see them and would strip a type the public API still names. lines.push(annotatedTs); lines.push(""); } @@ -758,7 +867,20 @@ function hasInternalMethods(node: Record): boolean { lines.push(...emitClientGlobalApiRegistration(schema.clientGlobal)); } - const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); + // Apply @internal to RPC types in a final pass over the assembled file. + // The client/server method signatures that reference these types are emitted + // after the per-schema type chunks, so the tagging must happen here rather + // than per-chunk. assertNoPublicInternalReferences then enforces hard that no + // public declaration slipped through referencing a type the schema marked internal. + let rpcTs = lines.join("\n"); + for (const intType of internalTypes) { + rpcTs = rpcTs.replace( + new RegExp(`(^|\\n)(export (?:interface|type) ${intType}\\b)`, "m"), + `$1/** @internal */\n$2` + ); + } + assertNoPublicInternalReferences(rpcTs, internalTypes); + const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", rpcTs); console.log(` ✓ ${outPath}`); } @@ -983,6 +1105,9 @@ function emitClientGlobalApiRegistration(clientSchema: Record): for (const [groupName, methods] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; + const publicMethods = methods.filter((m) => m.visibility !== "internal"); + // Skip groups that have no public methods — they are handled internally by the SDK. + if (publicMethods.length === 0) continue; const groupDeprecated = isNodeFullyDeprecated(clientSchema[groupName] as Record); const groupExperimental = isNodeFullyExperimental(clientSchema[groupName] as Record); if (groupDeprecated) { @@ -994,7 +1119,7 @@ function emitClientGlobalApiRegistration(clientSchema: Record): lines.push(`/** Handler for \`${groupName}\` client global API methods. */`); } lines.push(`export interface ${interfaceName} {`); - for (const method of methods) { + for (const method of publicMethods) { const name = handlerMethodName(method.rpcMethod); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); const pType = hasParams ? paramsTypeName(method) : ""; @@ -1019,7 +1144,9 @@ function emitClientGlobalApiRegistration(clientSchema: Record): lines.push(`/** All client global API handler groups. */`); lines.push(`export interface ClientGlobalApiHandlers {`); - for (const [groupName] of groups) { + for (const [groupName, methods] of groups) { + const publicMethods = methods.filter((m) => m.visibility !== "internal"); + if (publicMethods.length === 0) continue; const interfaceName = toPascalCase(groupName) + "Handler"; lines.push(` ${groupName}?: ${interfaceName};`); } @@ -1039,7 +1166,10 @@ function emitClientGlobalApiRegistration(clientSchema: Record): lines.push(`): void {`); for (const [groupName, methods] of groups) { - for (const method of methods) { + // Only wire up public methods; internal methods are handled directly by the SDK. + const publicMethods = methods.filter((m) => m.visibility !== "internal"); + if (publicMethods.length === 0) continue; + for (const method of publicMethods) { const name = handlerMethodName(method.rpcMethod); const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method));