From 0da5ec76aa0f0f3c8dac0e8fe7520b200fd992c4 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 6 Jun 2026 13:41:17 -0700 Subject: [PATCH 1/2] Report agent host build info via RootState._meta Surface the hosting VS Code CLI's build info (version, commit, date, quality) to clients so they can see which build is hosting an agent. The info is carried on a well-known `hostBuild` key inside the new optional `RootState._meta` property bag. RootState is delivered as a snapshot at connect time, so every client (local and remote) receives it reliably without a protocol-level addition. - Add `_meta?: Record` to the generated `RootState`. - Add host build info helpers in sessionState.ts (read/with/format + `hostBuildInfoFromProduct`). - Seed `_rootState._meta` in AgentHostStateManager from a new `hostBuildInfo` option, wired from the product service in agentService. - RemoteAgentHostLogForwarder writes a one-time "Agent host version ..." header into the Output channel on attach. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../state/protocol/channels-root/state.ts | 6 ++ .../agentHost/common/state/sessionState.ts | 101 ++++++++++++++++++ .../agentHost/node/agentHostStateManager.ts | 9 +- .../platform/agentHost/node/agentService.ts | 3 +- .../common/state/hostBuildInfoMeta.test.ts | 49 +++++++++ .../test/node/agentHostStateManager.test.ts | 12 ++- .../browser/remoteAgentHostLogForwarder.ts | 27 +++++ 7 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/vs/platform/agentHost/test/common/state/hostBuildInfoMeta.test.ts diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 90c87aa4e4ba15..e9d897bde9ba3c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -37,6 +37,12 @@ export interface RootState { terminals?: TerminalInfo[]; /** Agent host configuration schema and current values */ config?: RootConfigState; + /** + * Additional implementation-defined metadata about the agent host itself. + * + * Clients MAY look for well-known keys here to provide enhanced UI. + */ + _meta?: Record; } /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 7cd72764c78e4f..aa75fcc1d73cbf 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -13,6 +13,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { hasKey } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; +import { IProductService } from '../../../product/common/productService.js'; import { SessionLifecycle, TerminalState, @@ -493,3 +494,103 @@ export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISe } return Object.keys(next).length > 0 ? next : undefined; } + +// ---- RootState _meta accessors --------------------------------------------- + +/** + * VS Code-side alias for the protocol's open `_meta` property bag on + * {@link RootState}. Keys SHOULD be namespaced to avoid collisions; values MUST + * be JSON-serializable. + */ +export type RootMeta = Record; + +/** + * Reserved key under {@link RootMeta} for the well-known host-build payload. + * Value at this key, when present, MUST be shaped like {@link IHostBuildInfo}. + * This is a VS Code-specific convention layered on top of the protocol's + * generic `_meta` bag — the protocol itself does not know about build info. + */ +export const ROOT_META_HOST_BUILD_KEY = 'hostBuild'; + +/** + * Build information about the program hosting the agent host (the VS Code CLI), + * carried under {@link RootMeta} at {@link ROOT_META_HOST_BUILD_KEY}. Lets a + * client see which build is hosting it — useful when inspecting the output of a + * remote agent host. + * + * All fields except {@link version} are optional — a build that does not track + * a particular field should omit it. + */ +export interface IHostBuildInfo { + /** Product version (e.g. `1.96.0`). */ + readonly version: string; + /** Commit SHA of the build, if known. */ + readonly commit?: string; + /** Build date (ISO 8601), if known. */ + readonly date?: string; + /** Release quality (e.g. `stable`, `insider`), if known. */ + readonly quality?: string; +} + +/** + * Derives {@link IHostBuildInfo} from the host's {@link IProductService}. + */ +export function hostBuildInfoFromProduct(productService: IProductService): IHostBuildInfo { + return { + version: productService.version, + commit: productService.commit, + date: productService.date, + quality: productService.quality, + }; +} + +/** + * Reads the well-known host-build payload from {@link RootMeta}, if present. + * Returns `undefined` when the meta bag is absent or the value at the host-build + * key is not a plain object with a string `version`. Optional fields with wrong + * types are silently dropped. + */ +export function readHostBuildInfo(meta: RootMeta | undefined): IHostBuildInfo | undefined { + const value = meta?.[ROOT_META_HOST_BUILD_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw['version'] !== 'string') { + return undefined; + } + const result: { version: string; commit?: string; date?: string; quality?: string } = { + version: raw['version'], + }; + if (typeof raw['commit'] === 'string') { result.commit = raw['commit']; } + if (typeof raw['date'] === 'string') { result.date = raw['date']; } + if (typeof raw['quality'] === 'string') { result.quality = raw['quality']; } + return result; +} + +/** + * Returns a new {@link RootMeta} with the host-build payload set to + * `buildInfo`, or with the slot removed if `buildInfo` is `undefined`. Returns + * `undefined` if the result would be empty. + */ +export function withHostBuildInfo(meta: RootMeta | undefined, buildInfo: IHostBuildInfo | undefined): RootMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (buildInfo !== undefined) { + next[ROOT_META_HOST_BUILD_KEY] = buildInfo; + } else { + delete next[ROOT_META_HOST_BUILD_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * Formats {@link IHostBuildInfo} as a short single-line human-readable string, + * e.g. `1.96.0 (commit abc1234, 2024-01-02T03:04:05Z, insider)`. + */ +export function formatHostBuildInfo(info: IHostBuildInfo): string { + const details: string[] = []; + if (info.commit) { details.push(`commit ${info.commit}`); } + if (info.date) { details.push(info.date); } + if (info.quality) { details.push(info.quality); } + return details.length > 0 ? `${info.version} (${details.join(', ')})` : info.version; +} diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index de577ea5672e1b..8a8ed8b68332d8 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -12,7 +12,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, ChangesetAction, isRootAction, isSessionAction, isChangesetAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, changesetReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, isAhpRootChannel, SessionLifecycle, type ChangesetState, type ChangesetSummary, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type ChangesetState, type ChangesetSummary, type IHostBuildInfo, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -20,6 +20,12 @@ import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOpt export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; + /** + * Build information about the program hosting the agent host. When + * provided, it is published on {@link RootState._meta} so clients can see + * which build is hosting them. + */ + readonly hostBuildInfo?: IHostBuildInfo; } /** @@ -108,6 +114,7 @@ export class AgentHostStateManager extends Disposable { [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.USAGE), }), }, + _meta: withHostBuildInfo(this._rootState._meta, options.hostBuildInfo), }; } private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d26d23e30e04df..8631432d6aa8bb 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -29,7 +29,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { SessionPendingMessageSetAction, SessionTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -199,6 +199,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info('AgentService initialized'); this._authService = new AgentHostAuthenticationService(_logService); this._stateManager = this._register(new AgentHostStateManager(_logService, { + hostBuildInfo: hostBuildInfoFromProduct(this._productService), changesetStateRetention: { // The cache calls this lazily after construction. If a future state-manager // initialization path registers changesets before `_changesets` is assigned, diff --git a/src/vs/platform/agentHost/test/common/state/hostBuildInfoMeta.test.ts b/src/vs/platform/agentHost/test/common/state/hostBuildInfoMeta.test.ts new file mode 100644 index 00000000000000..fb491ed3575622 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/state/hostBuildInfoMeta.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ROOT_META_HOST_BUILD_KEY, formatHostBuildInfo, readHostBuildInfo, withHostBuildInfo, type IHostBuildInfo } from '../../../common/state/sessionState.js'; + +suite('Host build info _meta helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const full: IHostBuildInfo = { version: '1.96.0', commit: 'abc1234', date: '2024-01-02T03:04:05Z', quality: 'insider' }; + + test('round-trips through withHostBuildInfo / readHostBuildInfo', () => { + const meta = withHostBuildInfo(undefined, full); + assert.deepStrictEqual(readHostBuildInfo(meta), full); + }); + + test('readHostBuildInfo rejects malformed payloads', () => { + assert.strictEqual(readHostBuildInfo(undefined), undefined); + assert.strictEqual(readHostBuildInfo({}), undefined); + assert.strictEqual(readHostBuildInfo({ [ROOT_META_HOST_BUILD_KEY]: 'nope' }), undefined); + assert.strictEqual(readHostBuildInfo({ [ROOT_META_HOST_BUILD_KEY]: [] }), undefined); + // Missing required `version`. + assert.strictEqual(readHostBuildInfo({ [ROOT_META_HOST_BUILD_KEY]: { commit: 'x' } }), undefined); + }); + + test('readHostBuildInfo drops fields with wrong types but keeps version', () => { + const meta = { [ROOT_META_HOST_BUILD_KEY]: { version: '1.0.0', commit: 42, date: '2024', quality: true } }; + assert.deepStrictEqual(readHostBuildInfo(meta), { version: '1.0.0', date: '2024' }); + }); + + test('withHostBuildInfo preserves other keys and removes on undefined', () => { + const withOther = withHostBuildInfo({ other: 1 }, full); + assert.strictEqual(withOther?.['other'], 1); + const removed = withHostBuildInfo(withOther, undefined); + assert.strictEqual(removed?.[ROOT_META_HOST_BUILD_KEY], undefined); + assert.strictEqual(removed?.['other'], 1); + // Removing the only key yields undefined. + assert.strictEqual(withHostBuildInfo({ [ROOT_META_HOST_BUILD_KEY]: full }, undefined), undefined); + }); + + test('formatHostBuildInfo renders details and falls back to version', () => { + assert.strictEqual(formatHostBuildInfo(full), '1.96.0 (commit abc1234, 2024-01-02T03:04:05Z, insider)'); + assert.strictEqual(formatHostBuildInfo({ version: '2.0.0' }), '2.0.0'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index d4d9a5cd915f32..9e029fc2fb7878 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; +import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, readHostBuildInfo, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -69,6 +69,16 @@ suite('AgentHostStateManager', () => { assert.ok(root.config, 'root state should include a seeded config'); }); + test('seeds host build info into root state _meta when provided', () => { + const buildInfo = { version: '1.96.0', commit: 'abc1234', date: '2024-01-02T03:04:05Z', quality: 'insider' }; + const localManager = disposables.add(new AgentHostStateManager(new NullLogService(), { hostBuildInfo: buildInfo })); + assert.deepStrictEqual(readHostBuildInfo(localManager.rootState._meta), buildInfo); + }); + + test('omits host build info from root state _meta when not provided', () => { + assert.strictEqual(readHostBuildInfo(manager.rootState._meta), undefined); + }); + test('getSnapshot returns session snapshot after creation', () => { manager.createSession(makeSessionSummary()); const snapshot = manager.getSnapshot(sessionUri); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts index 05f296bb16e446..e9a226ddb045c0 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts @@ -11,6 +11,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { iterateOtlpLogRecords, logLevelToOtlpLevelName, severityNumberToLogLevel, type IOtlpLogRecord, type OtlpLogLevelName } from '../../../../../platform/agentHost/common/otlp/otlpLogEmitter.js'; import { AgentHostClientState, type RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { formatHostBuildInfo, readHostBuildInfo } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../workbench/services/output/common/output.js'; /** @@ -45,6 +46,8 @@ export class RemoteAgentHostLogForwarder extends Disposable { private readonly _channelLabel: string; private _outputChannel: IOutputChannel | undefined; private _channelRegistered = false; + /** Whether the one-time host build-info header has been written. */ + private _buildInfoHeaderWritten = false; /** Tracks whatever needs to be torn down for a single subscribe cycle. */ private readonly _subscriptionStore = this._register(new MutableDisposable()); private _currentLevel: OtlpLogLevelName | undefined; @@ -128,6 +131,7 @@ export class RemoteAgentHostLogForwarder extends Disposable { // Output channel is registered lazily so hosts without an OTLP // logs channel never produce an empty entry in the picker. this._ensureChannelRegistered(); + this._writeHostBuildInfoHeader(); const store = new DisposableStore(); this._subscriptionStore.value = store; @@ -242,6 +246,29 @@ export class RemoteAgentHostLogForwarder extends Disposable { } } + /** + * Write a one-time header line with the host's build info (version, + * commit, date, quality) read from the connected client's root state. + * Lets the user see which build is hosting the agent host at the top of + * the forwarded Output channel. No-op when the root state has not arrived + * or carries no build info, and only ever writes once. + */ + private _writeHostBuildInfoHeader(): void { + if (this._buildInfoHeaderWritten) { + return; + } + const rootState = this._client.rootState.value; + if (!rootState || rootState instanceof Error) { + return; + } + const buildInfo = readHostBuildInfo(rootState._meta); + if (!buildInfo) { + return; + } + this._buildInfoHeaderWritten = true; + this._appendLine(`Agent host version ${formatHostBuildInfo(buildInfo)}`); + } + private _appendLine(text: string): void { if (!this._outputChannel) { this._outputChannel = this._outputService.getChannel(this._channelId); From fdd1373b8015da9aa0f1ceb7241e9a6a4f188d0f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 6 Jun 2026 15:13:54 -0700 Subject: [PATCH 2/2] Address Copilot review feedback - Use a type-only import for IProductService in the common state module (it's only referenced as a type), avoiding an unnecessary runtime dependency / potential circular import. - Reword the host build info header doc comment to not imply guaranteed ordering, since the line is appended to the Output channel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/state/sessionState.ts | 2 +- .../remoteAgentHost/browser/remoteAgentHostLogForwarder.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index aa75fcc1d73cbf..9bf083f717e52c 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -13,7 +13,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { hasKey } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; -import { IProductService } from '../../../product/common/productService.js'; +import type { IProductService } from '../../../product/common/productService.js'; import { SessionLifecycle, TerminalState, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts index e9a226ddb045c0..cac05786daa489 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts @@ -249,8 +249,8 @@ export class RemoteAgentHostLogForwarder extends Disposable { /** * Write a one-time header line with the host's build info (version, * commit, date, quality) read from the connected client's root state. - * Lets the user see which build is hosting the agent host at the top of - * the forwarded Output channel. No-op when the root state has not arrived + * Lets the user see which build is hosting the agent host in the + * forwarded Output channel. No-op when the root state has not arrived * or carries no build info, and only ever writes once. */ private _writeHostBuildInfoHeader(): void {