Skip to content

Commit b5def30

Browse files
authored
Merge pull request microsoft#302639 from microsoft/connor4312/agent-host-types2
agentHost: migrate to use protocol types
2 parents 12373f4 + 2cd7a84 commit b5def30

42 files changed

Lines changed: 3338 additions & 1883 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"extensions/terminal-suggest/src/completions/upstream/**": true,
7373
"test/smoke/out/**": true,
7474
"test/automation/out/**": true,
75+
"src/vs/platform/agentHost/common/state/protocol/**": true,
7576
"test/integration/browser/out/**": true,
7677
// "src/vs/sessions/**": true
7778
},
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
// Copies type definitions from the sibling `agent-host-protocol` repo into
7+
// `src/vs/platform/agentHost/common/state/protocol/`. Run via:
8+
//
9+
// npx tsx scripts/sync-agent-host-protocol.ts
10+
//
11+
// Transformations applied:
12+
// 1. Converts `const enum` to `const` object + string literal union (VS Code
13+
// tsconfig uses `preserveConstEnums` which makes `const enum` nominal).
14+
// 2. Converts 2-space indentation to tabs.
15+
// 3. Merges duplicate imports from the same module.
16+
// 4. Formats with the project's tsfmt.json settings.
17+
// 5. Adds Microsoft copyright header.
18+
//
19+
// URI stays as `string` (the protocol's canonical representation). VS Code code
20+
// should call `URI.parse()` at point-of-use where a URI class is needed.
21+
22+
import * as fs from 'fs';
23+
import * as path from 'path';
24+
import { execSync } from 'child_process';
25+
import * as ts from 'typescript';
26+
27+
const ROOT = path.resolve(__dirname, '..');
28+
const PROTOCOL_REPO = path.resolve(ROOT, '../agent-host-protocol');
29+
const TYPES_DIR = path.join(PROTOCOL_REPO, 'types');
30+
const DEST_DIR = path.join(ROOT, 'src/vs/platform/agentHost/common/state/protocol');
31+
32+
// Load tsfmt.json formatting options once
33+
const TSFMT_PATH = path.join(ROOT, 'tsfmt.json');
34+
const FORMAT_OPTIONS: ts.FormatCodeSettings = JSON.parse(fs.readFileSync(TSFMT_PATH, 'utf-8'));
35+
36+
/**
37+
* Formats a TypeScript source string using the TypeScript language service
38+
* formatter with the project's tsfmt.json settings.
39+
*/
40+
function formatTypeScript(content: string, fileName: string): string {
41+
const host: ts.LanguageServiceHost = {
42+
getCompilationSettings: () => ({}),
43+
getScriptFileNames: () => [fileName],
44+
getScriptVersion: () => '1',
45+
getScriptSnapshot: (name: string) => name === fileName ? ts.ScriptSnapshot.fromString(content) : undefined,
46+
getCurrentDirectory: () => ROOT,
47+
getDefaultLibFileName: () => '',
48+
fileExists: () => false,
49+
readFile: () => undefined,
50+
};
51+
const ls = ts.createLanguageService(host);
52+
const edits = ls.getFormattingEditsForDocument(fileName, FORMAT_OPTIONS);
53+
// Apply edits in reverse order to preserve offsets
54+
for (let i = edits.length - 1; i >= 0; i--) {
55+
const edit = edits[i];
56+
content = content.substring(0, edit.span.start) + edit.newText + content.substring(edit.span.start + edit.span.length);
57+
}
58+
ls.dispose();
59+
return content;
60+
}
61+
62+
const COPYRIGHT = `/*---------------------------------------------------------------------------------------------
63+
* Copyright (c) Microsoft Corporation. All rights reserved.
64+
* Licensed under the MIT License. See License.txt in the project root for license information.
65+
*--------------------------------------------------------------------------------------------*/`;
66+
67+
const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts';
68+
69+
// Files to copy. All go into protocol/.
70+
const FILES: { src: string; dest: string }[] = [
71+
{ src: 'state.ts', dest: 'state.ts' },
72+
{ src: 'actions.ts', dest: 'actions.ts' },
73+
{ src: 'commands.ts', dest: 'commands.ts' },
74+
{ src: 'errors.ts', dest: 'errors.ts' },
75+
{ src: 'notifications.ts', dest: 'notifications.ts' },
76+
{ src: 'messages.ts', dest: 'messages.ts' },
77+
{ src: 'version/registry.ts', dest: 'version/registry.ts' },
78+
];
79+
80+
function getSourceCommitHash(): string {
81+
try {
82+
return execSync('git rev-parse --short HEAD', { cwd: PROTOCOL_REPO, encoding: 'utf-8' }).trim();
83+
} catch {
84+
return 'unknown';
85+
}
86+
}
87+
88+
function stripExistingHeader(content: string): string {
89+
return content.replace(/^\/\*\*?[\s\S]*?\*\/\s*/, '');
90+
}
91+
92+
function convertIndentation(content: string): string {
93+
const lines = content.split('\n');
94+
return lines.map(line => {
95+
const match = line.match(/^( +)/);
96+
if (!match) {
97+
return line;
98+
}
99+
const spaces = match[1].length;
100+
const tabs = Math.floor(spaces / 2);
101+
const remainder = spaces % 2;
102+
return '\t'.repeat(tabs) + ' '.repeat(remainder) + line.slice(spaces);
103+
}).join('\n');
104+
}
105+
106+
/**
107+
* Merges duplicate imports from the same module.
108+
* Combines `import type { A }` and `import { B }` from the same module into
109+
* `import { B, type A }` to satisfy the no-duplicate-imports lint rule.
110+
*/
111+
function mergeDuplicateImports(content: string): string {
112+
// Collapse multi-line imports into single lines first
113+
content = content.replace(/import\s+(type\s+)?\{([^}]+)\}\s+from\s+'([^']+)';/g, (_match, typeKeyword, names, mod) => {
114+
const collapsed = names.replace(/\s+/g, ' ').trim();
115+
return typeKeyword ? `import type { ${collapsed} } from '${mod}';` : `import { ${collapsed} } from '${mod}';`;
116+
});
117+
118+
const importsByModule = new Map<string, { typeNames: string[]; valueNames: string[] }>();
119+
const otherLines: string[] = [];
120+
const seenModules = new Set<string>();
121+
122+
for (const line of content.split('\n')) {
123+
const typeMatch = line.match(/^import type \{([^}]+)\} from '([^']+)';$/);
124+
const valueMatch = line.match(/^import \{([^}]+)\} from '([^']+)';$/);
125+
126+
if (typeMatch) {
127+
const [, names, mod] = typeMatch;
128+
if (!importsByModule.has(mod)) {
129+
importsByModule.set(mod, { typeNames: [], valueNames: [] });
130+
}
131+
importsByModule.get(mod)!.typeNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
132+
if (!seenModules.has(mod)) {
133+
seenModules.add(mod);
134+
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
135+
}
136+
} else if (valueMatch) {
137+
const [, names, mod] = valueMatch;
138+
if (!importsByModule.has(mod)) {
139+
importsByModule.set(mod, { typeNames: [], valueNames: [] });
140+
}
141+
importsByModule.get(mod)!.valueNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0));
142+
if (!seenModules.has(mod)) {
143+
seenModules.add(mod);
144+
otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`);
145+
}
146+
} else {
147+
otherLines.push(line);
148+
}
149+
}
150+
151+
return otherLines.map(line => {
152+
if (line.startsWith('__IMPORT_PLACEHOLDER__')) {
153+
const mod = line.substring('__IMPORT_PLACEHOLDER__'.length);
154+
const entry = importsByModule.get(mod)!;
155+
const uniqueTypes = [...new Set(entry.typeNames)];
156+
const uniqueValues = [...new Set(entry.valueNames)];
157+
158+
if (uniqueValues.length > 0 && uniqueTypes.length > 0) {
159+
const allNames = [...uniqueValues, ...uniqueTypes.map(n => `type ${n}`)];
160+
return `import { ${allNames.join(', ')} } from '${mod}';`;
161+
} else if (uniqueValues.length > 0) {
162+
return `import { ${uniqueValues.join(', ')} } from '${mod}';`;
163+
} else {
164+
return `import type { ${uniqueTypes.join(', ')} } from '${mod}';`;
165+
}
166+
}
167+
return line;
168+
}).join('\n');
169+
}
170+
171+
// Global enum definitions collected from all files before per-file processing
172+
let globalEnumDefs = new Map<string, Map<string, string>>();
173+
174+
function collectAllEnumDefs(): void {
175+
globalEnumDefs = new Map();
176+
for (const file of FILES) {
177+
const srcPath = path.join(TYPES_DIR, file.src);
178+
if (!fs.existsSync(srcPath)) {
179+
continue;
180+
}
181+
const content = fs.readFileSync(srcPath, 'utf-8');
182+
content.replace(
183+
/export const enum (\w+) \{([^}]+)\}/g,
184+
(_match, name: string, body: string) => {
185+
const members = new Map<string, string>();
186+
for (const line of body.split('\n')) {
187+
const memberMatch = line.match(/^\s*(\w+)\s*=\s*'([^']+)'/);
188+
if (memberMatch) {
189+
members.set(memberMatch[1], memberMatch[2]);
190+
}
191+
}
192+
if (members.size > 0) {
193+
globalEnumDefs.set(name, members);
194+
}
195+
return _match;
196+
}
197+
);
198+
}
199+
}
200+
201+
/**
202+
* Converts `const enum Foo { A = 'a', B = 'b' }` into:
203+
* ```
204+
* export const Foo = { A: 'a', B: 'b' } as const;
205+
* export type Foo = typeof Foo[keyof typeof Foo];
206+
* ```
207+
* Then replaces `Foo.A` in type positions with the string literal `'a'`,
208+
* using the global enum definitions collected from all protocol files.
209+
*/
210+
function convertConstEnums(content: string): string {
211+
// Replace the const enum declarations in this file
212+
content = content.replace(
213+
/export const enum (\w+) \{([^}]+)\}/g,
214+
(_match, name: string) => {
215+
const members = globalEnumDefs.get(name);
216+
if (!members) {
217+
return _match;
218+
}
219+
const objEntries = [...members.entries()].map(([k, v]) => ` ${k}: '${v}'`).join(',\n');
220+
return `export const ${name} = {\n${objEntries},\n} as const;\nexport type ${name} = typeof ${name}[keyof typeof ${name}];`;
221+
}
222+
);
223+
224+
// Replace Enum.Member references with their resolved string literals
225+
for (const [enumName, members] of globalEnumDefs) {
226+
for (const [memberName, value] of members) {
227+
const ref = `${enumName}.${memberName}`;
228+
content = content.split(ref).join(`'${value}'`);
229+
}
230+
}
231+
232+
// Remove value imports of enums that are no longer referenced as values
233+
content = content.replace(
234+
/import \{([^}]+)\} from '([^']+)';/g,
235+
(_match, names: string, from: string) => {
236+
const parts = names.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0);
237+
const remaining = parts.filter((name: string) => {
238+
if (!globalEnumDefs.has(name)) {
239+
return true;
240+
}
241+
const uses = content.split(name).length - 1;
242+
return uses > 1;
243+
});
244+
if (remaining.length === 0) {
245+
return '';
246+
}
247+
if (remaining.length === parts.length) {
248+
return _match;
249+
}
250+
return `import { ${remaining.join(', ')} } from '${from}';`;
251+
}
252+
);
253+
254+
return content;
255+
}
256+
257+
function processFile(src: string, dest: string, commitHash: string): void {
258+
let content = fs.readFileSync(src, 'utf-8');
259+
content = stripExistingHeader(content);
260+
261+
// Convert `const enum` to plain `const` object + string literal union
262+
content = convertConstEnums(content);
263+
264+
// Merge duplicate imports from the same module
265+
content = mergeDuplicateImports(content);
266+
267+
content = convertIndentation(content);
268+
content = content.split('\n').map(line => line.trimEnd()).join('\n');
269+
270+
const header = `${COPYRIGHT}\n\n${BANNER}\n// Synced from agent-host-protocol @ ${commitHash}\n`;
271+
content = header + '\n' + content;
272+
273+
if (!content.endsWith('\n')) {
274+
content += '\n';
275+
}
276+
277+
const destPath = path.join(DEST_DIR, dest);
278+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
279+
content = formatTypeScript(content, dest);
280+
fs.writeFileSync(destPath, content, 'utf-8');
281+
console.log(` ${dest}`);
282+
}
283+
284+
// ---- Main -------------------------------------------------------------------
285+
286+
function main() {
287+
if (!fs.existsSync(TYPES_DIR)) {
288+
console.error(`ERROR: Cannot find ${TYPES_DIR}`);
289+
console.error('Clone agent-host-protocol as a sibling of the VS Code repo:');
290+
console.error(' git clone git@github.com:microsoft/agent-host-protocol.git ../agent-host-protocol');
291+
process.exit(1);
292+
}
293+
294+
const commitHash = getSourceCommitHash();
295+
console.log(`Syncing from agent-host-protocol @ ${commitHash}`);
296+
console.log(` Source: ${TYPES_DIR}`);
297+
console.log(` Dest: ${DEST_DIR}`);
298+
console.log();
299+
300+
// Collect all enum definitions across all protocol files
301+
collectAllEnumDefs();
302+
console.log(` Collected ${globalEnumDefs.size} const enums`);
303+
304+
// Copy protocol files
305+
for (const file of FILES) {
306+
const srcPath = path.join(TYPES_DIR, file.src);
307+
if (!fs.existsSync(srcPath)) {
308+
console.error(` SKIP (not found): ${file.src}`);
309+
continue;
310+
}
311+
processFile(srcPath, file.dest, commitHash);
312+
}
313+
314+
console.log();
315+
console.log('Done.');
316+
}
317+
318+
main();

