Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions clients/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion clients/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions clients/typescript/test/fixtures/package-consumer.ts
Original file line number Diff line number Diff line change
@@ -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)}`);
}
61 changes: 61 additions & 0 deletions clients/typescript/test/package-consumer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
5 changes: 5 additions & 0 deletions docs/.changes/20260911-typescript-public-enums.json
Original file line number Diff line number Diff line change
@@ -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"]
}
26 changes: 19 additions & 7 deletions scripts/generate-typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}