From 53dfff33b2c679f0658c914daca93f97c6ad63c9 Mon Sep 17 00:00:00 2001 From: Galin Iliev Date: Fri, 11 Sep 2026 22:50:05 -0700 Subject: [PATCH] fix(typescript): expose public enums to isolated consumers Generate ordinary enums in the TypeScript SDK mirror so package declarations work with isolatedModules and verbatimModuleSyntax. Preserve canonical protocol sources and all emitted JavaScript values. Build before SDK tests and exercise an installed npm tarball through TypeScript compilation and plain Node execution, including numeric enum flags and reverse mappings. --- clients/typescript/README.md | 12 ++-- clients/typescript/package.json | 2 +- .../test/fixtures/package-consumer.ts | 52 ++++++++++++++++ .../typescript/test/package-consumer.test.ts | 61 +++++++++++++++++++ .../20260911-typescript-public-enums.json | 5 ++ scripts/generate-typescript.ts | 26 +++++--- 6 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 clients/typescript/test/fixtures/package-consumer.ts create mode 100644 clients/typescript/test/package-consumer.test.ts create mode 100644 docs/.changes/20260911-typescript-public-enums.json diff --git a/clients/typescript/README.md b/clients/typescript/README.md index d8b85a4c2..1a84d3a19 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -207,9 +207,11 @@ by `(hostId, uri)` so URIs that legitimately collide across hosts ## Wire types The wire types under `src/types/` are generated from `types/*.ts` at the -repository root and are **not committed** to the repo — avoiding a -byte-for-byte duplication of the canonical TypeScript sources. Regenerate -them whenever you pull or change the protocol: +repository root and are **not committed** to the repo. The generator emits +ordinary enums in the SDK so its declarations support consumers using +`isolatedModules` or `verbatimModuleSyntax`, while preserving the canonical +enum members and their wire values. Regenerate whenever you pull or change +the protocol: ```bash npm run generate:typescript # from the repo root @@ -266,7 +268,9 @@ npm run build CI runs the generate step automatically before the install/typecheck/test/build sequence, so contributors only need to remember step 1 locally after pulling -protocol changes. +protocol changes. `npm test` builds the package and includes a packed-package +consumer check that compiles with `isolatedModules` and `verbatimModuleSyntax` +and runs the emitted JavaScript. ## License diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 0494576d6..26d1bde01 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -49,7 +49,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --test --import tsx test/*.test.ts", + "test": "npm run build && node --test --import tsx test/*.test.ts", "clean": "rm -rf dist" }, "devDependencies": { diff --git a/clients/typescript/test/fixtures/package-consumer.ts b/clients/typescript/test/fixtures/package-consumer.ts new file mode 100644 index 000000000..36f184b64 --- /dev/null +++ b/clients/typescript/test/fixtures/package-consumer.ts @@ -0,0 +1,52 @@ +import { + ActionType, + PolicyState, + ResponsePartKind, + SessionStatus, + type ResponsePart, + type StateAction, +} from '@microsoft/agent-host-protocol'; +import { AhpClient } from '@microsoft/agent-host-protocol/client'; +import { MultiHostClient } from '@microsoft/agent-host-protocol/hosts'; +import { WebSocketTransport } from '@microsoft/agent-host-protocol/ws'; + +const action: StateAction = { + type: ActionType.RootActiveSessionsChanged, + activeSessions: 2, +}; +const response: ResponsePart = { + kind: ResponsePartKind.Markdown, + id: 'response-1', + content: 'Hello', +}; +const status: SessionStatus = SessionStatus.InputNeeded; + +// Run the emitted JavaScript without a TypeScript loader: imports must resolve +// to real enum objects, including numeric reverse mappings and bit flags. +const actual = { + action: action.type, + response: response.kind, + policy: PolicyState.Enabled, + status, + inProgress: status & SessionStatus.InProgress, + statusName: SessionStatus[SessionStatus.InputNeeded], + actionObject: typeof ActionType, + client: typeof AhpClient, + hosts: typeof MultiHostClient, + ws: typeof WebSocketTransport, +}; +const expected = { + action: 'root/activeSessionsChanged', + response: 'markdown', + policy: 'enabled', + status: 24, + inProgress: 8, + statusName: 'InputNeeded', + actionObject: 'object', + client: 'function', + hosts: 'function', + ws: 'function', +}; +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected package exports: ${JSON.stringify(actual)}`); +} diff --git a/clients/typescript/test/package-consumer.test.ts b/clients/typescript/test/package-consumer.test.ts new file mode 100644 index 000000000..6130a814e --- /dev/null +++ b/clients/typescript/test/package-consumer.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const tsc = createRequire(import.meta.url).resolve('typescript/bin/tsc'); + +function npm(args: string[], cwd: string): string { + const npmCli = process.env.npm_execpath; + return execFileSync(npmCli ? process.execPath : 'npm', npmCli ? [npmCli, ...args] : args, { + cwd, + encoding: 'utf8', + stdio: 'pipe', + }); +} + +test('packed package supports isolatedModules and verbatimModuleSyntax consumers', (t) => { + const consumer = mkdtempSync(path.join(tmpdir(), 'ahp-package-consumer-')); + t.after(() => rmSync(consumer, { recursive: true, force: true })); + + // npm test builds first. Pack and install that artifact outside the checkout + // so package exports and declarations are resolved just as for a consumer. + const [packed] = JSON.parse(npm(['pack', '--json', '--pack-destination', consumer], packageRoot)); + writeFileSync(path.join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + npm([ + 'install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', + path.join(consumer, packed.filename), + ], consumer); + copyFileSync(new URL('./fixtures/package-consumer.ts', import.meta.url), path.join(consumer, 'index.ts')); + + for (const verbatimModuleSyntax of [false, true]) { + writeFileSync(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + isolatedModules: true, + verbatimModuleSyntax, + skipLibCheck: false, + types: [], + outDir: './out', + }, + files: ['index.ts'], + })); + execFileSync(process.execPath, [tsc, '-p', consumer], { encoding: 'utf8', stdio: 'pipe' }); + execFileSync(process.execPath, [path.join(consumer, 'out/index.js')], { encoding: 'utf8', stdio: 'pipe' }); + } + + const dist = path.join(consumer, 'node_modules/@microsoft/agent-host-protocol/dist'); + const declarations = readdirSync(dist, { recursive: true, encoding: 'utf8' }).filter(file => file.endsWith('.d.ts')); + assert.ok(declarations.length > 0, 'the package must contain declarations'); + for (const file of declarations) { + assert.doesNotMatch(readFileSync(path.join(dist, file), 'utf8'), /\bdeclare\s+const\s+enum\b/, file); + } +}); diff --git a/docs/.changes/20260911-typescript-public-enums.json b/docs/.changes/20260911-typescript-public-enums.json new file mode 100644 index 000000000..daa4643b8 --- /dev/null +++ b/docs/.changes/20260911-typescript-public-enums.json @@ -0,0 +1,5 @@ +{ + "type": "fixed", + "message": "Public enum declarations now support TypeScript consumers using `isolatedModules` or `verbatimModuleSyntax` without changing runtime enum values.", + "targets": ["typescript"] +} diff --git a/scripts/generate-typescript.ts b/scripts/generate-typescript.ts index 6119c1644..e505cc030 100644 --- a/scripts/generate-typescript.ts +++ b/scripts/generate-typescript.ts @@ -2,14 +2,19 @@ * TypeScript Client Generator — Copies the canonical TypeScript protocol * sources under `types/` into the TypeScript client source tree at * `clients/typescript/src/types/`, prepending a generated-file banner to - * each emitted file. + * each emitted file and emitting ordinary enums for the public SDK. * * Unlike the Rust and Swift generators (which translate the TypeScript - * source into a different language and commit the result), this output is - * a literal copy and is intentionally **not** committed. The destination + * source into a different language and commit the result), this source + * mirror is intentionally **not** committed. The destination * directory is gitignored in `clients/typescript/.gitignore`; CI and the * documented dev flow regenerate it from the canonical sources. * + * Canonical const enums become ordinary enums in this mirror so the built + * declarations work for isolatedModules and verbatimModuleSyntax consumers. + * The SDK already emits runtime enum objects under isolatedModules; the + * canonical sources and the enum members' wire values are left unchanged. + * * Before copying, the generator runs `generateActionOrigin` so the * derived `action-origin.generated.ts` file in `types/` is current. This * makes `npm run generate:typescript` self-contained — running just that @@ -27,7 +32,7 @@ * Output: clients/typescript/src/types/**\/*.ts */ -import { Project, SourceFile } from 'ts-morph'; +import { Project, SourceFile, SyntaxKind } from 'ts-morph'; import fs from 'fs'; import path from 'path'; import { generateActionOrigin } from './generate-action-origin.js'; @@ -95,6 +100,8 @@ export function generateTypeScriptClient(project: Project, typesDir: string, out generateActionOrigin(project, typesDir); const sources = project.getSourceFiles().filter(sf => shouldEmit(sf, typesDir)); + // Keep SDK-only transformations out of the project shared by all generators. + const outputProject = new Project({ useInMemoryFileSystem: true }); rmDirContents(outDir); ensureDir(outDir); @@ -105,9 +112,14 @@ export function generateTypeScriptClient(project: Project, typesDir: string, out ensureDir(path.dirname(destPath)); const raw = fs.readFileSync(sf.getFilePath(), 'utf-8'); - const withBanner = raw.startsWith(COPY_BANNER_MARKER) - ? raw - : `${GENERATED_BANNER}\n${raw}`; + const outputSource = outputProject.createSourceFile(rel, raw); + for (const declaration of outputSource.getDescendantsOfKind(SyntaxKind.EnumDeclaration)) { + declaration.setIsConstEnum(false); + } + const contents = outputSource.getFullText(); + const withBanner = contents.startsWith(COPY_BANNER_MARKER) + ? contents + : `${GENERATED_BANNER}\n${contents}`; fs.writeFileSync(destPath, withBanner); } }