src/vs/platform/agentHost/common/agentService.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface IAgentSessionMetadata {
3232
readonly summary?: string;
3333
}
3434

35-
export type AgentProvider = 'copilot' | 'mock';
35+
export type AgentProvider = string;
3636

3737
/** Metadata describing an agent backend, discovered over IPC. */
3838
export interface IAgentDescriptor {
@@ -239,20 +239,20 @@ export namespace AgentSession {
239239

240240
/**
241241
* Extracts the raw session ID from a session URI (the path without leading slash).
242+
* Accepts both a URI object and a URI string.
242243
*/
243-
export function id(session: URI): string {
244-
return session.path.substring(1);
244+
export function id(session: URI | string): string {
245+
const parsed = typeof session === 'string' ? URI.parse(session) : session;
246+
return parsed.path.substring(1);
245247
}
246248

247249
/**
248250
* Extracts the provider name from a session URI scheme.
251+
* Accepts both a URI object and a URI string.
249252
*/
250-
export function provider(session: URI): AgentProvider | undefined {
251-
const scheme = session.scheme;
252-
if (scheme === 'copilot' || scheme === 'mock') {
253-
return scheme;
254-
}
255-
return undefined;
253+
export function provider(session: URI | string): AgentProvider | undefined {
254+
const parsed = typeof session === 'string' ? URI.parse(session) : session;
255+
return parsed.scheme || undefined;
256256
}
257257
}
258258

src/vs/platform/agentHost/common/remoteAgentHostService.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Event } from '../../../base/common/event.js';
7-
import { URI } from '../../../base/common/uri.js';
87
import { createDecorator } from '../../instantiation/common/instantiation.js';
98
import type { IAgentConnection } from './agentService.js';
109

@@ -49,7 +48,7 @@ export interface IRemoteAgentHostConnectionInfo {
4948
readonly address: string;
5049
readonly name: string;
5150
readonly clientId: string;
52-
readonly defaultDirectory?: URI;
51+
readonly defaultDirectory?: string;
5352
}
5453

5554
export class NullRemoteAgentHostService implements IRemoteAgentHostService {

0 commit comments

Comments
 (0)