From 1637b487a28c3c6f165d9f7258afd5aa8b96aaa9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 3 Jun 2026 01:06:00 +0200 Subject: [PATCH 1/9] sessions: experimental setting for local agent host as default provider (#319653) Introduce an experimental `chat.agentHost.defaultSessionsProvider` setting (default `false`, gated behind `chat.agentHost.enabled`). When enabled, the local agent host's session types are surfaced before other providers'. Ordering is driven by a new `order` property on `ISessionsProvider` (lower sorts first, default `0`, ties keep registration order) so the management service stays provider-agnostic. The local agent host provider sets its order reactively from the setting and fires `onDidChangeSessionTypes` on change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/SESSIONS.md | 2 + .../common/agentHostSessionsProvider.ts | 9 ++++ .../browser/sessionWorkspacePicker.test.ts | 1 + .../browser/baseAgentHostSessionsProvider.ts | 2 + .../browser/localAgentHost.contribution.ts | 17 +++++++ .../browser/localAgentHostSessionsProvider.ts | 22 ++++++++- .../browser/copilotChatSessionsProvider.ts | 1 + .../browser/localChatSessionsProvider.ts | 1 + .../browser/sessionsManagementService.ts | 15 +++++- .../sessions/common/sessionsProvider.ts | 9 ++++ .../browser/sessionsManagementService.test.ts | 48 ++++++++++++++++++- 11 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 77305a764a1ce..ca0d8a4967c69 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -109,6 +109,8 @@ Tasks with `runOptions.runOn === "worktreeCreated"` are dispatched client-side o An **`ISessionType`** identifies an agent backend (e.g., `'copilot-cli'`, `'copilot-cloud'`). Each provider declares which session types it supports and can dynamically update the list via `onDidChangeSessionTypes`. The management service exposes `getAllSessionTypes()` for UI pickers. +Session types are surfaced ordered by each provider's `order` property (lower first; ties keep registration order). The default `order` is `0`, so the Copilot Chat sessions provider keeps precedence by default. The local agent host provider sets its `order` reactively from the experimental `chat.agentHost.defaultSessionsProvider` setting (default `false`, gated behind `chat.agentHost.enabled`): when enabled it returns a negative order so its session types sort before all other providers; otherwise it sorts after the defaults. The provider fires `onDidChangeSessionTypes` when the setting toggles so the management service re-collects and re-sorts. The sort itself lives in `SessionsManagementService._getOrderedProviders()` and applies to both `getAllSessionTypes()` and `getSessionTypesForFolder()` — the orchestration layer stays provider-agnostic (it sorts purely by `order`, with no knowledge of specific provider ids). + ### Changesets Sessions produce file changes organized into **`ISessionChangeset`** groups — named, togglable collections of file modifications that let users review and selectively apply changes. diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index f674a71eeab26..65dc41aa17989 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -135,6 +135,15 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { } export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; + +/** + * Experimental setting id controlling whether the local agent host acts as the + * default sessions provider. When enabled (and `chat.agentHost.enabled` is + * true), the local agent host's session types are surfaced before those of + * other providers. Defaults to `false`. + */ +export const LocalAgentHostDefaultProviderSettingId = 'chat.agentHost.defaultSessionsProvider'; + export const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; export const REMOTE_AGENT_HOST_PROVIDER_RE = /^agenthost-/; export const ANY_AGENT_HOST_PROVIDER_RE = /^(local-agent-host|agenthost-)/; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 8cd33248e7cc3..5b4049c4b420f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -66,6 +66,7 @@ function createMockProvider(id: string, opts?: { id, label: `Provider ${id}`, icon: Codicon.remote, + order: 0, sessionTypes: [], onDidChangeSessionTypes: Event.None, browseActions: opts?.browseActions ?? [], diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index ea8c7c3a877ff..7fcdf73241e41 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1049,6 +1049,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement abstract readonly icon: ThemeIcon; abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + get order(): number { return 0; } + get sessionTypes(): readonly ISessionType[] { return this._sessionTypes; } protected _sessionTypes: ISessionType[] = []; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index e08125e06c57c..e17f645e888fb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -13,8 +13,25 @@ import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../workbe import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; +import { LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { localize } from '../../../../../nls.js'; import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider.js'; +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [LocalAgentHostDefaultProviderSettingId]: { + type: 'boolean', + default: false, + tags: ['experimental'], + experiment: { mode: 'startup' }, + markdownDescription: localize('sessions.chat.agentHost.defaultSessionsProvider', "When enabled, the local agent host is used as the default sessions provider and its session types are shown first in the Agents window. Requires `#{0}#`.", AgentHostEnabledSettingId), + }, + }, +}); + /** * Registers the {@link LocalAgentHostSessionsProvider} as a sessions provider * when `chat.agentHost.enabled` is true. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 9a954e2e7b1c4..467333ec3869c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -23,7 +23,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID, LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -48,6 +48,17 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; + /** + * When the experimental {@link LocalAgentHostDefaultProviderSettingId} + * setting is enabled, the local agent host becomes the default sessions + * provider: its session types sort before every other provider (negative + * order). Otherwise it sorts after the default providers so Copilot Chat + * keeps precedence. + */ + override get order(): number { + return this._configurationService.getValue(LocalAgentHostDefaultProviderSettingId) ? -1 : 1; + } + constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @IChatSessionsService chatSessionsService: IChatSessionsService, @@ -96,6 +107,15 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } this._refreshSessions(); })); + + // When the "default sessions provider" preference changes, the + // provider's `order` flips. Re-fire `onDidChangeSessionTypes` so the + // management service re-collects and re-sorts the session types. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(LocalAgentHostDefaultProviderSettingId)) { + this._onDidChangeSessionTypes.fire(); + } + })); } // -- BaseAgentHostSessionsProvider hooks --------------------------------- diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index b9864f6bca345..fa03afe9454e0 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1363,6 +1363,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; + readonly order = 0; get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts index afb453f0fdfc1..dcf4ee59ad560 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts @@ -397,6 +397,7 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr readonly id = LOCAL_PROVIDER_ID; readonly label = localize('localChatSessionsProvider', "Local Chat"); readonly icon = Codicon.vm; + readonly order = 0; readonly browseActions: readonly [] = []; readonly supportsLocalWorkspaces = true; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index ee36fb323ebf4..f5b925b2394d1 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -147,6 +147,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._updateSessionTypes(); })); this._subscribeToProviders(this.sessionsProvidersService.getProviders()); + this._sessionTypes = this._collectSessionTypes(); // Session navigation history this._navigation = this._register(new SessionsNavigation( @@ -350,7 +351,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa getSessionTypesForFolder(folderUri: URI): IProviderSessionType[] { const result: IProviderSessionType[] = []; - for (const provider of this.sessionsProvidersService.getProviders()) { + for (const provider of this._getOrderedProviders()) { if (!provider.resolveWorkspace(folderUri)) { continue; } @@ -374,7 +375,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private _collectSessionTypes(): ISessionType[] { const types: ISessionType[] = []; const seen = new Set(); - for (const provider of this.sessionsProvidersService.getProviders()) { + for (const provider of this._getOrderedProviders()) { for (const type of provider.sessionTypes) { if (!seen.has(type.id)) { seen.add(type.id); @@ -385,6 +386,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return types; } + /** + * Returns the registered providers in the order their session types should + * be surfaced, sorted by each provider's {@link ISessionsProvider.order} + * (lower first). The sort is stable, so providers with equal order keep + * their registration order. + */ + private _getOrderedProviders(): ISessionsProvider[] { + return [...this.sessionsProvidersService.getProviders()].sort((a, b) => a.order - b.order); + } + private _updateSessionTypes(): void { // Always fire — the deduplicated flat list (used by surfaces that // only need a set of type ids) may be unchanged, but the per-folder diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index f12cc62f5baab..c76216d8b64b3 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -51,6 +51,15 @@ export interface ISessionsProvider { */ readonly icon: ThemeIcon; + /** + * Sort order that determines the precedence of this provider's session + * types relative to other providers. Lower values are surfaced first; + * providers with equal order keep their registration order. The default is + * `0`. A provider may change this dynamically (e.g. based on a setting) and + * fire `onDidChangeSessionTypes` to have consumers re-evaluate the order. + */ + readonly order: number; + /** * Session types supported by this provider. The provider is expected to update this list and fire `onDidChangeSessionTypes` */ diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index dc9327a31627a..d77d3de4ac61f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -29,6 +29,7 @@ import { ISessionChangeEvent, ISendRequestOptions, ISessionsProvider } from '../ import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; const stubChat = { resource: URI.parse('test:///chat'), @@ -149,9 +150,10 @@ class TestSessionsProvidersService extends mock() { } class TestSessionsProvider extends mock() { - override readonly id = 'test'; + override readonly id: string = 'test'; override readonly label = 'Test'; override readonly icon = Codicon.vm; + override readonly order: number = 0; override readonly sessionTypes: readonly ISessionType[] = [{ id: 'test', label: 'Test', icon: Codicon.vm }]; override readonly onDidChangeSessionTypes = Event.None; override readonly onDidChangeSessions = Event.None; @@ -395,4 +397,48 @@ suite('SessionsManagementService', () => { assert.strictEqual(sendRequestStarted, true); assert.strictEqual(service.activeSession.get(), undefined); }); + + test('getAllSessionTypes orders providers by their order property (lower first)', () => { + const service = createOrderedTypesService(disposables, 0, 1); + assert.deepStrictEqual(service.getAllSessionTypes().map(type => type.id), ['copilot', 'agent-host']); + }); + + test('getAllSessionTypes surfaces local agent host types first when it has lower order', () => { + const service = createOrderedTypesService(disposables, 0, -1); + assert.deepStrictEqual(service.getAllSessionTypes().map(type => type.id), ['agent-host', 'copilot']); + }); }); + +/** + * Builds a management service with a Copilot-style provider and a + * local-agent-host provider, each with an explicit {@link ISessionsProvider.order}. + * Used to assert that the management service surfaces session types ordered by + * provider order (lower first). + */ +function createOrderedTypesService(disposables: ReturnType, copilotOrder: number, agentHostOrder: number): ISessionsManagementService { + const copilotProvider = new class extends TestSessionsProvider { + override readonly id = 'default-copilot'; + override readonly order = copilotOrder; + override readonly sessionTypes: readonly ISessionType[] = [{ id: 'copilot', label: 'Copilot', icon: Codicon.vm }]; + }(stubSession({ sessionId: 'c1', providerId: 'default-copilot' })); + const agentHostProvider = new class extends TestSessionsProvider { + override readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; + override readonly order = agentHostOrder; + override readonly sessionTypes: readonly ISessionType[] = [{ id: 'agent-host', label: 'Agent Host', icon: Codicon.vm }]; + }(stubSession({ sessionId: 'a1', providerId: LOCAL_AGENT_HOST_PROVIDER_ID })); + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([copilotProvider, agentHostProvider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IAgentSessionsService, new TestAgentSessionsService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock() { + override readonly onDidSubmitRequest = Event.None; + }); + + return disposables.add(instantiationService.createInstance(SessionsManagementService)); +} From 79e25fe4130cd54efee63e961103b0bb9d93506e Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:06:59 -0700 Subject: [PATCH 2/9] Memoize getFirstNonCopilotModel in scenario automation endpoint provider (#319638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Memoize getFirstNonCopilotModel in scenario automation endpoint provider In scenario-automation runs against a BYOK no-auth user, `ScenarioAutomationEndpointProviderImpl.getChatEndpoint` is invoked many times per agent step (main model + utility + utility-small + tool detection + per-MCP-call). Each call hit `getFirstNonCopilotModel`, which always called `lm.selectChatModels()` (empty selector). The empty selector fans out across every registered language-model vendor and re-resolves each one — sustained ~4 Hz for an entire 10-minute agent turn in BYOK eval runs, ~2.4k empty-selector calls per turn, with 99.99 percent reporting `no changes`. Cache the first non-copilot model for the lifetime of the provider and invalidate on `lm.onDidChangeChatModels`, debounced through a `MicrotaskDelay` `Delayer` so synchronous bursts collapse into a single invalidation. The change listener is installed lazily on first cache use, so production paths that never hit the no-auth branch do not subscribe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix stray brace from autofix; add logging for visibility - Repair syntax error introduced by autofix (extra closing brace after the IIFE block in _resolveFirstNonCopilotModel that prematurely closed the class). - Add diagnostic logs so an eval bundle at default level shows the BYOK redirect engaging (info on resolve and on cache invalidation) and trace logs identifying which redirect branch ran for each request. - Promote the silent `no models found` throw to error and the capi-proxy family-resolve fallback from trace to warn so both are visible at default log level. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scenarioAutomationEndpointProviderImpl.ts | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/vscode-node/scenarioAutomationEndpointProviderImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/scenarioAutomationEndpointProviderImpl.ts index 22c34d3682d8c..11abf8cded0fd 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/scenarioAutomationEndpointProviderImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/scenarioAutomationEndpointProviderImpl.ts @@ -8,32 +8,47 @@ import { ConfigKey } from '../../../platform/configuration/common/configurationS import { ChatEndpointFamily } from '../../../platform/endpoint/common/endpointProvider'; import { ExtensionContributedChatEndpoint } from '../../../platform/endpoint/vscode-node/extChatEndpoint'; import { IChatEndpoint } from '../../../platform/networking/common/networking'; +import { Delayer } from '../../../util/vs/base/common/async'; +import { MicrotaskDelay } from '../../../util/vs/base/common/symbols'; import { ProductionEndpointProvider } from './endpointProviderImpl'; export class ScenarioAutomationEndpointProviderImpl extends ProductionEndpointProvider { + /** + * Cached first-non-copilot model. Resolved lazily on first use and invalidated when the + * registered chat-model set changes. Without this cache, `getChatEndpoint` would call + * `lm.selectChatModels()` (empty selector) on every invocation — which fans out across + * all registered vendors and re-resolves each one. In long automation runs that run at + * several Hz for the entire turn, this can dominate renderer/CDP traffic. + */ + private _firstNonCopilotModelPromise: Promise | undefined; + private _invalidateDelayer: Delayer | undefined; + private _changeListenerInstalled = false; + override async getChatEndpoint(requestOrFamilyOrModel: LanguageModelChat | ChatRequest | ChatEndpointFamily): Promise { const isProxyingCAPI = !!this._configService.getConfig(ConfigKey.Shared.DebugOverrideCAPIUrl) || !!this._configService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl); if (this._authService.copilotToken?.isNoAuthUser && !isProxyingCAPI) { // When using no auth in scenario automation, we want to force using a custom model / non-copilot for all requests const getFirstNonCopilotModel = async () => { - const allModels = await lm.selectChatModels(); - const firstNonCopilotModel = allModels.find(m => m.vendor !== 'copilot'); + const firstNonCopilotModel = await this._resolveFirstNonCopilotModel(); if (firstNonCopilotModel) { - this._logService.trace(`Using custom contributed chat model`); + this._logService.trace(`ScenarioAutomation: using BYOK model ${firstNonCopilotModel.vendor}/${firstNonCopilotModel.id}`); return this._instantiationService.createInstance(ExtensionContributedChatEndpoint, firstNonCopilotModel); } else { + this._logService.error(`ScenarioAutomation: no non-copilot models registered`); throw new Error('No custom contributed chat models found.'); } }; // Check if we have a hard-coded family which indicates a copilot model if (typeof requestOrFamilyOrModel === 'string') { + this._logService.trace(`ScenarioAutomation: redirecting family '${requestOrFamilyOrModel}' to BYOK`); return getFirstNonCopilotModel(); } // Check if a copilot model was explicitly requested in the picker const model = 'model' in requestOrFamilyOrModel ? requestOrFamilyOrModel.model : requestOrFamilyOrModel; if (model.vendor === 'copilot') { + this._logService.trace(`ScenarioAutomation: redirecting copilot model '${model.id}' to BYOK`); return getFirstNonCopilotModel(); } } @@ -44,10 +59,46 @@ export class ScenarioAutomationEndpointProviderImpl extends ProductionEndpointPr // In scenario automation, some model families (e.g. copilot-utility-small → gpt-4o-mini) may // not be available via the capi proxy. Fall back to copilot-utility. if (typeof requestOrFamilyOrModel === 'string') { - this._logService.trace(`ScenarioAutomation: failed to resolve model family '${requestOrFamilyOrModel}', falling back to copilot-utility`); + this._logService.warn(`ScenarioAutomation: failed to resolve model family '${requestOrFamilyOrModel}', falling back to copilot-utility: ${error}`); return super.getChatEndpoint('copilot-utility'); } throw error; } } + + private _resolveFirstNonCopilotModel(): Promise { + this._ensureChangeListener(); + if (!this._firstNonCopilotModelPromise) { + this._firstNonCopilotModelPromise = (async () => { + try { + const allModels = await lm.selectChatModels(); + const found = allModels.find(m => m.vendor !== 'copilot'); + this._logService.info(`ScenarioAutomation: resolved BYOK model ${found ? `${found.vendor}/${found.id}` : ''} from ${allModels.length} registered model(s)`); + return found; + } catch (err) { + this._logService.warn(`ScenarioAutomation: selectChatModels failed; clearing cache: ${err}`); + this._firstNonCopilotModelPromise = undefined; + throw err; + } + })(); + } + return this._firstNonCopilotModelPromise; + } + + private _ensureChangeListener(): void { + if (this._changeListenerInstalled) { + return; + } + this._changeListenerInstalled = true; + // Coalesce bursts of model-set changes (e.g. when a BYOK provider activates and + // publishes several utility-alias models in quick succession) into a single + // invalidation so we don't churn the cache. + this._invalidateDelayer = this._register(new Delayer(MicrotaskDelay)); + this._register(lm.onDidChangeChatModels(() => { + this._invalidateDelayer!.trigger(() => { + this._logService.info(`ScenarioAutomation: chat model set changed; invalidating cached BYOK model`); + this._firstNonCopilotModelPromise = undefined; + }).catch(() => { /* cancelled on dispose */ }); + })); + } } \ No newline at end of file From 7d90332297d6a3e2dd5f1af57274570b1308ecdb Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:29:11 -0700 Subject: [PATCH 3/9] Browser: update suggestions dynamically as tabs change (#319654) * Browser: update suggestions dynamically as tabs change * feedback --- .../features/browserTabManagementFeatures.ts | 110 +++++++-- .../widgets/browserUrlBarWidget.ts | 224 +++++++++++------- 2 files changed, 224 insertions(+), 110 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index e86e2d90f1aba..524c352be18d7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -9,10 +9,11 @@ import { ServicesAccessor, IInstantiationService } from '../../../../../platform import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; -import { IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; import { EditorsOrder, EditorResourceAccessor, GroupIdentifier, SideBySideEditor } from '../../../../common/editor.js'; import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -734,6 +735,10 @@ BrowserEditor.registerContribution(LinkOpenedHintPill); */ class BrowserTabUrlSuggestions extends BrowserEditorContribution { + private readonly _onDidChange = this._register(new Emitter()); + private readonly _groupListeners = this._register(new DisposableMap()); + private readonly _editorLabelListeners = this._register(new DisposableMap()); + private readonly _provider: IBrowserUrlSuggestionProvider; constructor( @@ -743,32 +748,42 @@ class BrowserTabUrlSuggestions extends BrowserEditorContribution { @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, ) { super(editor); + + // Re-fire onDidChange whenever the set of tabs, the group structure, or + // any tab's label changes so the URL picker's open-tabs list stays live + // (additions/removals) and ordered by current group visibility. + for (const group of this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + this._trackGroup(group); + } + this._register(this._editorGroupsService.onDidAddGroup(group => { + this._trackGroup(group); + this._onDidChange.fire(); + })); + this._register(this._editorGroupsService.onDidRemoveGroup(group => { + this._groupListeners.deleteAndDispose(group.id); + this._onDidChange.fire(); + })); + this._register(this._editorGroupsService.onDidMoveGroup(() => this._onDidChange.fire())); + this._register(this._editorGroupsService.onDidChangeGroupIndex(() => this._onDidChange.fire())); + + this._refreshEditorLabelListeners(); + this._register(this._browserViewService.onDidChangeBrowserViews(() => { + this._refreshEditorLabelListeners(); + this._onDidChange.fire(); + })); + this._provider = { label: localize('browser.openTabs', "Open Tabs"), description: localize('browser.openTabsDescription', "Select a tab to switch"), order: 100, actions: [], + onDidChange: this._onDidChange.event, getSuggestions: async ({ input }) => { // Only surface tab suggestions on a new / empty tab. if (input.url) { return []; } - const suggestions: IBrowserUrlSuggestion[] = []; - for (const tab of this._browserViewService.getKnownBrowserViews().values()) { - if (tab === input) { - continue; - } - const rawIcon = tab.getIcon(); - suggestions.push({ - id: tab.id, - label: tab.getName(), - description: tab.getDescription(), - icon: rawIcon instanceof URI ? undefined : rawIcon, - iconPath: rawIcon instanceof URI ? { dark: rawIcon } : undefined, - apply: source => this._switchToTab(source, tab), - }); - } - return suggestions; + return this._collectSuggestions(input); }, }; } @@ -777,6 +792,65 @@ class BrowserTabUrlSuggestions extends BrowserEditorContribution { return [this._provider]; } + private _trackGroup(group: IEditorGroup): void { + this._groupListeners.set(group.id, group.onDidModelChange(() => this._onDidChange.fire())); + } + + private _refreshEditorLabelListeners(): void { + const known = this._browserViewService.getKnownBrowserViews(); + for (const id of [...this._editorLabelListeners.keys()]) { + if (!known.has(id)) { + this._editorLabelListeners.deleteAndDispose(id); + } + } + for (const [id, editor] of known) { + if (!this._editorLabelListeners.has(id)) { + this._editorLabelListeners.set(id, editor.onDidChangeLabel(() => this._onDidChange.fire())); + } + } + } + + /** + * Return tabs in editor-group visibility order (grid appearance, then + * within-group editor order), with background tabs (known but not open + * in any group) appended at the end. Excludes the editor's own input. + */ + private _collectSuggestions(input: BrowserEditorInput): IBrowserUrlSuggestion[] { + const ordered: BrowserEditorInput[] = []; + const seen = new Set(); + for (const group of this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + for (const editor of group.editors) { + if (editor instanceof BrowserEditorInput && !seen.has(editor.id)) { + seen.add(editor.id); + ordered.push(editor); + } + } + } + for (const tab of this._browserViewService.getKnownBrowserViews().values()) { + if (!seen.has(tab.id)) { + seen.add(tab.id); + ordered.push(tab); + } + } + + const suggestions: IBrowserUrlSuggestion[] = []; + for (const tab of ordered) { + if (tab === input) { + continue; + } + const rawIcon = tab.getIcon(); + suggestions.push({ + id: tab.id, + label: tab.getName(), + description: tab.getDescription(), + icon: rawIcon instanceof URI ? undefined : rawIcon, + iconPath: rawIcon instanceof URI ? { dark: rawIcon } : undefined, + apply: source => this._switchToTab(source, tab), + }); + } + return suggestions; + } + /** * Close {@link source} and focus {@link target} where it already lives. * diff --git a/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts index 7abd20946901a..0bac059c3a977 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts @@ -6,10 +6,10 @@ import { localize } from '../../../../../nls.js'; import { $, addDisposableListener, EventType, isHTMLInputElement } from '../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator, QuickInputHideReason } from '../../../../../platform/quickinput/common/quickInput.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; @@ -383,7 +383,7 @@ export class BrowserUrlBarWidget extends Disposable { /** * Build the synchronous "Go to " picker item (when there is a * non-empty value). Provider-contributed suggestions are loaded - * asynchronously by {@link _loadProviderSuggestions} and appended below. + * asynchronously and appended below by the picker open flow. */ private _buildSuggestionItems(value: string): (IUrlPickerItem | IQuickPickSeparator)[] { const items: (IUrlPickerItem | IQuickPickSeparator)[] = []; @@ -398,67 +398,25 @@ export class BrowserUrlBarWidget extends Disposable { return items; } - /** - * Run all suggestion providers in parallel against the current text and - * push their results below the synchronous "Go to" item. Returns the full - * item list (synchronous + provider) so the caller can update the picker. - */ - private async _loadProviderSuggestions( - value: string, - input: BrowserEditorInput, - token: CancellationToken, - ): Promise<(IUrlPickerItem | IQuickPickSeparator)[]> { - const items: (IUrlPickerItem | IQuickPickSeparator)[] = this._buildSuggestionItems(value); - if (this._suggestionProviders.length === 0) { - return items; - } - const context = { text: value, input }; - const results = await Promise.all( - this._suggestionProviders.map(p => - p.getSuggestions(context, token) - .then(r => ({ provider: p, suggestions: r })) - .catch(() => ({ provider: p, suggestions: [] as readonly IBrowserUrlSuggestion[] })) - ) - ); - if (token.isCancellationRequested) { - return items; + /** Convert a provider suggestion to its picker-item representation. */ + private _toPickerItem(s: IBrowserUrlSuggestion): IUrlPickerItem { + const item: IUrlPickerItem = { + id: s.id, + label: s.label, + description: s.description, + apply: s.apply, + }; + if (s.iconPath) { + item.iconPath = s.iconPath; + } else if (s.icon) { + item.iconClass = ThemeIcon.asClassName(s.icon); } - for (const { provider, suggestions } of results) { - if (suggestions.length === 0) { - continue; - } - if (provider.label) { - // `buttons: []` opts the separator into being rendered as - // its own row (a separator without buttons is otherwise - // collapsed into the first item below it as a header). - items.push({ - type: 'separator', - label: provider.label, - description: provider.description, - buttons: provider.actions, - }); - } - for (const s of suggestions) { - const item: IUrlPickerItem = { - id: s.id, - label: s.label, - description: s.description, - apply: s.apply, - }; - if (s.iconPath) { - item.iconPath = s.iconPath; - } else if (s.icon) { - item.iconClass = ThemeIcon.asClassName(s.icon); - } - if (s.actions && s.actions.length > 0) { - // Per-item buttons. We pass the action objects through directly - // so onDidTriggerItemButton hands them back to us as the IBrowserUrlSuggestionAction. - item.buttons = s.actions; - } - items.push(item); - } + if (s.actions && s.actions.length > 0) { + // Per-item buttons. We pass the action objects through directly + // so onDidTriggerItemButton hands them back to us as the IBrowserUrlSuggestionAction. + item.buttons = s.actions; } - return items; + return item; } /** @@ -497,44 +455,124 @@ export class BrowserUrlBarWidget extends Disposable { picker.valueSelection = [0, this._canonicalUrl.length]; } const disposables = new DisposableStore(); - const loadCts = disposables.add(new MutableDisposable()); - const applyItems = (value: string) => { - // Show the synchronous "Go to" item immediately so the picker is - // never blank while providers load. - const sync = this._buildSuggestionItems(value); - picker.items = sync; - const hasGo = sync.some(i => i.type !== 'separator'); - if (!hasGo) { - picker.activeItems = []; + + // Each provider keeps its own cached suggestions + cancellation so a + // single provider's onDidChange (or a per-provider re-fetch) updates + // just that group, without recomputing the rest. Cancellation tokens + // (not just disposal) are needed so an in-flight `.then` for the + // previous request doesn't overwrite newer cached results. + type ProviderState = { + suggestions: readonly IBrowserUrlSuggestion[]; + cts: MutableDisposable; + }; + const providerStates = new Map(); + // Register the cancellation hook before the per-provider MutableDisposables + // so it runs first on picker close. `CancellationTokenSource.dispose()` + // only releases internal state — it does NOT cancel — so without this, + // in-flight provider requests would keep running after the picker is gone. + disposables.add(toDisposable(() => { + for (const state of providerStates.values()) { + state.cts.value?.cancel(); } - // Cancel any in-flight provider load and start a new one. - // (MutableDisposable.dispose only disposes the prior CTS; it does - // not cancel its token, so an in-flight `.then` could otherwise - // overwrite newer results with stale ones.) - loadCts.value?.cancel(); - const cts = new CancellationTokenSource(); - loadCts.value = cts; - const inputAtRequest = this._host.input; - if (!inputAtRequest) { + })); + for (const provider of this._suggestionProviders) { + providerStates.set(provider, { + suggestions: [], + cts: disposables.add(new MutableDisposable()), + }); + } + + let currentValue = picker.value; + + // Preserve the user's current selection across re-renders (typing, + // per-provider refresh) by matching the previously active item's id. + // Without this, setting `picker.items` snaps activeItems back to the + // first row, so e.g. opening/closing a tab while the user is + // arrow-keyed onto an open-tab suggestion would yank them back to + // "Go to". + const restoreActiveById = (previousId: string | undefined, items: readonly (IUrlPickerItem | IQuickPickSeparator)[]) => { + if (previousId === undefined) { return; } - void this._loadProviderSuggestions(value, inputAtRequest, cts.token).then(full => { - if (cts.token.isCancellationRequested || this._picker.value !== picker) { - return; + const match = items.find((i): i is IUrlPickerItem => i.type !== 'separator' && i.id === previousId); + if (match) { + picker.activeItems = [match]; + } + }; + + // Rebuild `picker.items` from the synchronous "Go to" entry plus each + // provider's current cached suggestions, in provider sort order. + const render = () => { + const previousActiveId = picker.activeItems[0]?.id; + const items = this._buildSuggestionItems(currentValue); + const hasGo = items.some(i => i.type !== 'separator'); + for (const provider of this._suggestionProviders) { + const state = providerStates.get(provider); + if (!state || state.suggestions.length === 0) { + continue; } - picker.items = full; - if (!hasGo) { - // Empty value: don't auto-activate the first provider entry. - picker.activeItems = []; + if (provider.label) { + // `buttons: []` opts the separator into being rendered as + // its own row (a separator without buttons is otherwise + // collapsed into the first item below it as a header). + items.push({ + type: 'separator', + label: provider.label, + description: provider.description, + buttons: provider.actions, + }); } - }); + for (const s of state.suggestions) { + items.push(this._toPickerItem(s)); + } + } + picker.items = items; + if (!hasGo) { + picker.activeItems = []; + } + restoreActiveById(previousActiveId, items); }; - applyItems(picker.value); - // Re-run providers if any of them reports a state change while the picker is open. + // Re-fetch a single provider against the current value, cancelling + // any in-flight request for it. On success, update its cached + // suggestions and re-render. Errors are swallowed (leave prior + // cached results in place) so one failing provider can't blank the + // picker. + const refreshProvider = (provider: IBrowserUrlSuggestionProvider) => { + const state = providerStates.get(provider); + const input = this._host.input; + if (!state || !input) { + return; + } + state.cts.value?.cancel(); + const cts = new CancellationTokenSource(); + state.cts.value = cts; + void provider.getSuggestions({ text: currentValue, input }, cts.token).then( + results => { + if (cts.token.isCancellationRequested || this._picker.value !== picker) { + return; + } + state.suggestions = results; + render(); + }, + () => { /* keep prior cached suggestions on error */ } + ); + }; + + const refreshAllProviders = () => { + for (const provider of this._suggestionProviders) { + refreshProvider(provider); + } + }; + + render(); + refreshAllProviders(); + + // Per-provider state change: refresh only that provider so unrelated + // groups keep their cached suggestions and selection. for (const provider of this._suggestionProviders) { if (provider.onDidChange) { - disposables.add(provider.onDidChange(() => applyItems(picker.value))); + disposables.add(provider.onDidChange(() => refreshProvider(provider))); } } @@ -552,7 +590,9 @@ export class BrowserUrlBarWidget extends Disposable { } })); disposables.add(picker.onDidChangeValue(value => { - applyItems(value); + currentValue = value; + render(); + refreshAllProviders(); // Mirror the picker's typed value into the display continuously, // running URL renderers so decorations stay live. The picker is // the source of truth while it's open. From b70081faab5d0cd8b15d7559782b8eeba67bff3f Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:34:23 -0700 Subject: [PATCH 4/9] [Windows-Sandboxing] Update MXC sdk package to 0.6.0 (#319649) mxc_upgrade --- package-lock.json | 8 ++++---- package.json | 2 +- remote/package-lock.json | 8 ++++---- remote/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 975fe47f25ea2..9447372a5601c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.3.0", + "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-14", @@ -2071,9 +2071,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.3.0.tgz", - "integrity": "sha512-eAjVfS4+RdG03Wh/DemgaMmjkuTGPDDNR3xRxkRLRs6lCpezNZq8OdNLjCy4N9cKr/H9mlpYAwPQUs07/+BywA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.0.tgz", + "integrity": "sha512-O+cKLjO4mE/D4dDp2GmVJ8hAj43vQHLf1YTMUWUtU4+41ddThhb1SYkn6W9b3FLl63bJW/4dqReJG6PIBk8jqQ==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/package.json b/package.json index c891804720942..a5c68ab95ac34 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", - "@microsoft/mxc-sdk": "0.3.0", + "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-14", diff --git a/remote/package-lock.json b/remote/package-lock.json index 79826ac1d7089..53b66573cd472 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -12,7 +12,7 @@ "@github/copilot-sdk": "1.0.0-beta.8", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.3.0", + "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", @@ -297,9 +297,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@microsoft/mxc-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.3.0.tgz", - "integrity": "sha512-eAjVfS4+RdG03Wh/DemgaMmjkuTGPDDNR3xRxkRLRs6lCpezNZq8OdNLjCy4N9cKr/H9mlpYAwPQUs07/+BywA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.6.0.tgz", + "integrity": "sha512-O+cKLjO4mE/D4dDp2GmVJ8hAj43vQHLf1YTMUWUtU4+41ddThhb1SYkn6W9b3FLl63bJW/4dqReJG6PIBk8jqQ==", "license": "MIT", "dependencies": { "node-pty": "^1.2.0-beta.12", diff --git a/remote/package.json b/remote/package.json index ab5406a9ff755..e8ae28f5b5409 100644 --- a/remote/package.json +++ b/remote/package.json @@ -7,7 +7,7 @@ "@github/copilot-sdk": "1.0.0-beta.8", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/mxc-sdk": "0.3.0", + "@microsoft/mxc-sdk": "0.6.0", "@parcel/watcher": "^2.5.6", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", From 47f9080ec653922e7f17461467608a90432f5beb Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:39:23 +0200 Subject: [PATCH 5/9] [Codex] Add app-server agent host provider (#318822) * [Codex] Add app-server agent host provider * Hide Codex reasoning effort config chips * Fix Codex first session activation * Fix Agent Host new session auth race * Fix Codex editor new session races * Fix Agent Host config chip overflow * Normalize Codex model identifiers * Update remote Agent Host auth-pending tests New Agent Host sessions now defer config resolution and eager backend creation until authenticationPending settles. That avoids AuthRequired races for providers such as Codex, but it also means RemoteAgentHostSessionsProvider tests must explicitly settle authentication before expecting resolved config or forwarded create-session config. * Update Codex for message protocol * Address Codex agent review cleanup * revert unneeded changes pt 1 * add test * revert unneded change * fix codex config chip schema refresh * Fix auth-settled new session resume --- .../common/agentHostCustomizationConfig.ts | 1 - .../agentHostStarter.config.contribution.ts | 25 + .../agentHost/common/agentPluginManager.ts | 1 - .../platform/agentHost/common/agentService.ts | 40 + .../platform/agentHost/common/customAgents.ts | 1 - .../common/state/agentSubscription.ts | 86 +- .../electron-main/electronAgentHostStarter.ts | 14 +- .../platform/agentHost/node/agentHostMain.ts | 15 +- .../agentHost/node/agentHostServerMain.ts | 20 +- .../claudeSessionCustomizationsProjector.ts | 1 - .../agentHost/node/codex/codexAgent.ts | 1476 +++++++++++++++++ .../node/codex/codexMapAppServerEvents.ts | 747 +++++++++ .../node/codex/codexPromptResolver.ts | 120 ++ .../agentHost/node/codex/codexProxyService.ts | 478 ++++++ .../agentHost/node/codex/codexReplayMapper.ts | 78 + .../node/codex/codexSessionConfigKeys.ts | 98 ++ .../node/codex/codexSessionMetadataStore.ts | 112 ++ .../agentHost/node/nodeAgentHostStarter.ts | 19 +- .../node/shared/copilotApiService.ts | 60 + .../test/common/agentSubscription.test.ts | 84 +- .../test/node/claudeAgent.integrationTest.ts | 6 +- .../agentHost/test/node/claudeAgent.test.ts | 4 +- .../test/node/claudeProxyService.test.ts | 8 +- .../codex/codexMapAppServerEvents.test.ts | 494 ++++++ .../node/codex/codexPromptResolver.test.ts | 79 + .../test/node/codex/codexReplayMapper.test.ts | 108 ++ .../node/codex/codexSessionConfigKeys.test.ts | 111 ++ .../protocol/codexRealSdk.integrationTest.ts | 48 + .../test/node/protocol/realSdkTestHelpers.ts | 11 +- .../test/node/protocol/testHelpers.ts | 5 +- src/vs/sessions/SESSIONS.md | 7 +- .../browser/agentHostSessionConfigPicker.ts | 43 +- .../browser/baseAgentHostSessionsProvider.ts | 102 +- .../browser/localAgentHostSessionsProvider.ts | 1 + .../localAgentHostSessionsProvider.test.ts | 205 ++- .../remoteAgentHostSessionsProvider.ts | 3 + .../remoteAgentHostSessionsProvider.test.ts | 4 +- .../agentHost/agentHostChatInputPicker.ts | 38 +- ...ntHostUntitledProvisionalSessionService.ts | 60 +- ...tUntitledProvisionalSessionService.test.ts | 43 + 40 files changed, 4766 insertions(+), 90 deletions(-) create mode 100644 src/vs/platform/agentHost/node/codex/codexAgent.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexPromptResolver.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexProxyService.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexReplayMapper.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts create mode 100644 src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts create mode 100644 src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts create mode 100644 src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts create mode 100644 src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts create mode 100644 src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 3841b40148d8d..52eeb108be1d3 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -114,4 +114,3 @@ export function toContainerCustomization(entry: IPersistedCustomizationConfigEnt enabled: true, }; } - diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 511b5fccc83ec..4ae8b0469640e 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -9,6 +9,9 @@ import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { AgentHostClaudeAgentSdkPathSettingId, + AgentHostCodexAgentBinaryArgsSettingId, + AgentHostCodexAgentBinaryPathSettingId, + AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, @@ -46,6 +49,28 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, + [AgentHostCodexAgentBinaryPathSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.codexAgent.path', "Experimental, for local testing only. Absolute path to a locally-installed `codex` binary. When set, the Codex agent provider is registered inside the agent host and `codex app-server` is spawned from this path. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), + default: '', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostCodexAgentCodexHomeSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.codexAgent.codexHome', "Optional override for `$CODEX_HOME`. Controls where the codex binary reads config and writes rollouts. When empty, codex uses its default (`~/.codex`)."), + default: '', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostCodexAgentBinaryArgsSettingId]: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.agentHost.codexAgent.binaryArgs', "Additional command-line arguments passed to `codex app-server`. Primarily useful for debugging (for example, `--log-level=debug`)."), + default: [], + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, [AgentHostOTelEnabledSettingId]: { type: 'boolean', markdownDescription: nls.localize('chat.agentHost.otel.enabled', "When enabled, the agent host emits OpenTelemetry traces from the Copilot SDK. Requires `#chat.agentHost.enabled#`. Either configure `#chat.agentHost.otel.otlpEndpoint#` to ship traces to an external collector or enable `#chat.agentHost.otel.dbSpanExporter.enabled#` to capture them locally."), diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index 2c2517f3c9fb4..987a49b47ea64 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -53,4 +53,3 @@ export interface IAgentPluginManager { */ syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], progress?: (status: Customization) => void): Promise; } - diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index c1ccc75194a4f..b0bb21d2737bc 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -76,6 +76,46 @@ export const AgentHostClaudeAgentSdkPathSettingId = 'chat.agentHost.claudeAgent. */ export const AgentHostClaudeSdkPathEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_SDK_PATH'; +// -- Codex agent settings -------------------------------------------------------- +// +// Codex is opt-in via `chat.agentHost.codexAgent.path`. The setting points at +// an absolute path to the `codex` binary; the agent host spawns +// ` app-server` as a long-lived child process and speaks JSON-RPC over +// stdio. The binary is not bundled; users install codex themselves (typically +// via `npm install -g @openai/codex` or a platform package manager). + +/** + * Absolute path to a locally-installed `codex` binary. When non-empty, the + * Codex agent provider is registered inside the agent host. Empty (the + * default) disables the provider entirely. + */ +export const AgentHostCodexAgentBinaryPathSettingId = 'chat.agentHost.codexAgent.path'; + +/** + * Optional override for `$CODEX_HOME`. When set, the codex app-server child + * process inherits this value, controlling where rollouts and config live. + */ +export const AgentHostCodexAgentCodexHomeSettingId = 'chat.agentHost.codexAgent.codexHome'; + +/** + * Additional command-line arguments passed to `codex app-server`. Mainly for + * debugging (e.g. `--log-level=debug`). + */ +export const AgentHostCodexAgentBinaryArgsSettingId = 'chat.agentHost.codexAgent.binaryArgs'; + +/** + * Environment variable the agent host process reads to locate the codex + * binary. Forwarded by the starters from + * {@link AgentHostCodexAgentBinaryPathSettingId}. + */ +export const AgentHostCodexAgentBinaryPathEnvVar = 'VSCODE_AGENT_HOST_CODEX_APP_SERVER_PATH'; + +/** Forwarded `$CODEX_HOME`. */ +export const AgentHostCodexAgentCodexHomeEnvVar = 'CODEX_HOME'; + +/** Forwarded extra args for `codex app-server` (JSON-encoded string[]). */ +export const AgentHostCodexAgentBinaryArgsEnvVar = 'VSCODE_AGENT_HOST_CODEX_APP_SERVER_ARGS'; + // -- OpenTelemetry settings ------------------------------------------------------ // // The `chat.agentHost.otel.*` namespace surfaces the same exporter knobs the CLI diff --git a/src/vs/platform/agentHost/common/customAgents.ts b/src/vs/platform/agentHost/common/customAgents.ts index d99eda46321ac..c52a8eaa6b914 100644 --- a/src/vs/platform/agentHost/common/customAgents.ts +++ b/src/vs/platform/agentHost/common/customAgents.ts @@ -80,4 +80,3 @@ export function resolveAgentHostAgent( } return storedAgentUri ? agents.find(a => a.uri === storedAgentUri) : undefined; } - diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index f402af91de827..d141394591eee 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -9,7 +9,7 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { ActionEnvelope, ChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isSessionAction } from './sessionActions.js'; +import { ActionEnvelope, ActionType, ChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isSessionAction } from './sessionActions.js'; import { changesetReducer, rootReducer, sessionReducer } from './sessionReducers.js'; import { terminalReducer } from './protocol/reducers.js'; import type { RootAction, SessionAction as IProtocolSessionAction, TerminalAction } from './protocol/action-origin.generated.js'; @@ -278,11 +278,32 @@ export class SessionStateSubscription extends BaseAgentSubscription p.action.type === ActionType.SessionTurnStarted && p.action.turnId === action.turnId); + if (index === -1) { + return; + } + const [{ action: pendingAction }] = this._pendingActions.splice(index, 1); + if (this._confirmedState && (!this._confirmedState.activeTurn || this._confirmedState.activeTurn.id !== action.turnId)) { + this._confirmedState = this._applyReducer(this._confirmedState, pendingAction); + } + } + private _confirmedApply(action: StateAction): void { if (this._confirmedState) { this._confirmedState = this._applyReducer(this._confirmedState, action); @@ -401,8 +422,13 @@ export class ChangesetStateSubscription extends BaseAgentSubscription; refCount: number }>(); + private readonly _subscriptions = new ResourceMap(); private readonly _rootState: RootStateSubscription; private readonly _clientId: string; private readonly _seqAllocator: () => number; @@ -470,11 +495,18 @@ export class AgentSubscriptionManager extends Disposable { getSubscription(kind: StateComponents, resource: URI): IReference> { const existing = this._subscriptions.get(resource); if (existing) { - existing.refCount++; - return { - object: existing.sub, - dispose: () => this._releaseSubscription(resource), - }; + if (existing.sub.value instanceof Error) { + // Failed subscriptions should not poison the resource forever. Evict + // the errored entry so this acquire performs a fresh subscribe. + this._subscriptions.delete(resource); + this._disposeSubscriptionEntry(resource, existing); + } else { + existing.refCount++; + return { + object: existing.sub as unknown as IAgentSubscription, + dispose: () => this._releaseSubscription(resource, existing), + }; + } } // Create new subscription based on caller-specified kind @@ -497,11 +529,28 @@ export class AgentSubscriptionManager extends Disposable { }); return { - object: sub, - dispose: () => this._releaseSubscription(resource), + object: sub as unknown as IAgentSubscription, + dispose: () => this._releaseSubscription(resource, entry), }; } + private _disposeSubscriptionEntry(resource: URI, entry: ManagedSubscriptionEntry): void { + this._tryUnsubscribe(resource); + if (entry.sub instanceof SessionStateSubscription) { + entry.sub.clearPending(); + } + entry.sub.dispose(); + } + + private _tryUnsubscribe(resource: URI): void { + try { + this._unsubscribe(resource); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this._log(`Failed to unsubscribe ${resource.toString()}: ${message}`); + } + } + /** * Route an incoming action envelope to all active subscriptions. */ @@ -615,8 +664,7 @@ export class AgentSubscriptionManager extends Disposable { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _createSubscription(kind: StateComponents, key: string): BaseAgentSubscription { + private _createSubscription(kind: StateComponents, key: string): ManagedSubscription { switch (kind) { case StateComponents.Session: return new SessionStateSubscription(key, this._clientId, this._seqAllocator, this._log); @@ -631,25 +679,23 @@ export class AgentSubscriptionManager extends Disposable { } } - private _releaseSubscription(resource: URI): void { + private _releaseSubscription(resource: URI, expected?: ManagedSubscriptionEntry): void { const entry = this._subscriptions.get(resource); - if (!entry) { + // A failed subscription can be evicted and replaced while old references + // still exist; stale disposals must not release the replacement entry. + if (!entry || (expected && entry !== expected)) { return; } entry.refCount--; if (entry.refCount <= 0) { this._subscriptions.delete(resource); - try { this._unsubscribe(resource); } catch { /* best-effort */ } - if (entry.sub instanceof SessionStateSubscription) { - entry.sub.clearPending(); - } - entry.sub.dispose(); + this._disposeSubscriptionEntry(resource, entry); } } override dispose(): void { for (const [resource, entry] of this._subscriptions) { - try { this._unsubscribe(resource); } catch { /* best-effort */ } + this._tryUnsubscribe(resource); entry.sub.dispose(); } this._subscriptions.clear(); diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 3532cdb6aede1..1f5e3616cba33 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentBinaryPathEnvVar, AgentHostCodexAgentBinaryPathSettingId, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -75,6 +75,15 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt || process.env[AgentHostClaudeSdkPathEnvVar] || ''; + // Codex agent is opt-in: enabled when the user points the binary-path + // setting at a locally-installed `codex` CLI, or when the env var is + // already set on the parent process (developer override). + const codexBinaryPath = this._configurationService.getValue(AgentHostCodexAgentBinaryPathSettingId) + || process.env[AgentHostCodexAgentBinaryPathEnvVar] + || ''; + const codexHome = this._configurationService.getValue(AgentHostCodexAgentCodexHomeSettingId) || ''; + const codexArgs = this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId); + // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins // (developer override) — see `buildAgentHostOTelEnv` for the precedence. @@ -108,6 +117,9 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_PIPE_LOGGING: 'true', VSCODE_VERBOSE_LOGGING: 'true', ...(claudeSdkPath ? { [AgentHostClaudeSdkPathEnvVar]: claudeSdkPath } : {}), + ...(codexBinaryPath ? { [AgentHostCodexAgentBinaryPathEnvVar]: codexBinaryPath } : {}), + ...(codexHome ? { [AgentHostCodexAgentCodexHomeEnvVar]: codexHome } : {}), + ...(Array.isArray(codexArgs) && codexArgs.length > 0 ? { [AgentHostCodexAgentBinaryArgsEnvVar]: JSON.stringify(codexArgs) } : {}), ...otelEnv, } }); diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 3bbd6447aad4b..173f7871956b0 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeSdkPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -25,6 +25,8 @@ import { CopilotApiService, ICopilotApiService } from './shared/copilotApiServic import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { CodexAgent } from './codex/codexAgent.js'; +import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; @@ -153,6 +155,8 @@ async function startAgentHost(): Promise { diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); + const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); + diServices.set(ICodexProxyService, codexProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); @@ -173,6 +177,15 @@ async function startAgentHost(): Promise { if (process.env[AgentHostClaudeSdkPathEnvVar]) { agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); } + // The Codex agent provider is opt-in. Gated on the + // `chat.agentHost.codexAgent.path` workbench setting being non-empty, + // forwarded by the agent host starters as `VSCODE_AGENT_HOST_CODEX_APP_SERVER_PATH`. + // The codex binary is intentionally not bundled; users install it + // themselves (`npm install -g @openai/codex` or equivalent) and + // point this setting at the absolute path. + if (process.env[AgentHostCodexAgentBinaryPathEnvVar]) { + agentService.registerProvider(instantiationService.createInstance(CodexAgent)); + } } catch (err) { logService.error('Failed to create AgentService', err); throw err; diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 5371e66762402..362c186b70138 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // Standalone agent host server with WebSocket protocol transport. -// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--claude-sdk-path ] [--quiet] [--log ] +// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--claude-sdk-path ] [--codex-binary-path ] [--quiet] [--log ] import { fileURLToPath } from 'url'; @@ -37,10 +37,12 @@ import { CopilotApiService, ICopilotApiService } from './shared/copilotApiServic import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { CodexAgent } from './codex/codexAgent.js'; +import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; -import { AgentHostClaudeSdkPathEnvVar } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryPathEnvVar } from '../common/agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -85,6 +87,8 @@ interface IServerOptions { readonly enableMockAgent: boolean; /** Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package, or empty to disable the Claude agent. */ readonly claudeSdkPath: string; + /** Absolute path to a locally-installed `codex` binary, or empty to disable the Codex agent. */ + readonly codexBinaryPath: string; readonly quiet: boolean; /** Connection token string, or `undefined` when `--without-connection-token`. */ readonly connectionToken: string | undefined; @@ -105,6 +109,8 @@ function parseServerOptions(): IServerOptions { // The SDK is intentionally not bundled with VS Code. const sdkPathIdx = argv.indexOf('--claude-sdk-path'); const claudeSdkPath = (sdkPathIdx >= 0 ? argv[sdkPathIdx + 1] : process.env[AgentHostClaudeSdkPathEnvVar]) ?? ''; + const codexBinaryPathIdx = argv.indexOf('--codex-binary-path'); + const codexBinaryPath = (codexBinaryPathIdx >= 0 ? argv[codexBinaryPathIdx + 1] : process.env[AgentHostCodexAgentBinaryPathEnvVar]) ?? ''; const quiet = argv.includes('--quiet'); // Connection token @@ -147,7 +153,7 @@ function parseServerOptions(): IServerOptions { connectionToken = generateUuid(); } - return { port, host, enableMockAgent, claudeSdkPath, quiet, connectionToken }; + return { port, host, enableMockAgent, claudeSdkPath, codexBinaryPath, quiet, connectionToken }; } // ---- Main ------------------------------------------------------------------- @@ -240,6 +246,8 @@ async function main(): Promise { diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); + const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); + diServices.set(ICodexProxyService, codexProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); @@ -253,6 +261,12 @@ async function main(): Promise { agentService.registerProvider(claudeAgent); log('ClaudeAgent registered'); } + if (options.codexBinaryPath) { + process.env[AgentHostCodexAgentBinaryPathEnvVar] = options.codexBinaryPath; + const codexAgent = disposables.add(instantiationService.createInstance(CodexAgent)); + agentService.registerProvider(codexAgent); + log('CodexAgent registered'); + } } if (options.enableMockAgent) { diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts index 6daba64717891..a5682d59bc773 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts @@ -37,4 +37,3 @@ export function projectSessionCustomizations( return result; } - diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts new file mode 100644 index 0000000000000..ed8ece96ae3ca --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -0,0 +1,1476 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { type IObservable, observableValue } from '../../../../base/common/observable.js'; +import { basename, dirname, isAbsolute, join, resolve, sep } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../log/common/log.js'; +import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentBinaryPathEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, type AgentProvider } from '../../common/agentService.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition } from '../../common/state/protocol/state.js'; +import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type SessionInputAnswer, SessionInputResponseKind, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import type { ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; +import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; +import { CodexAppServerClient, JsonRpcError, transportFromChildProcess, type ICodexAppServerClient } from './codexAppServerClient.js'; +import { ICodexProxyService, type ICodexProxyHandle } from './codexProxyService.js'; +import { createCodexSessionMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangeOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, type ICodexSessionMapState } from './codexMapAppServerEvents.js'; +import { resolveCodexInput } from './codexPromptResolver.js'; +import { replayThreadToTurns } from './codexReplayMapper.js'; +import { CodexSessionMetadataStore } from './codexSessionMetadataStore.js'; +import { CodexSessionConfigKey, isCodexSupportedModel, narrowAdditionalDirectories, narrowApprovalPolicy, narrowBoolean, narrowReasoningEffort, narrowSandboxMode, narrowWebSearchMode, normalizeCodexModelId, type CodexApprovalPolicy } from './codexSessionConfigKeys.js'; +import type { ReasoningEffort } from './protocol/generated/ReasoningEffort.js'; +import type { WebSearchMode } from './protocol/generated/WebSearchMode.js'; +import type { SandboxMode } from './protocol/generated/v2/SandboxMode.js'; +import type { SandboxPolicy } from './protocol/generated/v2/SandboxPolicy.js'; +import type { CommandExecutionApprovalDecision } from './protocol/generated/v2/CommandExecutionApprovalDecision.js'; +import type { CommandExecutionRequestApprovalParams } from './protocol/generated/v2/CommandExecutionRequestApprovalParams.js'; +import type { CommandExecutionRequestApprovalResponse } from './protocol/generated/v2/CommandExecutionRequestApprovalResponse.js'; +import type { GetAccountResponse } from './protocol/generated/v2/GetAccountResponse.js'; +import type { ModelListResponse } from './protocol/generated/v2/ModelListResponse.js'; +import type { Thread } from './protocol/generated/v2/Thread.js'; +import type { ThreadListResponse } from './protocol/generated/v2/ThreadListResponse.js'; +import type { ThreadReadResponse } from './protocol/generated/v2/ThreadReadResponse.js'; +import type { TurnCompletedNotification } from './protocol/generated/v2/TurnCompletedNotification.js'; +import type { TurnStartedNotification } from './protocol/generated/v2/TurnStartedNotification.js'; +import type { TurnStartParams } from './protocol/generated/v2/TurnStartParams.js'; + +const CLIENT_INFO = { + name: 'vscode_agent_host', + title: 'VS Code Agent Host', + // The codex `clientInfo.version` is informational. Hardcoded to a + // non-empty placeholder; bumping it isn't required when our code + // changes. + version: '0.1.0', +}; + +const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel'; +const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high']; + +const codexSessionConfigSchema = createSchema({ + [CodexSessionConfigKey.ApprovalPolicy]: schemaProperty({ + type: 'string', + title: localize('codex.sessionConfig.approvalPolicy', "Approvals"), + description: localize('codex.sessionConfig.approvalPolicyDescription', "How Codex requests approval for tool calls."), + enum: ['never', 'on-request', 'on-failure', 'untrusted'], + enumLabels: [ + localize('codex.sessionConfig.approvalPolicy.never', "No Escalations"), + localize('codex.sessionConfig.approvalPolicy.onRequest', "Ask When Needed"), + localize('codex.sessionConfig.approvalPolicy.onFailure', "Ask on Failure"), + localize('codex.sessionConfig.approvalPolicy.untrusted', "Ask More Often"), + ], + enumDescriptions: [ + localize('codex.sessionConfig.approvalPolicy.neverDescription', "Never ask for elevated permission; commands that cannot run in the sandbox are rejected."), + localize('codex.sessionConfig.approvalPolicy.onRequestDescription', "Ask only when Codex determines a command needs elevated permission."), + localize('codex.sessionConfig.approvalPolicy.onFailureDescription', "Try commands in the sandbox first, then ask to retry with elevated permission if the sandbox blocks them."), + localize('codex.sessionConfig.approvalPolicy.untrustedDescription', "Ask before more command categories so you can review actions more closely."), + ], + default: 'on-request', + sessionMutable: true, + }), + [CodexSessionConfigKey.SandboxMode]: schemaProperty({ + type: 'string', + title: localize('codex.sessionConfig.sandboxMode', "Sandbox"), + description: localize('codex.sessionConfig.sandboxModeDescription', "Filesystem and network restrictions applied to tool calls."), + enum: ['read-only', 'workspace-write', 'danger-full-access'], + enumLabels: [ + localize('codex.sessionConfig.sandboxMode.readOnly', "Read-Only"), + localize('codex.sessionConfig.sandboxMode.workspaceWrite', "Workspace Write"), + localize('codex.sessionConfig.sandboxMode.dangerFullAccess', "Full Access (Dangerous)"), + ], + enumDescriptions: [ + localize('codex.sessionConfig.sandboxMode.readOnlyDescription', "Tool calls can read the workspace but cannot modify files."), + localize('codex.sessionConfig.sandboxMode.workspaceWriteDescription', "Tool calls can read and write within the workspace; network is controlled separately."), + localize('codex.sessionConfig.sandboxMode.dangerFullAccessDescription', "Tool calls have unrestricted disk and network access."), + ], + default: 'workspace-write', + sessionMutable: true, + }), + [CodexSessionConfigKey.WebSearchMode]: schemaProperty({ + type: 'string', + title: localize('codex.sessionConfig.webSearchMode', "Web Search"), + description: localize('codex.sessionConfig.webSearchModeDescription', "Web-search tool availability for the model."), + enum: ['disabled', 'cached', 'live'], + enumLabels: [ + localize('codex.sessionConfig.webSearchMode.disabled', "Disabled"), + localize('codex.sessionConfig.webSearchMode.cached', "Cached Only"), + localize('codex.sessionConfig.webSearchMode.live', "Live"), + ], + default: 'disabled', + sessionMutable: false, + }), + [CodexSessionConfigKey.ModelReasoningEffort]: schemaProperty({ + type: 'string', + title: localize('codex.sessionConfig.modelReasoningEffort', "Reasoning Effort"), + description: localize('codex.sessionConfig.modelReasoningEffortDescription', "Controls how much reasoning effort Codex uses."), + enum: [...CODEX_REASONING_EFFORTS], + enumLabels: [ + localize('codex.sessionConfig.modelReasoningEffort.minimal', "Minimal"), + localize('codex.sessionConfig.modelReasoningEffort.low', "Low"), + localize('codex.sessionConfig.modelReasoningEffort.medium', "Medium"), + localize('codex.sessionConfig.modelReasoningEffort.high', "High"), + ], + default: 'medium', + sessionMutable: true, + }), + [CodexSessionConfigKey.AdditionalDirectories]: schemaProperty({ + type: 'array', + title: localize('codex.sessionConfig.additionalDirectories', "Additional Writable Directories"), + description: localize('codex.sessionConfig.additionalDirectoriesDescription', "Absolute paths the sandbox is allowed to write to, in addition to the workspace. Only applies when Sandbox is Workspace Write."), + items: { type: 'string', title: localize('codex.sessionConfig.additionalDirectories.item', "Directory") }, + enumDynamic: true, + default: [], + sessionMutable: true, + }), + [CodexSessionConfigKey.NetworkAccessEnabled]: schemaProperty({ + type: 'boolean', + title: localize('codex.sessionConfig.networkAccessEnabled', "Network"), + description: localize('codex.sessionConfig.networkAccessEnabledDescription', "Allow sandboxed tool calls to make outbound network requests. Only applies when Sandbox is Workspace Write."), + default: false, + sessionMutable: true, + }), + [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions], +}); + +const codexVisibleSessionConfigSchema = createSchema({ + [CodexSessionConfigKey.ApprovalPolicy]: codexSessionConfigSchema.definition[CodexSessionConfigKey.ApprovalPolicy], + [CodexSessionConfigKey.SandboxMode]: codexSessionConfigSchema.definition[CodexSessionConfigKey.SandboxMode], + [CodexSessionConfigKey.WebSearchMode]: codexSessionConfigSchema.definition[CodexSessionConfigKey.WebSearchMode], + [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions], +}); + +const codexWorkspaceWriteSessionConfigSchema = createSchema({ + ...codexVisibleSessionConfigSchema.definition, + [CodexSessionConfigKey.NetworkAccessEnabled]: codexSessionConfigSchema.definition[CodexSessionConfigKey.NetworkAccessEnabled], +}); + +interface ICodexSessionConfigDefaults { + readonly [CodexSessionConfigKey.ApprovalPolicy]: CodexApprovalPolicy; + readonly [CodexSessionConfigKey.SandboxMode]: SandboxMode; + readonly [CodexSessionConfigKey.WebSearchMode]: WebSearchMode; + readonly [CodexSessionConfigKey.ModelReasoningEffort]: ReasoningEffort; + readonly [CodexSessionConfigKey.AdditionalDirectories]: string[]; + readonly [CodexSessionConfigKey.NetworkAccessEnabled]: boolean; +} + +const codexSessionConfigDefaults: ICodexSessionConfigDefaults = { + [CodexSessionConfigKey.ApprovalPolicy]: 'on-request', + [CodexSessionConfigKey.SandboxMode]: 'workspace-write', + [CodexSessionConfigKey.WebSearchMode]: 'disabled', + [CodexSessionConfigKey.ModelReasoningEffort]: 'medium', + [CodexSessionConfigKey.AdditionalDirectories]: [], + [CodexSessionConfigKey.NetworkAccessEnabled]: false, +}; + +const CodexPrewarmTtlMs = 60_000; + +/** + * Per-session bookkeeping. The codex thread is owned by the shared + * connection in {@link CodexAgent}; this struct only tracks what the + * `IAgent` surface needs. + */ +interface ICodexSession { + /** Caller-facing session id used in the `codex:/` URI; may differ from the codex thread id. */ + readonly sessionId: string; + /** + * Codex app-server thread id used in JSON-RPC `thread/*` and `turn/*` calls. + * Undefined until the session has been materialized (first `sendMessage` + * triggers `thread/start`). Decoupling materialization from + * `createSession` mirrors the Claude harness's provisional/materialize + * split and avoids spawning an orphan codex thread when the workbench + * rebinds a provisional URI after a chip-selection. + */ + threadId: string | undefined; + readonly sessionUri: URI; + readonly workingDirectory: URI | undefined; + readonly mapState: ICodexSessionMapState; + /** + * Phase 4: parked deferreds for `item/commandExecution/requestApproval`, + * keyed by the host-side toolCallId. Resolved by + * {@link CodexAgent.respondToPermissionRequest}. + */ + readonly pendingCommandApprovals: PendingRequestRegistry; + /** + * Per-session set of "accept for session" decisions. When the user + * picks Accept-for-Session in a previous approval, subsequent + * approval requests on the same session resolve automatically. + */ + readonly acceptedForSession: Set; + model: ModelSelection | undefined; + /** Workbench-facing turn id for the active turn. */ + currentTurnId: string | undefined; + /** Codex app-server turn id for the active turn. */ + currentAppTurnId: string | undefined; + /** Codex app-server turn id -> workbench-facing turn id. */ + readonly hostTurnIdByAppTurnId: Map; + /** Set when this session was restored (Phase 3) and needs `thread/resume` before the first `turn/start`. */ + needsResume: boolean; + /** Most recent user prompt sent on this session — used as fallback userMessage text in `turn/started`. */ + lastPromptText: string; + /** True once the workbench has disposed this session. Guards background prewarm continuations. */ + disposed: boolean; + /** In-flight background or foreground materialization, shared across callers. */ + materializePromise: Promise | undefined; + /** Whether the workbench-facing materialize event has been emitted. */ + materializedEventFired: boolean; + /** TTL timer for a materialized-but-unused prewarmed thread. */ + prewarmTimer: ReturnType | undefined; + /** True once the prewarmed session has been claimed by a user turn. */ + prewarmClaimed: boolean; +} + +/** + * Connection state machine. The codex process is spawned lazily on first + * need (Decision 6) and stays alive for the agent's lifetime. + */ +type ConnectionState = + | { readonly kind: 'idle' } + | { readonly kind: 'starting'; readonly promise: Promise } + | ({ readonly kind: 'ready' } & IConnectionReady); + +interface IConnectionReady { + readonly client: ICodexAppServerClient; + readonly proxyHandle: ICodexProxyHandle; + readonly child: ChildProcessWithoutNullStreams; +} + +/** + * `IAgent` implementation backed by `codex app-server`. + * + * Phase 2 surface: createSession (blocks on `thread/start`), sendMessage + * (one `turn/start`, streams `agentMessage` deltas), setPendingMessages + * (steering via `turn/steer`), abortSession (`turn/interrupt`), + * disposeSession (`thread/unsubscribe`, no process kill). + * + * Decisions 3 (shared process), 6 (lazy spawn), 7 (session id == threadId), + * 10 (no cwd → reject), 15 (cancel, keep streamed content), 16 (steering), + * 17 (attachments), 18 (apikey auth). + */ +export class CodexAgent extends Disposable implements IAgent { + + readonly id: AgentProvider = 'codex'; + + private readonly _onDidSessionProgress = this._register(new Emitter()); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _onDidMaterializeSession = this._register(new Emitter()); + readonly onDidMaterializeSession = this._onDidMaterializeSession.event; + + private readonly _models = observableValue(this, []); + readonly models: IObservable = this._models; + + /** Keyed by caller-facing sessionId (the URI host). */ + private readonly _sessions = new Map(); + /** Inverse map: codex threadId → caller-facing sessionId, for routing codex notifications back to sessions. */ + private readonly _sessionIdByThreadId = new Map(); + private _githubToken: string | undefined; + private _connection: ConnectionState = { kind: 'idle' }; + private _modelsRefreshPromise: Promise | undefined; + private readonly _metadataStore: CodexSessionMetadataStore; + + constructor( + @ILogService private readonly _logService: ILogService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @ICodexProxyService private readonly _codexProxyService: ICodexProxyService, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._metadataStore = instantiationService.createInstance(CodexSessionMetadataStore); + } + + // #region Auth + + getProtectedResources(): ProtectedResourceMetadata[] { + return [GITHUB_COPILOT_PROTECTED_RESOURCE]; + } + + async authenticate(resource: string, token: string): Promise { + if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + return false; + } + const changed = this._githubToken !== token; + this._githubToken = token; + if (changed && this._connection.kind === 'ready') { + // Codex stays running — proxy reads the new token from its + // own cell on the next request (Decision 4). + this._connection.proxyHandle.setToken(token); + this._queueModelRefresh(token); + } else if (changed) { + // Defer model refresh until the connection comes up. + this._queueModelRefresh(token); + } + this._logService.info('[Codex] Auth token updated'); + return true; + } + + private _queueModelRefresh(token: string): void { + const refreshPromise = this._refreshModels(token).finally(() => { + if (this._modelsRefreshPromise === refreshPromise) { + this._modelsRefreshPromise = undefined; + } + }); + this._modelsRefreshPromise = refreshPromise; + void this._modelsRefreshPromise; + } + + private _ensureAuthenticated(): string { + const token = this._githubToken; + if (!token) { + throw new ProtocolError( + AHP_AUTH_REQUIRED, + 'Authentication is required to use Codex', + this.getProtectedResources(), + ); + } + return token; + } + + private _defaultModel(): ModelSelection | undefined { + const models = this._models.get(); + const chosen = models[0]; + return chosen ? { id: chosen.id } : undefined; + } + + private _supportedModelOrUndefined(model: ModelSelection | undefined): ModelSelection | undefined { + if (model) { + const normalizedId = normalizeCodexModelId(model.id); + if (normalizedId) { + return normalizedId === model.id ? model : { ...model, id: normalizedId }; + } + } + if (model) { + this._logService.warn(`[Codex] Ignoring unsupported model '${model.id}'`); + } + return this._defaultModel(); + } + + private async _resolveModel(session: ICodexSession): Promise { + const selected = this._supportedModelOrUndefined(session.model); + if (selected) { + session.model = selected; + return selected; + } + if (this._modelsRefreshPromise) { + await this._modelsRefreshPromise; + } + const refreshed = this._defaultModel(); + if (refreshed) { + session.model = refreshed; + return refreshed; + } + throw new Error('Codex requires a GPT-5 or Codex model, but no supported models are available.'); + } + + private _createReasoningEffortConfigSchema(): ConfigSchema { + return { + type: 'object', + properties: { + [CODEX_THINKING_LEVEL_KEY]: { + type: 'string', + title: localize('codex.modelThinkingLevel.title', "Thinking Level"), + description: localize('codex.modelThinkingLevel.description', "Controls how much reasoning effort Codex uses."), + default: 'medium', + enum: [...CODEX_REASONING_EFFORTS], + enumLabels: [ + localize('codex.modelThinkingLevel.minimal', "Minimal"), + localize('codex.modelThinkingLevel.low', "Low"), + localize('codex.modelThinkingLevel.medium', "Medium"), + localize('codex.modelThinkingLevel.high', "High"), + ], + }, + }, + }; + } + + private _getReasoningEffort(session: ICodexSession): ReasoningEffort | undefined { + const modelConfigEffort = narrowReasoningEffort(session.model?.config?.[CODEX_THINKING_LEVEL_KEY]); + if (modelConfigEffort) { + return modelConfigEffort; + } + const config = this._configurationService.getSessionConfigValues(session.sessionUri.toString()); + return narrowReasoningEffort(config?.[CodexSessionConfigKey.ModelReasoningEffort]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ModelReasoningEffort]; + } + + private _readSessionConfig(session: ICodexSession): ReturnType { + return codexSessionConfigSchema.validateOrDefault( + this._configurationService.getSessionConfigValues(session.sessionUri.toString()), + codexSessionConfigDefaults, + ); + } + + private _sandboxPolicy(session: ICodexSession, config: ReturnType): SandboxPolicy { + const mode = narrowSandboxMode(config[CodexSessionConfigKey.SandboxMode]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.SandboxMode]; + if (mode === 'danger-full-access') { + return { type: 'dangerFullAccess' }; + } + const networkAccess = narrowBoolean(config[CodexSessionConfigKey.NetworkAccessEnabled]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.NetworkAccessEnabled]; + if (mode === 'read-only') { + return { type: 'readOnly', networkAccess: false }; + } + const writableRoots = [ + ...(session.workingDirectory ? [session.workingDirectory.fsPath] : []), + ...(narrowAdditionalDirectories(config[CodexSessionConfigKey.AdditionalDirectories]) ?? []), + ]; + return { + type: 'workspaceWrite', + writableRoots, + networkAccess, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }; + } + + private _turnStartOptions(session: ICodexSession): Pick { + const config = this._readSessionConfig(session); + const approvalPolicy = narrowApprovalPolicy(config[CodexSessionConfigKey.ApprovalPolicy]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ApprovalPolicy]; + const sandboxPolicy = this._sandboxPolicy(session, config); + const runtimeWorkspaceRoots = sandboxPolicy.type === 'workspaceWrite' ? sandboxPolicy.writableRoots : undefined; + return { + approvalPolicy, + sandboxPolicy, + effort: this._getReasoningEffort(session), + ...(runtimeWorkspaceRoots ? { runtimeWorkspaceRoots } : {}), + }; + } + + private async _refreshModels(token: string): Promise { + try { + const all = await this._copilotApiService.models(token); + const conn = await this._ensureConnection(); + const codexModelDefaults = new Map(); + let cursor: string | null | undefined = null; + do { + const response: ModelListResponse = await conn.client.request<'model/list', ModelListResponse>('model/list', { cursor, includeHidden: false }); + for (const model of response.data) { + if (!model.hidden) { + codexModelDefaults.set(model.model, model.isDefault); + } + } + cursor = response.nextCursor; + } while (cursor); + if (this._githubToken !== token) { + return; + } + const configSchema = this._createReasoningEffortConfigSchema(); + const filtered = all + .filter(m => !!m.supported_endpoints?.includes('/responses') && codexModelDefaults.has(m.id) && isCodexSupportedModel(m.id, m.name)) + .sort((a, b) => (Number(b.is_chat_default) - Number(a.is_chat_default)) || (Number(codexModelDefaults.get(b.id)) - Number(codexModelDefaults.get(a.id)))) + .map((m): IAgentModelInfo => ({ + provider: this.id, + id: m.id, + name: m.name ?? m.id, + maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + supportsVision: !!m.capabilities?.supports?.vision, + configSchema, + })); + this._models.set(filtered, undefined); + } catch (err) { + this._logService.warn(`[Codex] Failed to refresh models: ${err instanceof Error ? err.message : String(err)}`); + if (this._githubToken === token) { + this._models.set([], undefined); + } + } + } + + // #endregion + + // #region Connection lifecycle + + /** + * Lazily spawn the codex app-server, initialize the connection, + * authenticate via apiKey, and return the ready connection. Idempotent + * — concurrent callers share the same promise. + */ + private _ensureConnection(): Promise { + if (this._connection.kind === 'ready') { + return Promise.resolve(this._connection); + } + if (this._connection.kind === 'starting') { + return this._connection.promise; + } + const token = this._ensureAuthenticated(); + const promise = this._startConnection(token).then(ready => { + this._connection = { kind: 'ready', ...ready }; + return ready; + }).catch(err => { + this._connection = { kind: 'idle' }; + throw err; + }); + this._connection = { kind: 'starting', promise }; + return promise; + } + + private async _startConnection(token: string): Promise { + const binaryPath = process.env[AgentHostCodexAgentBinaryPathEnvVar]; + if (!binaryPath) { + throw new Error(`Codex binary path not configured. Set 'chat.agentHost.codexAgent.path' to an absolute path to the codex CLI.`); + } + try { + fs.accessSync(binaryPath, fs.constants.X_OK); + } catch (err) { + throw new Error(`Codex binary not executable: ${binaryPath} (${err instanceof Error ? err.message : String(err)})`); + } + + const proxyHandle = await this._codexProxyService.start(token); + + // Build child env: inherit, override OPENAI_API_KEY so the proxy's + // nonce check passes. The proxy provider is plumbed via `-c` CLI + // overrides below; we deliberately do NOT write a config.toml, + // which would force a managed CODEX_HOME and trip codex's + // "refusing to write helper binaries under TMPDIR" warning. + const env: NodeJS.ProcessEnv = { + ...process.env, + OPENAI_API_KEY: proxyHandle.nonce, + }; + const userCodexHome = process.env[AgentHostCodexAgentCodexHomeEnvVar]; + if (userCodexHome) { + env.CODEX_HOME = userCodexHome; + } + + // Define an in-memory `vscode-proxy` provider that points at our + // local proxy with WebSocket transport disabled. Using `-c` + // overrides composes with the user's ~/.codex/config.toml — their + // other settings (model, MCP servers, etc.) still apply. + const providerOverrides = [ + `model_provider="vscode-proxy"`, + `model_providers.vscode-proxy.name="VS Code Proxy"`, + `model_providers.vscode-proxy.base_url="${proxyHandle.baseUrl}/v1"`, + `model_providers.vscode-proxy.wire_api="responses"`, + `model_providers.vscode-proxy.env_key="OPENAI_API_KEY"`, + `model_providers.vscode-proxy.requires_openai_auth=false`, + `model_providers.vscode-proxy.supports_websockets=false`, + ]; + + // Extra args forwarded as JSON from the workbench setting. + const extraArgs = parseBinaryArgs(process.env[AgentHostCodexAgentBinaryArgsEnvVar]); + const args = ['app-server']; + for (const kv of providerOverrides) { + args.push('-c', kv); + } + args.push(...extraArgs); + + this._logService.info(`[Codex] spawning ${binaryPath} ${args.join(' ')}`); + const child = spawn(binaryPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + + // Surface stderr to the log channel — codex writes useful startup + // diagnostics there. Mirror Claude's pattern. + child.stderr.setEncoding('utf8'); + child.stderr.on('data', chunk => this._logService.info(`[Codex stderr] ${String(chunk).trimEnd()}`)); + + const transport = transportFromChildProcess(child); + const client = new CodexAppServerClient(transport, (level, msg) => { + this._logService.info(`[CodexClient ${level}] ${msg}`); + }); + + // Tear everything down if the child dies on its own. + client.onExit(e => { + this._logService.warn(`[Codex] app-server exited code=${e.code} signal=${e.signal}`); + this._handleConnectionLost(); + }); + client.onTransportError(err => { + this._logService.error(`[Codex] transport error: ${err.message}`); + this._handleConnectionLost(); + }); + + // Initialize handshake. Failure here is fatal for the connection. + try { + await client.request<'initialize'>('initialize', { + clientInfo: CLIENT_INFO, + capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: null }, + }); + client.notify<'initialized'>('initialized', undefined as never); + // With `requires_openai_auth = false` on the proxy provider, + // codex does not require a separate login step — the proxy + // nonce is read from OPENAI_API_KEY by the provider's env_key. + if (userCodexHome) { + // User-provided CODEX_HOME may target a provider that + // still requires auth; preserve the apiKey login path. + await client.request<'account/login/start'>('account/login/start', { + type: 'apiKey', + apiKey: proxyHandle.nonce, + }); + } + void this._logAccountSnapshot(client); + } catch (err) { + client.dispose(); + proxyHandle.dispose(); + try { child.kill('SIGKILL'); } catch { /* already dead */ } + throw err; + } + + // Wire global notification → SessionAction dispatch. + this._registerIgnoredNotifications(client); + this._register(client.onNotification('turn/started', params => this._dispatchByThread(params.threadId, s => this._handleTurnStartedNotification(s, params)))); + this._register(client.onNotification('item/started', params => this._dispatchByThread(params.threadId, s => mapItemStarted(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/agentMessage/delta', params => this._dispatchByThread(params.threadId, s => mapAgentMessageDelta(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/commandExecution/outputDelta', params => this._dispatchByThread(params.threadId, s => mapCommandExecutionOutputDelta(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/fileChange/patchUpdated', params => this._dispatchByThread(params.threadId, s => mapFileChangePatchUpdated(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/fileChange/outputDelta', params => this._dispatchByThread(params.threadId, s => mapFileChangeOutputDelta(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/mcpToolCall/progress', params => this._dispatchByThread(params.threadId, s => mapMcpToolCallProgress(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/reasoning/summaryPartAdded', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryPartAdded(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/reasoning/summaryTextDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryTextDelta(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/reasoning/textDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningTextDelta(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('thread/tokenUsage/updated', params => this._dispatchByThread(params.threadId, s => mapTokenUsageUpdated(this._withHostTurnId(s, params))))); + this._register(client.onNotification('item/completed', params => this._dispatchByThread(params.threadId, s => mapItemCompleted(s.mapState, this._withHostTurnId(s, params))))); + this._register(client.onNotification('turn/completed', params => this._dispatchByThread(params.threadId, s => this._handleTurnCompletedNotification(s, params)))); + + // Phase 4: command-execution approval requests. Park on a + // per-session deferred, emit `SessionToolCallReady` in the + // PendingConfirmation state, and answer codex when the user + // (or accept-for-session memoization) decides. + this._register(client.onRequest<'item/commandExecution/requestApproval'>( + 'item/commandExecution/requestApproval', + params => this._handleCommandApprovalRequestRpc(params), + )); + + return { client, proxyHandle, child }; + } + + private _hostTurnId(session: ICodexSession, appTurnId: string): string { + return session.hostTurnIdByAppTurnId.get(appTurnId) ?? appTurnId; + } + + private _withHostTurnId(session: ICodexSession, params: T): T { + const turnId = this._hostTurnId(session, params.turnId); + return turnId === params.turnId ? params : { ...params, turnId }; + } + + private _withHostTurn(session: ICodexSession, params: T): T { + const appTurnId = params.turn.id; + const hostTurnId = session.currentTurnId ?? this._hostTurnId(session, appTurnId); + session.hostTurnIdByAppTurnId.set(appTurnId, hostTurnId); + session.currentAppTurnId = appTurnId; + return hostTurnId === appTurnId ? params : { ...params, turn: { ...params.turn, id: hostTurnId } }; + } + + private _handleTurnStartedNotification(session: ICodexSession, params: TurnStartedNotification): SessionAction[] { + // The workbench already dispatched the canonical turn start before sendMessage. + // Codex's event only establishes app-server turn id correlation for later items. + mapTurnStarted(session.mapState, this._withHostTurn(session, params), session.lastPromptText); + return []; + } + + private _handleTurnCompletedNotification(session: ICodexSession, params: TurnCompletedNotification): SessionAction[] { + const appTurnId = params.turn.id; + const out = mapTurnCompleted(session.mapState, this._withHostTurn(session, params)); + // Codex reports app-server turn ids, while the workbench owns host turn ids. + // Clear the correlation after completion so later turns cannot reuse stale ids. + if (session.currentAppTurnId === appTurnId || session.currentTurnId === this._hostTurnId(session, appTurnId)) { + session.currentTurnId = undefined; + session.currentAppTurnId = undefined; + } + session.hostTurnIdByAppTurnId.delete(appTurnId); + return out; + } + + private _registerIgnoredNotifications(client: ICodexAppServerClient): void { + const ignored = [ + 'thread/started', // thread/start response is authoritative for session materialization. + 'thread/status/changed', // Codex thread status is not surfaced in Agent Host state yet. + 'thread/settings/updated', // VS Code owns session config; Codex settings echoes are not consumed yet. + 'thread/goal/updated', // Goals are not surfaced in the Agent Host UI yet. + 'thread/goal/cleared', // Goals are not surfaced in the Agent Host UI yet. + 'account/updated', // Account state is read on connect; live account updates are not surfaced yet. + 'account/rateLimits/updated', // Rate-limit UI/state is not implemented yet. + 'remoteControl/status/changed', // Remote-control state is not part of the VS Code integration. + 'serverRequest/resolved', // We resolve requests through JSON-RPC responses, so this echo is informational. + ] as const; + for (const method of ignored) { + this._register(client.onNotification(method, () => { /* intentionally ignored */ })); + } + } + + private async _logAccountSnapshot(client: ICodexAppServerClient): Promise { + try { + const response = await client.request<'account/read', GetAccountResponse>('account/read', { refreshToken: false }); + const accountType = response.account?.type ?? 'none'; + const planType = response.account?.type === 'chatgpt' ? response.account.planType : undefined; + this._logService.info(`[Codex] account/read accountType=${accountType} requiresOpenaiAuth=${response.requiresOpenaiAuth}${planType ? ` planType=${planType}` : ''}`); + } catch (err) { + this._logService.warn(`[Codex] account/read failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private _dispatchByThread(threadId: string, mapFn: (s: ICodexSession) => ReturnType): void { + const sessionId = this._sessionIdByThreadId.get(threadId); + const session = sessionId ? this._sessions.get(sessionId) : undefined; + if (!session) { + // Usually an unclaimed prewarm; ignore. + this._logService.trace(`[Codex] Ignoring notification for untracked threadId=${threadId}; likely unclaimed prewarm`); + return; + } + const actions = mapFn(session); + for (const action of actions) { + this._onDidSessionProgress.fire({ kind: 'action', session: session.sessionUri, action }); + } + } + + /** + * Phase 4: handle `item/commandExecution/requestApproval` from + * codex. Look up the host-side tool call for the item, emit a + * `SessionToolCallReady` in PendingConfirmation, park on a deferred + * keyed by toolCallId, and resolve when the user (or the + * accept-for-session memo) decides. Unknown sessions / items + * decline silently so codex stops blocking. + */ + private async _handleCommandApprovalRequestRpc(params: CommandExecutionRequestApprovalParams): Promise<{ readonly result: CommandExecutionRequestApprovalResponse }> { + // The request handler must return Codex's JSON-RPC result wrapper; keep + // the approval method below focused on the host-side permission decision. + const decision = await this._handleCommandApprovalRequest(params); + return { result: { decision } }; + } + + private async _handleCommandApprovalRequest(params: { + readonly threadId: string; + readonly turnId: string; + readonly itemId: string; + readonly command?: string | null; + readonly reason?: string | null; + }): Promise { + const sessionId = this._sessionIdByThreadId.get(params.threadId); + const session = sessionId ? this._sessions.get(sessionId) : undefined; + if (!session) { + this._logService.warn(`[Codex] commandExecution/requestApproval for unknown threadId=${params.threadId}; declining`); + return 'decline'; + } + const entry = session.mapState.itemToToolCall.get(params.itemId); + if (!entry) { + this._logService.warn(`[Codex:${sessionId}] commandExecution/requestApproval for unknown itemId=${params.itemId}; declining`); + return 'decline'; + } + const command = params.command ?? ''; + // Accept-for-session memo: if the user previously accepted this + // exact command for the session, auto-accept without prompting. + if (command && session.acceptedForSession.has(command)) { + return 'acceptForSession'; + } + const confirmationTitle = params.reason ?? 'Run shell command'; + // Atomically register the deferred and fire the + // PendingConfirmation signal so a synchronous responder can't + // miss the registration. + const decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => { + this._fire(session.sessionUri, { + type: ActionType.SessionToolCallReady, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + invocationMessage: command, + toolInput: command, + confirmationTitle, + }); + }); + // Track accept-for-session decisions for the next request. + if (decision === 'acceptForSession' && command) { + session.acceptedForSession.add(command); + } + return decision; + } + + private _handleConnectionLost(): void { + const conn = this._connection; + if (conn.kind !== 'ready') { + return; + } + this._connection = { kind: 'idle' }; + // Notify every known session with a single SessionError + complete + // pair so the UI surfaces "agent disconnected" cleanly. + for (const session of this._sessions.values()) { + // Unpark any pending approvals so awaiters unwind. + session.pendingCommandApprovals.denyAll('decline'); + const turnId = session.currentTurnId; + const appTurnId = session.currentAppTurnId; + session.currentTurnId = undefined; + session.currentAppTurnId = undefined; + if (appTurnId) { + session.hostTurnIdByAppTurnId.delete(appTurnId); + } + if (turnId) { + this._onDidSessionProgress.fire({ + kind: 'action', + session: session.sessionUri, + action: { + type: ActionType.SessionError, + turnId, + error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, + }, + }); + this._onDidSessionProgress.fire({ + kind: 'action', + session: session.sessionUri, + action: { type: ActionType.SessionTurnComplete, turnId }, + }); + } + } + // Release resources. The proxy handle is refcounted and drops + // the underlying server once everyone releases. + try { + conn.client.dispose(); + } catch (err) { + this._logService.error(`[Codex] Failed to dispose app-server client after connection lost: ${err instanceof Error ? err.message : String(err)}`); + } + try { + conn.proxyHandle.dispose(); + } catch (err) { + this._logService.error(`[Codex] Failed to dispose proxy handle after connection lost: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // #endregion + + // #region IAgent methods + + getDescriptor(): IAgentDescriptor { + return { + provider: this.id, + displayName: localize('codexAgent.displayName', "Codex"), + description: localize('codexAgent.description', "Codex agent backed by the OpenAI Codex app-server"), + }; + } + + async createSession(config: IAgentCreateSessionConfig = {}): Promise { + this._logService.info(`[Codex DEBUG] createSession session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectory?.toString() ?? '(none)'}`); + this._ensureAuthenticated(); + if (config.fork) { + throw new Error('Codex agent does not support session forking'); + } + if (!config.workingDirectory) { + throw new Error('Codex requires a working directory; pass `workingDirectory` to createSession'); + } + + // Provisional / lazy materialize. We DON'T call `thread/start` here + // because the workbench may rebind this URI to a fresh one when the + // user changes a chip selection, and we'd otherwise leak an + // orphan codex thread per rebind. The actual `thread/start` happens + // on the first `sendMessage` (or `getSessionMetadata` for restore). + const effectiveModel = this._supportedModelOrUndefined(config.model); + const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); + const sessionUri = config.session ?? AgentSession.uri(this.id, sessionId); + + // If the workbench is rebinding this URI (createSession arriving + // after a previous dispose for the same id), reuse the existing + // entry so we don't lose accumulated state. + const existing = this._sessions.get(sessionId); + if (existing) { + existing.model = effectiveModel ?? existing.model; + return { + session: sessionUri, + workingDirectory: existing.workingDirectory ?? config.workingDirectory, + provisional: existing.threadId === undefined, + }; + } + + const session: ICodexSession = { + sessionId, + threadId: undefined, + sessionUri, + workingDirectory: config.workingDirectory, + mapState: createCodexSessionMapState(), + pendingCommandApprovals: new PendingRequestRegistry(), + acceptedForSession: new Set(), + model: effectiveModel, + currentTurnId: undefined, + currentAppTurnId: undefined, + hostTurnIdByAppTurnId: new Map(), + needsResume: false, + lastPromptText: '', + disposed: false, + materializePromise: undefined, + materializedEventFired: false, + prewarmTimer: undefined, + prewarmClaimed: false, + }; + this._sessions.set(sessionId, session); + this._schedulePrewarm(session); + return { + session: sessionUri, + workingDirectory: config.workingDirectory, + provisional: true, + }; + } + + /** + * Lazily start (or resume) a codex thread for `session`. Idempotent: + * if `threadId` is already populated, just returns. Called from + * `sendMessage` before the first `turn/start`. + */ + private async _materializeIfNeeded(session: ICodexSession, fireMaterializedEvent = true): Promise { + if (session.disposed) { + return; + } + if (session.threadId !== undefined) { + if (fireMaterializedEvent) { + this._fireMaterialized(session); + } + return; + } + if (session.materializePromise) { + await session.materializePromise; + if (fireMaterializedEvent) { + this._fireMaterialized(session); + } + return; + } + session.materializePromise = this._materialize(session).finally(() => { + session.materializePromise = undefined; + }); + await session.materializePromise; + if (fireMaterializedEvent) { + this._fireMaterialized(session); + } + } + + private async _materialize(session: ICodexSession): Promise { + if (session.disposed) { + return; + } + if (!session.workingDirectory) { + throw new Error(`Cannot materialize codex session ${session.sessionId}: no working directory`); + } + const conn = await this._ensureConnection(); + const config = this._readSessionConfig(session); + const model = await this._resolveModel(session); + const startResult = await conn.client.request<'thread/start', { thread: { id: string } }>('thread/start', { + cwd: session.workingDirectory.fsPath, + model: model.id, + approvalPolicy: narrowApprovalPolicy(config[CodexSessionConfigKey.ApprovalPolicy]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ApprovalPolicy], + sandbox: narrowSandboxMode(config[CodexSessionConfigKey.SandboxMode]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.SandboxMode], + config: { + web_search: narrowWebSearchMode(config[CodexSessionConfigKey.WebSearchMode]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.WebSearchMode], + }, + }); + const threadId = startResult.thread.id; + if (session.disposed) { + try { + await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); + } catch (err) { + this._logService.info(`[Codex:${threadId}] thread/unsubscribe after disposed prewarm failed: ${err instanceof Error ? err.message : String(err)}`); + } + return; + } + session.threadId = threadId; + this._logService.info(`[Codex DEBUG] materialized session=${session.sessionUri.toString()} threadId=${session.threadId}`); + this._sessionIdByThreadId.set(session.threadId, session.sessionId); + } + + private _fireMaterialized(session: ICodexSession): void { + if (session.disposed) { + return; + } + if (session.materializedEventFired) { + return; + } + session.materializedEventFired = true; + this._onDidMaterializeSession.fire({ + session: session.sessionUri, + workingDirectory: session.workingDirectory, + project: undefined, + }); + } + + private _schedulePrewarm(session: ICodexSession): void { + if (!session.workingDirectory) { + return; + } + void this._materializeIfNeeded(session, false).then(() => { + if (session.prewarmClaimed || session.threadId === undefined) { + return; + } + this._logService.info(`[Codex] prewarm ready session=${session.sessionUri.toString()} threadId=${session.threadId}`); + const prewarmTimer = setTimeout(() => { + void this._expirePrewarm(session); + }, CodexPrewarmTtlMs); + session.prewarmTimer = prewarmTimer; + }).catch(err => { + this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`); + }); + } + + private async _expirePrewarm(session: ICodexSession): Promise { + if (session.disposed || session.prewarmClaimed || session.threadId === undefined) { + return; + } + const threadId = session.threadId; + session.threadId = undefined; + this._sessionIdByThreadId.delete(threadId); + try { + const conn = await this._ensureConnection(); + await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); + this._logService.info(`[Codex] prewarm TTL eviction session=${session.sessionUri.toString()} threadId=${threadId}`); + } catch (err) { + this._logService.warn(`[Codex] prewarm TTL eviction failed session=${session.sessionUri.toString()} threadId=${threadId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private _persistMaterializedSession(session: ICodexSession): void { + if (session.disposed || !session.threadId) { + return; + } + // Persist only once the prewarmed thread is claimed by a turn. This + // avoids restoring an expired, never-used prewarm as a live session. + void this._metadataStore.write(session.sessionUri, { + threadId: session.threadId, + cwd: session.workingDirectory, + modelId: session.model?.id, + }); + } + + private _claimPrewarm(session: ICodexSession): void { + session.prewarmClaimed = true; + if (session.prewarmTimer) { + clearTimeout(session.prewarmTimer); + session.prewarmTimer = undefined; + } + } + + async sendMessage(sessionUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { + this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); + const sessionId = AgentSession.id(sessionUri); + const session = this._sessions.get(sessionId); + if (!session) { + throw new Error(`Codex session not found: ${sessionUri.toString()}`); + } + const conn = await this._ensureConnection(); + const effectiveTurnId = turnId ?? generateUuid(); + + // Materialize codex thread on first send (provisional → live). + // `_materializeIfNeeded` is idempotent. + try { + this._claimPrewarm(session); + await this._materializeIfNeeded(session); + this._persistMaterializedSession(session); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._logService.error(`[Codex:${sessionId}] materialize failed: ${message}`); + this._fire(sessionUri, { + type: ActionType.SessionError, + turnId: effectiveTurnId, + error: { errorType: 'CodexMaterializeFailed', message }, + }); + this._fire(sessionUri, { type: ActionType.SessionTurnComplete, turnId: effectiveTurnId }); + return; + } + const threadId = session.threadId!; + + // Phase 3 resume path: defer to first sendMessage. If this session + // was restored, we haven't yet told codex about it. + if (session.needsResume) { + try { + await conn.client.request<'thread/resume'>('thread/resume', { + threadId, + }); + session.needsResume = false; + } catch (err) { + this._fire(sessionUri, { + type: ActionType.SessionError, + turnId: effectiveTurnId, + error: { + errorType: 'CodexResumeFailed', + message: err instanceof Error ? err.message : String(err), + }, + }); + this._fire(sessionUri, { type: ActionType.SessionTurnComplete, turnId: effectiveTurnId }); + return; + } + } + + const { input, cleanupPaths } = resolveCodexInput(prompt, attachments); + // Buffer the prompt text for `turn/started`'s userMessage fallback. + session.lastPromptText = prompt; + session.currentTurnId = effectiveTurnId; + try { + const turnOptions = this._turnStartOptions(session); + const model = await this._resolveModel(session); + await conn.client.request<'turn/start'>('turn/start', { + threadId, + input: input.slice(), + model: model.id, + ...turnOptions, + }); + // We don't await turn completion here — the notification + // stream emits SessionTurnComplete asynchronously. + } catch (err) { + if (err instanceof CancellationError) { + this._fire(sessionUri, { type: ActionType.SessionTurnCancelled, turnId: effectiveTurnId }); + return; + } + const message = err instanceof Error ? err.message : String(err); + this._logService.error(`[Codex:${sessionId}] turn/start error: ${message}`); + this._fire(sessionUri, { + type: ActionType.SessionError, + turnId: effectiveTurnId, + error: { errorType: 'CodexTurnError', message }, + }); + this._fire(sessionUri, { type: ActionType.SessionTurnComplete, turnId: effectiveTurnId }); + } finally { + // Best-effort temp-file cleanup. Image-on-localImage will be + // re-read by codex synchronously during the turn so this is + // safe to defer slightly; we delete after a generous grace. + if (cleanupPaths.length > 0) { + setTimeout(() => { + for (const p of cleanupPaths) { + try { fs.unlinkSync(p); } catch { /* ignore */ } + } + }, 30_000); + } + } + } + + setPendingMessages(sessionUri: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { + if (!steeringMessage) { + return; + } + const sessionId = AgentSession.id(sessionUri); + const session = this._sessions.get(sessionId); + if (!session) { + return; + } + const appTurnId = session.currentAppTurnId; + if (!appTurnId) { + // No active turn — let the framework re-queue this as a normal sendMessage. + return; + } + const conn = this._connection; + if (conn.kind !== 'ready') { + return; + } + const text = steeringMessage.message.text; + if (text.length === 0 && (!steeringMessage.message.attachments || steeringMessage.message.attachments.length === 0)) { + return; + } + const { input } = resolveCodexInput(text, steeringMessage.message.attachments); + if (session.threadId === undefined) { + return; + } + const threadId = session.threadId; + void conn.client.request<'turn/steer'>('turn/steer', { + threadId, + input: input.slice(), + expectedTurnId: appTurnId, + }).catch(err => { + if (err instanceof JsonRpcError) { + // `expectedTurnId` mismatch is benign — framework will requeue. + this._logService.info(`[Codex:${sessionId}] turn/steer skipped: ${err.message}`); + return; + } + this._logService.warn(`[Codex:${sessionId}] turn/steer failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + + async abortSession(sessionUri: URI): Promise { + const sessionId = AgentSession.id(sessionUri); + const session = this._sessions.get(sessionId); + if (!session || !session.currentAppTurnId || session.threadId === undefined) { + return; + } + const threadId = session.threadId; + const conn = this._connection; + if (conn.kind !== 'ready') { + return; + } + try { + await conn.client.request<'turn/interrupt'>('turn/interrupt', { + threadId, + turnId: session.currentAppTurnId, + }); + } catch (err) { + this._logService.warn(`[Codex:${sessionId}] turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + async disposeSession(sessionUri: URI): Promise { + this._logService.info(`[Codex DEBUG] disposeSession session=${sessionUri.toString()}`); + const sessionId = AgentSession.id(sessionUri); + const session = this._sessions.get(sessionId); + if (!session) { + return; + } + session.disposed = true; + this._claimPrewarm(session); + this._sessions.delete(sessionId); + if (session.threadId !== undefined) { + this._sessionIdByThreadId.delete(session.threadId); + } + // Unpark any pending approvals so codex doesn't deadlock waiting + // on a response we will never deliver. + session.pendingCommandApprovals.denyAll('decline'); + const conn = this._connection; + if (conn.kind === 'ready' && session.threadId !== undefined) { + const threadId = session.threadId; + // `thread/unsubscribe` is the codex-native way to release a + // session. Codex evicts after its 30-minute idle grace. + try { + await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); + } catch (err) { + this._logService.info(`[Codex:${threadId}] thread/unsubscribe failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + + async changeModel(sessionUri: URI, model: ModelSelection): Promise { + const session = this._sessions.get(AgentSession.id(sessionUri)); + if (session) { + const supported = this._supportedModelOrUndefined(model); + if (supported) { + session.model = supported; + } + } + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + // `requestId` is the host-side toolCallId; iterate sessions and + // resolve the first match. Mirrors the Claude/Copilot agents. + for (const session of this._sessions.values()) { + if (session.pendingCommandApprovals.respond(requestId, approved ? 'accept' : 'decline')) { + return; + } + } + this._logService.info(`[Codex] respondToPermissionRequest: unknown requestId=${requestId}`); + } + + respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { + // Phase 4 wires this. + this._logService.info('[Codex] respondToUserInputRequest called (Phase 4 stub)'); + } + + getSessionMessages(session: URI): Promise { + return this._readSession(session).then(read => read ? replayThreadToTurns(read.thread) : []); + } + + async getSessionMetadata(session: URI): Promise { + const sessionId = AgentSession.id(session); + const read = await this._readSession(session); + if (!read) { + return undefined; + } + // Register the session in our map so subsequent sendMessage triggers + // thread/resume (Decision 8). The threadId came from the metadata + // overlay or from `thread/list` (when the session was materialized + // in a prior process); `_readSession` returns the resolved id. + if (!this._sessions.has(sessionId)) { + const workingDirectory = read.thread.cwd ? URI.file(read.thread.cwd) : undefined; + const threadId = read.thread.id; + this._sessions.set(sessionId, { + sessionId, + threadId, + sessionUri: session, + workingDirectory, + mapState: createCodexSessionMapState(), + pendingCommandApprovals: new PendingRequestRegistry(), + acceptedForSession: new Set(), + model: undefined, + currentTurnId: undefined, + currentAppTurnId: undefined, + hostTurnIdByAppTurnId: new Map(), + needsResume: true, + lastPromptText: '', + disposed: false, + materializePromise: undefined, + materializedEventFired: true, + prewarmTimer: undefined, + prewarmClaimed: true, + }); + this._sessionIdByThreadId.set(threadId, sessionId); + } + return this._threadToMetadata(read.thread, session); + } + + private async _readSession(session: URI): Promise { + // Resolve the codex thread id for this session URI. Resolution + // order: in-memory session → persisted metadata overlay → URI host + // (for sessions materialized in a prior process where sessionId + // equals threadId by convention). + const sessionId = AgentSession.id(session); + const existing = this._sessions.get(sessionId); + let threadId = existing?.threadId; + if (threadId === undefined) { + const overlay = await this._metadataStore.read(session); + threadId = overlay.threadId ?? sessionId; + } + try { + const conn = await this._ensureConnection(); + const response = await conn.client.request<'thread/read', ThreadReadResponse>('thread/read', { + threadId, + includeTurns: true, + }); + return response; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // `thread not loaded` is app-server's expected response for any + // thread we have not yet resumed in this process; sendMessage's + // `thread/resume` path will handle it. Log at info level. + if (/thread not loaded/i.test(message)) { + this._logService.info(`[Codex:${threadId}] thread/read: not loaded yet (will resume on first send)`); + } else { + this._logService.warn(`[Codex:${threadId}] thread/read failed: ${message}`); + } + return undefined; + } + } + + async listSessions(): Promise { + if (!this._githubToken) { + return []; + } + try { + const conn = await this._ensureConnection(); + const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', { + limit: 200, + }); + // Map persisted threads back to the URI the workbench already + // knows them by. After `_materializeIfNeeded` runs, the codex + // thread is persisted to disk under its thread id but the + // workbench/state-manager keyed the session by its provisional + // URI (`codex:/`). If we returned a fresh + // `codex:/` URI here, `_refreshSessions` would treat + // the provisional URI as missing and evict the live session + // the user is actively viewing. + const liveUriByThreadId = new Map(); + for (const s of this._sessions.values()) { + if (s.threadId !== undefined) { + liveUriByThreadId.set(s.threadId, s.sessionUri); + } + } + return response.data.map(t => this._threadToMetadata( + t, + liveUriByThreadId.get(t.id) ?? AgentSession.uri(this.id, t.id), + )); + } catch (err) { + this._logService.warn(`[Codex] thread/list failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } + } + + private _threadToMetadata(thread: Thread, sessionUri: URI): IAgentSessionMetadata { + return { + session: sessionUri, + // Codex returns Unix seconds; the agent host expects ms. + startTime: (thread.createdAt ?? 0) * 1000, + modifiedTime: (thread.updatedAt ?? thread.createdAt ?? 0) * 1000, + summary: thread.name ?? thread.preview ?? undefined, + workingDirectory: thread.cwd ? URI.file(thread.cwd) : undefined, + }; + } + + setClientTools(_session: URI, _clientId: string, _tools: ToolDefinition[]): void { + // Phase 6+: in-process MCP client tools. Not implemented in Phase 2. + } + + onClientToolCallComplete(_session: URI, _toolCallId: string, _result: ToolCallResult): void { + // Phase 4+. + } + + setClientCustomizations(_session: URI, _clientId: string, _customizations: ClientPluginCustomization[]): Promise { + return Promise.resolve([]); + } + + setCustomizationEnabled(_uri: string, _enabled: boolean): void { + // no-op; customizations not yet wired for codex. + } + + async shutdown(): Promise { + if (this._connection.kind === 'ready') { + try { this._connection.client.dispose(); } catch { /* ignore */ } + try { this._connection.proxyHandle.dispose(); } catch { /* ignore */ } + } + this._connection = { kind: 'idle' }; + for (const s of this._sessions.values()) { + s.pendingCommandApprovals.denyAll('decline'); + } + this._sessions.clear(); + this._sessionIdByThreadId.clear(); + } + + resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + const values = codexSessionConfigSchema.validateOrDefault(params.config, codexSessionConfigDefaults); + const isWorkspaceWrite = values[CodexSessionConfigKey.SandboxMode] === 'workspace-write'; + const schema = isWorkspaceWrite + ? codexWorkspaceWriteSessionConfigSchema.toProtocol() + : codexVisibleSessionConfigSchema.toProtocol(); + const resolvedValues: Record = { + [CodexSessionConfigKey.ApprovalPolicy]: values[CodexSessionConfigKey.ApprovalPolicy], + [CodexSessionConfigKey.SandboxMode]: values[CodexSessionConfigKey.SandboxMode], + [CodexSessionConfigKey.WebSearchMode]: values[CodexSessionConfigKey.WebSearchMode], + }; + if (isWorkspaceWrite) { + resolvedValues[CodexSessionConfigKey.NetworkAccessEnabled] = values[CodexSessionConfigKey.NetworkAccessEnabled]; + } + return Promise.resolve({ values: resolvedValues, schema }); + } + + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + if (params.property !== CodexSessionConfigKey.AdditionalDirectories) { + return { items: [] }; + } + const query = params.query?.trim(); + if (!query) { + return { items: [] }; + } + const workingDirectory = params.workingDirectory?.fsPath; + const resolved = isAbsolute(query) + ? query + : resolve(workingDirectory ?? process.cwd(), query); + const parent = query.endsWith(sep) ? resolved : dirname(resolved); + const prefix = query.endsWith(sep) ? '' : basename(resolved).toLowerCase(); + try { + const entries = await fs.promises.readdir(parent, { withFileTypes: true }); + return { + items: entries + .filter(entry => entry.isDirectory() && entry.name.toLowerCase().startsWith(prefix)) + .slice(0, 50) + .map(entry => { + const value = join(parent, entry.name); + return { value, label: entry.name, description: value }; + }), + }; + } catch { + return { items: [] }; + } + } + + // #endregion + + private _fire(sessionUri: URI, action: SessionAction): void { + this._onDidSessionProgress.fire({ kind: 'action', session: sessionUri, action }); + } + + override dispose(): void { + if (this._connection.kind === 'ready') { + try { this._connection.client.dispose(); } catch { /* ignore */ } + try { this._connection.proxyHandle.dispose(); } catch { /* ignore */ } + } + this._connection = { kind: 'idle' }; + for (const s of this._sessions.values()) { + s.pendingCommandApprovals.denyAll('decline'); + } + this._sessions.clear(); + this._sessionIdByThreadId.clear(); + super.dispose(); + } +} + +function parseBinaryArgs(json: string | undefined): string[] { + if (!json) { + return []; + } + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts new file mode 100644 index 0000000000000..5e122e5c64195 --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -0,0 +1,747 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolResultContentType, TurnState } from '../../common/state/sessionState.js'; +import type { AgentMessageDeltaNotification } from './protocol/generated/v2/AgentMessageDeltaNotification.js'; +import type { CommandExecutionOutputDeltaNotification } from './protocol/generated/v2/CommandExecutionOutputDeltaNotification.js'; +import type { FileChangeOutputDeltaNotification } from './protocol/generated/v2/FileChangeOutputDeltaNotification.js'; +import type { FileChangePatchUpdatedNotification } from './protocol/generated/v2/FileChangePatchUpdatedNotification.js'; +import type { FileUpdateChange } from './protocol/generated/v2/FileUpdateChange.js'; +import type { ItemCompletedNotification } from './protocol/generated/v2/ItemCompletedNotification.js'; +import type { ItemStartedNotification } from './protocol/generated/v2/ItemStartedNotification.js'; +import type { McpToolCallProgressNotification } from './protocol/generated/v2/McpToolCallProgressNotification.js'; +import type { McpToolCallResult } from './protocol/generated/v2/McpToolCallResult.js'; +import type { ReasoningSummaryPartAddedNotification } from './protocol/generated/v2/ReasoningSummaryPartAddedNotification.js'; +import type { ReasoningSummaryTextDeltaNotification } from './protocol/generated/v2/ReasoningSummaryTextDeltaNotification.js'; +import type { ReasoningTextDeltaNotification } from './protocol/generated/v2/ReasoningTextDeltaNotification.js'; +import type { ThreadTokenUsageUpdatedNotification } from './protocol/generated/v2/ThreadTokenUsageUpdatedNotification.js'; +import type { TurnCompletedNotification } from './protocol/generated/v2/TurnCompletedNotification.js'; +import type { TurnStartedNotification } from './protocol/generated/v2/TurnStartedNotification.js'; +import type { WebSearchAction } from './protocol/generated/v2/WebSearchAction.js'; +import type { DynamicToolCallOutputContentItem } from './protocol/generated/v2/DynamicToolCallOutputContentItem.js'; +import type { JsonValue } from './protocol/generated/serde_json/JsonValue.js'; + +/** + * Per-session mutable state held by the mapper. Carries the bookkeeping + * needed to glue codex's item-stream (each `agentMessage` item has its + * own id) to the agent host protocol (each markdown part has its own id). + * + * Phase 2 tracks only `itemId → partId` for agent messages. Phase 4 + * extends this with tool-call correlation; Phase 6 adds reasoning parts. + */ +export interface ICodexSessionMapState { + /** Stable codex `itemId` → our markdown response part id. */ + readonly itemToPartId: Map; + /** + * Stable codex `itemId` → tool-call bookkeeping. Phase 4 tracks + * `commandExecution` here so completion/approval handlers can find + * the right toolCallId/turnId for each item. + */ + readonly itemToToolCall: Map; + /** Stable codex reasoning item/index → our reasoning response part id. */ + readonly itemToReasoningPartId: Map; + /** Current turn id (per `turn/started`). */ + currentTurnId: string | undefined; +} + +export interface ICodexToolCallEntry { + readonly toolCallId: string; + readonly turnId: string; + readonly toolName: string; + output: string; +} + +export function createCodexSessionMapState(): ICodexSessionMapState { + return { + itemToPartId: new Map(), + itemToToolCall: new Map(), + itemToReasoningPartId: new Map(), + currentTurnId: undefined, + }; +} + +function reasoningKey(itemId: string, kind: 'summary' | 'text', index: number): string { + return `${itemId}:${kind}:${index}`; +} + +function ensureReasoningPart(state: ICodexSessionMapState, turnId: string, key: string): { readonly partId: string; readonly actions: SessionAction[] } { + const existing = state.itemToReasoningPartId.get(key); + if (existing) { + return { partId: existing, actions: [] }; + } + const partId = generateUuid(); + state.itemToReasoningPartId.set(key, partId); + return { + partId, + actions: [{ + type: ActionType.SessionResponsePart, + turnId, + part: { kind: ResponsePartKind.Reasoning, id: partId, content: '' }, + }], + }; +} + +function describeWebSearch(query: string, action: WebSearchAction | null): string { + if (action?.type === 'search') { + return action.queries?.join(', ') ?? action.query ?? query; + } + if (action?.type === 'openPage') { + return action.url ?? query; + } + if (action?.type === 'findInPage') { + return [action.pattern, action.url].filter(Boolean).join(' in ') || query; + } + return query; +} + +function describeFileChange(changes: readonly FileUpdateChange[]): string { + return changes.map(change => { + const kind = change.kind.type === 'update' && change.kind.move_path + ? `rename from ${change.kind.move_path}` + : change.kind.type; + return `${kind}: ${change.path}`; + }).join('\n'); +} + +function fileChangeOutput(changes: readonly FileUpdateChange[]): string { + return changes.map(change => `${describeFileChange([change])}\n${change.diff}`.trim()).join('\n\n'); +} + +function jsonValueToText(value: JsonValue): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} + +function toolInputText(value: JsonValue): string { + return JSON.stringify(value, null, 2); +} + +function dynamicToolOutput(contentItems: readonly DynamicToolCallOutputContentItem[] | null): string { + return contentItems?.map(item => item.type === 'inputText' ? item.text : item.imageUrl).join('\n') ?? ''; +} + +function mcpToolOutput(result: McpToolCallResult | null, errorMessage?: string): string { + if (errorMessage) { + return errorMessage; + } + if (!result) { + return ''; + } + const content = result.content.map(jsonValueToText).join('\n'); + const structuredContent = result.structuredContent !== null ? jsonValueToText(result.structuredContent) : ''; + return [content, structuredContent].filter(Boolean).join('\n'); +} + +/** + * Translate `turn/started` into a `SessionTurnStarted` action. + * + * Codex's `turn/started.turn.items[0]` SHOULD be the userMessage that + * kicked off the turn; we reconstruct the user message from it. If + * codex didn't include items (it may not), we synthesize an empty user + * message so the agent host can still create the turn shell — the actual + * prompt text was sent via `turn/start` and is already known by the host + * via the prior `sendMessage` call. + */ +export function mapTurnStarted( + state: ICodexSessionMapState, + params: TurnStartedNotification, + fallbackUserText: string, +): SessionAction[] { + state.currentTurnId = params.turn.id; + state.itemToPartId.clear(); + state.itemToToolCall.clear(); + state.itemToReasoningPartId.clear(); + let userText = fallbackUserText; + const first = params.turn.items?.[0]; + if (first && first.type === 'userMessage') { + const collected: string[] = []; + for (const c of first.content) { + if (c.type === 'text') { + collected.push(c.text); + } + } + if (collected.length > 0) { + userText = collected.join('\n\n'); + } + } + return [ + { + type: ActionType.SessionTurnStarted, + turnId: params.turn.id, + message: { text: userText, origin: { kind: MessageKind.User } }, + }, + ]; +} + +export function mapReasoningSummaryPartAdded( + state: ICodexSessionMapState, + params: ReasoningSummaryPartAddedNotification, +): SessionAction[] { + return ensureReasoningPart(state, params.turnId, reasoningKey(params.itemId, 'summary', params.summaryIndex)).actions; +} + +export function mapReasoningSummaryTextDelta( + state: ICodexSessionMapState, + params: ReasoningSummaryTextDeltaNotification, +): SessionAction[] { + const ensured = ensureReasoningPart(state, params.turnId, reasoningKey(params.itemId, 'summary', params.summaryIndex)); + return [ + ...ensured.actions, + { type: ActionType.SessionReasoning, turnId: params.turnId, partId: ensured.partId, content: params.delta }, + ]; +} + +export function mapReasoningTextDelta( + state: ICodexSessionMapState, + params: ReasoningTextDeltaNotification, +): SessionAction[] { + const ensured = ensureReasoningPart(state, params.turnId, reasoningKey(params.itemId, 'text', params.contentIndex)); + return [ + ...ensured.actions, + { type: ActionType.SessionReasoning, turnId: params.turnId, partId: ensured.partId, content: params.delta }, + ]; +} + +export function clearReasoningForItem(state: ICodexSessionMapState, itemId: string): void { + for (const key of [...state.itemToReasoningPartId.keys()]) { + if (key.startsWith(`${itemId}:`)) { + state.itemToReasoningPartId.delete(key); + } + } +} + +export function mapTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification): SessionAction[] { + const last = params.tokenUsage.last; + return [{ + type: ActionType.SessionUsage, + turnId: params.turnId, + usage: { + inputTokens: last.inputTokens, + outputTokens: last.outputTokens, + cacheReadTokens: last.cachedInputTokens, + _meta: { + reasoningOutputTokens: last.reasoningOutputTokens, + modelContextWindow: params.tokenUsage.modelContextWindow, + }, + }, + }]; +} + +/** + * `item/started` for an `agentMessage` becomes a `SessionResponsePart` + * action with an empty `MarkdownResponsePart` shell. Subsequent + * `item/agentMessage/delta` notifications append to that part. + * + * Other item types are ignored in Phase 2 — they'll be picked up by + * Phase 6's tool-call mapper. + */ +export function mapItemStarted( + state: ICodexSessionMapState, + params: ItemStartedNotification, +): SessionAction[] { + if (params.item.type === 'agentMessage') { + const partId = generateUuid(); + state.itemToPartId.set(params.item.id, partId); + return [ + { + type: ActionType.SessionResponsePart, + turnId: params.turnId, + part: { + kind: ResponsePartKind.Markdown, + id: partId, + content: params.item.text ?? '', + }, + }, + ]; + } + if (params.item.type === 'commandExecution') { + // Phase 4: surface shell commands as tool calls. We allocate a + // fresh toolCallId; the `commandExecution` item id only + // disambiguates the codex side. + const toolCallId = generateUuid(); + state.itemToToolCall.set(params.item.id, { + toolCallId, + turnId: params.turnId, + toolName: 'shell', + output: '', + }); + const command = params.item.command ?? ''; + return [ + { + type: ActionType.SessionToolCallStart, + turnId: params.turnId, + toolCallId, + toolName: 'shell', + displayName: 'Run shell command', + _meta: { toolKind: 'terminal' }, + }, + { + type: ActionType.SessionToolCallDelta, + turnId: params.turnId, + toolCallId, + content: command, + }, + { + type: ActionType.SessionToolCallReady, + turnId: params.turnId, + toolCallId, + invocationMessage: command, + toolInput: command, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { toolKind: 'terminal' }, + }, + ]; + } + if (params.item.type === 'webSearch') { + const toolCallId = generateUuid(); + state.itemToToolCall.set(params.item.id, { + toolCallId, + turnId: params.turnId, + toolName: 'web_search', + output: '', + }); + const query = describeWebSearch(params.item.query, params.item.action); + return [ + { + type: ActionType.SessionToolCallStart, + turnId: params.turnId, + toolCallId, + toolName: 'web_search', + displayName: 'Web search', + _meta: { toolKind: 'search' }, + }, + { + type: ActionType.SessionToolCallDelta, + turnId: params.turnId, + toolCallId, + content: query, + }, + { + type: ActionType.SessionToolCallReady, + turnId: params.turnId, + toolCallId, + invocationMessage: query, + toolInput: query, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { toolKind: 'search' }, + }, + ]; + } + if (params.item.type === 'fileChange') { + const toolCallId = generateUuid(); + const output = fileChangeOutput(params.item.changes); + state.itemToToolCall.set(params.item.id, { + toolCallId, + turnId: params.turnId, + toolName: 'file_edit', + output, + }); + const summary = describeFileChange(params.item.changes) || 'Apply file changes'; + return [ + { + type: ActionType.SessionToolCallStart, + turnId: params.turnId, + toolCallId, + toolName: 'file_edit', + displayName: 'Apply file changes', + }, + { + type: ActionType.SessionToolCallDelta, + turnId: params.turnId, + toolCallId, + content: summary, + }, + { + type: ActionType.SessionToolCallReady, + turnId: params.turnId, + toolCallId, + invocationMessage: summary, + toolInput: summary, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + ...(output ? [{ + type: ActionType.SessionToolCallContentChanged, + turnId: params.turnId, + toolCallId, + content: [{ type: ToolResultContentType.Text, text: output }], + } satisfies SessionAction] : []), + ]; + } + if (params.item.type === 'mcpToolCall') { + const toolCallId = generateUuid(); + const toolName = `${params.item.server}.${params.item.tool}`; + const toolInput = toolInputText(params.item.arguments); + state.itemToToolCall.set(params.item.id, { + toolCallId, + turnId: params.turnId, + toolName, + output: '', + }); + return [ + { + type: ActionType.SessionToolCallStart, + turnId: params.turnId, + toolCallId, + toolName, + displayName: params.item.tool, + }, + { + type: ActionType.SessionToolCallDelta, + turnId: params.turnId, + toolCallId, + content: toolInput, + }, + { + type: ActionType.SessionToolCallReady, + turnId: params.turnId, + toolCallId, + invocationMessage: `Calling ${toolName}`, + toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + ]; + } + if (params.item.type === 'dynamicToolCall') { + const toolCallId = generateUuid(); + const toolName = params.item.namespace ? `${params.item.namespace}.${params.item.tool}` : params.item.tool; + const toolInput = toolInputText(params.item.arguments); + const output = dynamicToolOutput(params.item.contentItems); + state.itemToToolCall.set(params.item.id, { + toolCallId, + turnId: params.turnId, + toolName, + output, + }); + return [ + { + type: ActionType.SessionToolCallStart, + turnId: params.turnId, + toolCallId, + toolName, + displayName: params.item.tool, + }, + { + type: ActionType.SessionToolCallDelta, + turnId: params.turnId, + toolCallId, + content: toolInput, + }, + { + type: ActionType.SessionToolCallReady, + turnId: params.turnId, + toolCallId, + invocationMessage: `Calling ${toolName}`, + toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + ...(output ? [{ + type: ActionType.SessionToolCallContentChanged, + turnId: params.turnId, + toolCallId, + content: [{ type: ToolResultContentType.Text, text: output }], + } satisfies SessionAction] : []), + ]; + } + return []; +} + +export function mapCommandExecutionOutputDelta( + state: ICodexSessionMapState, + params: CommandExecutionOutputDeltaNotification, +): SessionAction[] { + const entry = state.itemToToolCall.get(params.itemId); + if (!entry) { + return []; + } + entry.output += params.delta; + return [{ + type: ActionType.SessionToolCallContentChanged, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + content: [{ type: ToolResultContentType.Text, text: entry.output }], + }]; +} + +export function mapFileChangePatchUpdated( + state: ICodexSessionMapState, + params: FileChangePatchUpdatedNotification, +): SessionAction[] { + const entry = state.itemToToolCall.get(params.itemId); + if (!entry) { + return []; + } + entry.output = fileChangeOutput(params.changes); + return [{ + type: ActionType.SessionToolCallContentChanged, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + content: entry.output ? [{ type: ToolResultContentType.Text, text: entry.output }] : [], + }]; +} + +export function mapFileChangeOutputDelta( + state: ICodexSessionMapState, + params: FileChangeOutputDeltaNotification, +): SessionAction[] { + const entry = state.itemToToolCall.get(params.itemId); + if (!entry) { + return []; + } + entry.output += params.delta; + return [{ + type: ActionType.SessionToolCallContentChanged, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + content: [{ type: ToolResultContentType.Text, text: entry.output }], + }]; +} + +export function mapMcpToolCallProgress( + state: ICodexSessionMapState, + params: McpToolCallProgressNotification, +): SessionAction[] { + const entry = state.itemToToolCall.get(params.itemId); + if (!entry) { + return []; + } + entry.output = [entry.output, params.message].filter(Boolean).join('\n'); + return [{ + type: ActionType.SessionToolCallContentChanged, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + content: [{ type: ToolResultContentType.Text, text: entry.output }], + }]; +} + +export function mapAgentMessageDelta( + state: ICodexSessionMapState, + params: AgentMessageDeltaNotification, +): SessionAction[] { + const partId = state.itemToPartId.get(params.itemId); + if (!partId) { + // Got a delta before we saw the corresponding `item/started`. + // Drop it — Phase 2 is best-effort and the lost text is replaced + // when `item/completed` arrives with the full `text` field. + return []; + } + return [ + { + type: ActionType.SessionDelta, + turnId: params.turnId, + partId, + content: params.delta, + }, + ]; +} + +/** + * `item/completed` for an `agentMessage` — the part is finalized server + * side. For Phase 2 we don't need to emit an extra action: the deltas + * already updated the part's content. We just drop the mapping so the + * memory pressure stays bounded. + * + * For `commandExecution`, emit a synthetic `SessionToolCallReady` + * (auto-confirmed; the codex server already decided to run the command + * — any host-side approval was settled via the `requestApproval` + * server-request handler before we got here) followed by a + * `SessionToolCallComplete` carrying the aggregated output. + */ +export function mapItemCompleted( + state: ICodexSessionMapState, + params: ItemCompletedNotification, +): SessionAction[] { + if (params.item.type === 'agentMessage') { + state.itemToPartId.delete(params.item.id); + return []; + } + if (params.item.type === 'reasoning') { + clearReasoningForItem(state, params.item.id); + return []; + } + if (params.item.type === 'commandExecution') { + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const success = params.item.status === 'completed' && (params.item.exitCode === 0 || params.item.exitCode === null); + const output = params.item.aggregatedOutput ?? entry.output; + const command = params.item.command ?? ''; + const exit = params.item.exitCode; + const pastTense = success + ? `Ran \`${command}\`` + : exit !== null + ? `Ran \`${command}\` (exit ${exit})` + : `Ran \`${command}\` (failed)`; + return [ + { + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result: { + success, + pastTenseMessage: pastTense, + content: output + ? [{ type: ToolResultContentType.Text, text: output }] + : undefined, + error: success ? undefined : { + message: exit !== null ? `Exit code ${exit}` : 'Command failed', + }, + }, + }, + ]; + } + if (params.item.type === 'webSearch') { + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const query = describeWebSearch(params.item.query, params.item.action); + return [{ + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result: { + success: true, + pastTenseMessage: `Searched ${query}`, + }, + }]; + } + if (params.item.type === 'fileChange') { + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const output = fileChangeOutput(params.item.changes) || entry.output; + const success = params.item.status === 'completed'; + const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; + const result = { + success, + pastTenseMessage: success ? 'Applied file changes' : 'Failed to apply file changes', + content, + ...(success ? {} : { error: { message: `Patch ${params.item.status}` } }), + }; + return [{ + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result, + }]; + } + if (params.item.type === 'mcpToolCall') { + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const success = params.item.status === 'completed' && !params.item.error; + const output = mcpToolOutput(params.item.result, params.item.error?.message) || entry.output; + const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; + return [{ + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result: { + success, + pastTenseMessage: success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`, + content, + ...(success ? {} : { error: { message: params.item.error?.message ?? `MCP tool ${params.item.status}` } }), + }, + }]; + } + if (params.item.type === 'dynamicToolCall') { + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const success = params.item.success === true || params.item.status === 'completed'; + const output = dynamicToolOutput(params.item.contentItems) || entry.output; + const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; + return [{ + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result: { + success, + pastTenseMessage: success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`, + content, + ...(success ? {} : { error: { message: `Dynamic tool ${params.item.status}` } }), + }, + }]; + } + return []; +} + +/** + * `turn/completed` translates to either a normal complete signal or, when + * the turn ended with `status: 'failed'`, an error followed by the + * complete signal so consumers can react to both. + */ +export function mapTurnCompleted( + state: ICodexSessionMapState, + params: TurnCompletedNotification, +): SessionAction[] { + state.currentTurnId = undefined; + state.itemToPartId.clear(); + state.itemToReasoningPartId.clear(); + const orphanedToolCalls = [...state.itemToToolCall.values()]; + state.itemToToolCall.clear(); + const turnId = params.turn.id; + const status = params.turn.status; + const orphanedToolCallActions: SessionAction[] = orphanedToolCalls.map(entry => ({ + type: ActionType.SessionToolCallComplete, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + result: { + success: false, + pastTenseMessage: `Stopped ${entry.toolName}`, + content: entry.output ? [{ type: ToolResultContentType.Text as const, text: entry.output }] : undefined, + error: { message: status === 'interrupted' ? 'Turn interrupted before the tool completed' : 'Turn completed before the tool reported completion' }, + }, + })); + if (status === 'failed' && params.turn.error) { + const errMessage = params.turn.error.message ?? 'Codex turn failed'; + return [ + ...orphanedToolCallActions, + { + type: ActionType.SessionError, + turnId, + error: { + errorType: 'CodexError', + message: errMessage, + }, + }, + { + type: ActionType.SessionTurnComplete, + turnId, + }, + ]; + } + if (status === 'interrupted') { + return [...orphanedToolCallActions, { type: ActionType.SessionTurnCancelled, turnId }]; + } + return [...orphanedToolCallActions, { type: ActionType.SessionTurnComplete, turnId }]; +} + +/** + * Build a {@link TurnState} from a codex `Turn.status`. Mostly useful + * for replay (Phase 3). + */ +export function turnStateFromStatus(status: string): TurnState { + switch (status) { + case 'completed': + return TurnState.Complete; + case 'interrupted': + return TurnState.Cancelled; + case 'failed': + return TurnState.Error; + default: + return TurnState.Complete; + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts b/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts new file mode 100644 index 0000000000000..4acc64247feeb --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexPromptResolver.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/sessionState.js'; +import type { UserInput } from './protocol/generated/v2/UserInput.js'; +import type { TextElement } from './protocol/generated/v2/TextElement.js'; + +/** + * Translate the agent host's `(prompt, attachments)` shape into codex's + * `turn/start.input[]`. + * + * Phase 2 minimum: + * - The prompt text becomes a single `{ type: 'text' }` input item. + * - `Resource` attachments referencing local files are inlined into the + * text as `@` mentions so codex's prompt template picks them up. + * - `Simple` attachments with a `modelRepresentation` get appended to the + * prompt text as a separate paragraph. + * - `EmbeddedResource` attachments with an `image/*` content type are + * written to a temp file and surfaced as `{ type: 'localImage' }`. The + * returned files are tracked in `cleanupPaths` so the caller can unlink + * them after the turn completes. + * + * Skill / app mentions are deferred to a later phase. + */ +export interface IResolvedCodexInput { + readonly input: ReadonlyArray; + /** Temporary files created during resolution. Caller MUST unlink. */ + readonly cleanupPaths: readonly string[]; +} + +const EMPTY_TEXT_ELEMENTS: TextElement[] = []; + +export function resolveCodexInput( + prompt: string, + attachments: readonly MessageAttachment[] | undefined, +): IResolvedCodexInput { + const cleanupPaths: string[] = []; + const input: UserInput[] = []; + const textChunks: string[] = [prompt]; + + if (attachments && attachments.length > 0) { + for (const att of attachments) { + switch (att.type) { + case MessageAttachmentKind.Resource: { + // Resource attachments reference a URI (on the wire, + // already a string). For file URIs we inline the + // absolute path as a `@` mention so the codex + // prompt template can render / read it. + const uri = URI.parse(att.uri); + if (uri.scheme === 'file') { + textChunks.push(`@${uri.fsPath}`); + } else { + // Non-file URIs (vscode-userdata://, untitled://, …) + // are surfaced as a plain string so they still show + // up in the prompt, even if codex can't resolve them. + textChunks.push(uri.toString()); + } + break; + } + case MessageAttachmentKind.EmbeddedResource: { + if (att.contentType.startsWith('image/')) { + const ext = guessImageExtension(att.contentType); + const tmp = join(os.tmpdir(), `codex-img-${crypto.randomBytes(8).toString('hex')}${ext}`); + try { + fs.writeFileSync(tmp, Buffer.from(att.data, 'base64')); + cleanupPaths.push(tmp); + input.push({ type: 'localImage', path: tmp }); + } catch { + // If writing the temp file fails, drop the + // attachment silently — better to send the prompt + // without the image than to fail the whole turn. + } + } + // Non-image embedded resources are not yet supported. + break; + } + case MessageAttachmentKind.Simple: { + const rep = att.modelRepresentation; + if (typeof rep === 'string' && rep.length > 0) { + textChunks.push(rep); + } + break; + } + } + } + } + + const text = textChunks.filter(s => s.length > 0).join('\n\n'); + // Always include a text input first, even if empty (codex needs at + // least one element). + input.unshift({ type: 'text', text, text_elements: EMPTY_TEXT_ELEMENTS }); + + return { input, cleanupPaths }; +} + +function guessImageExtension(contentType: string): string { + const subtype = contentType.slice('image/'.length).toLowerCase(); + switch (subtype) { + case 'jpeg': + case 'jpg': + return '.jpg'; + case 'png': + return '.png'; + case 'gif': + return '.gif'; + case 'webp': + return '.webp'; + case 'bmp': + return '.bmp'; + default: + return ''; + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexProxyService.ts b/src/vs/platform/agentHost/node/codex/codexProxyService.ts new file mode 100644 index 0000000000000..e5118813baf20 --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexProxyService.ts @@ -0,0 +1,478 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as http from 'http'; +import * as fs from 'fs'; +import { join } from '../../../../base/common/path.js'; +import { AddressInfo } from 'net'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { CopilotApiError, ICopilotApiService } from '../shared/copilotApiService.js'; + +/** + * Refcounted handle to the local OpenAI-Responses → CAPI proxy. + * + * The handle owns a nonce that the codex CLI passes as `Bearer ` on + * every request. The proxy validates that nonce, then re-issues the request + * to CAPI using the **current** GitHub Copilot token — which can rotate + * underneath the codex process without affecting it. Call + * {@link setToken} when the upstream token changes; in-flight requests keep + * using the value they captured at dispatch time, new requests pick up the + * fresh value. + * + * Subprocess-ownership invariant: any subprocess given `baseUrl` / `nonce` + * MUST be killed before this handle is disposed; otherwise the proxy may + * rebind on a different port on next `start()` and the subprocess silently + * loses its endpoint. + */ +export interface ICodexProxyHandle extends IDisposable { + /** e.g. `http://127.0.0.1:54321` — no trailing slash. */ + readonly baseUrl: string; + /** Random per-process nonce used as `Bearer ` by the codex CLI. */ + readonly nonce: string; + /** + * Replace the GitHub Copilot token used for outbound CAPI calls. The + * codex process and its nonce are unchanged. + */ + setToken(githubToken: string): void; +} + +export interface ICodexProxyService { + readonly _serviceBrand: undefined; + + /** + * Start the proxy (if not already running) and return a refcounted + * handle. The provided token is the initial value; rotate via + * {@link ICodexProxyHandle.setToken}. + */ + start(githubToken: string): Promise; + + /** Force-close the proxy regardless of refcount. Idempotent. */ + dispose(): void; +} + +export const ICodexProxyService = createDecorator('codexProxyService'); + +interface IInFlight { + readonly ac: AbortController; + readonly res: http.ServerResponse; + clientGone: boolean; +} + +interface IProxyRuntime { + readonly server: http.Server; + readonly baseUrl: string; + readonly nonce: string; + readonly inFlight: Set; + /** Token cell — read fresh on each outbound request. */ + githubToken: string; + refcount: number; +} + +const PROXY_USER_FACING_NAME = 'CodexProxyService'; + +/** + * When set to an absolute directory path, every `/v1/responses` request body + * and its full upstream response stream are written to that directory as + * `req-NNN-.json` and `res-NNN-.txt` so we can diff bodies / decode + * SSE without flooding the log channel. Off by default. + */ +const DEBUG_DUMP_DIR_ENV = 'VSCODE_CODEX_PROXY_DUMP_DIR'; + +let _dumpSeq = 0; +function nextDumpSeq(): string { + return String(++_dumpSeq).padStart(4, '0'); +} + +function getDumpDir(): string | undefined { + const dir = process.env[DEBUG_DUMP_DIR_ENV]; + if (!dir) { + return undefined; + } + try { + fs.mkdirSync(dir, { recursive: true }); + return dir; + } catch { + return undefined; + } +} + +function generateNonce(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += bytes[i].toString(16).padStart(2, '0'); + } + return out; +} + +function writeJsonError(res: http.ServerResponse, status: number, type: string, message: string): void { + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { type, message } })); +} + +function readRequestBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +/** + * Local HTTP server that speaks the OpenAI Responses API on its inbound + * side and forwards to {@link ICopilotApiService.responses} on the + * outbound side. The codex app-server connects via env / `--config + * openai_base_url=/v1` + Bearer `` and sees this as a + * real OpenAI endpoint. + * + * Lifecycle: refcounted handles, single shared bind, in-flight requests + * aborted on teardown. + */ +export class CodexProxyService implements ICodexProxyService { + + declare readonly _serviceBrand: undefined; + + private _runtime: IProxyRuntime | undefined; + private _starting: Promise | undefined; + private _disposed = false; + + constructor( + @ILogService private readonly _logService: ILogService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + ) { } + + async start(githubToken: string): Promise { + if (this._disposed) { + throw new Error('CodexProxyService has been disposed'); + } + const runtime = await this._ensureRuntime(githubToken); + if (this._disposed || this._runtime !== runtime) { + throw new Error('CodexProxyService has been disposed'); + } + // Most recent token wins for the runtime — single-tenant assumption. + runtime.githubToken = githubToken; + runtime.refcount++; + + let disposed = false; + return { + baseUrl: runtime.baseUrl, + nonce: runtime.nonce, + setToken: (newToken: string) => { + if (disposed) { + return; + } + // Update the shared runtime's token cell. In-flight requests + // keep the value they captured at dispatch; new requests + // pick up the fresh value on `_handleResponses`. + runtime.githubToken = newToken; + }, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + this._releaseHandle(runtime); + }, + }; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + this._teardownRuntime(); + } + + private _ensureRuntime(githubToken: string): Promise { + if (this._runtime) { + return Promise.resolve(this._runtime); + } + if (!this._starting) { + this._starting = (async () => { + try { + const rt = await this._startServer(githubToken); + if (this._disposed) { + rt.server.closeAllConnections(); + rt.server.close(); + throw new Error('CodexProxyService has been disposed'); + } + this._runtime = rt; + return rt; + } finally { + this._starting = undefined; + } + })(); + } + return this._starting; + } + + private _releaseHandle(runtime: IProxyRuntime): void { + if (this._runtime !== runtime) { + return; + } + runtime.refcount--; + if (runtime.refcount === 0) { + this._teardownRuntime(); + } + } + + private _teardownRuntime(): void { + const runtime = this._runtime; + if (!runtime) { + return; + } + this._runtime = undefined; + for (const entry of runtime.inFlight) { + entry.ac.abort(); + } + runtime.server.closeAllConnections(); + runtime.server.close(err => { + if (err) { + this._logService.warn(`[${PROXY_USER_FACING_NAME}] server.close error: ${err.message}`); + } + }); + } + + private async _startServer(githubToken: string): Promise { + const nonce = generateNonce(); + const inFlight = new Set(); + const httpModule = await import('http'); + const server = httpModule.createServer(); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + server.once('error', onError); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error(`${PROXY_USER_FACING_NAME} failed to bind: unexpected address ${String(address)}`); + } + const baseUrl = `http://127.0.0.1:${(address as AddressInfo).port}`; + this._logService.info(`[${PROXY_USER_FACING_NAME}] listening on ${baseUrl}`); + + const runtime: IProxyRuntime = { + server, + baseUrl, + nonce, + inFlight, + githubToken, + refcount: 0, + }; + + server.on('request', (req, res) => { + this._handleRequest(req, res, runtime).catch(err => { + this._logService.error(`[${PROXY_USER_FACING_NAME}] unhandled request error`, err); + if (!res.headersSent) { + try { + writeJsonError(res, 500, 'api_error', 'Internal proxy error'); + } catch { /* ignore */ } + } else if (!res.writableEnded) { + try { res.end(); } catch { /* ignore */ } + } + }); + }); + + return runtime; + } + + private async _handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + runtime: IProxyRuntime, + ): Promise { + const method = req.method ?? 'GET'; + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + const incomingHeaders = Object.keys(req.headers).join(', '); + this._logService.info(`[${PROXY_USER_FACING_NAME}] >>> ${method} ${pathname} (headers: ${incomingHeaders})`); + + if (method === 'GET' && pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + // Codex CLI sends `Bearer ` — plain nonce, no sessionId suffix. + const authHeader = req.headers['authorization']; + const expected = `Bearer ${runtime.nonce}`; + if (typeof authHeader !== 'string' || authHeader !== expected) { + writeJsonError(res, 401, 'authentication_error', 'Invalid authentication'); + return; + } + + // Codex sends `/v1/responses`, `//responses` (when base_url ends in `/`), + // or plain `/responses`. Accept all three. + if (method === 'POST' && (pathname === '/v1/responses' || pathname === '/responses' || pathname === '//responses')) { + await this._handleResponses(req, res, runtime); + return; + } + + writeJsonError(res, 404, 'not_found_error', `No route for ${method} ${pathname}`); + } + + private async _handleResponses( + req: http.IncomingMessage, + res: http.ServerResponse, + runtime: IProxyRuntime, + ): Promise { + let body: string; + try { + body = await readRequestBody(req); + } catch (err) { + writeJsonError(res, 400, 'invalid_request_error', `Failed to read request body: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + const dumpDir = getDumpDir(); + const dumpSeq = dumpDir ? nextDumpSeq() : undefined; + if (dumpDir && dumpSeq) { + const reqFile = join(dumpDir, `req-${dumpSeq}-${Date.now()}.json`); + try { + fs.writeFileSync(reqFile, body); + this._logService.info(`[${PROXY_USER_FACING_NAME}] dumped request body to ${reqFile}`); + } catch (err) { + this._logService.warn(`[${PROXY_USER_FACING_NAME}] failed to dump request body: ${err instanceof Error ? err.message : String(err)}`); + } + } + try { + const parsed = JSON.parse(body); + this._logService.info(`[${PROXY_USER_FACING_NAME}] >>> /responses body: model=${parsed.model ?? ''}, previous_response_id=${parsed.previous_response_id ?? ''}, stream=${parsed.stream ?? ''}, input_items=${Array.isArray(parsed.input) ? parsed.input.length : ''}`); + if (Array.isArray(parsed.input)) { + for (let i = 0; i < parsed.input.length; i++) { + const item = parsed.input[i]; + const type = item?.type ?? ''; + const keys = item && typeof item === 'object' ? Object.keys(item).join(',') : typeof item; + let detail = ''; + if (type === 'message') { + const text: string = item?.content?.[0]?.text ?? ''; + detail = `role=${item?.role ?? '?'} chars=${text.length}`; + } else if (type === 'function_call') { + detail = `name=${item?.name ?? '?'} call_id=${item?.call_id ?? '?'}`; + } else if (type === 'function_call_output') { + const output = item?.output ?? ''; + detail = `call_id=${item?.call_id ?? '?'} output_chars=${typeof output === 'string' ? output.length : 0}`; + } else if (type === 'reasoning') { + const summary = item?.summary ?? item?.content ?? ''; + detail = `summary_chars=${typeof summary === 'string' ? summary.length : JSON.stringify(summary).length} encrypted=${typeof item?.encrypted_content === 'string'}`; + } else { + detail = JSON.stringify(item).slice(0, 120); + } + this._logService.info(`[${PROXY_USER_FACING_NAME}] input[${i}] type=${type} keys=[${keys}] ${detail}`); + } + } + const topLevelKeys = Object.keys(parsed).filter(k => k !== 'input').sort(); + this._logService.info(`[${PROXY_USER_FACING_NAME}] top-level keys (excl. input)=[${topLevelKeys.join(', ')}]`); + for (const k of topLevelKeys) { + if (k === 'instructions' || k === 'tools') { + const v = parsed[k]; + const size = typeof v === 'string' ? v.length : JSON.stringify(v).length; + this._logService.info(`[${PROXY_USER_FACING_NAME}] ${k}=<${size} chars elided>`); + continue; + } + const v = parsed[k]; + const preview = typeof v === 'object' ? JSON.stringify(v).slice(0, 300) : String(v); + this._logService.info(`[${PROXY_USER_FACING_NAME}] ${k}=${preview}`); + } + } catch { + this._logService.info(`[${PROXY_USER_FACING_NAME}] >>> /responses body (unparseable): ${body.slice(0, 200)}`); + } + + const entry: IInFlight = { ac: new AbortController(), res, clientGone: false }; + runtime.inFlight.add(entry); + const onClose = () => { + entry.clientGone = true; + entry.ac.abort(); + }; + res.on('close', onClose); + + // Snapshot the token at dispatch time so an in-flight request keeps + // using the value it started with; subsequent requests will pick up + // whatever `runtime.githubToken` has been rotated to. + const dispatchedToken = runtime.githubToken; + + try { + this._logService.info(`[${PROXY_USER_FACING_NAME}] forwarding to CAPI responses...`); + const upstream = await this._copilotApiService.responses(dispatchedToken, body, { signal: entry.ac.signal }); + const contentType = upstream.headers.get('content-type') ?? 'application/json'; + const upstreamHeaders = [...upstream.headers.entries()].map(([k, v]) => `${k}: ${v}`).join(', '); + this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< CAPI response: status=${upstream.status}, contentType=${contentType}, headers=[${upstreamHeaders}]`); + res.writeHead(upstream.status, { 'Content-Type': contentType }); + if (!upstream.body) { + res.end(); + return; + } + const reader = upstream.body.getReader(); + const resDumpStream = dumpDir && dumpSeq + ? fs.createWriteStream(join(dumpDir, `res-${dumpSeq}-${Date.now()}.txt`)) + : undefined; + let sseBuf = ''; + const eventCounts: Record = {}; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (entry.clientGone) { + break; + } + if (value && value.byteLength > 0) { + const buf = Buffer.from(value); + res.write(buf); + if (resDumpStream) { + resDumpStream.write(buf); + } + sseBuf += buf.toString('utf8'); + let nl: number; + while ((nl = sseBuf.indexOf('\n')) >= 0) { + const line = sseBuf.slice(0, nl).trimEnd(); + sseBuf = sseBuf.slice(nl + 1); + if (line.startsWith('event:')) { + const ev = line.slice('event:'.length).trim(); + eventCounts[ev] = (eventCounts[ev] ?? 0) + 1; + } + } + } + } + } finally { + try { reader.releaseLock(); } catch { /* ignore */ } + resDumpStream?.end(); + } + if (Object.keys(eventCounts).length) { + const summary = Object.entries(eventCounts).map(([k, v]) => `${k}=${v}`).join(', '); + this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< SSE event counts: ${summary}`); + } + res.end(); + } catch (err) { + if (entry.clientGone) { + this._logService.info(`[${PROXY_USER_FACING_NAME}] client disconnected during upstream call`); + return; + } + if (err instanceof CopilotApiError) { + this._logService.error(`[${PROXY_USER_FACING_NAME}] CAPI error: status=${err.status}, message=${err.message}`); + writeJsonError(res, err.status, 'api_error', err.message); + return; + } + this._logService.error(`[${PROXY_USER_FACING_NAME}] upstream error: ${err instanceof Error ? err.message : String(err)}`); + writeJsonError(res, 502, 'api_error', err instanceof Error ? err.message : String(err)); + } finally { + res.removeListener('close', onClose); + runtime.inFlight.delete(entry); + } + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts new file mode 100644 index 0000000000000..77a590ea18f7f --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../base/common/uuid.js'; +import { MessageKind, ResponsePartKind, type Turn, type ResponsePart } from '../../common/state/sessionState.js'; +import type { Thread } from './protocol/generated/v2/Thread.js'; +import type { ThreadItem } from './protocol/generated/v2/ThreadItem.js'; +import type { Turn as CodexTurn } from './protocol/generated/v2/Turn.js'; +import { turnStateFromStatus } from './codexMapAppServerEvents.js'; + +/** + * Reconstruct protocol {@link Turn}s from codex's `thread/read` response. + * + * Codex stores each conversation as a stream of {@link CodexTurn}, each + * with an array of {@link ThreadItem}s. We collapse that into the agent + * host's turn shape: each user message opens a turn; subsequent assistant + * items become response parts on that turn until `turn/completed` closes it. + * + * Phase 3 produces: + * - `userMessage` → opens a `Turn` with `userMessage: { text }` + * - `agentMessage` → `MarkdownResponsePart` with the full text + * - everything else → currently dropped (Phase 6 will add tool/reasoning) + * + * Mirrors the live mapper's translation kernel so restored sessions render + * identically to active ones. + */ +export function replayThreadToTurns(thread: Thread): Turn[] { + const turns: Turn[] = []; + for (const codexTurn of thread.turns ?? []) { + const turn = replayTurnToTurn(codexTurn); + if (turn) { + turns.push(turn); + } + } + return turns; +} + +function replayTurnToTurn(codexTurn: CodexTurn): Turn | undefined { + let userText = ''; + const parts: ResponsePart[] = []; + for (const item of codexTurn.items ?? []) { + if (item.type === 'userMessage') { + const collected: string[] = []; + for (const c of item.content) { + if (c.type === 'text') { + collected.push(c.text); + } + } + if (collected.length > 0) { + userText = collected.join('\n\n'); + } + } else if (item.type === 'agentMessage') { + if (item.text && item.text.length > 0) { + parts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: item.text, + }); + } + } + // Other item types (plan/reasoning/commandExecution/fileChange/…) + // are deferred to Phase 6. + } + // If we got nothing recognizable, drop the turn — there's nothing for + // the UI to render. + if (!userText && parts.length === 0) { + return undefined; + } + return { + id: codexTurn.id, + message: { text: userText, origin: { kind: MessageKind.User } }, + responseParts: parts, + usage: undefined, + state: turnStateFromStatus(codexTurn.status), + }; +} diff --git a/src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts b/src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts new file mode 100644 index 0000000000000..7eebe0a7e025d --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ReasoningEffort } from './protocol/generated/ReasoningEffort.js'; +import type { WebSearchMode } from './protocol/generated/WebSearchMode.js'; +import type { AskForApproval } from './protocol/generated/v2/AskForApproval.js'; +import type { SandboxMode } from './protocol/generated/v2/SandboxMode.js'; + +export const enum CodexSessionConfigKey { + ApprovalPolicy = 'codex.approvalPolicy', + SandboxMode = 'codex.sandboxMode', + AdditionalDirectories = 'codex.additionalDirectories', + NetworkAccessEnabled = 'codex.networkAccessEnabled', + WebSearchMode = 'codex.webSearchMode', + ModelReasoningEffort = 'codex.modelReasoningEffort', +} + +export type CodexApprovalPolicy = Extract; + +export function isCodexSupportedModel(id: string, name?: string): boolean { + if (id.toLowerCase() === 'auto') { + return false; + } + return /^(gpt-5|codex)/i.test(id) || /codex/i.test(name ?? ''); +} + +export function normalizeCodexModelId(id: string): string | undefined { + if (isCodexSupportedModel(id)) { + return id; + } + const slashIndex = id.lastIndexOf('/'); + if (slashIndex === -1 || slashIndex === id.length - 1) { + return undefined; + } + const rawId = id.substring(slashIndex + 1); + return isCodexSupportedModel(rawId) ? rawId : undefined; +} + +export function narrowApprovalPolicy(value: unknown): CodexApprovalPolicy | undefined { + switch (value) { + case 'never': + case 'on-request': + case 'on-failure': + case 'untrusted': + return value; + default: + return undefined; + } +} + +export function narrowSandboxMode(value: unknown): SandboxMode | undefined { + switch (value) { + case 'read-only': + case 'workspace-write': + case 'danger-full-access': + return value; + default: + return undefined; + } +} + +export function narrowAdditionalDirectories(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); +} + +export function narrowBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +export function narrowWebSearchMode(value: unknown): WebSearchMode | undefined { + switch (value) { + case 'disabled': + case 'cached': + case 'live': + return value; + default: + return undefined; + } +} + +export function narrowReasoningEffort(value: unknown): ReasoningEffort | undefined { + switch (value) { + case 'none': + case 'minimal': + case 'low': + case 'medium': + case 'high': + case 'xhigh': + return value; + default: + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts b/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts new file mode 100644 index 0000000000000..14ded96e49e77 --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; + +/** + * Per-session bookkeeping codex needs to persist across agent host + * restarts. The fundamental tension this store resolves: codex's + * `thread/start` mints the canonical thread id server-side, but the + * workbench owns the chat session URI and refuses to accept a different + * one back from `createSession`. We therefore keep a stable mapping + * `workbench session URI ↔ codex thread id` here so restored sessions + * can be resumed without leaking duplicate sidebar entries. + * + * Layout (per-session SQLite DB, opened via {@link ISessionDataService}): + * `codex.threadId` — the codex app-server thread id assigned at + * materialize time. + * `codex.cwd` — absolute path to the working directory the + * session was created against (URI string). + * `codex.model` — serialized {@link ModelSelection.id} string, + * remembered for restore so resumed sessions reuse + * the model picked during the prior process. + */ + +export interface ICodexSessionOverlay { + readonly threadId?: string; + readonly cwd?: URI; + readonly modelId?: string; +} + +export interface ICodexSessionOverlayUpdate { + readonly threadId?: string; + readonly cwd?: URI; + readonly modelId?: string; +} + +export class CodexSessionMetadataStore { + + private static readonly KEY_THREAD_ID = 'codex.threadId'; + private static readonly KEY_CWD = 'codex.cwd'; + private static readonly KEY_MODEL = 'codex.model'; + + constructor( + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { } + + /** + * Persist the supplied overlay fields. Only-write-on-defined. + * Best-effort: failures are logged and swallowed because the caller + * has already committed in-memory state and a corrupt DB shouldn't + * abort the current turn. + */ + async write(session: URI, fields: ICodexSessionOverlayUpdate): Promise { + try { + const ref = this._sessionDataService.openDatabase(session); + const db = ref.object; + try { + const work: Promise[] = []; + if (fields.threadId !== undefined) { + work.push(db.setMetadata(CodexSessionMetadataStore.KEY_THREAD_ID, fields.threadId)); + } + if (fields.cwd !== undefined) { + work.push(db.setMetadata(CodexSessionMetadataStore.KEY_CWD, fields.cwd.toString())); + } + if (fields.modelId !== undefined) { + work.push(db.setMetadata(CodexSessionMetadataStore.KEY_MODEL, fields.modelId)); + } + await Promise.all(work); + } finally { + ref.dispose(); + } + } catch (err) { + this._logService.warn(`[Codex] metadata write failed for ${session.toString()}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** + * Read overlay fields for `session`. Returns `{}` when no DB has + * been created yet (fresh session, or external codex CLI thread the + * workbench has never touched). + */ + async read(session: URI): Promise { + try { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return {}; + } + try { + const [threadId, cwdRaw, modelId] = await Promise.all([ + ref.object.getMetadata(CodexSessionMetadataStore.KEY_THREAD_ID), + ref.object.getMetadata(CodexSessionMetadataStore.KEY_CWD), + ref.object.getMetadata(CodexSessionMetadataStore.KEY_MODEL), + ]); + return { + threadId: threadId ?? undefined, + cwd: cwdRaw ? URI.parse(cwdRaw) : undefined, + modelId: modelId ?? undefined, + }; + } finally { + ref.dispose(); + } + } catch (err) { + this._logService.warn(`[Codex] metadata read failed for ${session.toString()}: ${err instanceof Error ? err.message : String(err)}`); + return {}; + } + } +} diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 733472086387e..c5373682c3c51 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentBinaryPathEnvVar, AgentHostCodexAgentBinaryPathSettingId, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -86,6 +86,23 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte env[AgentHostClaudeSdkPathEnvVar] = claudeSdkPath; } + // Codex agent is opt-in via `chat.agentHost.codexAgent.path`. Mirrors the + // Claude opt-in pattern above. + const codexBinaryPath = this._configurationService.getValue(AgentHostCodexAgentBinaryPathSettingId) + || process.env[AgentHostCodexAgentBinaryPathEnvVar] + || ''; + if (codexBinaryPath) { + env[AgentHostCodexAgentBinaryPathEnvVar] = codexBinaryPath; + } + const codexHome = this._configurationService.getValue(AgentHostCodexAgentCodexHomeSettingId) || ''; + if (codexHome) { + env[AgentHostCodexAgentCodexHomeEnvVar] = codexHome; + } + const codexArgs = this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId); + if (Array.isArray(codexArgs) && codexArgs.length > 0) { + env[AgentHostCodexAgentBinaryArgsEnvVar] = JSON.stringify(codexArgs); + } + // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins // (developer override) — see `buildAgentHostOTelEnv`. diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 664cfb296929b..cd9152b3fa904 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -391,6 +391,21 @@ export interface ICopilotApiService { */ models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise; + /** + * Pass-through to CAPI's OpenAI-shaped Responses endpoint + * (`{capiBaseUrl}/responses`). Used by `CodexProxyService` to forward + * `/v1/responses` requests from the Codex CLI without deserializing + * the body. The caller owns the returned `Response` (its body and any + * streaming) and is responsible for consuming or aborting it. + * + * @throws on non-2xx upstream response. + */ + responses( + githubToken: string, + body: string, + options?: ICopilotApiServiceRequestOptions, + ): Promise; + /** * Send arbitrary user chat messages through CAPI's `/chat/completions` * endpoint and return the assistant text. @@ -493,6 +508,51 @@ export class CopilotApiService implements ICopilotApiService { return json.data ?? []; } + async responses( + githubToken: string, + body: string, + options?: ICopilotApiServiceRequestOptions, + ): Promise { + const capiClient = await this._getClientForToken(githubToken); + const requestId = generateUuid(); + + // Parse the request body to log the model being sent (debug aid; failures + // are non-fatal — the body is forwarded byte-for-byte regardless). + let requestModel = ''; + try { + const parsed = JSON.parse(body); + requestModel = parsed.model ?? ''; + } catch { /* ignore parse errors */ } + this._logService.info(`[CopilotApiService] POST responses: requestId=${requestId}, model=${requestModel}`); + + const response = await capiClient.makeRequest( + { + method: 'POST', + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${githubToken}`, + 'X-Request-Id': requestId, + 'OpenAI-Intent': 'conversation', + }, + body, + signal: options?.signal, + }, + { type: RequestType.ChatResponses }, + ); + + this._logService.info(`[CopilotApiService] responses status=${response.status}, requestId=${requestId}`); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this._invalidateClientForToken(githubToken); + } + const text = await response.text().catch(() => ''); + throw buildCopilotApiHttpError(response.status, response.statusText, text, 'CAPI responses request failed'); + } + return response; + } + async utilityChatCompletion( githubToken: string, request: ICopilotUtilityChatCompletionRequest, diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 96865293c4cce..4dc9c76488c11 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -8,7 +8,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js'; -import { SessionLifecycle, SessionStatus, TerminalClaimKind, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js'; +import { MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js'; import { ROOT_STATE_URI, StateComponents } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; @@ -291,6 +291,33 @@ suite('SessionStateSubscription', () => { assert.strictEqual((sub.value as SessionState).summary.title, 'Local'); }); + test('server terminal turn action drops stale optimistic turn start', () => { + const sub = createSub(); + sub.handleSnapshot(makeSessionState(sessionUri), 0); + + sub.applyOptimistic({ + type: ActionType.SessionTurnStarted, + turnId: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + + assert.strictEqual((sub.value as SessionState).activeTurn?.id, 'turn-1'); + + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.SessionTurnComplete, turnId: 'turn-1' }, + 1, + undefined, + )); + + assert.deepStrictEqual({ + activeTurn: (sub.value as SessionState).activeTurn, + turns: (sub.value as SessionState).turns.map(turn => ({ id: turn.id, state: turn.state })), + }, { + activeTurn: undefined, + turns: [{ id: 'turn-1', state: TurnState.Complete }], + }); + }); + test('after all pending cleared, value falls through to verifiedValue', () => { const sub = createSub(); sub.handleSnapshot(makeSessionState(sessionUri), 0); @@ -462,19 +489,19 @@ suite('AgentSubscriptionManager', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createManager(): AgentSubscriptionManager { + function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState; fromSeq: number }> = async (resource) => { + subscribedResources.push(resource.toString()); + const key = resource.toString(); + if (key.startsWith('copilot:')) { + return { resource: key, state: makeSessionState(key), fromSeq: 0 }; + } + return { resource: key, state: makeTerminalState(), fromSeq: 0 }; + }): AgentSubscriptionManager { return disposables.add(new AgentSubscriptionManager( 'c1', () => ++seq, noop, - async (resource) => { - subscribedResources.push(resource.toString()); - const key = resource.toString(); - if (key.startsWith('copilot:')) { - return { resource: key, state: makeSessionState(key), fromSeq: 0 }; - } - return { resource: key, state: makeTerminalState(), fromSeq: 0 }; - }, + subscribe, (resource) => { unsubscribedResources.push(resource.toString()); }, @@ -654,4 +681,41 @@ suite('AgentSubscriptionManager', () => { const after = mgr.getSubscriptionUnmanaged(uri); assert.strictEqual(after, undefined); }); + + test('getSubscription retries after a failed subscribe for the same resource', async () => { + let subscribeAttempts = 0; + const mgr = createManager(async resource => { + subscribedResources.push(resource.toString()); + subscribeAttempts++; + if (subscribeAttempts === 1) { + throw new Error('not found yet'); + } + return { resource: resource.toString(), state: makeSessionState(resource.toString(), { summary: { ...makeSessionState(resource.toString()).summary, title: 'Retried' } }), fromSeq: 0 }; + }); + const uri = URI.parse(sessionUri); + + const failedRef = mgr.getSubscription(StateComponents.Session, uri); + await new Promise(r => setTimeout(r, 0)); + + assert.ok(failedRef.object.value instanceof Error); + + const retryRef = mgr.getSubscription(StateComponents.Session, uri); + await new Promise(r => setTimeout(r, 0)); + + assert.deepStrictEqual({ + subscribeAttempts, + retriedTitle: (retryRef.object.value as SessionState).summary.title, + unmanagedIsRetry: mgr.getSubscriptionUnmanaged(uri) === retryRef.object, + }, { + subscribeAttempts: 2, + retriedTitle: 'Retried', + unmanagedIsRetry: true, + }); + + failedRef.dispose(); + assert.strictEqual(mgr.getSubscriptionUnmanaged(uri), retryRef.object); + + retryRef.dispose(); + assert.strictEqual(mgr.getSubscriptionUnmanaged(uri), undefined); + }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 6b9844c8138fc..f2d4079bfcd26 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -259,8 +259,12 @@ class StubCopilotApiService implements ICopilotApiService { return this.availableModels; } + async responses(): Promise { + throw new Error('responses not used in Claude integration tests'); + } + async utilityChatCompletion(): Promise { - throw new Error('utilityChatCompletion not implemented in this test'); + throw new Error('utilityChatCompletion not used in Claude integration tests'); } } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index b0ef070f7f335..90fcd5c29e876 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -114,6 +114,7 @@ class FakeCopilotApiService implements ICopilotApiService { messages(): never { throw new Error('not used in ClaudeAgent tests'); } countTokens(): Promise { throw new Error('not used in ClaudeAgent tests'); } + responses(): Promise { throw new Error('not used in ClaudeAgent tests'); } utilityChatCompletion(): Promise { throw new Error('not used in ClaudeAgent tests'); } } @@ -5018,6 +5019,3 @@ suite('ClaudeAgent — Phase 11 customizations', () => { }); // #endregion - - - diff --git a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts index 3b53ff1509fe9..f9c161ac4c4ee 100644 --- a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts @@ -120,8 +120,12 @@ class FakeCopilotApiService implements ICopilotApiService { return this.modelsResult.value; } + async responses(): Promise { + throw new Error('responses not used by Claude proxy tests'); + } + async utilityChatCompletion(): Promise { - throw new Error('utilityChatCompletion not implemented in this test'); + throw new Error('utilityChatCompletion not used by Claude proxy tests'); } } @@ -1244,6 +1248,7 @@ suite('ClaudeProxyService', () => { }) as ICopilotApiService['messages'], countTokens: () => Promise.reject(new Error('not used')), models: () => Promise.resolve([]), + responses: () => Promise.reject(new Error('not used')), utilityChatCompletion: () => Promise.reject(new Error('not used')), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); @@ -1312,6 +1317,7 @@ suite('ClaudeProxyService', () => { }) as ICopilotApiService['messages'], countTokens: fake.countTokens.bind(fake), models: fake.models.bind(fake), + responses: fake.responses.bind(fake), utilityChatCompletion: fake.utilityChatCompletion.bind(fake), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts new file mode 100644 index 0000000000000..ca0f7dfdbcc73 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -0,0 +1,494 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createCodexSessionMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, turnStateFromStatus } from '../../../node/codex/codexMapAppServerEvents.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolResultContentType, TurnState } from '../../../common/state/sessionState.js'; + +suite('codexMapAppServerEvents', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('turn/started emits SessionTurnStarted with user message text', () => { + const state = createCodexSessionMapState(); + const actions = mapTurnStarted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_a', + items: [{ + type: 'userMessage', + id: 'item_user', + content: [{ type: 'text', text: 'hello', text_elements: [] }], + }], + itemsView: { type: 'full' } as never, + status: 'inProgress' as never, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, 'fallback'); + assert.strictEqual(state.currentTurnId, 'turn_a'); + assert.deepStrictEqual(actions, [{ + type: ActionType.SessionTurnStarted, + turnId: 'turn_a', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }]); + }); + + test('turn/started falls back to provided text when items has no userMessage', () => { + const state = createCodexSessionMapState(); + const actions = mapTurnStarted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_b', + items: [], + itemsView: { type: 'full' } as never, + status: 'inProgress' as never, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, 'the prompt'); + assert.strictEqual((actions[0] as { message: { text: string } }).message.text, 'the prompt'); + }); + + test('item/started for agentMessage seeds a markdown part', () => { + const state = createCodexSessionMapState(); + const actions = mapItemStarted(state, { + item: { type: 'agentMessage', id: 'item_x', text: '', phase: null, memoryCitation: null }, + threadId: 'thr_1', + turnId: 'turn_a', + startedAtMs: 0, + }); + assert.strictEqual(actions.length, 1); + const a = actions[0] as { type: ActionType; turnId: string; part: { kind: ResponsePartKind; id: string; content: string } }; + assert.strictEqual(a.type, ActionType.SessionResponsePart); + assert.strictEqual(a.turnId, 'turn_a'); + assert.strictEqual(a.part.kind, ResponsePartKind.Markdown); + assert.strictEqual(typeof a.part.id, 'string'); + assert.ok(a.part.id.length > 0); + assert.strictEqual(state.itemToPartId.get('item_x'), a.part.id); + }); + + test('item/started for non-agentMessage item is ignored (Phase 2)', () => { + const state = createCodexSessionMapState(); + const actions = mapItemStarted(state, { + item: { type: 'plan', id: 'item_p', text: 'plan text' } as never, + threadId: 'thr_1', + turnId: 'turn_a', + startedAtMs: 0, + }); + assert.deepStrictEqual(actions, []); + assert.strictEqual(state.itemToPartId.size, 0); + }); + + test('item/agentMessage/delta emits SessionDelta for known itemId', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { type: 'agentMessage', id: 'item_x', text: '', phase: null, memoryCitation: null }, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const partId = state.itemToPartId.get('item_x')!; + const actions = mapAgentMessageDelta(state, { + threadId: 'thr_1', + turnId: 'turn_a', + itemId: 'item_x', + delta: 'chunk', + }); + assert.deepStrictEqual(actions, [{ + type: ActionType.SessionDelta, + turnId: 'turn_a', + partId, + content: 'chunk', + }]); + }); + + test('item/agentMessage/delta for unknown itemId is dropped', () => { + const state = createCodexSessionMapState(); + const actions = mapAgentMessageDelta(state, { + threadId: 'thr_1', turnId: 'turn_a', itemId: 'unknown', delta: 'orphan', + }); + assert.deepStrictEqual(actions, []); + }); + + test('item/reasoning summary events seed a reasoning part and stream deltas', () => { + const state = createCodexSessionMapState(); + const start = mapReasoningSummaryPartAdded(state, { + threadId: 'thr_1', turnId: 'turn_a', itemId: 'rs_1', summaryIndex: 0, + }); + const partId = state.itemToReasoningPartId.get('rs_1:summary:0'); + const delta = mapReasoningSummaryTextDelta(state, { + threadId: 'thr_1', turnId: 'turn_a', itemId: 'rs_1', summaryIndex: 0, delta: 'thinking', + }); + assert.deepStrictEqual({ + start: start.map(action => action.type), + partKind: start[0]?.type === ActionType.SessionResponsePart ? start[0].part.kind : undefined, + delta, + }, { + start: [ActionType.SessionResponsePart], + partKind: ResponsePartKind.Reasoning, + delta: [{ type: ActionType.SessionReasoning, turnId: 'turn_a', partId, content: 'thinking' }], + }); + }); + + test('item/reasoning text delta creates a reasoning part when start was missed', () => { + const state = createCodexSessionMapState(); + const actions = mapReasoningTextDelta(state, { + threadId: 'thr_1', turnId: 'turn_a', itemId: 'rs_2', contentIndex: 1, delta: 'raw thought', + }); + const partId = state.itemToReasoningPartId.get('rs_2:text:1'); + assert.deepStrictEqual({ + types: actions.map(action => action.type), + partKind: actions[0]?.type === ActionType.SessionResponsePart ? actions[0].part.kind : undefined, + delta: actions[1], + }, { + types: [ActionType.SessionResponsePart, ActionType.SessionReasoning], + partKind: ResponsePartKind.Reasoning, + delta: { type: ActionType.SessionReasoning, turnId: 'turn_a', partId, content: 'raw thought' }, + }); + }); + + test('thread/tokenUsage/updated emits SessionUsage for the turn', () => { + const actions = mapTokenUsageUpdated({ + threadId: 'thr_1', + turnId: 'turn_a', + tokenUsage: { + last: { inputTokens: 10, cachedInputTokens: 4, outputTokens: 6, reasoningOutputTokens: 2, totalTokens: 16 }, + total: { inputTokens: 100, cachedInputTokens: 40, outputTokens: 60, reasoningOutputTokens: 20, totalTokens: 160 }, + modelContextWindow: 200000, + }, + }); + assert.deepStrictEqual(actions, [{ + type: ActionType.SessionUsage, + turnId: 'turn_a', + usage: { + inputTokens: 10, + outputTokens: 6, + cacheReadTokens: 4, + _meta: { reasoningOutputTokens: 2, modelContextWindow: 200000 }, + }, + }]); + }); + + test('item/completed for agentMessage clears the mapping', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { type: 'agentMessage', id: 'item_x', text: '', phase: null, memoryCitation: null }, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + assert.strictEqual(state.itemToPartId.size, 1); + mapItemCompleted(state, { + item: { type: 'agentMessage', id: 'item_x', text: 'final', phase: null, memoryCitation: null }, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.strictEqual(state.itemToPartId.size, 0); + }); + + test('item/started for commandExecution emits SessionToolCallStart + Delta + Ready and registers tool-call entry', () => { + const state = createCodexSessionMapState(); + const actions = mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_1', + command: 'ls -la', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + assert.strictEqual(actions.length, 3); + const start = actions[0]; + const delta = actions[1]; + const ready = actions[2]; + assert.strictEqual(start.type, ActionType.SessionToolCallStart); + assert.strictEqual(delta.type, ActionType.SessionToolCallDelta); + assert.strictEqual(ready.type, ActionType.SessionToolCallReady); + const entry = state.itemToToolCall.get('cmd_1'); + assert.ok(entry); + assert.strictEqual(entry!.toolCallId, (start as { toolCallId: string }).toolCallId); + assert.strictEqual(entry!.turnId, 'turn_a'); + assert.strictEqual((delta as { content: string }).content, 'ls -la'); + assert.strictEqual((ready as { confirmed: ToolCallConfirmationReason }).confirmed, ToolCallConfirmationReason.NotNeeded); + assert.deepStrictEqual((start as { _meta?: Record })._meta, { toolKind: 'terminal' }); + }); + + test('item/commandExecution/outputDelta streams running tool content', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_output', + command: 'echo hi', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('cmd_output')!.toolCallId; + const first = mapCommandExecutionOutputDelta(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'cmd_output', delta: 'hi' }); + const second = mapCommandExecutionOutputDelta(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'cmd_output', delta: '\n' }); + assert.deepStrictEqual({ first, second }, { + first: [{ type: ActionType.SessionToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'hi' }] }], + second: [{ type: ActionType.SessionToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'hi\n' }] }], + }); + }); + + test('item/completed for commandExecution emits SessionToolCallComplete with aggregated output', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_2', + command: 'echo hi', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('cmd_2')!.toolCallId; + const actions = mapItemCompleted(state, { + item: { + type: 'commandExecution', id: 'cmd_2', + command: 'echo hi', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'completed' as never, + commandActions: [], aggregatedOutput: 'hi\n', + exitCode: 0, durationMs: 12, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.strictEqual(actions.length, 1); + const complete = actions[0] as { type: ActionType; toolCallId: string; result: { success: boolean; content?: { type: ToolResultContentType; text: string }[] } }; + assert.strictEqual(complete.type, ActionType.SessionToolCallComplete); + assert.strictEqual(complete.toolCallId, toolCallId); + assert.strictEqual(complete.result.success, true); + assert.deepStrictEqual(complete.result.content, [{ type: ToolResultContentType.Text, text: 'hi\n' }]); + assert.strictEqual(state.itemToToolCall.size, 0); + }); + + test('item/completed for commandExecution with non-zero exit reports failure', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_3', + command: 'false', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const actions = mapItemCompleted(state, { + item: { + type: 'commandExecution', id: 'cmd_3', + command: 'false', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'completed' as never, + commandActions: [], aggregatedOutput: '', + exitCode: 1, durationMs: 3, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + const complete = actions[0] as { result: { success: boolean; error?: { message: string } } }; + assert.strictEqual(complete.result.success, false); + assert.strictEqual(complete.result.error?.message, 'Exit code 1'); + }); + + test('webSearch item maps to search tool call lifecycle', () => { + const state = createCodexSessionMapState(); + const startActions = mapItemStarted(state, { + item: { + type: 'webSearch', id: 'web_1', query: 'vscode tests', + action: { type: 'search', query: 'vscode tests', queries: null }, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('web_1')!.toolCallId; + const completeActions = mapItemCompleted(state, { + item: { + type: 'webSearch', id: 'web_1', query: 'vscode tests', + action: { type: 'search', query: 'vscode tests', queries: null }, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.deepStrictEqual({ + startTypes: startActions.map(action => action.type), + startMeta: startActions[0]?.type === ActionType.SessionToolCallStart ? startActions[0]._meta : undefined, + delta: startActions[1], + ready: startActions[2], + complete: completeActions, + remainingToolCalls: state.itemToToolCall.size, + }, { + startTypes: [ActionType.SessionToolCallStart, ActionType.SessionToolCallDelta, ActionType.SessionToolCallReady], + startMeta: { toolKind: 'search' }, + delta: { type: ActionType.SessionToolCallDelta, turnId: 'turn_a', toolCallId, content: 'vscode tests' }, + ready: { type: ActionType.SessionToolCallReady, turnId: 'turn_a', toolCallId, invocationMessage: 'vscode tests', toolInput: 'vscode tests', confirmed: ToolCallConfirmationReason.NotNeeded, _meta: { toolKind: 'search' } }, + complete: [{ type: ActionType.SessionToolCallComplete, turnId: 'turn_a', toolCallId, result: { success: true, pastTenseMessage: 'Searched vscode tests' } }], + remainingToolCalls: 0, + }); + }); + + test('fileChange item maps to file edit tool call lifecycle', () => { + const state = createCodexSessionMapState(); + const changes = [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '@@ -1 +1 @@\n-old\n+new' }] as const; + const startActions = mapItemStarted(state, { + item: { type: 'fileChange', id: 'file_1', changes, status: 'inProgress' } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('file_1')!.toolCallId; + const patchActions = mapFileChangePatchUpdated(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'file_1', changes: [{ path: 'src/b.ts', kind: { type: 'add' }, diff: '+hello' }] }); + const completeActions = mapItemCompleted(state, { + item: { type: 'fileChange', id: 'file_1', changes, status: 'completed' } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.deepStrictEqual({ + startTypes: startActions.map(action => action.type), + delta: startActions[1], + ready: startActions[2], + initialContent: startActions[3], + patchActions, + completeActions, + remainingToolCalls: state.itemToToolCall.size, + }, { + startTypes: [ActionType.SessionToolCallStart, ActionType.SessionToolCallDelta, ActionType.SessionToolCallReady, ActionType.SessionToolCallContentChanged], + delta: { type: ActionType.SessionToolCallDelta, turnId: 'turn_a', toolCallId, content: 'update: src/a.ts' }, + ready: { type: ActionType.SessionToolCallReady, turnId: 'turn_a', toolCallId, invocationMessage: 'update: src/a.ts', toolInput: 'update: src/a.ts', confirmed: ToolCallConfirmationReason.NotNeeded }, + initialContent: { type: ActionType.SessionToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'update: src/a.ts\n@@ -1 +1 @@\n-old\n+new' }] }, + patchActions: [{ type: ActionType.SessionToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'add: src/b.ts\n+hello' }] }], + completeActions: [{ type: ActionType.SessionToolCallComplete, turnId: 'turn_a', toolCallId, result: { success: true, pastTenseMessage: 'Applied file changes', content: [{ type: ToolResultContentType.Text, text: 'update: src/a.ts\n@@ -1 +1 @@\n-old\n+new' }] } }], + remainingToolCalls: 0, + }); + }); + + test('mcpToolCall item maps to tool call lifecycle with progress', () => { + const state = createCodexSessionMapState(); + const startActions = mapItemStarted(state, { + item: { type: 'mcpToolCall', id: 'mcp_1', server: 'github', tool: 'search', status: 'inProgress', arguments: { query: 'vscode' }, mcpAppResourceUri: undefined, pluginId: null, result: null, error: null, durationMs: null } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('mcp_1')!.toolCallId; + const progressActions = mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message: 'Searching' }); + const completeActions = mapItemCompleted(state, { + item: { type: 'mcpToolCall', id: 'mcp_1', server: 'github', tool: 'search', status: 'completed', arguments: { query: 'vscode' }, mcpAppResourceUri: undefined, pluginId: null, result: { content: ['done'], structuredContent: { count: 1 }, _meta: null }, error: null, durationMs: 5 } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.deepStrictEqual({ + startTypes: startActions.map(action => action.type), + delta: startActions[1], + ready: startActions[2], + progressActions, + completeActions, + remainingToolCalls: state.itemToToolCall.size, + }, { + startTypes: [ActionType.SessionToolCallStart, ActionType.SessionToolCallDelta, ActionType.SessionToolCallReady], + delta: { type: ActionType.SessionToolCallDelta, turnId: 'turn_a', toolCallId, content: '{\n "query": "vscode"\n}' }, + ready: { type: ActionType.SessionToolCallReady, turnId: 'turn_a', toolCallId, invocationMessage: 'Calling github.search', toolInput: '{\n "query": "vscode"\n}', confirmed: ToolCallConfirmationReason.NotNeeded }, + progressActions: [{ type: ActionType.SessionToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'Searching' }] }], + completeActions: [{ type: ActionType.SessionToolCallComplete, turnId: 'turn_a', toolCallId, result: { success: true, pastTenseMessage: 'Called github.search', content: [{ type: ToolResultContentType.Text, text: 'done\n{\n "count": 1\n}' }] } }], + remainingToolCalls: 0, + }); + }); + + test('dynamicToolCall item maps to tool call lifecycle', () => { + const state = createCodexSessionMapState(); + const startActions = mapItemStarted(state, { + item: { type: 'dynamicToolCall', id: 'dyn_1', namespace: 'client', tool: 'lookup', arguments: { symbol: 'A' }, status: 'inProgress', contentItems: null, success: null, durationMs: null } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('dyn_1')!.toolCallId; + const completeActions = mapItemCompleted(state, { + item: { type: 'dynamicToolCall', id: 'dyn_1', namespace: 'client', tool: 'lookup', arguments: { symbol: 'A' }, status: 'completed', contentItems: [{ type: 'inputText', text: 'Found A' }, { type: 'inputImage', imageUrl: 'https://example.test/a.png' }], success: true, durationMs: 5 } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.deepStrictEqual({ + startTypes: startActions.map(action => action.type), + delta: startActions[1], + ready: startActions[2], + completeActions, + remainingToolCalls: state.itemToToolCall.size, + }, { + startTypes: [ActionType.SessionToolCallStart, ActionType.SessionToolCallDelta, ActionType.SessionToolCallReady], + delta: { type: ActionType.SessionToolCallDelta, turnId: 'turn_a', toolCallId, content: '{\n "symbol": "A"\n}' }, + ready: { type: ActionType.SessionToolCallReady, turnId: 'turn_a', toolCallId, invocationMessage: 'Calling client.lookup', toolInput: '{\n "symbol": "A"\n}', confirmed: ToolCallConfirmationReason.NotNeeded }, + completeActions: [{ type: ActionType.SessionToolCallComplete, turnId: 'turn_a', toolCallId, result: { success: true, pastTenseMessage: 'Called client.lookup', content: [{ type: ToolResultContentType.Text, text: 'Found A\nhttps://example.test/a.png' }] } }], + remainingToolCalls: 0, + }); + }); + + test('turn/completed with status=completed emits SessionTurnComplete', () => { + const state = createCodexSessionMapState(); + state.currentTurnId = 'turn_a'; + const actions = mapTurnCompleted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_a', + items: [], itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, startedAt: null, completedAt: null, durationMs: null, + }, + }); + assert.deepStrictEqual(actions, [{ type: ActionType.SessionTurnComplete, turnId: 'turn_a' }]); + assert.strictEqual(state.currentTurnId, undefined); + }); + + test('turn/completed completes orphaned tool calls before completing the turn', () => { + const state = createCodexSessionMapState(); + state.itemToToolCall.set('cmd_1', { toolCallId: 'tc_1', turnId: 'turn_a', toolName: 'shell', output: 'partial output' }); + const actions = mapTurnCompleted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_a', items: [], itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, startedAt: null, completedAt: null, durationMs: null, + }, + }); + assert.deepStrictEqual({ actions, remainingToolCalls: state.itemToToolCall.size }, { + actions: [ + { type: ActionType.SessionToolCallComplete, turnId: 'turn_a', toolCallId: 'tc_1', result: { success: false, pastTenseMessage: 'Stopped shell', content: [{ type: ToolResultContentType.Text, text: 'partial output' }], error: { message: 'Turn completed before the tool reported completion' } } }, + { type: ActionType.SessionTurnComplete, turnId: 'turn_a' }, + ], + remainingToolCalls: 0, + }); + }); + + test('turn/completed with status=failed emits SessionError + SessionTurnComplete', () => { + const state = createCodexSessionMapState(); + const actions = mapTurnCompleted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_a', items: [], itemsView: { type: 'full' } as never, + status: 'failed' as never, + error: { message: 'boom' } as never, + startedAt: null, completedAt: null, durationMs: null, + }, + }); + assert.strictEqual(actions.length, 2); + assert.strictEqual((actions[0] as { type: ActionType }).type, ActionType.SessionError); + assert.strictEqual((actions[1] as { type: ActionType }).type, ActionType.SessionTurnComplete); + }); + + test('turn/completed with status=interrupted emits SessionTurnCancelled', () => { + const state = createCodexSessionMapState(); + const actions = mapTurnCompleted(state, { + threadId: 'thr_1', + turn: { + id: 'turn_a', items: [], itemsView: { type: 'full' } as never, + status: 'interrupted' as never, + error: null, startedAt: null, completedAt: null, durationMs: null, + }, + }); + assert.strictEqual(actions.length, 1); + assert.strictEqual((actions[0] as { type: ActionType }).type, ActionType.SessionTurnCancelled); + }); + + test('turnStateFromStatus maps strings correctly', () => { + assert.strictEqual(turnStateFromStatus('completed'), TurnState.Complete); + assert.strictEqual(turnStateFromStatus('interrupted'), TurnState.Cancelled); + assert.strictEqual(turnStateFromStatus('failed'), TurnState.Error); + assert.strictEqual(turnStateFromStatus('weird'), TurnState.Complete); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts new file mode 100644 index 0000000000000..c1469fc947b7d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexPromptResolver.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as fs from 'fs'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../../../common/state/sessionState.js'; +import { resolveCodexInput } from '../../../node/codex/codexPromptResolver.js'; + +suite('codexPromptResolver', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('plain prompt becomes a single text input', () => { + const { input, cleanupPaths } = resolveCodexInput('hello world', undefined); + assert.strictEqual(input.length, 1); + assert.strictEqual(input[0].type, 'text'); + assert.strictEqual((input[0] as { text: string }).text, 'hello world'); + assert.strictEqual(cleanupPaths.length, 0); + }); + + test('Resource (file:) attachment becomes @ mention', () => { + const uri = URI.file('/tmp/foo.txt'); + const att: MessageAttachment = { + type: MessageAttachmentKind.Resource, + label: 'foo.txt', + uri: uri.toString(), + } as MessageAttachment; + const { input } = resolveCodexInput('look at this', [att]); + assert.strictEqual(input.length, 1); + const text = (input[0] as { text: string }).text; + assert.ok(text.includes(`@${uri.fsPath}`), `text: ${text}`); + assert.ok(text.includes('look at this')); + }); + + test('Simple attachment with modelRepresentation is appended', () => { + const att: MessageAttachment = { + type: MessageAttachmentKind.Simple, + label: 'meta', + modelRepresentation: 'extra context', + } as MessageAttachment; + const { input } = resolveCodexInput('top', [att]); + const text = (input[0] as { text: string }).text; + assert.ok(text.includes('top')); + assert.ok(text.includes('extra context')); + }); + + test('EmbeddedResource image becomes localImage and tracks cleanup', () => { + const att: MessageAttachment = { + type: MessageAttachmentKind.EmbeddedResource, + label: 'pic', + data: Buffer.from('fake-png-bytes').toString('base64'), + contentType: 'image/png', + } as MessageAttachment; + const { input, cleanupPaths } = resolveCodexInput('see image', [att]); + assert.strictEqual(cleanupPaths.length, 1); + const imageItem = input.find(i => i.type === 'localImage') as { type: 'localImage'; path: string }; + assert.ok(imageItem, 'expected localImage item'); + assert.ok(imageItem.path.endsWith('.png')); + // Cleanup so the test doesn't leak the tmp file. + try { fs.unlinkSync(cleanupPaths[0]); } catch { /* ignore */ } + }); + + test('non-image EmbeddedResource is dropped silently', () => { + const att: MessageAttachment = { + type: MessageAttachmentKind.EmbeddedResource, + label: 'pdf', + data: 'ZmFrZQ==', + contentType: 'application/pdf', + } as MessageAttachment; + const { input, cleanupPaths } = resolveCodexInput('', [att]); + assert.strictEqual(cleanupPaths.length, 0); + assert.strictEqual(input.length, 1); + assert.strictEqual(input[0].type, 'text'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts new file mode 100644 index 0000000000000..eeb4cae3833ba --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { replayThreadToTurns } from '../../../node/codex/codexReplayMapper.js'; +import { ResponsePartKind, TurnState } from '../../../common/state/sessionState.js'; + +suite('codexReplayMapper', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('empty thread → no turns', () => { + const turns = replayThreadToTurns({ id: 'thr', turns: [] } as never); + assert.deepStrictEqual(turns, []); + }); + + test('thread with one user/agent exchange → one Turn', () => { + const turns = replayThreadToTurns({ + id: 'thr', + turns: [{ + id: 'turn_a', + items: [ + { type: 'userMessage', id: 'u1', content: [{ type: 'text', text: 'hi', text_elements: [] }] }, + { type: 'agentMessage', id: 'a1', text: 'hello back', phase: null, memoryCitation: null }, + ], + itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, + startedAt: null, completedAt: null, durationMs: null, + }], + } as never); + assert.strictEqual(turns.length, 1); + assert.strictEqual(turns[0].id, 'turn_a'); + assert.strictEqual(turns[0].message.text, 'hi'); + assert.strictEqual(turns[0].state, TurnState.Complete); + assert.strictEqual(turns[0].responseParts.length, 1); + const part = turns[0].responseParts[0]; + assert.strictEqual(part.kind, ResponsePartKind.Markdown); + assert.strictEqual((part as { content: string }).content, 'hello back'); + }); + + test('failed turn maps to TurnState.Error', () => { + const turns = replayThreadToTurns({ + id: 'thr', + turns: [{ + id: 'turn_a', + items: [ + { type: 'userMessage', id: 'u1', content: [{ type: 'text', text: 'q', text_elements: [] }] }, + ], + itemsView: { type: 'full' } as never, + status: 'failed' as never, + error: { message: 'oops' } as never, + startedAt: null, completedAt: null, durationMs: null, + }], + } as never); + assert.strictEqual(turns.length, 1); + assert.strictEqual(turns[0].state, TurnState.Error); + }); + + test('turn with no recognizable items is dropped', () => { + const turns = replayThreadToTurns({ + id: 'thr', + turns: [{ + id: 'turn_a', + items: [ + { type: 'plan', id: 'p', text: 'planning' } as never, + ], + itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, + startedAt: null, completedAt: null, durationMs: null, + }], + } as never); + assert.deepStrictEqual(turns, []); + }); + + test('multi-turn thread preserves order', () => { + const turns = replayThreadToTurns({ + id: 'thr', + turns: [ + { + id: 't1', + items: [ + { type: 'userMessage', id: 'u', content: [{ type: 'text', text: 'first', text_elements: [] }] }, + { type: 'agentMessage', id: 'a', text: 'one', phase: null, memoryCitation: null }, + ], + itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, startedAt: null, completedAt: null, durationMs: null, + }, + { + id: 't2', + items: [ + { type: 'userMessage', id: 'u2', content: [{ type: 'text', text: 'second', text_elements: [] }] }, + { type: 'agentMessage', id: 'a2', text: 'two', phase: null, memoryCitation: null }, + ], + itemsView: { type: 'full' } as never, + status: 'completed' as never, + error: null, startedAt: null, completedAt: null, durationMs: null, + }, + ], + } as never); + assert.deepStrictEqual(turns.map(t => t.id), ['t1', 't2']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts new file mode 100644 index 0000000000000..3425f6d8b5cc1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CodexSessionConfigKey, isCodexSupportedModel, narrowAdditionalDirectories, narrowApprovalPolicy, narrowBoolean, narrowReasoningEffort, narrowSandboxMode, narrowWebSearchMode, normalizeCodexModelId } from '../../../node/codex/codexSessionConfigKeys.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { CodexAgent } from '../../../node/codex/codexAgent.js'; +import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; +import { IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; +import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; + +function createAgent(disposables: Pick): CodexAgent { + const instantiationService = new TestInstantiationService(); + instantiationService.stub(ISessionDataService, { _serviceBrand: undefined }); + instantiationService.stub(ICopilotApiService, { _serviceBrand: undefined }); + instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); + instantiationService.stub(IAgentConfigurationService, { _serviceBrand: undefined }); + instantiationService.stub(ILogService, new NullLogService()); + return disposables.add(instantiationService.createInstance(CodexAgent)); +} + +suite('codexSessionConfigKeys', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('narrows valid values and rejects invalid values', () => { + assert.deepStrictEqual({ + approvalPolicy: [narrowApprovalPolicy('never'), narrowApprovalPolicy('on-request'), narrowApprovalPolicy('nope')], + sandboxMode: [narrowSandboxMode('read-only'), narrowSandboxMode('workspace-write'), narrowSandboxMode('folder')], + additionalDirectories: [narrowAdditionalDirectories(['/tmp/a', '', 1, '/tmp/b']), narrowAdditionalDirectories('nope')], + boolean: [narrowBoolean(true), narrowBoolean(false), narrowBoolean('true')], + webSearchMode: [narrowWebSearchMode('disabled'), narrowWebSearchMode('cached'), narrowWebSearchMode('online')], + reasoningEffort: [narrowReasoningEffort('minimal'), narrowReasoningEffort('medium'), narrowReasoningEffort('max')], + }, { + approvalPolicy: ['never', 'on-request', undefined], + sandboxMode: ['read-only', 'workspace-write', undefined], + additionalDirectories: [['/tmp/a', '/tmp/b'], undefined], + boolean: [true, false, undefined], + webSearchMode: ['disabled', 'cached', undefined], + reasoningEffort: ['minimal', 'medium', undefined], + }); + }); + + test('filters Codex models to supported OpenAI model ids', () => { + assert.deepStrictEqual([ + isCodexSupportedModel('auto', 'Codex Auto'), + isCodexSupportedModel('claude-sonnet-4.5', 'Claude Sonnet 4.5'), + isCodexSupportedModel('gpt-5.2', 'GPT-5.2'), + isCodexSupportedModel('gpt-5.1-codex-max', 'GPT-5.1 Codex Max'), + isCodexSupportedModel('codex-mini-latest', 'Codex Mini'), + ], [false, false, true, true, true]); + }); + + test('normalizes provider-prefixed Codex model ids', () => { + assert.deepStrictEqual({ + raw: normalizeCodexModelId('gpt-5.2'), + prefixed: normalizeCodexModelId('copilot/gpt-5.2'), + unsupportedRaw: normalizeCodexModelId('claude-sonnet-4.5'), + unsupportedPrefixed: normalizeCodexModelId('copilot/auto'), + }, { + raw: 'gpt-5.2', + prefixed: 'gpt-5.2', + unsupportedRaw: undefined, + unsupportedPrefixed: undefined, + }); + }); + + test('resolveSessionConfig scopes Codex-specific config properties', async () => { + const agent = createAgent(disposables); + + const readOnly = await agent.resolveSessionConfig({ config: { [CodexSessionConfigKey.SandboxMode]: 'read-only' } }); + const workspaceWrite = await agent.resolveSessionConfig({ config: { [CodexSessionConfigKey.SandboxMode]: 'workspace-write' } }); + + assert.deepStrictEqual({ + readOnlyProperties: Object.keys(readOnly.schema.properties).filter(key => key.startsWith('codex.')).sort(), + readOnlyValues: readOnly.values, + workspaceWriteProperties: Object.keys(workspaceWrite.schema.properties).filter(key => key.startsWith('codex.')).sort(), + workspaceWriteValues: { + additionalDirectories: workspaceWrite.values[CodexSessionConfigKey.AdditionalDirectories], + networkAccessEnabled: workspaceWrite.values[CodexSessionConfigKey.NetworkAccessEnabled], + }, + }, { + readOnlyProperties: [ + CodexSessionConfigKey.ApprovalPolicy, + CodexSessionConfigKey.SandboxMode, + CodexSessionConfigKey.WebSearchMode, + ].sort(), + readOnlyValues: { + [CodexSessionConfigKey.ApprovalPolicy]: 'on-request', + [CodexSessionConfigKey.SandboxMode]: 'read-only', + [CodexSessionConfigKey.WebSearchMode]: 'disabled', + }, + workspaceWriteProperties: [ + CodexSessionConfigKey.ApprovalPolicy, + CodexSessionConfigKey.NetworkAccessEnabled, + CodexSessionConfigKey.SandboxMode, + CodexSessionConfigKey.WebSearchMode, + ].sort(), + workspaceWriteValues: { + additionalDirectories: undefined, + networkAccessEnabled: false, + }, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts new file mode 100644 index 0000000000000..e18a66f9184ba --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Codex app-server integration tests. + * + * Disabled by default. To run, set `AGENT_HOST_REAL_CODEX=1`. The Codex CLI + * is resolved automatically from the dev dependency in + * `node_modules/@openai/codex`. + * + * AGENT_HOST_REAL_CODEX=1 ./scripts/test-integration.sh --run \ + * src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts + * + * **Authentication:** token from `GITHUB_TOKEN` (preferred) or `gh auth + * token`. The agent host's Codex proxy forwards the app-server's Responses API + * traffic to Copilot CAPI using that token. + */ + +import { existsSync } from 'fs'; +import { join } from '../../../../../base/common/path.js'; +import { defineSharedRealSdkTests, type IRealSdkProviderConfig } from './realSdkTestHelpers.js'; + +const REAL_CODEX_ENABLED = process.env['AGENT_HOST_REAL_CODEX'] === '1'; + +function resolveCodexBinaryPath(): string | undefined { + const candidate = join(process.cwd(), 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); + return existsSync(candidate) ? candidate : undefined; +} + +const CODEX_BINARY_PATH = REAL_CODEX_ENABLED ? resolveCodexBinaryPath() : undefined; + +const CODEX_CONFIG: IRealSdkProviderConfig = { + suiteTitle: 'Protocol WebSocket - Real Codex App Server', + provider: 'codex', + scheme: 'codex', + shellToolName: 'shell', + subagentToolNames: [], + exitPlanModeToolName: 'exit_plan_mode', + enabled: REAL_CODEX_ENABLED && !!CODEX_BINARY_PATH, + codexBinaryPath: CODEX_BINARY_PATH, + supportsWorktreeIsolation: false, + supportsSubagents: false, + supportsPlanMode: false, +}; + +defineSharedRealSdkTests(CODEX_CONFIG); diff --git a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts index 8ed86b2aedc4b..7ebabee72a65f 100644 --- a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts @@ -103,6 +103,8 @@ export interface IRealSdkProviderConfig { * the Claude provider. */ readonly claudeSdkPath?: string; + /** Optional path to a locally installed `codex` binary. Forwarded to `startRealServer`. */ + readonly codexBinaryPath?: string; /** * Provider implements `config.isolation: 'worktree'` and resolves the * working directory to a `.worktrees/...` path on materialization. @@ -307,6 +309,9 @@ export async function driveTurnToCompletion(c: TestProtocolClient, session: stri continue; } + + const action = getActionEnvelope(notification).action as { turnId: string }; + assert.strictEqual(action.turnId, turnId); break; } @@ -469,7 +474,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { setup(async function () { this.timeout(60_000); - server = await startRealServer({ claudeSdkPath: config.claudeSdkPath }); + server = await startRealServer({ claudeSdkPath: config.claudeSdkPath, codexBinaryPath: config.codexBinaryPath }); client = new TestProtocolClient(server.port); await client.connect(); }); @@ -510,7 +515,9 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const sessionUri = await createRealSession(client, config, `real-sdk-simple-${config.provider}`, createdSessions, URI.file(tmpdir()).toString()); dispatchTurn(client, sessionUri, 'turn-1', 'Say exactly "hello" and nothing else', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); + const complete = await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); + const completeAction = getActionEnvelope(complete).action as { turnId: string }; + assert.strictEqual(completeAction.turnId, 'turn-1'); const responseParts = client.receivedNotifications(n => isActionNotification(n, 'session/responsePart')); assert.ok(responseParts.length > 0, 'should have received at least one response part'); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index 1eddb9f5df755..e056f0eabc8f7 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -226,13 +226,16 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly * Start the agent host server with the real Copilot SDK agent (no mock agent). * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options?: { readonly claudeSdkPath?: string }): Promise { +export async function startRealServer(options?: { readonly claudeSdkPath?: string; readonly codexBinaryPath?: string }): Promise { return new Promise((resolve, reject) => { const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); const args = ['--port', '0', '--without-connection-token']; if (options?.claudeSdkPath) { args.push('--claude-sdk-path', options.claudeSdkPath); } + if (options?.codexBinaryPath) { + args.push('--codex-binary-path', options.codexBinaryPath); + } const child = fork(serverPath, args, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], }); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index ca0d8a4967c69..f672039e67621 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -143,14 +143,13 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — → Calls provider.createNewChat(sessionId) → Provider creates the backend chat model and returns an IChat → Management service opens the chat widget with that chat's resource + → ChatView locks the embedded ChatWidget to the contributed chat session type + (for example agent-host-codex) before setting the model, so follow-up turns + keep routing to the provider that owns the session; local chat sessions unlock → Delegates to provider.sendRequest(sessionId, chatResource, options) → Provider sends request, returns committed session → Management service fires onDidStartSession(committedSession) → isNewChatSession context → false - -Agent-host providers seed new-session config from the last values picked in the -session-config UI (stored in profile storage), while `chat.permissions.default` -takes precedence for `autoApprove` (with policy-safe normalization). ``` Follow-up messages to an existing chat go through `SessionsManagementService.sendRequest(session, chat, options)`. This always diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 7b82bf2459650..028c9140446b6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -107,10 +107,17 @@ function toActionItems(property: string, items: readonly IConfigPickerItem[], cu description: item.description, group: { title: '', icon: getConfigIcon(property, item.value) }, disabled: policyRestricted && (item.value === 'autoApprove' || item.value === 'autopilot'), - item: { ...item, label: item.value === currentValue ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, + item: { ...item, label: isSelectedValue(currentValue, item.value) ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, })); } +function isSelectedValue(currentValue: unknown | undefined, itemValue: string): boolean { + if (typeof currentValue === 'boolean') { + return currentValue === (itemValue === 'true'); + } + return itemValue === currentValue; +} + function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: DisposableStore, onOpen: () => void): HTMLElement { const trigger = dom.append(slot, disabled ? dom.$('span.action-label') : dom.$('a.action-label')); if (disabled) { @@ -308,15 +315,7 @@ export class AgentHostSessionConfigPicker extends Disposable { const properties = this._orderProperties(Object.entries(resolvedConfig.schema.properties)); for (const [property, schema] of properties) { - // Only render pickers for properties we know how to present. Today - // that's string properties with either a static `enum` or a - // dynamic enum sourced via `getSessionConfigCompletions`. - // Anything else (objects, arrays, free-form strings, numbers, - // booleans) has no enumerable choice set and is edited through - // the JSONC settings editor instead. - const hasStaticEnum = !!schema.enum && schema.enum.length > 0; - const hasDynamicEnum = !!schema.enumDynamic; - if (schema.type !== 'string' || (!hasStaticEnum && !hasDynamicEnum)) { + if (!this._isPickable(schema)) { continue; } if (!this._shouldRenderProperty(property, schema, isNewSession)) { @@ -355,6 +354,16 @@ export class AgentHostSessionConfigPicker extends Disposable { } } + private _isPickable(schema: SessionConfigPropertySchema): boolean { + if (schema.type === 'boolean') { + return true; + } + if (schema.type !== 'string') { + return false; + } + return !!schema.enumDynamic || (Array.isArray(schema.enum) && schema.enum.length > 0); + } + /** * Order the schema properties for rendering. The base implementation * enforces a stable visual sequence for well-known properties: @@ -458,7 +467,8 @@ export class AgentHostSessionConfigPicker extends Disposable { } } - provider.setSessionConfigValue(sessionId, property, item.value).catch(() => { /* best-effort */ }); + const nextValue = schema.type === 'boolean' ? item.value === 'true' : item.value; + provider.setSessionConfigValue(sessionId, property, nextValue).catch(() => { /* best-effort */ }); }, onFilter: schema.enumDynamic ? query => this._filterDelayer.trigger(async () => { @@ -487,6 +497,12 @@ export class AgentHostSessionConfigPicker extends Disposable { } protected async _getItems(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, query?: string): Promise { + if (schema.type === 'boolean') { + return [ + { value: 'true', label: localize('agentHostSessionConfig.boolean.true', "On") }, + { value: 'false', label: localize('agentHostSessionConfig.boolean.false', "Off") }, + ]; + } const dynamicItems = schema.enumDynamic ? await provider.getSessionConfigCompletions(sessionId, property, query) : undefined; @@ -510,6 +526,11 @@ export class AgentHostSessionConfigPicker extends Disposable { } private _getLabel(schema: SessionConfigPropertySchema, value: unknown | undefined): string { + if (schema.type === 'boolean') { + return value === true + ? localize('agentHostSessionConfig.boolean.onLabel', "On") + : localize('agentHostSessionConfig.boolean.offLabel', "Off"); + } if (typeof value === 'string') { const index = schema.enum?.indexOf(value) ?? -1; return index >= 0 ? schema.enumLabels?.[index] ?? value : value; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 7fcdf73241e41..8e0a3376aa83f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -915,6 +915,9 @@ class NewSession extends Disposable { */ eagerCreate(connection: IAgentConnection): void { const backendUri = AgentSession.uri(this.agentProvider, this.session.resource.path.substring(1)); + if (this._backendUri?.toString() === backendUri.toString() || this._subscription) { + return; + } this._backendUri = backendUri; this._connection = connection; @@ -1132,6 +1135,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Full resolved config (schema + values) for running sessions, keyed by session ID. */ protected readonly _runningSessionConfigs = new Map(); + private readonly _runningSessionConfigResolveSeq = new Map(); /** * Lazy session-state subscriptions used to seed {@link _runningSessionConfigs} @@ -1406,17 +1410,35 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionConfig.fire(newSession.sessionId); // Kick off the initial config resolve and the eager backend session - // in parallel. Both are non-blocking; failures are surfaced through - // the session's loading observable. + // in parallel after authentication settles. While auth is pending, + // providers such as Codex reject both paths with AuthRequired; the + // subclass calls _resumeNewSessionAfterAuthenticationSettles when the + // first auth pass completes. if (connection) { - void this._refreshNewSessionConfig(newSession); - newSession.eagerCreate(connection); + if (!this.authenticationPending.get()) { + this._startNewSessionBackend(newSession, connection); + } } else { newSession.setLoading(false); } return newSession.session; } + protected _resumeNewSessionAfterAuthenticationSettles(): void { + const connection = this.connection; + if (!connection) { + return; + } + for (const newSession of this._newSessions.values()) { + this._startNewSessionBackend(newSession, connection); + } + } + + private _startNewSessionBackend(newSession: NewSession, connection: IAgentConnection): void { + void this._refreshNewSessionConfig(newSession); + newSession.eagerCreate(connection); + } + /** * Re-resolve the session config against the agent host and pulse * {@link _onDidChangeSessionConfig}. The {@link NewSession} owns its own @@ -1569,9 +1591,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } // Update local cache optimistically + const nextValues = { ...runningConfig.values, [property]: normalizedValue }; this._runningSessionConfigs.set(sessionId, { ...runningConfig, - values: { ...runningConfig.values, [property]: normalizedValue }, + values: nextValues, }); this._onDidChangeSessionConfig.fire(sessionId); @@ -1582,6 +1605,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const sessionUri = AgentSession.uri(cached.agentProvider, rawId); const action = { type: ActionType.SessionConfigChanged as const, config: { [property]: normalizedValue } }; connection.dispatch(sessionUri.toString(), action); + void this._resolveRunningSessionConfig(sessionId, cached, nextValues); } } @@ -1632,6 +1656,30 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement replace: true, }; connection.dispatch(sessionUri.toString(), action); + void this._resolveRunningSessionConfig(sessionId, cached, nextValues); + } + } + + private async _resolveRunningSessionConfig(sessionId: string, cached: AgentHostSessionAdapter, values: Record): Promise { + const connection = this.connection; + if (!connection) { + return; + } + const seq = (this._runningSessionConfigResolveSeq.get(sessionId) ?? 0) + 1; + this._runningSessionConfigResolveSeq.set(sessionId, seq); + try { + const resolved = await connection.resolveSessionConfig({ + provider: cached.agentProvider, + workingDirectory: cached.workspace.get()?.folders[0]?.root, + config: values, + }); + if (this._runningSessionConfigResolveSeq.get(sessionId) !== seq) { + return; + } + this._runningSessionConfigs.set(sessionId, resolved); + this._onDidChangeSessionConfig.fire(sessionId); + } catch (err) { + this._logService.warn(`[${this.id}] Failed to re-resolve session config for ${sessionId}: ${err}`); } } @@ -1817,6 +1865,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement await connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); this._sessionCache.delete(rawId); this._runningSessionConfigs.delete(sessionId); + this._runningSessionConfigResolveSeq.delete(sessionId); this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); } } @@ -1929,6 +1978,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // notification before sendRequest resolves. this._ensureSessionCache(); const existingKeys = new Set(this._sessionCache.keys()); + // The eagerly-created session may already be cached before first send. + // Treat that raw id as the session we are waiting for, not old state. + const newSessionRawId = chatResource.path.replace(/^\//, ''); + existingKeys.delete(newSessionRawId); const result = await this._chatService.sendRequest(chatResource, query, sendOptions); if (result.kind === 'rejected') { @@ -2172,11 +2225,34 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (Object.keys(stateConfig.schema.properties).length === 0) { return; } - const seeded: ResolveSessionConfigResult = { - schema: { type: 'object', properties: { ...stateConfig.schema.properties } }, - values: { ...stateConfig.values }, - }; const existing = this._runningSessionConfigs.get(sessionId); + let seeded: ResolveSessionConfigResult; + if (existing && this._runningSessionConfigResolveSeq.has(sessionId)) { + const values = { ...existing.values }; + for (const key of Object.keys(existing.schema.properties)) { + if (Object.hasOwn(stateConfig.values, key)) { + values[key] = stateConfig.values[key]; + } + } + seeded = { + schema: { type: 'object', properties: { ...existing.schema.properties } }, + values, + }; + } else { + seeded = { + schema: { + type: 'object', + properties: { + ...(existing?.schema.properties ?? {}), + ...stateConfig.schema.properties, + }, + }, + values: { + ...(existing?.values ?? {}), + ...stateConfig.values, + }, + }; + } if (existing && resolvedConfigsEqual(existing, seeded)) { return; } @@ -2242,10 +2318,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const removed: ISession[] = []; + // Some hosts briefly omit the just-sent eager session from listSessions. + // Keep the pending session visible until sendRequest graduates it. + const pendingRawId = this._pendingSession?.resource.path.replace(/^\//, ''); for (const [key, cached] of this._sessionCache) { if (!currentKeys.has(key)) { + if (key === pendingRawId) { + continue; + } this._sessionCache.delete(key); this._runningSessionConfigs.delete(cached.sessionId); + this._runningSessionConfigResolveSeq.delete(cached.sessionId); removed.push(cached); } } @@ -2392,6 +2475,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (cached) { this._sessionCache.delete(rawId); this._runningSessionConfigs.delete(cached.sessionId); + this._runningSessionConfigResolveSeq.delete(cached.sessionId); this._sessionStateIdleTimers.deleteAndDispose(cached.sessionId); this._sessionStateSubscriptions.deleteAndDispose(cached.sessionId); this._lastSessionStates.delete(cached.sessionId); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 467333ec3869c..cc04a9d10b655 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -106,6 +106,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide return; } this._refreshSessions(); + this._resumeNewSessionAfterAuthenticationSettles(); })); // When the "default sessions provider" preference changes, the 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 b53d99306dd86..b39b70b124935 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 @@ -1631,20 +1631,84 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(session!.loading.get(), false); }); - test('new session loading reflects authenticationPending until config resolves', async () => { + test('new session defers backend startup until authentication settles', async () => { agentHost.setAuthenticationPending(true); const provider = createProvider(disposables, agentHost); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); - // Wait for the resolved config (the mock returns `values.isolation: 'worktree'`) - // so that the per-session loading flag has been turned off. + + await timeout(0); + + // While auth is pending, config/backend work is intentionally deferred. + // Providers such as Codex reject those calls with AuthRequired before the + // first auth pass settles. + assert.deepStrictEqual({ + loading: session.loading.get(), + createdSessions: agentHost.createdSessionUris.length, + resolveRequests: agentHost.resolveSessionConfigRequests.length, + config: provider.getSessionConfig(session.sessionId), + }, { + loading: true, + createdSessions: 0, + resolveRequests: 0, + config: { schema: { type: 'object', properties: {} }, values: {} }, + }); + + agentHost.setAuthenticationPending(false); await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); - // Even though config has resolved (per-session loading is false), the - // auth-pending flag keeps the session in the loading state. - assert.strictEqual(session.loading.get(), true); + assert.deepStrictEqual({ + loading: session.loading.get(), + createdSessions: agentHost.createdSessionUris.length, + resolveRequests: agentHost.resolveSessionConfigRequests.length, + config: provider.getSessionConfig(session.sessionId), + }, { + loading: false, + createdSessions: 1, + resolveRequests: 1, + config: { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }, + }); + }); + + test('new session stays loading after authentication settles when required config is missing', async () => { + agentHost.setAuthenticationPending(true); + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', required: ['branch'], properties: { branch: { type: 'string', title: 'Branch', enum: ['main'] } } }, + values: {}, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + + await timeout(0); + + assert.deepStrictEqual({ + loading: session.loading.get(), + createdSessions: agentHost.createdSessionUris.length, + resolveRequests: agentHost.resolveSessionConfigRequests.length, + config: provider.getSessionConfig(session.sessionId), + }, { + loading: true, + createdSessions: 0, + resolveRequests: 0, + config: { schema: { type: 'object', properties: {} }, values: {} }, + }); agentHost.setAuthenticationPending(false); - assert.strictEqual(session.loading.get(), false); + await waitForSessionConfig(provider, session.sessionId, config => config?.schema.required?.includes('branch') === true); + + assert.deepStrictEqual({ + loading: session.loading.get(), + createdSessions: agentHost.createdSessionUris.length, + resolveRequests: agentHost.resolveSessionConfigRequests.length, + config: provider.getSessionConfig(session.sessionId), + }, { + loading: true, + createdSessions: 1, + resolveRequests: 1, + config: { + schema: { type: 'object', required: ['branch'], properties: { branch: { type: 'string', title: 'Branch', enum: ['main'] } } }, + values: {}, + }, + }); }); // ---- sendRequest ------- @@ -1727,6 +1791,54 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('running config state seeding preserves already-resolved schema properties', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('seed-schema', { summary: 'Schema Preserve Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Schema Preserve Session'); + assert.ok(session); + + const fullState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'seed-schema').toString(), provider: 'copilotcli', title: 'Schema Preserve Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config: { + schema: { + type: 'object', + properties: { + 'codex.sandboxMode': { type: 'string', title: 'Sandbox', enum: ['read-only', 'workspace-write'], sessionMutable: true }, + 'codex.networkAccessEnabled': { type: 'boolean', title: 'Network', default: false, sessionMutable: true }, + }, + }, + values: { 'codex.sandboxMode': 'workspace-write', 'codex.networkAccessEnabled': false }, + }, + }; + agentHost.setSessionState('seed-schema', 'copilotcli', fullState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.schema.properties['codex.networkAccessEnabled'] !== undefined); + + agentHost.setSessionState('seed-schema', 'copilotcli', { + ...fullState, + config: { + schema: { + type: 'object', + properties: { + 'codex.sandboxMode': { type: 'string', title: 'Sandbox', enum: ['read-only', 'workspace-write'], sessionMutable: true }, + }, + }, + values: { 'codex.sandboxMode': 'workspace-write' }, + }, + }); + + assert.deepStrictEqual({ + properties: Object.keys(provider.getSessionConfig(session!.sessionId)?.schema.properties ?? {}).sort(), + values: provider.getSessionConfig(session!.sessionId)?.values, + }, { + properties: ['codex.networkAccessEnabled', 'codex.sandboxMode'], + values: { 'codex.sandboxMode': 'workspace-write', 'codex.networkAccessEnabled': false }, + }); + })); + test('removing a session disposes its session-state subscription', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('seed-2', { summary: 'Sub Session' })); const provider = createProvider(disposables, agentHost); @@ -1887,6 +1999,73 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('running session config write re-resolves schema-dependent properties', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('schema-write', { summary: 'Schema Write Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Schema Write Session'); + assert.ok(session); + + const config: SessionConfigState = { + schema: { + type: 'object', + properties: { + 'codex.sandboxMode': { type: 'string', title: 'Sandbox', enum: ['read-only', 'workspace-write'], sessionMutable: true }, + 'codex.networkAccessEnabled': { type: 'boolean', title: 'Network', default: false, sessionMutable: true }, + }, + }, + values: { 'codex.sandboxMode': 'workspace-write', 'codex.networkAccessEnabled': false }, + }; + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'schema-write').toString(), provider: 'copilotcli', title: 'Schema Write Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config, + }; + agentHost.setSessionState('schema-write', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values['codex.sandboxMode'] === 'workspace-write'); + + agentHost.resolveSessionConfigResult = { + schema: { + type: 'object', + properties: { + 'codex.sandboxMode': { type: 'string', title: 'Sandbox', enum: ['read-only', 'workspace-write'], sessionMutable: true }, + }, + }, + values: { 'codex.sandboxMode': 'read-only' }, + }; + + await provider.setSessionConfigValue(session!.sessionId, 'codex.sandboxMode', 'read-only'); + await waitForSessionConfig(provider, session!.sessionId, c => c?.schema.properties['codex.networkAccessEnabled'] === undefined); + + assert.deepStrictEqual({ + resolveConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + properties: Object.keys(provider.getSessionConfig(session!.sessionId)?.schema.properties ?? {}).sort(), + values: provider.getSessionConfig(session!.sessionId)?.values, + }, { + resolveConfig: { 'codex.sandboxMode': 'read-only', 'codex.networkAccessEnabled': false }, + properties: ['codex.sandboxMode'], + values: { 'codex.sandboxMode': 'read-only' }, + }); + + agentHost.setSessionState('schema-write', 'copilotcli', { + ...fakeState, + config: { + ...config, + values: { 'codex.sandboxMode': 'read-only', 'codex.networkAccessEnabled': true }, + }, + }); + + assert.deepStrictEqual({ + properties: Object.keys(provider.getSessionConfig(session!.sessionId)?.schema.properties ?? {}).sort(), + values: provider.getSessionConfig(session!.sessionId)?.values, + }, { + properties: ['codex.sandboxMode'], + values: { 'codex.sandboxMode': 'read-only' }, + }); + })); + test('replaceSessionConfig is a no-op when nothing editable actually changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('rep-2', { summary: 'No-op Session' })); const provider = createProvider(disposables, agentHost); @@ -2021,11 +2200,12 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip ensureNoDisposablesAreLeakedInTestSuite(); - function makeActive(rawId: string, sessionType: string = 'copilotcli'): IActiveSession { + function makeActive(rawId: string, sessionType: string = 'copilotcli', status: SessionStatus = SessionStatus.Completed): IActiveSession { return { // providerId: 'unused', sessionType, resource: URI.from({ scheme: `agent-host-${sessionType}`, path: `/${rawId}` }), + status: constObservable(status), } as unknown as IActiveSession; } @@ -2107,6 +2287,15 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip ); }); + test('does NOT subscribe to uncommitted changes for an untitled active session', () => { + createProvider(disposables, agentHost, undefined, { activeSession }); + + activeSession.set(makeActive('sess-new', 'copilotcli', SessionStatus.Untitled), undefined); + + const subKeys = [...agentHost.sessionSubscribeCounts.keys()].filter(k => k.endsWith('/changeset/uncommitted')); + assert.deepStrictEqual(subKeys, [], 'new-session composer should not restore the backend session just to refresh changes'); + }); + test('releases the subscription when no session is active', () => { const provider = createProvider(disposables, agentHost, undefined, { activeSession }); addAndObserve(provider, 'sess-A'); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index de1953942ee6b..95491b1b73005 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -358,6 +358,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._authenticationSettled = true; } this._authenticationPending.set(pending, undefined); + if (!pending) { + this._resumeNewSessionAfterAuthenticationSettles(); + } } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index a1a3cb3407ba2..fdc5fe8bd6d44 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -801,6 +801,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }; const provider = createProvider(disposables, connection); const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id); + provider.setAuthenticationPending(false); await waitForSessionConfig(provider, session.sessionId, config => config?.schema.required?.includes('branch') === true); assert.strictEqual(session.loading.get(), true); @@ -913,7 +914,8 @@ suite('RemoteAgentHostSessionsProvider', () => { }, }); const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id); - await timeout(0); + provider.setAuthenticationPending(false); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); const chat = await provider.createNewChat(session.sessionId); await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 189a38e264dc3..2e0de30846a76 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -92,10 +92,17 @@ function toActionItems(property: string, items: readonly IConfigPickerItem[], cu description: item.description, group: { title: '', icon: getConfigIcon(property, item.value) }, disabled: policyRestricted && property === SessionConfigKey.AutoApprove && (item.value === 'autoApprove' || item.value === 'autopilot'), - item: { ...item, label: item.value === currentValue ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, + item: { ...item, label: isSelectedValue(currentValue, item.value) ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, })); } +function isSelectedValue(currentValue: unknown | undefined, itemValue: string): boolean { + if (typeof currentValue === 'boolean') { + return currentValue === (itemValue === 'true'); + } + return itemValue === currentValue; +} + function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: DisposableStore, onOpen: () => void): HTMLElement { const trigger = dom.append(slot, disabled ? dom.$('span.action-label') : dom.$('a.action-label')); if (disabled) { @@ -381,6 +388,11 @@ export class AgentHostChatInputPicker extends Disposable { } private _labelFor(schema: SessionConfigPropertySchema, value: unknown | undefined): string { + if (schema.type === 'boolean') { + return value === true + ? localize('agentHostChatInputPicker.boolean.onLabel', "On") + : localize('agentHostChatInputPicker.boolean.offLabel', "Off"); + } if (typeof value === 'string') { const index = schema.enum?.indexOf(value) ?? -1; return index >= 0 ? schema.enumLabels?.[index] ?? value : value; @@ -497,6 +509,12 @@ export class AgentHostChatInputPicker extends Disposable { } private async _getItems(schema: SessionConfigPropertySchema, query?: string): Promise { + if (schema.type === 'boolean') { + return [ + { value: 'true', label: localize('agentHostChatInputPicker.boolean.true', "On") }, + { value: 'false', label: localize('agentHostChatInputPicker.boolean.false', "Off") }, + ]; + } const sessionResource = this._widget.viewModel?.sessionResource; const backendSession = this._subRef.value?.backendSession ?? (sessionResource ? toBackendSessionUri(sessionResource) : undefined); @@ -537,11 +555,13 @@ export class AgentHostChatInputPicker extends Disposable { } private _readCurrentValues(): Record | undefined { + const sessionResource = this._widget.viewModel?.sessionResource; + const overlay = sessionResource ? this._provisional.getResolvedConfig(sessionResource) : undefined; const state = this._subRef.value?.sub.value; if (state && !(state instanceof Error)) { - return state.config?.values; + return { ...(state.config?.values ?? {}), ...(overlay?.values ?? {}) }; } - return this._initialResolved?.result.values; + return overlay?.values ?? this._initialResolved?.result.values; } private async _setValue(backendSession: URI, value: string): Promise { @@ -550,8 +570,12 @@ export class AgentHostChatInputPicker extends Disposable { return; } - const normalizedValue = normalizeConfigValue(this._property, value, isAutoApprovePolicyRestricted(this._configurationService)); + const ctx = this._readContext(); + const normalizedValue = ctx?.schema.type === 'boolean' + ? value === 'true' + : normalizeConfigValue(this._property, value, isAutoApprovePolicyRestricted(this._configurationService)); const partial = { [this._property]: normalizedValue }; + const nextConfig = { ...(this._readCurrentValues() ?? {}), ...partial }; if (isUntitledChatSession(sessionResource)) { // Route through the provisional service so the workbench-owned @@ -578,6 +602,12 @@ export class AgentHostChatInputPicker extends Disposable { type: ActionType.SessionConfigChanged, config: partial, }); + void this._provisional.refreshResolvedConfig( + sessionResource, + backendSession.scheme, + this._readWorkingDirectory(), + nextConfig, + ); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index 5a14e59582c06..3b4c871630110 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -146,9 +146,8 @@ export interface IAgentHostUntitledProvisionalSessionService { disposeSession(sessionResource: URI): Promise; /** - * Latest workbench-side re-resolved config (schema + values) for a - * provisional session, if any. Populated by {@link applyConfigChange} - * after a value change so dependent properties (e.g. branch ↔ isolation) + * Latest workbench-side re-resolved config (schema + values) for a chat + * session, if any. Populated after a value change so dependent properties * refresh without a protocol-level schema-update channel. * * Both the schema and the values matter: `resolveSessionConfig` runs @@ -156,6 +155,17 @@ export interface IAgentHostUntitledProvisionalSessionService { * derived defaults the consumer should prefer over `state.config.values`. */ getResolvedConfig(sessionResource: URI): ResolveSessionConfigResult | undefined; + + /** + * Re-resolve config for an already-created chat session and cache the + * schema/values overlay returned by the provider. + */ + refreshResolvedConfig( + sessionResource: URI, + provider: string, + workingDirectory: URI | undefined, + config: Record | undefined, + ): Promise; } interface IEntry { @@ -181,6 +191,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple private readonly _entries = new ResourceMap(); private readonly _pending = new ResourceMap>(); + private readonly _resolvedConfigs = new ResourceMap(); + private readonly _resolvedConfigRequestSeq = new ResourceMap(); // URIs that were the source of a successful `tryRebind`. The chat widget // briefly reattaches to the old untitled URI before its viewModel switches // to the new real URI; without this tombstone the picker would call @@ -209,6 +221,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple if (this._entries.has(sessionResource)) { void this.disposeSession(sessionResource); } + this._resolvedConfigs.delete(sessionResource); + this._resolvedConfigRequestSeq.delete(sessionResource); // Drop any tombstone for the abandoned untitled URI so the // set doesn't grow unbounded across the workbench lifetime. this._rebound.delete(sessionResource); @@ -325,8 +339,10 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple // Atomically swap entries: insert the new entry, drop the old one. // Order matters — the old entry's `dispose` below must not race with // the picker's `onDidChange` re-render reading the new entry. - this._entries.set(newSessionResource, { backendSession: created, config: { ...config } }); + this._entries.set(newSessionResource, { backendSession: created, config: { ...config }, resolvedConfig: oldEntry.resolvedConfig }); this._entries.delete(oldSessionResource); + this._resolvedConfigs.delete(oldSessionResource); + this._resolvedConfigRequestSeq.delete(oldSessionResource); this._rebound.add(oldSessionResource); // Only notify for the new resource. Firing for `oldSessionResource` // would race the chat widget's `onDidChangeViewModel`: the picker is @@ -346,6 +362,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple async disposeSession(sessionResource: URI): Promise { await this.waitForPending(sessionResource); const entry = this._entries.get(sessionResource); + this._resolvedConfigs.delete(sessionResource); + this._resolvedConfigRequestSeq.delete(sessionResource); if (!entry) { return; } @@ -366,6 +384,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple } this._entries.clear(); this._pending.clear(); + this._resolvedConfigs.clear(); + this._resolvedConfigRequestSeq.clear(); this._rebound.clear(); super.dispose(); } @@ -380,7 +400,37 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple } getResolvedConfig(sessionResource: URI): ResolveSessionConfigResult | undefined { - return this._entries.get(sessionResource)?.resolvedConfig; + return this._entries.get(sessionResource)?.resolvedConfig ?? this._resolvedConfigs.get(sessionResource); + } + + async refreshResolvedConfig( + sessionResource: URI, + provider: string, + workingDirectory: URI | undefined, + config: Record | undefined, + ): Promise { + const seq = (this._resolvedConfigRequestSeq.get(sessionResource) ?? 0) + 1; + this._resolvedConfigRequestSeq.set(sessionResource, seq); + try { + const resolved = await this._agentHostService.resolveSessionConfig({ + provider, + workingDirectory, + config, + }); + if (this._resolvedConfigRequestSeq.get(sessionResource) !== seq) { + return; + } + const entry = this._entries.get(sessionResource); + if (entry) { + entry.config = { ...entry.config, ...resolved.values }; + entry.resolvedConfig = resolved; + } else { + this._resolvedConfigs.set(sessionResource, resolved); + } + this._onDidChange.fire(sessionResource); + } catch (err) { + this._logService.warn(`[AgentHostProvisional] schema re-resolve failed: ${err instanceof Error ? err.message : String(err)}`); + } } async applyConfigChange( diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index c740e1f412b26..9f8b06c17e836 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -181,6 +181,49 @@ suite('AgentHostUntitledProvisionalSessionService', () => { assert.deepStrictEqual(agentHost.resolveCalls[0].config, { isolation: 'worktree' }); }); + test('refreshResolvedConfig stores a schema overlay for running sessions', async () => { + const ui = URI.from({ scheme: 'agent-host-copilot', path: '/real-j' }); + const resolved: ResolveSessionConfigResult = { + schema: makeSchema(true), + values: { isolation: 'folder', branch: 'main' }, + }; + agentHost.resolveQueue = [resolved]; + + let changeFires = 0; + cleanup.add(provisional.onDidChange(uri => { if (uri.toString() === ui.toString()) { changeFires++; } })); + + await provisional.refreshResolvedConfig(ui, 'copilot', undefined, { isolation: 'folder' }); + + assert.deepStrictEqual({ + overlay: provisional.getResolvedConfig(ui), + changeFires, + resolveConfig: agentHost.resolveCalls[0].config, + }, { + overlay: resolved, + changeFires: 1, + resolveConfig: { isolation: 'folder' }, + }); + }); + + test('refreshResolvedConfig ignores stale running-session responses', async () => { + const ui = URI.from({ scheme: 'agent-host-copilot', path: '/real-k' }); + const first = new DeferredPromise(); + const second = new DeferredPromise(); + cleanup.add({ dispose: () => { first.cancel(); second.cancel(); } }); + agentHost.resolveQueue = [first.p, second.p]; + + const a = provisional.refreshResolvedConfig(ui, 'copilot', undefined, { isolation: 'worktree' }); + const b = provisional.refreshResolvedConfig(ui, 'copilot', undefined, { isolation: 'folder' }); + + first.complete({ schema: makeSchema(false), values: { isolation: 'worktree' } }); + second.complete({ schema: makeSchema(true), values: { isolation: 'folder' } }); + + await a; + await b; + + assert.deepStrictEqual(provisional.getResolvedConfig(ui), { schema: makeSchema(true), values: { isolation: 'folder' } }); + }); + test('optimistic merge: overlay.values reflects partial before re-resolve completes', async () => { const ui = untitledChatUri('d'); // First applyConfigChange: seed an overlay. From ace11274c47c789b582165b5498979b507ffdd45 Mon Sep 17 00:00:00 2001 From: Aashna Garg Date: Tue, 2 Jun 2026 16:45:39 -0700 Subject: [PATCH 6/9] Add experiment flag to hide model name in chat auto mode (#319432) * Add experiment flag to hide model name in chat auto mode When the copilotchat.hideAutoModelName treatment is on, the response footer in chat auto mode shows 'Auto' (plus credits/multiplier) instead of the underlying model name. This supports an A/B testing the hypothesis that surfacing model identity drives users to switch from auto mode to the manual picker. * Fix flaky agentService test: add maxRetries to rmSync on Windows --------- Co-authored-by: Aashna Garg --- .../prompt/node/chatParticipantRequestHandler.ts | 11 +++++++++-- .../src/platform/chat/common/chatModelDetails.ts | 8 ++++++++ .../platform/agentHost/test/node/agentService.test.ts | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatParticipantRequestHandler.ts b/extensions/copilot/src/extension/prompt/node/chatParticipantRequestHandler.ts index 0e84a8aa741d1..9b9ce443fa7ab 100644 --- a/extensions/copilot/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/extensions/copilot/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -32,8 +32,10 @@ import { ICommandService } from '../../commands/node/commandService'; import { getAgentForIntent, Intent } from '../../common/constants'; import { IConversationStore } from '../../conversationStore/node/conversationStore'; import { IIntentService } from '../../intents/node/intentService'; +import { isAutoModel } from '../../../platform/endpoint/node/autoChatEndpoint'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { UnknownIntent } from '../../intents/node/unknownIntent'; -import { formatModelDetails } from '../../../platform/chat/common/chatModelDetails'; +import { formatAutoModeDetails, formatModelDetails } from '../../../platform/chat/common/chatModelDetails'; import { ContributedToolName } from '../../tools/common/toolNames'; import { ChatVariablesCollection } from '../common/chatVariablesCollection'; import { Conversation, getGlobalContextCacheKey, GlobalContextMessageMetadata, ICopilotChatResult, ICopilotChatResultIn, normalizeSummariesOnRounds, RenderedUserMessageMetadata, Turn, TurnStatus, TurnTokenUsageMetadata } from '../common/conversation'; @@ -87,6 +89,7 @@ export class ChatParticipantRequestHandler { @IAuthenticationService private readonly _authService: IAuthenticationService, @IAuthenticationChatUpgradeService private readonly _authenticationUpgradeService: IAuthenticationChatUpgradeService, @IChatQuotaService private readonly _chatQuotaService: IChatQuotaService, + @IExperimentationService private readonly _experimentationService: IExperimentationService, ) { this.location = this.getLocation(request); @@ -259,7 +262,11 @@ export class ChatParticipantRequestHandler { result = await chatResult; const endpoint = await this._endpointProvider.getChatEndpoint(this.request); const creditsUsed = this._chatQuotaService.getCreditsForTurn(this.turn.id); - if (this._authService.copilotToken?.isNoAuthUser) { + const hideAutoModelName = isAutoModel(endpoint) === 1 + && this._experimentationService.getTreatmentVariable('copilotchat.hideAutoModelName') === true; + if (hideAutoModelName) { + result.details = formatAutoModeDetails(creditsUsed, endpoint.multiplier); + } else if (this._authService.copilotToken?.isNoAuthUser) { result.details = endpoint.name; } else { result.details = formatModelDetails(endpoint.name, endpoint.multiplier, creditsUsed); diff --git a/extensions/copilot/src/platform/chat/common/chatModelDetails.ts b/extensions/copilot/src/platform/chat/common/chatModelDetails.ts index 77c7384dff86a..3df601e8bc9ae 100644 --- a/extensions/copilot/src/platform/chat/common/chatModelDetails.ts +++ b/extensions/copilot/src/platform/chat/common/chatModelDetails.ts @@ -43,3 +43,11 @@ export function formatModelDetailsWithCredits(modelName: string, creditsUsed: nu export function formatModelDetailsWithMultiplier(modelName: string, multiplier: number | undefined): string { return multiplier !== undefined ? l10n.t('{0} \u2022 {1}x', modelName, multiplier) : modelName; } + +/** + * Formats model details for auto mode when the model name should be hidden. + * Shows "Auto • N credits" or "Auto • Nx" instead of the actual model name. + */ +export function formatAutoModeDetails(creditsUsed: number | undefined, multiplier: number | undefined): string { + return formatModelDetails(l10n.t('Auto'), multiplier, creditsUsed); +} diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9671b3f4e6178..bfe729c7b7aff 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -172,7 +172,7 @@ suite('AgentService (node dispatcher)', () => { assert.ok(persisted, 'should persist the root config change'); } finally { - rmSync(tempDir.fsPath, { recursive: true, force: true }); + rmSync(tempDir.fsPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }); }); From 8390664332e48fe41e535ef4005c1b14b329bd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:59:39 -0700 Subject: [PATCH 7/9] Browser: Playwright API metrics (#316473) --- .../sharedProcess/sharedProcessMain.ts | 2 +- .../browserView/node/playwrightChannel.ts | 4 +- .../browserView/node/playwrightService.ts | 208 +++++++++++++++++- 3 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 8a86a911aa423..0c34bf6618e4a 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -494,7 +494,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Playwright const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService))); - const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService)); + const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService, accessor.get(ITelemetryService))); this.server.registerChannel('playwright', playwrightChannel); // Local Git diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts index 08a4882c33508..a2e67d4953ca8 100644 --- a/src/vs/platform/browserView/node/playwrightChannel.ts +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -8,6 +8,7 @@ import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { ILogService } from '../../log/common/log.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; import { BrowserViewGroupRemoteService } from './browserViewGroupRemoteService.js'; import { PlaywrightService } from './playwrightService.js'; @@ -31,6 +32,7 @@ export class PlaywrightChannel extends Disposable implements IServerChannel; + logCtx?: IExecutionLogContext; } & IDisposable>()); constructor( @@ -365,6 +369,7 @@ class PlaywrightSession extends Disposable { private readonly actionScope: IPlaywrightActionScope, private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, + private readonly telemetryService: ITelemetryService, ) { super(); @@ -411,18 +416,39 @@ class PlaywrightSession extends Disposable { async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { this.logService.info(`[PlaywrightSession] Invoking function on view ${pageId}`); + const logCtx: IExecutionLogContext = { + startedAt: Date.now(), + codeLength: fnDef.length, + codeLineCount: fnDef.split('\n').length, + pageMethodsCalled: new Map(), + wasDeferred: false, + resumeCount: 0, + logged: false, + }; + + let fn; + try { + fn = await this._compileFunction(fnDef); + } catch (err: unknown) { + // Surface compile/syntax errors as { error, summary }, like other execution failures. + this._logExecution(logCtx, false); + const summary = await this._getSummary(pageId); + return { error: err instanceof Error ? err.message : String(err), summary }; + } + const wrappedCallback = async (page: Page) => fn(createPageApiProxy(page, logCtx.pageMethodsCalled), args); + if (timeoutMs !== undefined) { - const fn = await this._compileFunction(fnDef); - return this._runWithDeferral(pageId, async (page) => fn(page, args ?? []), timeoutMs); + return this._runWithDeferral(pageId, wrappedCallback, timeoutMs, undefined, logCtx); } let result, error; try { - result = await this.invokeFunctionRaw(pageId, fnDef, ...args); + result = await this._runAgainstPage(pageId, wrappedCallback); } catch (err: unknown) { error = err instanceof Error ? err.message : String(err); } + this._logExecution(logCtx, !error); const summary = await this._getSummary(pageId); return { result, error, summary }; } @@ -433,9 +459,12 @@ class PlaywrightSession extends Disposable { throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); } - const { pageId, promise } = entry; + const { pageId, promise, logCtx } = entry; + if (logCtx) { + logCtx.resumeCount++; + } this._deferredResults.deleteAndDispose(deferredResultId); - return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId); + return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId, logCtx); } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { @@ -480,8 +509,18 @@ class PlaywrightSession extends Disposable { return tab.safeRunAgainstPage(async () => callback(page)); } - private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string): Promise { + private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string, logCtx?: IExecutionLogContext): Promise { const deferred = new DeferredPromise(); + + // Attach settlement logging once, on the initiating call: `deferred.p` settles + // when the page work finishes no matter how many times the result is deferred, + // resumed, or abandoned, so a deferred run is still logged once it settles. + // `_logExecution` is idempotent, so this is a no-op if the synchronous path + // below already logged a non-deferred completion. + if (existingDeferredId === undefined && logCtx) { + deferred.p.then(() => this._logExecution(logCtx, true), () => this._logExecution(logCtx, false)); + } + const wrappedPromise = this._runAgainstPage(pageId, async (page) => { const promise = callback(page); promise.catch(() => { /* prevent unhandled rejection if deferred */ }); @@ -503,16 +542,52 @@ class PlaywrightSession extends Disposable { let deferredResultId: string | undefined; if (interrupted) { + if (logCtx) { + logCtx.wasDeferred = true; + } deferredResultId = existingDeferredId ?? generateUuid(); const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); - this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() }); + this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, logCtx, dispose: () => cleanup.dispose() }); this.logService.info(`[PlaywrightSession] Execution interrupted, deferred as ${deferredResultId}`); + } else if (logCtx) { + // Completed or failed within the timeout: log the outcome now rather than + // relying on the settlement promise, which never settles if the page work + // threw before `settleWith` ran (e.g. the page could not be resolved). + this._logExecution(logCtx, !error); } const summary = await this._getSummary(pageId); return { result, error, summary, deferredResultId }; } + /** + * Emit completion telemetry for a single {@link invokeFunction} call, once the + * page work settles. Idempotent: only the first call for a given context emits, + * so the synchronous and settlement-promise paths can both call it safely. + */ + private _logExecution(ctx: IExecutionLogContext, success: boolean): void { + if (ctx.logged) { + return; + } + ctx.logged = true; + const entries = [...ctx.pageMethodsCalled.entries()]; + const total = entries.reduce((sum, [, count]) => sum + count, 0); + this.telemetryService.publicLog2( + 'integratedBrowser.tools.runPlaywrightCode.completed', + { + pageMethodsCalled: JSON.stringify(Object.fromEntries(entries)), + pageMethodsCalledDcount: entries.length, + pageMethodsCalledCount: total, + success: success ? 1 : 0, + wasDeferred: ctx.wasDeferred ? 1 : 0, + resumeCount: ctx.resumeCount, + durationMs: Math.round(Date.now() - ctx.startedAt), + codeLength: ctx.codeLength, + codeLineCount: ctx.codeLineCount, + } + ); + } + private async _compileFunction(fnDef: string): Promise<(page: Page, args: unknown[]) => unknown> { const vm = await import('vm'); return vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }) as (page: Page, args: unknown[]) => unknown; @@ -674,3 +749,122 @@ class PlaywrightSession extends Disposable { super.dispose(); } } + +/** + * Per-invocation state threaded through {@link PlaywrightSession.invokeFunction} + * and its deferral machinery so completion telemetry can be emitted exactly once + * when the underlying page work settles - even for deferred runs the caller + * never resumes. + */ +interface IExecutionLogContext { + /** {@link Date.now} timestamp captured when the invocation began. */ + readonly startedAt: number; + /** Character length of the executed function source. */ + readonly codeLength: number; + /** Line count of the executed function source. */ + readonly codeLineCount: number; + /** Per-method call counts accumulated by {@link createPageApiProxy}. */ + readonly pageMethodsCalled: Map; + /** Set once the execution is interrupted and deferred at least once. */ + wasDeferred: boolean; + /** Number of times the caller resumed this execution via {@link PlaywrightSession.waitForDeferredResult}. */ + resumeCount: number; + /** Guards against double-logging; set by {@link PlaywrightSession._logExecution}. */ + logged: boolean; +} + +type RunPlaywrightCodeEvent = { + pageMethodsCalled: string; + pageMethodsCalledDcount: number; + pageMethodsCalledCount: number; + success: number; + wasDeferred: number; + resumeCount: number; + durationMs: number; + codeLength: number; + codeLineCount: number; +}; + +type RunPlaywrightCodeClassification = { + pageMethodsCalled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON object mapping dotted `page.*` method names to their call counts (e.g. `{"click":2,"keyboard.press":5}`), in first-observed order.' }; + pageMethodsCalledDcount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of distinct `page.*` methods invoked.' }; + pageMethodsCalledCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total `page.*` method calls including duplicates (sum of all per-method counts).' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: '1 if the code completed without error, 0 otherwise.' }; + wasDeferred: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: '1 if the execution was interrupted and deferred at least once, 0 otherwise.' }; + resumeCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of times the caller resumed this execution by polling for its deferred result. 0 means the run either completed within the first timeout or was deferred and never resumed (settled in the background).' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock time in milliseconds from invocation start until the page work settled.' }; + codeLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Character length of the executed function source.' }; + codeLineCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Line count of the executed function source.' }; + owner: 'jruales'; + comment: 'Tracks how the run_playwright_code chat tool is exercised.'; +}; + +/** + * Property names that are skipped by {@link createPageApiProxy} so that JS + * runtime/idiomatic accesses don't show up as fake API usage. Includes + * `then`/`catch`/`finally` (so awaiting the proxy never records noise), + * conversion hooks, and `constructor`. + */ +const PAGE_PROXY_IGNORED_PROPS = new Set([ + 'then', + 'catch', + 'finally', + 'toJSON', + 'toString', + 'valueOf', + 'constructor', +]); + +/** + * Maximum nesting depth for the recursive page proxy. The Playwright `page` + * surface only nests one level deep in practice (e.g. `page.keyboard.press`), + * so 3 is generously above any real workload while preventing pathological + * cases on cyclic structures. + */ +const PAGE_PROXY_MAX_DEPTH = 3; + +/** + * Wrap a Playwright `page` so every call through the proxy increments a counter + * in {@link methodCalls}, keyed by the dotted path from `page` (e.g. `click`, + * `keyboard.press`). Object properties are proxied recursively (capped at + * {@link PAGE_PROXY_MAX_DEPTH}) so calls on namespaces like `keyboard` and + * `mouse` are visible; symbol keys, `_`-prefixed internals, and + * {@link PAGE_PROXY_IGNORED_PROPS} are skipped to avoid noise. + * + * Wrappers and nested proxies are cached per property so repeated reads return + * the same value, preserving Playwright's object identity (e.g. + * `page.keyboard === page.keyboard`). + */ +function createPageApiProxy(target: T, methodCalls: Map, prefix: string = '', depth: number = 0): T { + if (depth >= PAGE_PROXY_MAX_DEPTH) { + return target; + } + const cache = new Map(); + return new Proxy(target, { + get(t, prop, receiver) { + const value = Reflect.get(t, prop, receiver); + if (typeof prop !== 'string' || prop.startsWith('_') || PAGE_PROXY_IGNORED_PROPS.has(prop)) { + return value; + } + const cached = cache.get(prop); + if (cached !== undefined) { + return cached; + } + if (typeof value === 'function') { + const name = prefix + prop; + const wrapper = function (this: unknown, ...args: unknown[]) { + methodCalls.set(name, (methodCalls.get(name) ?? 0) + 1); + return Reflect.apply(value as Function, t, args); + }; + cache.set(prop, wrapper); + return wrapper; + } + if (value !== null && typeof value === 'object') { + const nested = createPageApiProxy(value as object, methodCalls, `${prefix}${prop}.`, depth + 1); + cache.set(prop, nested); + return nested; + } + return value; + }, + }); +} From b316794e64c3889330fb47028e4b89c48dcd709b Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:30:14 -0700 Subject: [PATCH 8/9] Make browser sharing availability contextual to the active session (#319660) * Make browser sharing availability contextual to the active session * feedback --- .../electron-browser/browserViewWorkbenchService.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index c95e513d7fe21..909f3407f5bc0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -23,7 +23,6 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { ChatConfiguration } from '../../chat/common/constants.js'; -import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { focusBorder } from '../../../../platform/theme/common/colors/baseColors.js'; import { buttonForeground, buttonBackground } from '../../../../platform/theme/common/colors/inputColors.js'; @@ -34,6 +33,7 @@ import { IChatWidgetService } from '../../chat/browser/chat.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; +import { localChatSessionType } from '../../chat/common/chatSessionsService.js'; /** * When enabled, integrated browser tools are exposed as client-provided tools @@ -68,9 +68,11 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV ContextKeyExpr.or( IsSessionsWindowContext.negate(), ContextKeyExpr.and( - IsSessionsWindowContext, - ContextKeyExpr.has(`config.${AgentHostEnabledSettingId}`), ContextKeyExpr.has(`config.${AgentHostChatToolsEnabledSettingId}`), + ContextKeyExpr.or( + ContextKeyExpr.equals('activeSessionType', localChatSessionType), + ContextKeyExpr.equals('sessions.isAgentHostSession', true), + ) ), ), )!; From 64d8ca886db19780b1762d54c3a0efd5a4de8c13 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 2 Jun 2026 17:30:27 -0700 Subject: [PATCH 9/9] agentHost: keep SSH connection registered after incompatible handshake (#319455) * agentHost: keep SSH connection registered after incompatible handshake When the remote server rejects the client's protocol version, SSH _setupConnection used to throw before calling addManagedConnection. That left IRemoteAgentHostService with no entry for the host, so triggerServerUpgrade could not find the still-open relay and the 'Update Server' flow was unusable from an SSH agent host. Extend addManagedConnection with an optional status parameter, and update the SSH path to detect incompatible handshakes via RemoteAgentHostConnectionStatus.fromConnectError. On an incompatible result, register the managed connection with status 'incompatible' and keep the relay (handle + protocolClient) alive so triggerServerUpgrade can reuse it, while still rethrowing the original error so the caller surfaces the failure to the user. Adds unit tests at both layers. Fixes #319381 (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: re-handshake on SSH reconnect after incompatible After the upgrade RPC succeeds and the SSH provider calls `SSHRemoteAgentHostService.reconnect()`, the main-side `replaceRelay` path tears down the old WebSocket relay and creates a fresh but it deliberately keeps the same `connectionId` soone nothing else (like the WebSocket relay channel routing) breaks. That meant `_setupConnection` saw the old renderer-side handle in its `_connections` map and short-circuited: const existing = this._connections.get(result.connectionId); if (existing) { return existing; } So the stale `RemoteAgentHostProtocolClient` (permanently stuck in `incompatible` after the original handshake rejection) was reused, even though the underlying server had just been upgraded and would now happily accept our protocol version. The connection therefore never recovered until the window was reloaded. Only short-circuit on existing handles when the managed entry is still in a usable state (`getConnection` returns the client). When it isn't, drop the stale without running itshandle `disconnectFn`, since the main service kept the SSH client alive across `replaceRelay` and we'd otherwise kill the brand-new tunnel and fall through to a fresh handshake. The subsequent `addManagedConnection` call replaces the stale managed entry and disposes the old protocol client. Adds a regression test that pins a stable `connectionId` across `connect`/`reconnect` to mimic `replaceRelay` and asserts that the second handshake produces a fresh client, a fresh `connected` registration, and no main-tunnel disconnect. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: keep tunnel connection registered after incompatible handshake Mirrors the SSH fix for the tunnel paths (desktop and web). When the protocol handshake fails with `UnsupportedProtocolVersion`, the service now registers the still-open relay as an `incompatible` managed connection instead of disposing it, so the renderer's `triggerServerUpgrade` flow can locate the protocol client and send `_vscodeUpgrade` over the existing transport. (Written by Copilot) Note: tunnels generate a fresh `connectionId` on every connect (unlike SSH's `replaceRelay` path), so the corresponding "stale-handle on reconnect" fix from the SSH side is not needed reconnect alwayshere creates a brand-new protocol client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use SemVer protocol version strings in upgrade tests Match the production shape (server advertises supportedVersions: ['^' + PROTOCOL_VERSION] in protocolServerHandler.ts) instead of date-like placeholders. No behavioural change. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostServiceImpl.ts | 6 +- .../common/remoteAgentHostService.ts | 7 +- .../sshRemoteAgentHostServiceImpl.ts | 73 ++++++++--- .../remoteAgentHostService.test.ts | 38 ++++++ .../sshRemoteAgentHostService.test.ts | 122 ++++++++++++++++-- .../browser/webTunnelAgentHostService.ts | 39 ++++-- .../tunnelAgentHostServiceImpl.ts | 46 ++++++- 7 files changed, 285 insertions(+), 46 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 0f17ac32f8e7f..29e70ed223194 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -284,7 +284,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return connection; } - async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise { + async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable, status = RemoteAgentHostConnectionStatus.connected): Promise { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { throw new Error('Remote agent host connections are not enabled.'); } @@ -311,7 +311,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Create a connection entry wrapping the pre-connected client const protocolClient = connection as RemoteAgentHostProtocolClient; store.add(protocolClient); - const connEntry: IConnectionEntry = { store, client: protocolClient, transportDisposable, connected: true, status: RemoteAgentHostConnectionStatus.connected }; + const connEntry: IConnectionEntry = { store, client: protocolClient, transportDisposable, connected: RemoteAgentHostConnectionStatus.isConnected(status), status }; this._entries.set(address, connEntry); this._names.set(address, entry.name); this._registeredEntries.set(address, entry); @@ -340,7 +340,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo name: entry.name, clientId: protocolClient.clientId, defaultDirectory: protocolClient.defaultDirectory, - status: RemoteAgentHostConnectionStatus.connected, + status, }; } diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 6ac254056a9e5..8cbf3833c04b2 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -256,8 +256,13 @@ export interface IRemoteAgentHostService { * Callers should put any teardown that needs to happen on entry removal * (e.g. closing the shared-process tunnel, dropping renderer-side handles) * into this disposable, so a single removal path tears down the whole stack. + * + * `status` defaults to `connected`. Pass `incompatible` when the managed + * transport is alive but the protocol handshake rejected the client version; + * this keeps recovery actions (such as server upgrade) addressable without + * exposing the connection as ready for session traffic. */ - addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise; + addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable, status?: RemoteAgentHostConnectionStatus): Promise; /** * Force the protocol client at `address` (if any) to treat its diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index dd31ae19ac400..a85bf8268a52f 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -13,13 +13,14 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IEnvironmentService } from '../../environment/common/environment.js'; import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../common/remoteAgentHostService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IQuickInputService } from '../../quickinput/common/quickInput.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; import { SSHRelayTransport } from './sshRelayTransport.js'; import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; +import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { ISSHRemoteAgentHostService, SSH_REMOTE_AGENT_HOST_CHANNEL, @@ -185,25 +186,57 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA private async _setupConnection(result: ISSHConnectResult): Promise { const existing = this._connections.get(result.connectionId); if (existing) { - this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); - return existing; + // Reuse the existing handle only if the managed entry is still + // in a usable state. After a `reconnect` that replaced the + // underlying SSH relay (e.g. following a CLI-driven server + // upgrade), the previous protocol client is bound to a + // torn-down transport and — if its handshake had failed with + // `incompatible` — will never re-handshake on its own. Drop + // the stale local state and fall through to a fresh + // handshake; the subsequent `addManagedConnection` call + // disposes the stale protocol client by replacing the entry. + if (this._remoteAgentHostService.getConnection(result.address)) { + this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); + return existing; + } + this._logService.info(`[SSHRemoteAgentHost] Replacing stale connection handle for ${result.address}`); + this._connections.delete(result.connectionId); + // Mark closed-by-main so disposing the handle does NOT call + // disconnect() — the main service kept the SSH client alive + // across `replaceRelay`, and we'd kill the brand-new tunnel + // otherwise. + existing.fireClose(); + existing.dispose(); + this._onDidChangeConnections.fire(); } - - let protocolClient: RemoteAgentHostProtocolClient | undefined; - let handle: SSHAgentHostConnectionHandle | undefined; let registeredHandle = false; + const protocolClient = this._createRelayClient(result); + let status = RemoteAgentHostConnectionStatus.connected; + let connectError: unknown; try { - protocolClient = this._createRelayClient(result); await protocolClient.connect(); this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); + } catch (err) { + const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); + if (!RemoteAgentHostConnectionStatus.isIncompatible(incompatible)) { + this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); + protocolClient.dispose(); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + this._logService.warn(`[SSHRemoteAgentHost] Incompatible with ${result.address}: ${incompatible.message}`); + status = incompatible; + connectError = err; + } - handle = new SSHAgentHostConnectionHandle( - result.config, - result.address, - result.name, - () => this._mainService.disconnect(result.connectionId), - ); + const handle = new SSHAgentHostConnectionHandle( + result.config, + result.address, + result.name, + () => this._mainService.disconnect(result.connectionId), + ); + try { this._connections.set(result.connectionId, handle); registeredHandle = true; this._onDidChangeConnections.fire(); @@ -219,20 +252,24 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA user: result.config.username || undefined, port: result.config.port, }, - }, protocolClient, this._createTransportDisposable(result.connectionId, handle)); - - return handle; + }, protocolClient, this._createTransportDisposable(result.connectionId, handle), status); } catch (err) { this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); if (registeredHandle && this._connections.get(result.connectionId) === handle) { this._connections.delete(result.connectionId); this._onDidChangeConnections.fire(); } - handle?.dispose(); - protocolClient?.dispose(); + handle.dispose(); + protocolClient.dispose(); this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); throw err; } + + if (connectError) { + throw connectError; + } + + return handle; } /** diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index b6123cdc2c0c5..1b3da874527a9 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -46,6 +46,7 @@ class MockProtocolClient extends Disposable { readonly connectionState = 'connecting' as const; readonly initializeResult = undefined; readonly telemetryCapabilities = undefined; + readonly triggerVscodeUpgradeCalls: string[] = []; public connectDeferred = new DeferredPromise(); @@ -57,6 +58,11 @@ class MockProtocolClient extends Disposable { return this.connectDeferred.p; } + async triggerVscodeUpgrade(method: string) { + this.triggerVscodeUpgradeCalls.push(method); + return { ok: true, upgradeStarted: true }; + } + fireClose(): void { this._onDidClose.fire(); } @@ -517,6 +523,38 @@ suite('RemoteAgentHostService', () => { ); } + test('keeps incompatible managed connection addressable for server upgrade', async () => { + const mockClient = disposables.add(new MockProtocolClient('ssh:remote.example')); + await service.addManagedConnection( + { + name: 'SSH Host', + connection: { + type: RemoteAgentHostEntryType.SSH, + address: 'ssh:remote.example', + sshConfigHost: 'remote', + hostName: 'remote.example', + }, + }, + mockClient as unknown as Parameters[1], + undefined, + RemoteAgentHostConnectionStatus.incompatible('Unsupported protocol version', ['0.3.0'], ['^0.2.0'], '_vscodeUpgrade'), + ); + + const upgradeResult = await service.triggerServerUpgrade('ssh:remote.example', '_vscodeUpgrade'); + + assert.deepStrictEqual({ + status: service.connections[0].status, + connectedConnection: service.getConnection('ssh:remote.example'), + upgradeCalls: mockClient.triggerVscodeUpgradeCalls, + upgradeResult, + }, { + status: RemoteAgentHostConnectionStatus.incompatible('Unsupported protocol version', ['0.3.0'], ['^0.2.0'], '_vscodeUpgrade'), + connectedConnection: undefined, + upgradeCalls: ['_vscodeUpgrade'], + upgradeResult: { ok: true, upgradeStarted: true }, + }); + }); + test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => { const t = makeTransportDisposable(); await addManaged('Managed', 'managed:1234', t.disposable); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts index 6081fae9d3e1b..3f3615a41cc7a 100644 --- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts @@ -16,8 +16,9 @@ import { IConfigurationService } from '../../../configuration/common/configurati import { ISharedProcessService } from '../../../ipc/electron-browser/services.js'; import { IQuickInputService } from '../../../quickinput/common/quickInput.js'; -import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../common/remoteAgentHostService.js'; import type { IAgentConnection } from '../../common/agentService.js'; +import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from '../../common/state/sessionProtocol.js'; import type { ISSHAgentHostConfig, ISSHConnectResult, @@ -26,6 +27,7 @@ import type { ISSHResolvedConfig, ISSHRemoteAgentHostMainService, } from '../../common/sshRemoteAgentHost.js'; +import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { ISSHRelayClientFactory, SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js'; import { RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; @@ -85,8 +87,8 @@ class MockSSHMainService { async reconnect(sshConfigHost: string, name: string): Promise { this.reconnectCalls.push({ sshConfigHost, name }); return { - connectionId: `conn-${this._nextConnectionId++}`, - address: `ssh:${sshConfigHost}`, + connectionId: this.connectResult?.connectionId ?? `conn-${this._nextConnectionId++}`, + address: this.connectResult?.address ?? `ssh:${sshConfigHost}`, name, connectionToken: 'test-token', config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost }, @@ -140,14 +142,36 @@ function asChannel(target: object): IChannel { /** Captures addManagedConnection calls so tests can inspect transportDisposable. */ class MockRemoteAgentHostService extends Disposable { - readonly added: Array<{ address: string; transport?: IDisposable }> = []; - private readonly _entries = new Map void } }>(); - - async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable): Promise { + readonly added: Array<{ address: string; status?: RemoteAgentHostConnectionStatus; transport?: IDisposable }> = []; + private readonly _entries = new Map void }; status: RemoteAgentHostConnectionStatus }>(); + // Holds transport disposables from prior registrations that were + // replaced by a later `addManagedConnection` for the same address. + // Production deliberately does NOT run them at replacement time (doing + // so would call _mainService.disconnect on the brand-new tunnel and + // kill it). They are released when the service itself is disposed. + private readonly _abandonedTransports: IDisposable[] = []; + + async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable, status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected): Promise { const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`; - this.added.push({ address, transport: transportDisposable }); - this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable }); - return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status: 0 }; + // Mirror RemoteAgentHostService: re-registering an address replaces + // the previous entry and disposes its protocol client (but NOT its + // transport disposable — the new entry owns the underlying tunnel). + const previous = this._entries.get(address); + if (previous) { + previous.client.dispose?.(); + if (previous.transport) { + this._abandonedTransports.push(previous.transport); + } + } + this.added.push({ address, status, transport: transportDisposable }); + this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable, status }); + return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status }; + } + + /** Mirrors IRemoteAgentHostService.getConnection: returns the client only when the entry is connected. */ + getConnection(address: string): IAgentConnection | undefined { + const entry = this._entries.get(address); + return entry && RemoteAgentHostConnectionStatus.isConnected(entry.status) ? entry.client as unknown as IAgentConnection : undefined; } notifyConnectionClosed(_address: string): void { @@ -173,6 +197,11 @@ class MockRemoteAgentHostService extends Disposable { e.transport?.dispose(); } this._entries.clear(); + // Release abandoned transports from prior registrations as well. + for (const t of this._abandonedTransports) { + t.dispose(); + } + this._abandonedTransports.length = 0; super.dispose(); } } @@ -268,11 +297,84 @@ suite('SSHRemoteAgentHostService (renderer)', () => { assert.strictEqual(remoteAgentHostService.added.length, 1); assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example'); + assert.strictEqual(remoteAgentHostService.added[0].status?.kind, 'connected'); assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel'); assert.strictEqual(service.connections.length, 1); assert.strictEqual(handle.localAddress, 'ssh:remote.example'); }); + test('incompatible handshake keeps SSH tunnel registered for server upgrade', async () => { + const connectPromise = service.connect(sampleConfig); + const client = await waitForClient(0); + await client.connectDeferred.error(new ProtocolError( + AHP_UNSUPPORTED_PROTOCOL_VERSION, + 'Unsupported protocol version', + { supportedVersions: ['^0.2.0'], _meta: { vscodeUpgradeMethod: '_vscodeUpgrade' } }, + )); + + await assert.rejects(connectPromise, /Unsupported protocol version/); + + assert.deepStrictEqual({ + added: remoteAgentHostService.added.map(({ address, status }) => ({ address, status })), + connections: service.connections.map(connection => connection.localAddress), + disconnectCalls: mainService.disconnectCalls, + }, { + added: [{ + address: 'ssh:remote.example', + status: RemoteAgentHostConnectionStatus.incompatible('Unsupported protocol version', [PROTOCOL_VERSION], ['^0.2.0'], '_vscodeUpgrade'), + }], + connections: ['ssh:remote.example'], + disconnectCalls: [], + }); + }); + + test('reconnect after incompatible handshake replaces the stale handle and re-handshakes', async () => { + // Pin a stable connectionId so the simulated `replaceRelay` reconnect + // returns the same id as the initial connect — that is the real + // behavior of SSHRemoteAgentHostMainService.connect(replaceRelay=true). + mainService.connectResult = { connectionId: 'conn-stable', address: 'ssh:remote.example' }; + + // First connect: handshake rejected as incompatible. Per the existing + // fix, this still registers a managed connection in `incompatible` + // state so the server-upgrade RPC can reach the host. + const firstConnect = service.connect(sampleConfig); + const firstClient = await waitForClient(0); + await firstClient.connectDeferred.error(new ProtocolError( + AHP_UNSUPPORTED_PROTOCOL_VERSION, + 'Unsupported protocol version', + { supportedVersions: ['^0.2.0'], _meta: { vscodeUpgradeMethod: '_vscodeUpgrade' } }, + )); + await assert.rejects(firstConnect, /Unsupported protocol version/); + + // User triggers the server upgrade and then the contribution reconnects. + // The reconnect must NOT short-circuit to the stale handle (whose + // protocol client is permanently stuck in incompatible state); it must + // build a fresh client and complete a fresh handshake against the + // upgraded server. + const reconnectPromise = service.reconnect('remote.example', 'My Remote'); + const secondClient = await waitForClient(1); + await secondClient.connectDeferred.complete(); + await reconnectPromise; + + assert.deepStrictEqual({ + clientCount: createdClients.length, + added: remoteAgentHostService.added.map(({ address, status }) => ({ address, statusKind: status?.kind })), + // The replaceRelay path keeps the SSH tunnel alive — we must not + // have asked the main service to disconnect it. + disconnectCalls: mainService.disconnectCalls, + // Exactly one renderer-side handle for the address. + connections: service.connections.map(connection => connection.localAddress), + }, { + clientCount: 2, + added: [ + { address: 'ssh:remote.example', statusKind: 'incompatible' }, + { address: 'ssh:remote.example', statusKind: 'connected' }, + ], + disconnectCalls: [], + connections: ['ssh:remote.example'], + }); + }); + test('disabled setting prevents SSH tunnel connects and reconnects', async () => { configurationService.setRemoteAgentHostsEnabled(false); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index d92f5a824f22c..a501f55c88219 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -6,7 +6,8 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; -import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import type { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import type { ProtocolMessage, AhpServerNotification, JsonRpcResponse } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../../platform/agentHost/common/transportConstants.js'; @@ -156,16 +157,34 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen RemoteAgentHostProtocolClient, address, transport, undefined, ); + // Keep an incompatible handshake from tearing down the relay: the + // protocol client must remain registered with IRemoteAgentHostService + // so `triggerServerUpgrade` can locate it and send `_vscodeUpgrade` + // over the still-open transport. + let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; + let connectError: unknown; try { await protocolClient.connect(); this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`); + } catch (err) { + const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); + if (!RemoteAgentHostConnectionStatus.isIncompatible(incompatible)) { + protocolClient.dispose(); + this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + throw err; + } + this._logService.warn(`${LOG_PREFIX} Incompatible with ${address}: ${incompatible.message}`); + status = incompatible; + connectError = err; + } - // Cache before announcing the live connection so the contribution's - // `onDidChangeTunnels` handler has created the provider by the time - // `onDidChangeConnections` fires from `addManagedConnection` and - // wires the connection. Also fires `onDidChangeTunnels`. - this.cacheTunnel(tunnel, authProvider); + // Cache before announcing the live connection so the contribution's + // `onDidChangeTunnels` handler has created the provider by the time + // `onDidChangeConnections` fires from `addManagedConnection` and + // wires the connection. Also fires `onDidChangeTunnels`. + this.cacheTunnel(tunnel, authProvider); + try { await this._remoteAgentHostService.addManagedConnection({ name: tunnel.name, connectionToken, @@ -176,12 +195,16 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen label: tunnel.name, authProvider, }, - }, protocolClient); + }, protocolClient, undefined, status); } catch (err) { protocolClient.dispose(); - this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + this._logService.error(`${LOG_PREFIX} addManagedConnection failed`, err); throw err; } + + if (connectError) { + throw connectError; + } } async disconnect(address: string): Promise { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index f62aa112a977e..db2fb534a3786 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -14,7 +14,8 @@ import { ISharedProcessService } from '../../../../../platform/ipc/electron-brow import { ILogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ITunnelAgentHostService, TUNNEL_AGENT_HOST_CHANNEL, @@ -105,7 +106,10 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo const result = await this._mainService.connect(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); this._logService.info(`${LOG_PREFIX} Tunnel relay connected, connectionId=${result.connectionId}`); - // Create relay transport + protocol client, then register with RemoteAgentHostService + // Build relay transport + protocol client. If construction itself + // fails (rare — would mean the AHP logger or transport ctor threw) + // tear the just-opened main-side relay down before propagating. + let protocolClient: RemoteAgentHostProtocolClient; try { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( @@ -113,15 +117,40 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo { logsHome: this._environmentService.logsHome, connectionId: result.connectionId, transport: 'tunnel' }, ) : undefined; const transport = new TunnelRelayTransport(result.connectionId, this._mainService, logger); - const protocolClient = this._instantiationService.createInstance( + protocolClient = this._instantiationService.createInstance( RemoteAgentHostProtocolClient, result.address, transport, undefined, ); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + // Keep an incompatible handshake from tearing down the relay: the + // protocol client must remain registered with IRemoteAgentHostService + // so `triggerServerUpgrade` can locate it and send `_vscodeUpgrade` + // over the still-open transport. + let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; + let connectError: unknown; + try { await protocolClient.connect(); this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${result.address}`); + } catch (err) { + const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(err, [PROTOCOL_VERSION]); + if (!RemoteAgentHostConnectionStatus.isIncompatible(incompatible)) { + this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + protocolClient.dispose(); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + this._logService.warn(`${LOG_PREFIX} Incompatible with ${result.address}: ${incompatible.message}`); + status = incompatible; + connectError = err; + } - this.cacheTunnel(tunnel, auth.provider); + this.cacheTunnel(tunnel, auth.provider); + try { await this._remoteAgentHostService.addManagedConnection({ name: result.name, connectionToken: result.connectionToken, @@ -132,12 +161,17 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo label: tunnel.name, authProvider: auth.provider, }, - }, protocolClient); + }, protocolClient, undefined, status); } catch (err) { - this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + this._logService.error(`${LOG_PREFIX} addManagedConnection failed`, err); + protocolClient.dispose(); this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); throw err; } + + if (connectError) { + throw connectError; + } } async disconnect(address: string): Promise {