diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 6f37530af0917..47ad72f6a343f 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -740,7 +740,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (config?.activeClient?.customizations) { this._grantImplicitReadsForCustomizations(config.activeClient.customizations); } - await this._sendRequest('createSession', { + const inflight = this._sendRequest('createSession', { channel: session.toString(), provider, model: config?.model, @@ -748,6 +748,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC config: config?.config, activeClient: config?.activeClient, }); + this._subscriptionManager.trackSessionCreate(session, inflight); + await inflight; return session; } diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index e0173a789d032..bd808c6098f35 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -474,6 +474,7 @@ type ManagedSubscriptionEntry = { sub: ManagedSubscription; kind: StateComponent export class AgentSubscriptionManager extends Disposable { private readonly _subscriptions = new ResourceMap(); + private readonly _inflightCreates = new ResourceMap>(); private _referenceOwnerIds = 0; private readonly _rootState: RootStateSubscription; private readonly _clientId: string; @@ -520,6 +521,20 @@ export class AgentSubscriptionManager extends Disposable { return entry?.sub as IAgentSubscription | undefined; } + /** + * Register an in-flight `createSession` Promise for a session URI. Any + * subscribe issued for this resource while the create is pending waits + * for the Promise before issuing the wire-level subscribe. + */ + trackSessionCreate(resource: URI, promise: Promise): void { + this._inflightCreates.set(resource, promise); + void promise.finally(() => { + if (this._inflightCreates.get(resource) === promise) { + this._inflightCreates.delete(resource); + } + }); + } + /** * Get or create a refcounted subscription to any resource. Disposing * the returned reference decrements the refcount; when it reaches zero @@ -553,15 +568,28 @@ export class AgentSubscriptionManager extends Disposable { // Kick off server subscription asynchronously. // Capture the entry reference so we can validate it hasn't been // replaced by a new subscription for the same key (race guard). - this._subscribe(resource).then(snapshot => { - if (this._subscriptions.get(resource) === entry) { - sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + void (async () => { + const inflight = this._inflightCreates.get(resource); + if (inflight) { + try { + await inflight; + } catch { + // Swallow — fall through to subscribe so the error + // surfaces consistently via setError() on the + // subscription, matching the no-inflight path. + } } - }).catch(err => { - if (this._subscriptions.get(resource) === entry) { - sub.setError(err instanceof Error ? err : new Error(String(err))); + try { + const snapshot = await this._subscribe(resource); + if (this._subscriptions.get(resource) === entry) { + sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + } + } catch (err) { + if (this._subscriptions.get(resource) === entry) { + sub.setError(err instanceof Error ? err : new Error(String(err))); + } } - }); + })(); return this._acquireReference(resource, entry, owner); } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 617d3b7830092..63fdbf5d71066 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -199,7 +199,17 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._proxy.listSessions(); } createSession(config?: IAgentCreateSessionConfig): Promise { - return this._proxy.createSession(config); + const promise = this._proxy.createSession(config); + // When the caller pre-specifies the session URI, a subscribe for + // that URI can race the in-flight create. Register the promise so + // `AgentSubscriptionManager.getSubscription` gates the wire-level + // subscribe on it (avoids transient `AHP_SESSION_NOT_FOUND`). + // When the server assigns the URI, no caller can subscribe to it + // ahead of `await createSession()`, so there's no race to track. + if (config?.session) { + this._subscriptionManager.trackSessionCreate(config.session, promise); + } + return promise; } resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { return this._proxy.resolveSessionConfig(params); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 8a8ed8b68332d..b54c1220b3490 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -141,6 +141,10 @@ export class AgentHostStateManager extends Disposable { return [...this._sessionStates.keys()]; } + getAnnouncedSessionSummaries(): SessionSummary[] { + return [...this._lastNotifiedSummaries.values()]; + } + /** * Returns all session URIs whose keys start with the given prefix. * Used to discover subagent sessions for a given parent. diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8631432d6aa8b..3a6d6b362dbe7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -418,8 +418,39 @@ export class AgentService extends Disposable implements IAgentService { return s; }); - this._logService.trace(`[AgentService] listSessions returned ${withStatus.length} sessions`); - return withStatus; + // Overlay any session that has been announced via `sessionAdded` + // but is missing from the providers' `listSessions` snapshot. + // Providers can briefly drop a just-materialized session (e.g. + // between firing `sessionAdded` and the SDK's session DB becoming + // visible to the next `listSessions` call), and immediately after + // `session/turnComplete` we've observed `CopilotAgent.listSessions` + // return an empty array transiently. Without this overlay, + // renderer-side session caches evict the live session, which + // closes the chat view holding the in-flight response bubble. + const known = new Set(withStatus.map(s => s.session.toString())); + const additions: IAgentSessionMetadata[] = []; + for (const summary of this._stateManager.getAnnouncedSessionSummaries()) { + if (known.has(summary.resource)) { + continue; + } + additions.push({ + session: URI.parse(summary.resource), + startTime: summary.createdAt, + modifiedTime: summary.modifiedAt, + summary: summary.title, + status: summary.status, + activity: summary.activity, + model: summary.model, + agent: summary.agent, + workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined, + ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), + changesets: summary.changesets, + }); + } + const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; + + this._logService.trace(`[AgentService] listSessions returned ${combined.length} sessions (${additions.length} state-manager fallback)`); + return combined; } async createSession(config?: IAgentCreateSessionConfig): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 23d008d0f91a3..25af913f89186 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -24,7 +24,7 @@ import { localize } from '../../../../nls.js'; import { IParsedPlugin, parseAgentFile, parsePlugin, parseSkillFile } from '../../../agentPlugins/common/pluginParsers.js'; import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; +import { ILogService, LogLevel } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformRootSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; @@ -51,6 +51,24 @@ import { SessionPluginBundler } from '../shared/sessionPluginBundler.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; import { getEffectiveAgents } from '../../common/customAgents.js'; +/** + * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` + * option so the spawned CLI logs (written to `~/.copilot/logs/process-*.log`) + * match the agent host's configured verbosity. `Trace` maps to the CLI's most + * verbose `'all'` level so renderer-side trace logging surfaces the CLI's + * internal diagnostics. + */ +function copilotCliLogLevelFor(level: LogLevel): NonNullable { + switch (level) { + case LogLevel.Off: return 'none'; + case LogLevel.Trace: return 'all'; + case LogLevel.Debug: return 'debug'; + case LogLevel.Info: return 'info'; + case LogLevel.Warning: return 'warning'; + case LogLevel.Error: return 'error'; + } +} + interface ICreatedWorktree { readonly repositoryRoot: URI; readonly worktree: URI; @@ -529,6 +547,7 @@ export class CopilotAgent extends Disposable implements IAgent { connection: RuntimeConnection.forStdio({ path: cliPath }), env, telemetry, + logLevel: copilotCliLogLevelFor(this._logService.getLevel()), enableRemoteSessions: this._isSessionSyncEnabled(), }; const client = this._createCopilotClient(clientOptions); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 824e33fbe7dbb..aa3f0a68f61d2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -25,7 +25,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, AgentSelection, Customization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentInfo, readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -1077,6 +1077,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; } protected _sessionTypes: ISessionType[] = []; + private _lastAgents: AgentInfo[] | undefined; + protected readonly _onDidChangeSessionTypes = this._register(new Emitter()); readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; @@ -1272,8 +1274,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * id/label set actually changed. */ protected _syncSessionTypesFromRootState(rootState: RootState): void { - this._onDidChangeCustomAgents.fire(); - this._onDidChangeCustomizations.fire(); + if (this._lastAgents !== rootState.agents) { + this._lastAgents = rootState.agents; + this._onDidChangeCustomAgents.fire(); + this._onDidChangeCustomizations.fire(); + } const next = rootState.agents.map((agent): ISessionType => ({ id: agent.provider, label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider), diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index c7fd1b6d2dea0..8929d1cc96f33 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -206,6 +206,20 @@ class MockAgentHostService extends mock() { this._onDidRootStateChange.fire(this._rootStateValue); } + /** + * Fires a root state change that preserves the current `agents` reference, + * simulating non-agent root deltas (e.g. `RootActiveSessionsChanged` on + * every turn start/complete) that the real reducer emits without + * replacing the `agents` slice. + */ + fireNonAgentRootStateChange(): void { + if (!this._rootStateValue || this._rootStateValue instanceof Error) { + throw new Error('rootState not initialized; call setAgents first'); + } + this._rootStateValue = { ...this._rootStateValue }; + this._onDidRootStateChange.fire(this._rootStateValue); + } + clearRootState(): void { this._rootStateValue = undefined; } @@ -945,12 +959,21 @@ suite('LocalAgentHostSessionsProvider', () => { let fired = 0; disposables.add(provider.onDidChangeCustomAgents(() => { fired++; })); - // Root state change should fire the event. + // A root state change that replaces the agents reference should + // fire the event. This is the only path that mutates agents in the + // real reducer (`RootAgentsChanged`). agentHost.setAgents([ { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, ]); const afterRoot = fired; - assert.ok(afterRoot > 0, 'expected event to fire on root state change'); + assert.ok(afterRoot > 0, 'expected event to fire when the agents reference is replaced'); + + // A subsequent root state change that preserves the agents reference + // (e.g. `activeSessionsChanged` on every turn start/complete) must + // NOT fire — firing on those caused chat session bubbles to be + // re-hydrated mid-turn, dropping streamed responses. + agentHost.fireNonAgentRootStateChange(); + assert.strictEqual(fired, afterRoot, 'expected event NOT to fire on non-agent root deltas (preserved agents reference)'); // Session-state update with new customizations should fire it again. provider.getSessionConfig(session!.sessionId); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 107317d11d971..360886f03d7aa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -54,6 +54,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private readonly _authTokenCache = new AgentHostAuthTokenCache(); private readonly _isSessionsWindow: boolean; + private readonly _enableSmokeTestDriver: boolean; constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -64,7 +65,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentHostFileSystemService _agentHostFileSystemService: IAgentHostFileSystemService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, @@ -72,8 +73,9 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr super(); this._isSessionsWindow = environmentService.isSessionsWindow; + this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver; - if (!configurationService.getValue(AgentHostEnabledSettingId)) { + if (!this._configurationService.getValue(AgentHostEnabledSettingId)) { return; } @@ -235,6 +237,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private async _authenticateWithServer(agents: readonly AgentInfo[]): Promise { this._agentHostService.setAuthenticationPending(true); try { + const testToken = this._getScenarioAutomationToken(); + if (testToken !== undefined) { + await this._seedTestToken(agents, testToken); + return; + } await authenticateProtectedResources(agents, { authTokenCache: this._authTokenCache, authenticationService: this._authenticationService, @@ -256,6 +263,14 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr * to the server. Returns true if authentication succeeded. */ private async _resolveAuthenticationInteractively(protectedResources: ProtectedResourceMetadata[]): Promise { + const testToken = this._getScenarioAutomationToken(); + if (testToken !== undefined) { + for (const resource of protectedResources) { + await this._agentHostService.authenticate({ resource: resource.resource, token: testToken }); + this._authTokenCache.updateAndIsChanged(resource.resource, resource.scopes_supported, testToken); + } + return protectedResources.length > 0; + } try { return await resolveAuthenticationInteractively(protectedResources, { authTokenCache: this._authTokenCache, @@ -269,4 +284,29 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } return false; } + + private async _seedTestToken(agents: readonly AgentInfo[], token: string): Promise { + for (const agent of agents) { + for (const resource of agent.protectedResources ?? []) { + if (!this._authTokenCache.updateAndIsChanged(resource.resource, resource.scopes_supported, token)) { + continue; + } + try { + await this._agentHostService.authenticate({ resource: resource.resource, token }); + } catch (err) { + this._authTokenCache.clear(resource.resource); + throw err; + } + } + } + } + + private _getScenarioAutomationToken(): string | undefined { + // Smoke-test escape hatch. + if (!this._enableSmokeTestDriver) { + return undefined; + } + const token = this._configurationService.getValue('chat.agentHost.unsafeTestToken'); + return typeof token === 'string' && token.length > 0 ? token : undefined; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 0c5f432661f3c..4a6f737223a99 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -853,25 +853,34 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!sub) { return undefined; } - if (sub.value === undefined) { - // Snapshot is in flight. Attach the listener before re-checking - // to close a race where the snapshot lands between the value - // read and the listener attachment. + if (sub.value !== undefined) { + return sub.value instanceof Error ? undefined : sub.value; + } + + // Snapshot is in flight. Pin the subscription with a fresh + // refcount for the duration of the await so the eager holder + // releasing concurrently can't tear down the underlying emitter + // (which would leave `onDidChange` silent and hang the await). + const pinRef = this._config.connection.getSubscription(StateComponents.Session, resolvedSession, 'AgentHostSessionHandler'); + try { await new Promise(resolve => { const store = new DisposableStore(); const settle = () => { store.dispose(); resolve(); }; - store.add(sub.onDidChange(settle)); + store.add(pinRef.object.onDidChange(settle)); store.add(token.onCancellationRequested(settle)); - if (sub.value !== undefined || token.isCancellationRequested) { + if (pinRef.object.value !== undefined || token.isCancellationRequested) { settle(); } }); + const value = pinRef.object.value; + this._logService.info(`[AgentHost] _readEagerlyCreatedSessionState: hydrated value=${value === undefined ? 'undefined' : value instanceof Error ? `error(${value.message})` : 'state'} cancelled=${token.isCancellationRequested} for ${resolvedSession.toString()}`); + return value instanceof Error ? undefined : value; + } finally { + pinRef.dispose(); } - const value = sub.value; - return (value && !(value instanceof Error)) ? value : undefined; } // ---- Pending message sync ----------------------------------------------- diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index 9f3f5990c68a2..b4b8f27ee9d41 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -93,25 +93,34 @@ export class AgentsWindow { const itemSel = `.action-widget .monaco-list-row`; const maxAttempts = 3; + const needle = label.toLowerCase(); // The picker click can silently do nothing if the active session - // isn't fully initialized yet. Retry the click until the dropdown - // rows appear. - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // isn't fully initialized yet, and the dropdown is async-populated: + // providers (e.g. the AgentHost-backed Copilot CLI variant) can + // register a few seconds after the dropdown first renders. Retry + // opening the dropdown and poll its rows until the requested label + // appears, instead of just waiting for "any item". + let lastSeen: string[] = []; + outer: for (let attempt = 1; attempt <= maxAttempts; attempt++) { await this.code.waitAndClick(SESSION_TYPE_PICKER_VISIBLE); - try { - await this.code.waitForElement(itemSel, el => !!el && (el.textContent ?? '').trim().length > 0, 30 /* ~3 seconds */); - break; - } catch { - if (attempt === maxAttempts) { - throw new Error(`Session type picker did not populate after ${maxAttempts} attempts`); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const items = await this.code.getElements(itemSel, /* recursive */ true); + lastSeen = (items ?? []).map(i => (i.textContent ?? '').trim()); + if (lastSeen.some(t => t.toLowerCase().includes(needle))) { + break outer; } - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, 250)); + } + if (attempt === maxAttempts) { + throw new Error(`Session type "${label}" not found in picker. Available: ${lastSeen.join(', ')}`); } + await new Promise(r => setTimeout(r, 2000)); } const items = await this.code.waitForElements(itemSel, /* recursive */ true); - const matchIndex = items.findIndex(el => (el.textContent ?? '').trim().toLowerCase().includes(label.toLowerCase())); + const matchIndex = items.findIndex(el => (el.textContent ?? '').trim().toLowerCase().includes(needle)); if (matchIndex < 0) { throw new Error(`Session type "${label}" not found in picker. Available: ${items.map(i => (i.textContent ?? '').trim()).join(', ')}`); } @@ -164,16 +173,20 @@ export class AgentsWindow { const responseSelector = `${RESPONSE_COMPLETE} .rendered-markdown`; const deadline = Date.now() + timeoutMs; + let lastTexts: string[] = []; while (Date.now() < deadline) { const elements = await this.code.getElements(responseSelector, /* recursive */ true); - for (const el of (elements ?? [])) { - const text = el.textContent || ''; + lastTexts = (elements ?? []).map(el => el.textContent || ''); + for (const text of lastTexts) { if (typeof predicate === 'string' ? text.includes(predicate) : predicate.test(text)) { return text; } } await new Promise(r => setTimeout(r, 500)); } - throw new Error(`Timed out waiting for assistant text matching ${predicate}`); + const seen = lastTexts.length + ? lastTexts.map((t, i) => ` [${i}] ${JSON.stringify(t.length > 500 ? t.slice(0, 500) + '…' : t)}`).join('\n') + : ' (no assistant response elements found)'; + throw new Error(`Timed out waiting for assistant text matching ${predicate}\nLast-seen response text(s):\n${seen}`); } } diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 703cacef1f18c..28a7cff582314 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -7,8 +7,8 @@ import * as assert from 'assert'; import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { Application, Logger } from '../../../../automation'; -import { getCopilotSmokeTestEnv, getMockLlmServerPath, installAllHandlers, MockLlmServer } from '../../utils'; +import { Application, ApplicationOptions, Logger } from '../../../../automation'; +import { createApp, getCopilotSmokeTestEnv, getMockLlmServerPath, installAppAfterHandler, installDiagnosticsHandler, installAllHandlers, MockLlmServer, suiteCrashPath, suiteLogsPath } from '../../utils'; /** * Per-test scenarios. Each test uses a unique scenario id so that the mock @@ -27,6 +27,9 @@ const LOCAL_REPLY = 'MOCKED_LOCAL_RESPONSE'; const CLAUDE_SCENARIO_ID = 'smoke-hello-claude'; const CLAUDE_REPLY = 'MOCKED_CLAUDE_RESPONSE'; +const AGENT_HOST_SCENARIO_ID = 'smoke-hello-agent-host'; +const AGENT_HOST_REPLY = 'MOCKED_AGENT_HOST_RESPONSE'; + export function setup(logger: Logger) { describe('Agents Window', () => { @@ -239,4 +242,135 @@ export function setup(logger: Logger) { ); }); }); + + describe('Agents Window (local AgentHost)', () => { + + let mockServer: MockLlmServer; + let logsPath: string; + + before(async function () { + const { startServer, ScenarioBuilder, registerScenario } = require(getMockLlmServerPath()); + + registerScenario('text-only', new ScenarioBuilder().emit('OK').build()); + registerScenario(AGENT_HOST_SCENARIO_ID, new ScenarioBuilder().emit(AGENT_HOST_REPLY).build()); + + mockServer = await startServer(0, { logger: (msg: string) => logger.log(msg) }); + logger.log(`Mock LLM server (AgentHost) started at ${mockServer.url}`); + }); + + installDiagnosticsHandler(logger); + + before(async function () { + const suiteName = this.test?.parent?.title ?? 'unknown'; + const defaultOptions: ApplicationOptions = { + ...this.defaultOptions, + logsPath: suiteLogsPath(this.defaultOptions, suiteName), + crashesPath: suiteCrashPath(this.defaultOptions, suiteName), + }; + logsPath = defaultOptions.logsPath; + this.app = createApp(defaultOptions, opts => ({ + ...opts, + extraEnv: { + ...(opts.extraEnv ?? {}), + ...getCopilotSmokeTestEnv(mockServer), + COPILOT_ENABLE_ALT_PROVIDERS: 'true', + COPILOT_API_URL: mockServer.url, + COPILOT_DEBUG_GITHUB_API_URL: mockServer.url, + GITHUB_COPILOT_API_TOKEN: 'smoketest-fake-agent-host-token', + }, + })); + + // Pre-seed settings.json on disk into BOTH the default profile and the Agents profile. + const userDataDir = (this.app as Application).userDataPath; + if (userDataDir) { + const settings = JSON.stringify({ + 'github.copilot.advanced.debug.overrideProxyUrl': mockServer.url, + 'chat.allowAnonymousAccess': true, + 'github.copilot.chat.githubMcpServer.enabled': false, + 'chat.agentHost.enabled': true, + 'chat.agentHost.ahpJsonlLoggingEnabled': true, + 'chat.agentHost.unsafeTestToken': 'smoketest-fake-agent-host-token', + }, null, 2); + for (const settingsPath of [ + path.join(userDataDir, 'User', 'settings.json'), + path.join(userDataDir, 'User', 'profiles', 'builtin', 'agents', 'settings.json'), + ]) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, settings); + } + } + + await (this.app as Application).start(); + }); + + installAppAfterHandler(); + + before(async function () { + const app = this.app as Application; + + cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); + + // Settings are pre-seeded to disk via `optionsTransform` above; no + // `addUserSettings` call here because writing settings after the + // workbench is up would race with AgentHostContribution’s startup + // (it gates on `chat.agentHost.enabled` at construction). + + const windowsBefore = app.code.driver.getAllWindows().length; + await app.workbench.agentsWindow.openCurrentFolderInAgentsWindow(); + await app.workbench.agentsWindow.switchToAgentsWindow(windowsBefore); + }); + + after(async function () { + if (mockServer) { + await mockServer.close(); + } + }); + + it('Test Copilot CLI session via AgentHost', async function () { + this.timeout(5 * 60 * 1000); + + const app = this.app as Application; + + const requestsBefore = mockServer.requestCount(); + await app.workbench.agentsWindow.waitForNewSessionView(); + await app.workbench.agentsWindow.selectSessionType('Local Agent Host'); + // May intermittently hit the CLI cold-start "No model available" race: github/copilot-agent-runtime#9876 + await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${AGENT_HOST_SCENARIO_ID}]`); + + const text = await app.workbench.agentsWindow.waitForAssistantText(AGENT_HOST_REPLY); + logger.log(`Agents Window (AgentHost) response: ${text}`); + + assert.ok( + mockServer.requestCount() > requestsBefore, + 'expected the mock LLM server to have received a new request from the AgentHost session' + ); + + // Confirm the request flowed through the AgentHost process (not + // the renderer-side Copilot Chat extension fallback) by checking + // for a `session/turnStarted` frame in the AHP JSONL transcript. + // The transcript is written through an async queue (see + // AhpJsonlLogger), so the frame may not be on disk yet even + // after the assistant reply has rendered — poll briefly. + const ahpLogDir = path.join(logsPath, 'ahp'); + const deadline = Date.now() + 5_000; + let ahpEntries: string[] = []; + let ahpFrames = ''; + while (Date.now() < deadline) { + ahpEntries = fs.existsSync(ahpLogDir) + ? fs.readdirSync(ahpLogDir).filter(f => f.endsWith('.jsonl')) + : []; + ahpFrames = ahpEntries + .map(f => fs.readFileSync(path.join(ahpLogDir, f), 'utf8')) + .join('\n'); + if (ahpFrames.includes('"type":"session/turnStarted"')) { + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok( + ahpFrames.includes('"type":"session/turnStarted"'), + `expected the AgentHost process to have received a session/turnStarted dispatchAction (checked ${ahpEntries.length} jsonl files under ${ahpLogDir}); if missing, the renderer-side extension likely served the reply instead` + ); + }); + }); }