Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/**
Expand Down
101 changes: 101 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 type { IProductService } from '../../../product/common/productService.js';
import {
SessionLifecycle,
TerminalState,
Expand Down Expand Up @@ -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<string, unknown>;

/**
* 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<string, unknown>;
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;
}
9 changes: 8 additions & 1 deletion src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ 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';
import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js';

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;
}

/**
Expand Down Expand Up @@ -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}`);
Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<DisposableStore>());
private _currentLevel: OtlpLogLevelName | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 in the
* forwarded Output channel. No-op when the root state has not arrived
* or carries no build info, and only ever writes once.
Comment thread
roblourens marked this conversation as resolved.
*/
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);
Expand Down
Loading