Agent Host: Smoke test Dev Container sessions (#334507)

* Smoke test Dev Container Agent Host sessions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Run Dev Container smoke tests across platforms

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Stabilize Dev Container smoke setup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Support packaged Dev Container smoke runs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Dev Container smoke coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix optional protected resource scopes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Windows environment assertion

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Probe Linux Docker for Dev Container smoke test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Handle missing Docker in smoke probe

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Christof Marti
2026-09-08 15:06:15 +02:00
committed by GitHub
co-authored by Copilot
parent f3e4cb8b7e
commit 92bb5ba22c
11 changed files with 284 additions and 42 deletions
@@ -1865,6 +1865,8 @@ interface CapturedRequest {
interface StartServerOptions {
logger?: (msg: string) => void;
verbose?: boolean;
/** Address to listen on. Defaults to loopback. */
host?: string;
/** Reject requests that do not carry this exact header. */
requiredRequestHeader?: {
name: string;
@@ -1936,7 +1938,7 @@ function _startServer(port = 0, options?: StartServerOptions): Promise<MockLlmSe
requestWaiters = requestWaiters.filter(fn => !fn());
handleRequest(req, res);
});
server.listen(port, '127.0.0.1', () => {
server.listen(port, options?.host ?? '127.0.0.1', () => {
const addr = server.address();
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
const url = `http://127.0.0.1:${actualPort}`;
@@ -387,10 +387,12 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev
}
protected _resolveShellEnvironment(): Promise<typeof process.env> {
this._shellEnvironment ??= this._resolveUserShellEnvironment().catch(error => {
this._logService.error(`${LOG_PREFIX} Unable to resolve shell environment; using inherited environment`, error);
return process.env;
});
this._shellEnvironment ??= this._resolveUserShellEnvironment()
.then(environment => ({ ...process.env, ...environment }))
.catch(error => {
this._logService.error(`${LOG_PREFIX} Unable to resolve shell environment; using inherited environment`, error);
return process.env;
});
return this._shellEnvironment;
}
@@ -239,14 +239,15 @@ export function buildCleanupOldCLIsCommand(serverDataFolderName: string, quality
*/
export function buildFindFallbackCLICommand(serverDataFolderName: string, quality: string): string {
const root = getRemoteCLIInstallRoot(serverDataFolderName);
const relativeRoot = root.slice(2);
const archive = getRemoteCLIArchiveName(quality);
const commitGlob = '[0-9a-f]'.repeat(40);
const q = validateShellToken(quality, 'quality');
const legacyDir = q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`;
const legacyBin = `${legacyDir}/${archive}`;
return [
`ls -1t -- ${root}/${archive}-${commitGlob} 2>/dev/null`,
`ls -1 -- ${legacyBin} 2>/dev/null`,
`(cd ~ && ls -1t -- ${relativeRoot}/${archive}-${commitGlob} 2>/dev/null | sed 's#^#~/#')`,
`test ! -f ${legacyBin} || printf '%s\\n' ${shellEscape(legacyBin)}`,
'true',
].join('; ');
}
@@ -11,6 +11,7 @@ import { tmpdir } from 'os';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { DeferredPromise } from '../../../../base/common/async.js';
import { join } from '../../../../base/common/path.js';
import { getCaseInsensitive } from '../../../../base/common/objects.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { mock } from '../../../../base/test/common/mock.js';
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
@@ -308,6 +309,20 @@ suite('Dev Container Agent Host Main Service', () => {
assert.strictEqual(await service.resolveShellEnvironment(), process.env);
});
test('merges the resolved shell environment with the inherited environment', async () => {
const service = store.add(new TestDevContainerAgentHostMainService('', false, undefined, { VSCODE_TEST_VALUE: 'resolved' }));
const environment = await service.resolveShellEnvironment();
assert.deepStrictEqual({
path: getCaseInsensitive(environment, 'PATH'),
testValue: environment.VSCODE_TEST_VALUE,
}, {
path: getCaseInsensitive(process.env, 'PATH'),
testValue: 'resolved',
});
});
test('reuses a standalone endpoint and exposes its relay', async () => {
const service = store.add(new TestDevContainerAgentHostMainService());
const output: string[] = [];
@@ -385,10 +385,11 @@ suite('SSH Remote Agent Host Helpers', () => {
test('lists commit-keyed candidates then legacy paths for insider', () => {
const cmd = buildFindFallbackCLICommand('.vscode-server-insiders', 'insider');
// New commit-keyed candidates in shared install root, sorted newest-first.
assert.ok(cmd.includes('~/.vscode-server-insiders/code-insiders-'), `cmd missing new path: ${cmd}`);
assert.ok(cmd.includes('.vscode-server-insiders/code-insiders-'), `cmd missing new path: ${cmd}`);
assert.ok(/ls -1t/.test(cmd), 'should sort commit-keyed candidates by mtime');
assert.ok(cmd.includes(`sed 's#^#~/#'`), `cmd should normalize candidates to validated paths: ${cmd}`);
// Legacy single-binary path (insider has the `-insider` dir suffix).
assert.ok(cmd.includes('~/.vscode-cli-insider/code-insiders'), `cmd missing legacy path: ${cmd}`);
assert.ok(cmd.includes(`printf '%s\\n' '~/.vscode-cli-insider/code-insiders'`), `cmd missing legacy path: ${cmd}`);
});
test('uses no-suffix legacy dir for stable', () => {
@@ -19,6 +19,7 @@ import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/
import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js';
import { type AgentInfo, type RootState } from '../../../../../platform/agentHost/common/state/sessionState.js';
import { NotificationType, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
@@ -27,7 +28,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
import { registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { OpenAgentHostStateFileAction } from '../../agentHost/browser/openAgentHostStateFileAction.js';
import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js';
import { authenticateAgentProtectedResourcesWithToken, authenticateProtectedResources, authenticateProtectedResourcesWithToken, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js';
import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js';
import { AgentHostSessionHandler } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js';
import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js';
@@ -45,6 +46,7 @@ import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvid
import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js';
import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js';
import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';
import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { logTerminalRecovery } from '../../../../common/sessionsTelemetry.js';
@@ -140,6 +142,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
/** Per-connection state: client state + per-agent registrations. */
private readonly _connections = this._register(new DisposableMap<string, ConnectionState>());
private readonly _enableSmokeTestDriver: boolean;
constructor(
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@@ -156,8 +159,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService,
@IRemoteAgentHostConnectionCustomizationService private readonly _connectionCustomizations: IRemoteAgentHostConnectionCustomizationService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,
) {
super();
this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver;
this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._reconcile()));
this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections()));
@@ -490,6 +496,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
const authTokenCache = this._connections.get(address)?.authTokenCache;
provider?.setAuthenticationPending(true);
try {
const testToken = this._getScenarioAutomationToken();
if (testToken !== undefined) {
await authenticateAgentProtectedResourcesWithToken(agents, testToken, {
authTokenCache,
authenticate: this._authenticateCallback(address, connection),
});
return;
}
await this._instantiationService.invokeFunction(authenticateProtectedResources, agents, {
authTokenCache,
logPrefix: '[RemoteAgentHost]',
@@ -502,6 +516,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
}
}
private _getScenarioAutomationToken(): string | undefined {
if (!this._enableSmokeTestDriver) {
return undefined;
}
const token = this._configurationService.getValue('chat.agentHost.unsafeTestToken');
return typeof token === 'string' && token.length > 0 ? token : undefined;
}
private _handleAuthenticationRequiredNotification(address: string, connection: IAgentConnection, notification: INotification): void {
if (notification.type !== NotificationType.AuthRequired) {
return;
@@ -560,6 +582,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
*/
private async _resolveAuthenticationInteractively(address: string, connection: IAgentConnection, protectedResources: readonly ProtectedResourceMetadata[]): Promise<boolean> {
const authTokenCache = this._connections.get(address)?.authTokenCache;
const testToken = this._getScenarioAutomationToken();
if (testToken !== undefined) {
await authenticateProtectedResourcesWithToken(protectedResources, testToken, {
authTokenCache,
authenticate: this._authenticateCallback(address, connection),
});
return protectedResources.length > 0;
}
return this._instantiationService.invokeFunction(resolveAuthenticationInteractively, protectedResources, {
authTokenCache,
logPrefix: '[RemoteAgentHost]',
@@ -405,7 +405,7 @@ export interface IAgentHostMcpAuthenticationOptionsBase {
async function forwardAuthenticationToken(
options: Pick<IAgentHostAuthenticationOptions, 'authTokenCache' | 'authenticate' | 'isCurrent'>,
resource: string,
scopes: readonly string[],
scopes: readonly string[] | undefined,
token: string,
): Promise<boolean> {
throwIfAuthenticationStale(options);
@@ -441,6 +441,26 @@ export async function authenticateProtectedResources(
}
}
export async function authenticateAgentProtectedResourcesWithToken(
agents: readonly AgentInfo[],
token: string,
options: Pick<IAgentHostAuthenticationOptions, 'authTokenCache' | 'authenticate' | 'isCurrent'>,
): Promise<void> {
for (const agent of agents) {
await authenticateProtectedResourcesWithToken(agent.protectedResources ?? [], token, options);
}
}
export async function authenticateProtectedResourcesWithToken(
protectedResources: readonly ProtectedResourceMetadata[],
token: string,
options: Pick<IAgentHostAuthenticationOptions, 'authTokenCache' | 'authenticate' | 'isCurrent'>,
): Promise<void> {
for (const resource of protectedResources) {
await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported, token);
}
}
/**
* Reconciles resources backed by an authentication session that was explicitly
* removed.
@@ -36,7 +36,7 @@ import { Target } from '../../../common/promptSyntax/promptTypes.js';
import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js';
import { agentHostProviderHasBuiltInGitHubMcpServer, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID } from './agentHostMcpServerSupport.js';
import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js';
import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from './agentHostAuth.js';
import { authenticateAgentProtectedResourcesWithToken, authenticateProtectedResources, authenticateProtectedResourcesWithToken, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from './agentHostAuth.js';
import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js';
import { AgentHostSessionHandler } from './agentHostSessionHandler.js';
import { AgentHostPromptCacheNotification } from './agentHostPromptCacheNotification.js';
@@ -415,7 +415,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
try {
const testToken = this._getScenarioAutomationToken();
if (testToken !== undefined) {
await this._seedTestToken(agents, testToken, generation);
await authenticateAgentProtectedResourcesWithToken(agents, testToken, {
authTokenCache: this._authTokenCache,
isCurrent: () => this._isAuthenticationCurrent(generation),
authenticate: request => this._authenticateIfCurrent(request, generation),
});
return;
}
await this._instantiationService.invokeFunction(authenticateProtectedResources, agents, {
@@ -475,14 +479,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
}
const testToken = this._getScenarioAutomationToken();
if (testToken !== undefined) {
for (const resource of protectedResources) {
await this._authTokenCache.authenticate(
resource.resource,
resource.scopes_supported,
testToken,
() => this._authenticateIfCurrent({ resource: resource.resource, token: testToken }, generation),
);
}
await authenticateProtectedResourcesWithToken(protectedResources, testToken, {
authTokenCache: this._authTokenCache,
isCurrent: () => this._isAuthenticationCurrent(generation),
authenticate: request => this._authenticateIfCurrent(request, generation),
});
return protectedResources.length > 0;
}
return this._instantiationService.invokeFunction(resolveAuthenticationInteractively, protectedResources, {
@@ -493,19 +494,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
});
}
private async _seedTestToken(agents: readonly AgentInfo[], token: string, generation: number): Promise<void> {
for (const agent of agents) {
for (const resource of agent.protectedResources ?? []) {
await this._authTokenCache.authenticate(
resource.resource,
resource.scopes_supported,
token,
() => this._authenticateIfCurrent({ resource: resource.resource, token }, generation),
);
}
}
}
private _getScenarioAutomationToken(): string | undefined {
// Smoke-test escape hatch.
if (!this._enableSmokeTestDriver) {
+34
View File
@@ -14,6 +14,8 @@ const SESSION_TYPE_PICKER = '.sessions-chat-session-type-picker .action-label';
const SESSION_TYPE_PICKER_VISIBLE = `${SESSION_TYPE_PICKER}:not(.hidden)`;
const NEW_CHAT_EDITOR = `${NEW_SESSION_VIEW} .sessions-chat-editor .monaco-editor[role="code"]`;
const SEND_BUTTON_ENABLED = `${NEW_SESSION_VIEW} .sessions-chat-send-button .monaco-button:not(.disabled)`;
const DEV_CONTAINER_CHECKBOX = `${NEW_SESSION_VIEW} .sessions-chat-dev-container-checkbox .monaco-checkbox`;
const NEW_WORKTREE_CHECKBOX = `${NEW_SESSION_VIEW} .sessions-chat-isolation-checkbox .monaco-checkbox`;
const ACTIVE_SESSION = `${AGENTS_WORKBENCH} .session-view.is-active`;
const ACTIVE_SESSION_INPUT_EDITOR = `${ACTIVE_SESSION} .interactive-session .interactive-input-part .monaco-editor[role="code"]`;
const ACTIVE_SESSION_SEND_BUTTON_ENABLED = `${ACTIVE_SESSION} .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up-compact`;
@@ -115,6 +117,38 @@ export class AgentsWindow {
await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR, undefined, retryCount);
}
async selectDevContainer(): Promise<void> {
const page = this.code.driver.currentPage;
const devContainer = page.locator(DEV_CONTAINER_CHECKBOX).first();
const newWorktree = page.locator(NEW_WORKTREE_CHECKBOX).first();
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (await newWorktree.count() > 0 && await newWorktree.getAttribute('aria-checked') === 'true') {
await newWorktree.click();
continue;
}
if (await devContainer.count() > 0 && await devContainer.getAttribute('aria-disabled') !== 'true') {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
if (await devContainer.count() === 0) {
throw new Error('Timed out waiting for Dev Container checkbox to appear');
}
if (await devContainer.getAttribute('aria-disabled') === 'true') {
throw new Error('Timed out waiting for Dev Container checkbox to become enabled');
}
if (await devContainer.getAttribute('aria-checked') !== 'true') {
await devContainer.click();
}
while (await devContainer.getAttribute('aria-checked') !== 'true') {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for Dev Container checkbox to become checked');
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
private async isSessionTypeSelected(label: string): Promise<boolean> {
const picker = this.code.driver.currentPage.locator(SESSION_TYPE_PICKER_VISIBLE).first();
return ((await picker.textContent()) ?? '').trim().toLowerCase() === label.trim().toLowerCase();
@@ -42,6 +42,7 @@ const AGENT_HOST_REPLY = 'MOCKED_AGENT_HOST_RESPONSE';
const AGENT_HOST_MODEL = 'gpt-5.3-codex';
const AGENT_HOST_REPLACEMENT_SCENARIO_ID = 'smoke-agent-host-session-replacement';
const AGENT_HOST_REPLACEMENT_REPLY = 'MOCKED_AGENT_HOST_REPLACEMENT_RESPONSE';
const DEV_CONTAINER_SCENARIO_ID = 'smoke-dev-container-agent-host';
const AGENT_HOST_SANDBOX_SCENARIO_ID = 'smoke-hello-agent-host-sandbox';
const AGENT_HOST_SANDBOX_REPLY = 'MOCKED_AGENT_HOST_SANDBOX_RESPONSE';
@@ -55,6 +56,27 @@ const AGENT_HOST_SDK_SANDBOX_REPLY = 'MOCKED_AGENT_HOST_SDK_SANDBOX_RESPONSE';
const AGENT_HOST_WARMUP_SCENARIO_ID = 'smoke-hello-agent-host-warmup';
const AGENT_HOST_WARMUP_REPLY = 'MOCKED_AGENT_HOST_WARMUP_RESPONSE';
function probeLinuxDocker(): { readonly available: boolean; readonly reason?: string } {
const result = cp.spawnSync('docker', ['info', '--format', '{{.OSType}}'], {
encoding: 'utf8',
timeout: 15_000,
windowsHide: true,
});
const operatingSystem = result.stdout?.trim().toLowerCase() ?? '';
if (result.status === 0 && operatingSystem === 'linux') {
return { available: true };
}
const stderr = result.stderr?.trim() ?? '';
return {
available: false,
reason: result.error?.message
?? (stderr || undefined)
?? (operatingSystem
? `Docker daemon reports '${operatingSystem}' containers`
: `docker info exited with code ${result.status ?? 'unknown'}`),
};
}
export function setup(logger: Logger) {
describe('Agents Window (local AgentHost)', () => {
@@ -220,6 +242,108 @@ export function setup(logger: Logger) {
});
});
const linuxDocker = probeLinuxDocker();
const runDevContainerSuite = linuxDocker.available || process.platform === 'linux';
if (!linuxDocker.available) {
logger.log(process.platform === 'linux'
? `Linux Docker probe failed: ${linuxDocker.reason}`
: `Skipping Agents Window (Dev Container AgentHost): ${linuxDocker.reason}`);
}
(runDevContainerSuite ? describe : describe.skip)('Agents Window (Dev Container AgentHost)', () => {
if (process.platform === 'linux') {
before(() => assert.ok(linuxDocker.available, `Expected a reachable Linux Docker daemon: ${linuxDocker.reason}`));
}
const devContainer = setupAgentHostSuite(logger, {
serverLabel: 'Dev Container AgentHost',
mockServerHost: '0.0.0.0',
registerScenarios: ({ ScenarioBuilder, registerScenario }) => {
registerScenario(DEV_CONTAINER_SCENARIO_ID, new ScenarioBuilder().emit('OK').build());
},
settings: {
'chat.agentHost.devContainer.enabled': true,
'chat.remoteAgentHostsEnabled': true,
},
prepareWorkspace: workspacePath => {
const configDirectory = path.join(workspacePath, '.devcontainer');
const mockServerUrl = `http://vscode-smoke.test:${devContainer.mockServer.port}`;
fs.mkdirSync(configDirectory, { recursive: true });
fs.writeFileSync(path.join(configDirectory, 'devcontainer.json'), JSON.stringify({
name: 'Agents Window Smoke',
image: 'mcr.microsoft.com/devcontainers/base:ubuntu-24.04',
remoteUser: 'vscode',
runArgs: ['--add-host=vscode-smoke.test:host-gateway'],
containerEnv: {
COPILOT_API_URL: mockServerUrl,
COPILOT_DEBUG_GITHUB_API_URL: mockServerUrl,
GITHUB_COPILOT_API_TOKEN: 'smoketest-fake-agent-host-token',
VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE: mockServerUrl,
VSCODE_SMOKE_TEST_PROXY_HEADER: 'dev-container',
},
postCreateCommand: [
'set -e',
'case "$(uname -m)" in x86_64) cli_arch=x64 ;; aarch64|arm64) cli_arch=arm64 ;; *) exit 1 ;; esac',
'mkdir -p ~/.vscode-cli-insider',
'curl -fsSL "https://update.code.visualstudio.com/latest/cli-linux-${cli_arch}/insider" | tar xz -C ~/.vscode-cli-insider',
'chmod +x ~/.vscode-cli-insider/code-insiders',
].join(' && '),
}, null, 2));
},
cleanupWorkspace: workspacePath => {
fs.rmSync(path.join(workspacePath, '.devcontainer'), { recursive: true, force: true });
const containerIds = cp.execFileSync('docker', [
'ps',
'-aq',
'--filter',
`label=devcontainer.local_folder=${workspacePath}`,
], { encoding: 'utf8' }).trim().split(/\s+/).filter(Boolean);
if (containerIds.length > 0) {
cp.execFileSync('docker', ['rm', '--force', ...containerIds], { stdio: 'pipe' });
}
},
});
it('Starts a session in a Dev Container', async function () {
this.timeout(10 * 60 * 1000);
cp.execFileSync('docker', ['info'], { stdio: 'pipe' });
const app = this.app as Application;
try {
const requestsBefore = devContainer.mockServer.requestCount();
await app.workbench.agentsWindow.waitForNewSessionView();
await app.workbench.agentsWindow.selectSessionType('Copilot');
await app.workbench.agentsWindow.selectDevContainer();
await app.workbench.agentsWindow.submitNewSessionPrompt(`start Dev Container [scenario:${DEV_CONTAINER_SCENARIO_ID}]`, 1_800);
await app.workbench.agentsWindow.waitForActiveSessionView(5 * 60 * 1000);
const text = await app.workbench.agentsWindow.waitForAssistantText('OK', 2 * 60 * 1000);
logger.log(`Agents Window (Dev Container AgentHost) response: ${text}`);
assert.ok(
devContainer.mockServer.requestCount() > requestsBefore,
'expected the mock LLM server to receive the Dev Container Agent Host request'
);
const ahpFrames = await waitForLogContent(
() => readAhpFrames(path.join(devContainer.logsPath, 'ahp')),
/"transport":"devcontainer"/,
30_000,
);
assert.match(ahpFrames, /"vscode\.clientConnectionKind":"dev_container"/);
assert.match(ahpFrames, /"transport":"devcontainer"/);
const rendererLogs = await waitForLogContent(
() => readRendererLogs(devContainer.logsPath),
/\[AgentHost\] _invokeAgent called for resource: remote-devcontainer__/,
30_000,
);
assert.match(rendererLogs, /\[AgentHost\] _invokeAgent called for resource: remote-devcontainer__/);
} catch (error) {
logger.log(`Agents Window (Dev Container AgentHost) FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
await dumpFailureDiagnostics(app, logger, 'Agents Window (Dev Container AgentHost)', { sendButtonSelector: AGENTS_SEND_BUTTON_SELECTOR });
throw error;
}
});
});
describe('Agents Window (local AgentHost, SDK sandbox)', () => {
// Variant of the AgentHost suite that leaves
@@ -513,11 +637,15 @@ interface IAgentHostSuiteContext {
*/
function setupAgentHostSuite(logger: Logger, config: {
readonly serverLabel: string;
readonly mockServerHost?: string;
readonly registerScenarios: (api: { ScenarioBuilder: any; registerScenario: (id: string, scenario: unknown) => void }) => void;
readonly settings: Record<string, unknown>;
readonly prepareWorkspace?: (workspacePath: string) => Promise<void> | void;
readonly cleanupWorkspace?: (workspacePath: string) => Promise<void> | void;
}): IAgentHostSuiteContext {
let mockServer: MockLlmServer;
let logsPath: string;
let workspacePath: string | undefined;
before(async function () {
const { startServer, ScenarioBuilder, registerScenario } = require(getMockLlmServerPath());
@@ -526,7 +654,10 @@ function setupAgentHostSuite(logger: Logger, config: {
registerScenario(AGENT_HOST_WARMUP_SCENARIO_ID, new ScenarioBuilder().emit(AGENT_HOST_WARMUP_REPLY).build());
config.registerScenarios({ ScenarioBuilder, registerScenario });
mockServer = await startServer(0, mockServerStartOptions((msg: string) => logger.log(msg)));
mockServer = await startServer(0, {
...mockServerStartOptions((msg: string) => logger.log(msg)),
host: config.mockServerHost,
});
logger.log(`Mock LLM server (${config.serverLabel}) started at ${getMockLlmServerUrl(mockServer)}`);
});
@@ -554,6 +685,8 @@ function setupAgentHostSuite(logger: Logger, config: {
VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE: getMockLlmServerUrl(mockServer),
},
}));
workspacePath = (this.app as Application).workspacePathOrFolder;
await config.prepareWorkspace?.(workspacePath);
// Pre-seed settings.json on disk into BOTH the default profile and the
// Agents profile so Agent Host startup observes the test configuration.
@@ -588,7 +721,11 @@ function setupAgentHostSuite(logger: Logger, config: {
await (this.app as Application).start();
});
installAppAfterHandler();
installAppAfterHandler(undefined, async () => {
if (workspacePath) {
await config.cleanupWorkspace?.(workspacePath);
}
});
before(async function () {
const app = this.app as Application;
@@ -641,3 +778,12 @@ function ahpJsonlFiles(ahpLogDir: string): string[] {
function readAhpFrames(ahpLogDir: string): string {
return ahpJsonlFiles(ahpLogDir).map(f => fs.readFileSync(path.join(ahpLogDir, f), 'utf8')).join('\n');
}
function readRendererLogs(logsPath: string): string {
return fs.existsSync(logsPath)
? fs.readdirSync(logsPath, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^window\d+$/.test(entry.name))
.map(entry => readFileIfExists(path.join(logsPath, entry.name, 'renderer.log')))
.join('\n')
: '';
}
+9 -6
View File
@@ -10,6 +10,7 @@ import { dirname, join } from 'path';
import { Application, ApplicationOptions, IModelConfigSection, Logger } from '../../automation';
export interface MockLlmServer {
readonly port: number;
readonly url: string;
requestCount(): number;
getRequests(): readonly { readonly path: string; readonly method: string; readonly body: unknown }[];
@@ -142,12 +143,14 @@ function installAppBeforeHandler(optionsTransform?: (opts: ApplicationOptions) =
export function installAppAfterHandler(appFn?: () => Application | undefined, joinFn?: () => Promise<unknown>) {
after(async function () {
const app: Application = appFn?.() ?? this.app;
if (app) {
await app.stop();
}
if (joinFn) {
await joinFn();
try {
if (app) {
await app.stop();
}
} finally {
if (joinFn) {
await joinFn();
}
}
});
}