diff --git a/clients/typescript/README.md b/clients/typescript/README.md index d8b85a4c..1a84d3a1 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 0494576d..26d1bde0 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 00000000..36f184b6 --- /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 00000000..6130a814 --- /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 00000000..daa4643b --- /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 6119c164..e505cc03 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); } }