diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 7b55a2f24fb..8554b9519fe 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -38,7 +38,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, MIGRATE_LEGACY_COPILOT_CLI_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -388,6 +388,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } this._updateSystemProxyEnabled(); } + if (e.affectsConfiguration(MIGRATE_LEGACY_COPILOT_CLI_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateMigrateLegacyCopilotCliEnabled(); + } if (e.affectsConfiguration(AgentHostCopilotMultiRootEnabledSettingId)) { if (this._state.kind !== AgentHostClientState.Connected) { return; @@ -650,6 +656,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._applyReconnectResult(result); + // Re-push renderer-owned config on reconnect too: a reconnected host may + // be a freshly restarted process that never received these values (the + // reconnect result itself carries none), which would otherwise leave + // early-read config like the migrate flag at its host-side default. + this._forwardClientConfig(); + // Drain the outbox BEFORE the transition so listeners reacting to // {@link onDidChangeConnectionState} that synchronously dispatch see // state=Connected and go direct, landing after the drained outbox @@ -710,6 +722,18 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const directory = result.defaultDirectory; this._defaultDirectory = typeof directory === 'string' ? URI.parse(directory).path : URI.revive(directory).path; } + this._forwardClientConfig(); + } + + /** + * Push the renderer-owned config values the host mirrors (telemetry level, + * proxy discovery, migrate flag, …) as `RootConfigChanged` actions. Called on + * initial connect AND on reconnect: a reconnected host may be a freshly + * restarted process (or one that lost these values), and re-pushing is a cheap + * no-op when nothing changed. Without this, a value read early — like the + * migrate flag in `listSessions` — can be missing after a window reload. + */ + private _forwardClientConfig(): void { this._updateTelemetryLevel(); this._updateEditTelemetryEnabled(); this._updateSessionSyncEnabled(); @@ -718,6 +742,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._updateAutoReplyEnabled(); this._updatePreferLongContextEnabled(); this._updateSystemProxyEnabled(); + this._updateMigrateLegacyCopilotCliEnabled(); this._updateCopilotMultiRootEnabled(); this._updateClaudeMultiRootEnabled(); this._updateCodexMultiRootEnabled(); @@ -1573,6 +1598,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, this._clientId, 0); } + private _updateMigrateLegacyCopilotCliEnabled(): void { + const enabled = this._configurationService.getValue(MIGRATE_LEGACY_COPILOT_CLI_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + private _updateCopilotMultiRootEnabled(): void { const enabled = this._configurationService.getValue(AgentHostCopilotMultiRootEnabledSettingId) === true; this.dispatchAction(ROOT_STATE_URI, { diff --git a/src/vs/platform/agentHost/common/agentHostCheckpointService.ts b/src/vs/platform/agentHost/common/agentHostCheckpointService.ts index 28689427968..c3586b4f8e1 100644 --- a/src/vs/platform/agentHost/common/agentHostCheckpointService.ts +++ b/src/vs/platform/agentHost/common/agentHostCheckpointService.ts @@ -105,6 +105,19 @@ export interface IAgentHostCheckpointService { * leaks the refs. */ deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise; + + /** + * Adopts the legacy extension-host Copilot CLI checkpoint refs + * (`refs/sessions//checkpoints/turn/`) for a migrated session: + * re-points each commit under this service's + * `refs/agents//checkpoints/turn/` namespace (same OIDs), seeds the + * session database per-turn checkpoint index keyed by the supplied ordered + * {@link turnIds} (the baseline and per-turn commits stay discoverable by the + * ref-name convention), and drops the legacy refs. Best-effort and idempotent; + * a no-op when the working directory is not git-backed or no legacy refs exist. + * Optional so fixtures that don't exercise migration can omit it. + */ + adoptLegacyCheckpoints?(sessionUri: URI, workingDirectory: URI, rawSessionId: string, turnIds: readonly string[]): Promise; } /** diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 705b1334633..061c30edd87 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -369,6 +369,14 @@ export interface IAgentHostGitService { */ revParse(repositoryRoot: URI, expression: string): Promise; + /** + * Lists refs matching `pattern` (a `git for-each-ref` glob such as + * `refs/sessions//*`) with their resolved commit OIDs. Returns an empty + * array when none match. Optional: implementations that don't support raw + * ref enumeration may omit it. + */ + listRefNamesWithOids?(repositoryRoot: URI, pattern: string): Promise>; + /** * Builds a new tree from `baseTreeOid` in which the single repo-relative * `path` is replaced by its content (blob + mode) from `sourceTreeOid`, or diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index d2324886355..6955433faf0 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -468,6 +468,15 @@ export const PREFER_LONG_CONTEXT_SETTING_ID = 'github.copilot.chat.preferLongCon /** Root config key forwarded from the renderer for automatic OS system proxy discovery. */ export const AgentHostSystemProxyEnabledConfigKey = 'systemProxyEnabled'; +// Root config key forwarded from the renderer when the `chat.agentSessions.migrateLegacyCopilotCli` +// setting changes. When `true`, `listSessions` surfaces un-adopted extension-host Copilot CLI +// sessions as adoptable agent-host sessions, and opening one adopts it in place. Experimental; off. +export const AgentHostMigrateLegacyCopilotCliEnabledConfigKey = 'migrateLegacyCopilotCliEnabled'; + +// The VS Code setting ID gating legacy Copilot CLI migration, forwarded into the agent host root +// config. Kept in sync with `ChatConfiguration.MigrateLegacyCopilotCliSessions` (workbench layer). +export const MIGRATE_LEGACY_COPILOT_CLI_SETTING_ID = 'chat.agentSessions.migrateLegacyCopilotCli'; + /** * Root config key forwarded from the renderer that gates multiple-working-directory * support for the Copilot provider. When `true`, the Copilot provider advertises @@ -749,6 +758,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.systemProxyEnabled.description', "Whether Copilot sessions automatically discover and use the operating system's proxy configuration."), default: true, }), + [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.migrateLegacyCopilotCliEnabled.title', "Migrate Legacy Copilot CLI Sessions"), + description: localize('agentHost.config.migrateLegacyCopilotCliEnabled.description', "Whether un-adopted extension-host Copilot CLI sessions are surfaced as adoptable agent-host sessions and migrated in place when opened."), + default: false, + }), [AgentHostCopilotMultiRootEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.copilotMultiRootEnabled.title', "Copilot Multiple Working Directories"), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index f29d56f20dd..01e46006fb2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1602,6 +1602,15 @@ export interface IAgent { */ readonly onDidMaterializeSession?: Event; + /** + * Fires (debounced) when the provider's on-disk session set may have changed + * out of band (e.g. a legacy Copilot CLI session was created by the extension + * host while the window is open). The {@link IAgentService} responds by + * announcing any adoptable-legacy sessions not yet known to clients. Optional: + * providers that cannot detect out-of-band changes omit it. + */ + readonly onDidChangeSessionList?: Event; + /** * Provides the agent host's server-tool host so the provider can advertise * and execute the agent host's server tools (feedback "comments" today, more @@ -1628,6 +1637,17 @@ export interface IAgent { /** Create a new session. Host-owned worktree fields are omitted from `config.config`. */ createSession(config?: IAgentCreateSessionConfig): Promise; + /** + * Adopt-on-open for a legacy on-disk session (e.g. one created by the + * extension-host Copilot CLI): if `session` has an on-disk SDK event log but + * no agent-host metadata yet, seed that metadata in place — reusing the event + * log verbatim — so the normal restore flow can resume it. Returns `true` iff + * it newly adopted the session (so the caller can run a one-time checkpoint + * bridge), `false` otherwise. Optional: providers without a legacy on-disk + * format omit it. + */ + ensureSessionAdopted?(session: URI): Promise; + /** Resolve provider-owned session configuration; host-owned worktree fields are omitted. */ resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 58fca5ce51f..c2188f8c153 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -407,7 +407,7 @@ export interface ISessionDataService { * Subscribers can register asynchronous cleanup work via * {@link IWillDeleteSessionDataEvent.waitUntil}; the deletion is * blocked until all registered promises settle. Used by - * `IAgentHostCheckpointService.disposeSessionData` to read the exact + * `IAgentHostCheckpointService.deleteCheckpoints` to read the exact * list of checkpoint refs from the (still-readable) database and * delete them before the directory is removed. * diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2639a49725d..97bff76c908 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1517,6 +1517,25 @@ export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, w return Object.keys(next).length > 0 ? next : undefined; } +/** + * `_meta` key marking a session as an un-adopted legacy Copilot CLI session + * surfaced (only under the migrate setting) as adoptable. Clients read it to + * avoid passively subscribing to — and thereby migrating — the session before + * the user opens it. Cleared implicitly once the session is adopted (it no + * longer surfaces as adoptable). + */ +export const SESSION_META_EHCLI_ADOPTABLE_KEY = 'ehcliAdoptable'; + +/** Whether the session is an un-adopted legacy Copilot CLI session surfaced as adoptable. */ +export function readSessionEhcliAdoptable(meta: SessionSummaryMeta | undefined): boolean { + return meta?.[SESSION_META_EHCLI_ADOPTABLE_KEY] === true; +} + +/** Returns a new {@link SessionSummaryMeta} with the adoptable-legacy marker set. */ +export function withSessionEhcliAdoptable(meta: SessionSummaryMeta | undefined): SessionSummaryMeta { + return { ...meta, [SESSION_META_EHCLI_ADOPTABLE_KEY]: true }; +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentHostCheckpointService.ts b/src/vs/platform/agentHost/node/agentHostCheckpointService.ts index 222407d2d51..ae8afb2f063 100644 --- a/src/vs/platform/agentHost/node/agentHostCheckpointService.ts +++ b/src/vs/platform/agentHost/node/agentHostCheckpointService.ts @@ -207,6 +207,58 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost return baselineRef ? baselineRefName : undefined; } + adoptLegacyCheckpoints(sessionUri: URI, workingDirectory: URI, rawSessionId: string, turnIds: readonly string[]): Promise { + return this._sequencer.queue(sessionUri.toString(), () => this._adoptLegacyCheckpoints(sessionUri, workingDirectory, rawSessionId, turnIds)); + } + + private async _adoptLegacyCheckpoints(sessionUri: URI, workingDirectory: URI, rawSessionId: string, turnIds: readonly string[]): Promise { + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repoRoot || !this._gitService.listRefNamesWithOids) { + return; // non-git session (no checkpoints existed) or capability unavailable + } + // Legacy EH checkpoint refs are `refs/sessions//checkpoints/turn/`. + // Pass the id prefix (no glob) so git's for-each-ref prefix match returns + // every nested ref regardless of depth. + const legacy = await this._gitService.listRefNamesWithOids(repoRoot, `refs/sessions/${rawSessionId}`); + if (legacy.length === 0) { + return; + } + // Parse the turn number from each legacy ref's trailing path segment. + const oidByTurn = new Map(); + for (const { ref, oid } of legacy) { + const n = parseInt(ref.substring(ref.lastIndexOf('/') + 1), 10); + if (Number.isFinite(n)) { + oidByTurn.set(n, oid); + } + } + const sanitized = this._sanitizedSessionId(sessionUri); + // Re-point each legacy commit under the agent-host ref namespace (same OIDs). + const refByTurn = new Map(); + for (const [n, oid] of oidByTurn) { + const refName = buildCheckpointRefName(sanitized, n); + await this._gitService.updateRef(repoRoot, refName, oid); + refByTurn.set(n, refName); + } + const ref = this._sessionDataService.openDatabase(sessionUri); + try { + // The baseline (turn 0) and per-turn commits are discoverable by the + // `buildCheckpointRefName` convention (re-pointed above via updateRef), so + // only the per-turn checkpoint index needs seeding here. The i-th resumed + // turn (0-based) corresponds to end-of-turn checkpoint N=i+1. + for (let i = 0; i < turnIds.length; i++) { + const refName = refByTurn.get(i + 1); + if (refName) { + await ref.object.setTurnCheckpointRef(turnIds[i], refName); + } + } + } finally { + ref.dispose(); + } + // Drop the legacy refs now the commits are reachable via the agent-host namespace. + await this._gitService.deleteRefs(repoRoot, legacy.map(l => l.ref)).catch(() => { }); + this._logService.info(`[AgentHostCheckpoint] Adopted ${refByTurn.size} legacy checkpoint refs for ${sessionUri.toString()}`); + } + async deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise { await this._sequencer.queue(sessionUri.toString(), () => this._deleteCheckpoints(sessionUri, workingDirectories)); } diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index e55e80af7a1..ed35aa603ba 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -623,6 +623,25 @@ export class AgentHostGitService implements IAgentHostGitService { return out?.trim() || undefined; } + async listRefNamesWithOids(repositoryRoot: URI, pattern: string): Promise> { + const out = await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(refname)%00%(objectname)', pattern]); + if (!out) { + return []; + } + const result: Array<{ ref: string; oid: string }> = []; + for (const line of out.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const [ref, oid] = trimmed.split('\x00'); + if (ref && oid) { + result.push({ ref, oid }); + } + } + return result; + } + async overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise { // Build a throwaway index seeded from `baseTreeOid`, replace/remove the // single `path` using `sourceTreeOid`, and write the result back out as diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index c474dd3b960..55795887fbf 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -695,7 +695,7 @@ export class AgentHostStateManager extends Disposable { * state is authoritative for those. No-ops for sessions that were already * announced (idempotent). */ - markSessionPersisted(session: URI, summary: SessionSummary): void { + markSessionPersisted(session: URI, summary: SessionSummary, force = false): void { const key = session.toString(); const entry = this._sessionStates.get(key); if (!entry) { @@ -705,8 +705,12 @@ export class AgentHostStateManager extends Disposable { // The notifier records a session's announced summary whenever it has // been surfaced to clients (either through `createSession` or here); // using it as the idempotency check keeps us from firing `SessionAdded` - // twice for a session whose creation was not deferred. - if (this._summaryNotifier.isAnnounced(key)) { + // twice for a session whose creation was not deferred. `force` overrides + // this for adopt, where `restoreSession` marks the summary announced + // without ever emitting, so clients (e.g. the workspace-scoped editor + // session list) that rely on the notification would otherwise miss it — + // a redundant re-announce is harmless (`SessionAdded` is idempotent). + if (!force && this._summaryNotifier.isAnnounced(key)) { return; } // Propagate the materialization-resolved fields so subscribers calling @@ -726,6 +730,31 @@ export class AgentHostStateManager extends Disposable { }); } + /** + * Announce a legacy Copilot CLI session that the provider discovered on disk + * (surfaced as adoptable) after startup, so clients add it to their list + * without a manual reload. Does NOT create persistent state — the session is + * materialized on demand when the user opens it (restore/adopt). No-ops if + * the session is already in state or was already announced. + */ + announceSurfacedSession(summary: SessionSummary): void { + const key = summary.resource; + if (this._sessionStates.has(key)) { + this._logService.trace(`[AgentHostStateManager] announceSurfacedSession: already in state ${key}`); + return; + } + if (this._summaryNotifier.isAnnounced(key)) { + this._logService.trace(`[AgentHostStateManager] announceSurfacedSession: already announced ${key}`); + return; + } + this._summaryNotifier.announce(key, summary); + this._onDidEmitNotification.fire({ + type: 'root/sessionAdded', + channel: ROOT_STATE_URI, + summary, + }); + } + /** * Restores a session from a previous server lifetime into the state manager * with pre-populated turns. The session is created in `ready` lifecycle diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6d8b3ae83c3..c8a420fa340 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -34,7 +34,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type ChatOrigin, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { IProductService } from '../../product/common/productService.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; @@ -74,7 +74,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { AgentHostEditTelemetryEnabledConfigKey } from '../common/agentHostSchema.js'; +import { AgentHostEditTelemetryEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; @@ -742,6 +742,9 @@ export class AgentService extends Disposable implements IAgentService { if (provider.onDidMaterializeSession) { this._providerSubscriptions.add(provider.onDidMaterializeSession(e => this._onDidMaterializeSession(e))); } + if (provider.onDidChangeSessionList) { + this._providerSubscriptions.add(provider.onDidChangeSessionList(() => this._onProviderSessionListChanged())); + } if (provider.onMcpNotification) { this._providerSubscriptions.add(provider.onMcpNotification(e => this._onMcpNotification.fire(e))); } @@ -1091,6 +1094,70 @@ export class AgentService extends Disposable implements IAgentService { return combined; } + /** Debounces provider `onDidChangeSessionList` bursts into one surface pass. */ + private readonly _surfaceSessionsDebounce = this._register(new MutableDisposable()); + /** Adoptable-legacy session keys already announced this AH lifetime, so bursts don't re-announce them. */ + private readonly _announcedSurfacedKeys = new Set(); + + /** + * A provider reported its on-disk session set may have changed (e.g. a legacy + * Copilot CLI session created by the extension host). Re-list and announce any + * adoptable-legacy sessions not yet known to clients so they surface without a + * manual reload. + */ + private _onProviderSessionListChanged(): void { + this._surfaceSessionsDebounce.value = disposableTimeout(() => { + void this._surfaceAdoptableLegacySessions(); + }, 250); + } + + private async _surfaceAdoptableLegacySessions(): Promise { + let listed: IAgentSessionMetadata[]; + try { + listed = await this.listSessions(); + } catch (err) { + this._logService.warn('[AgentService] surfaceAdoptableLegacySessions: listSessions failed', err); + return; + } + for (const meta of listed) { + // Only announce sessions surfaced as adoptable-legacy — never native + // sessions (which clients already know from their own listSessions). + if (!readSessionEhcliAdoptable(meta._meta)) { + continue; + } + const provider = AgentSession.provider(meta.session); + if (!provider) { + continue; // defensive: malformed session URI + } + const key = meta.session.toString(); + if (this._announcedSurfacedKeys.has(key)) { + continue; // already announced this lifetime + } + if (this._stateManager.getSessionState(key)) { + continue; // already adopted / restored + } + this._stateManager.announceSurfacedSession(this._surfacedSessionSummary(meta, provider)); + this._announcedSurfacedKeys.add(key); + } + } + + /** Synthesizes the minimal {@link SessionSummary} for an adoptable session surfaced by {@link listSessions}. */ + private _surfacedSessionSummary(meta: IAgentSessionMetadata, provider: string): SessionSummary { + return { + resource: meta.session.toString(), + provider, + title: meta.summary ?? '', + status: meta.status ?? SessionStatus.Idle, + createdAt: new Date(meta.startTime).toISOString(), + modifiedAt: new Date(meta.modifiedTime).toISOString(), + ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), + workingDirectories: meta.workingDirectories?.map(d => d.toString()), + // Marks the session adoptable so clients don't passively subscribe (and + // thereby migrate) it before the user opens it. + _meta: withSessionEhcliAdoptable(meta._meta), + }; + } + async createSession(config?: IAgentCreateSessionConfig): Promise { const providerId = config?.provider ?? this._defaultProvider; const provider = providerId ? this._providers.get(providerId) : undefined; @@ -1177,6 +1244,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); + // Record this session's opt-in so a cold SDK download triggered at // materialization (first message) is surfaced as progress. The download // is provider-global, so we only track interest here; emission is keyed @@ -2757,6 +2825,14 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`); } + // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session: seed its + // VS Code-layer metadata in place (reusing the on-disk event log) so the + // restore below can hydrate it. Gated on the migrate setting so the common + // (non-migration) restore path does no extra work; a no-op for native / + // already-adopted sessions. + const migrateLegacyEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; + const adopted = migrateLegacyEnabled ? (await agent.ensureSessionAdopted?.(session) ?? false) : false; + const meta = await this._getSessionMetadataForRestore(agent, session); if (!meta) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); @@ -2896,6 +2972,20 @@ export class AgentService extends Disposable implements IAgentService { const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); this._stateManager.restoreSession(summary, mergedTurns, { draft: defaultDraft, defaultChatTitle }); + // A freshly-adopted legacy session bridges its git checkpoints into the + // agent-host namespace once its turns are restored. Isolated so a failure + // here cannot break the restore. + if (adopted && this._checkpointService.adoptLegacyCheckpoints) { + try { + const checkpointWorkingDirectory = meta.workingDirectories?.[0]; + if (checkpointWorkingDirectory) { + await this._checkpointService.adoptLegacyCheckpoints(session, checkpointWorkingDirectory, AgentSession.id(session), mergedTurns.map(t => t.id)); + } + } catch (err) { + this._logService.warn(`[AgentService] adopt: checkpoint bridge failed for ${sessionStr}`, err); + } + } + const promises: Promise[] = []; // Eagerly register subagent child sessions discovered in the event log // so the client's per-subagent subscriptions resolve from in-memory diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index d34ad26be4c..8c0461e04c6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -37,7 +37,7 @@ import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBil import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; -import { AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; @@ -52,7 +52,7 @@ import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { getByokLmSelectionModelId } from '../../common/agentHostByokLm.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -490,6 +490,14 @@ export class CopilotAgent extends Disposable implements IAgent { readonly onDidSpawnChat = this._onDidSpawnChat.event; private readonly _onDidMaterializeSession = this._register(new Emitter()); readonly onDidMaterializeSession = this._onDidMaterializeSession.event; + /** + * Fires when the set of adoptable-legacy sessions the host should surface may + * have changed — today only when the renderer's migrate-legacy flag flips on + * (which can arrive after the first `listSessions`). The {@link AgentService} + * responds by re-listing and announcing any newly adoptable sessions. + */ + private readonly _onDidChangeSessionList = this._register(new Emitter()); + readonly onDidChangeSessionList = this._onDidChangeSessionList.event; /** * Per-session MCP notifications, fanned in from every active * {@link CopilotAgentSession}. Each session contributes a single @@ -680,6 +688,19 @@ export class CopilotAgent extends Disposable implements IAgent { ); })); + // The migrate-legacy flag is pushed from the renderer after connect, which + // can land AFTER the first `listSessions` (so it surfaced nothing). When it + // flips on, re-list so adoptable legacy sessions surface without a reload. + this._register(this._configurationService.onDidRootConfigChange(() => { + const enabled = this._isMigrateLegacyCopilotCliEnabled(); + if (enabled !== this._lastMigrateLegacyEnabled) { + this._lastMigrateLegacyEnabled = enabled; + if (enabled) { + this._onDidChangeSessionList.fire(); + } + } + })); + // Surface renderer BYOK models in the picker: republish them whenever the // set of connected renderer bridges, or any renderer's models, change. // The registry is only populated when `chat.agentHost.byokModels.enabled` @@ -723,6 +744,7 @@ export class CopilotAgent extends Disposable implements IAgent { private _lastCopilotSdkLogLevelSetting: CopilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting(); private _lastEnterpriseHost: string | undefined = this._getEnterpriseHost(); private _lastSystemProxyEnabled: boolean = this._isSystemProxyEnabled(); + private _lastMigrateLegacyEnabled: boolean = this._isMigrateLegacyCopilotCliEnabled(); private _isSessionSyncEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true; @@ -752,9 +774,12 @@ export class CopilotAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostSystemProxyEnabledConfigKey) !== false; } + private _isMigrateLegacyCopilotCliEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; + } + /** - * Restarts the CLI client when a config value that is only read at client - * startup has changed. The restart is deferred while any chat has an + * Restart the CLI client when a startup-baked value changes, but defer past any * in-flight turn — see {@link _requestClientRestart} — so the new values are * picked up at the next quiet point rather than by killing live work. * An in-flight start aborts if any startup value changes. @@ -1752,12 +1777,37 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); + const migrateLegacy = this._isMigrateLegacyCopilotCliEnabled(); const projectLimiter = new Limiter(4); const projectByContext = new Map>(); const mapped = await Promise.all(sessions.map(async s => { const session = AgentSession.uri(this.id, s.sessionId); const metadata = await this._readStoredSessionMetadata(session); - if (!metadata) { + // Only list sessions the agent host actually owns: a genuine native / + // already-migrated session has a persisted working directory. + if (!metadata?.workingDirectory) { + // No stored working directory. When migration is enabled, surface a + // genuinely un-adopted extension-host Copilot CLI session as adoptable + // so the agent host owns the list without the extension host; opening + // it adopts in place. `metadata === undefined` means there is no + // session database at all (i.e. not agent-host-owned), which excludes + // ghost DBs created empty by checkpoint / changeset / git services; the + // `vscode.metadata.json` marker excludes standalone CLI and provisional + // agent-host sessions. When disabled, nothing here is surfaced. + if (migrateLegacy + && metadata === undefined + && typeof s.context?.workingDirectory === 'string' + && await this._isExtensionHostCliSession(s.sessionId)) { + return { + session, + startTime: s.startTime.getTime(), + modifiedTime: s.modifiedTime.getTime(), + project: await this._resolveSessionProject(s.context, projectLimiter, projectByContext), + summary: s.summary, + workingDirectories: [URI.file(s.context.workingDirectory)], + _meta: withSessionEhcliAdoptable(undefined), + } satisfies IAgentSessionMetadata; + } return undefined; } let { project, resolved } = metadata; @@ -2234,6 +2284,98 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + /** + * Whether an on-disk Copilot session was created by the VS Code extension-host + * Copilot CLI feature — identified by its `vscode.metadata.json` marker under + * `~/.copilot/session-state//`. Distinguishes EH CLI sessions (the only + * ones we migrate) from other Copilot SDK sessions that share the same store + * (standalone `copilot` CLI runs, Local agent sessions, …). + */ + /** Absolute path of the extension-host Copilot CLI `vscode.metadata.json` marker for `sessionId`. */ + private _extensionHostCliMarkerPath(sessionId: string): string { + return join(getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), 'session-state', sessionId, 'vscode.metadata.json'); + } + + /** Memoizes the (stable) marker check so repeated `listSessions` calls don't re-stat the disk. */ + private readonly _isExtensionHostCliSessionCache = new Map>(); + + private _isExtensionHostCliSession(sessionId: string): Promise { + let cached = this._isExtensionHostCliSessionCache.get(sessionId); + if (!cached) { + cached = fs.access(this._extensionHostCliMarkerPath(sessionId)).then(() => true, () => false); + this._isExtensionHostCliSessionCache.set(sessionId, cached); + } + return cached; + } + + /** + * Reads the VS Code-layer custom title the extension-host Copilot CLI feature + * persisted for `sessionId` in its `vscode.metadata.json` marker, so adoption + * can carry the user-chosen session name over to the agent host. Returns + * `undefined` when the marker is absent/unreadable or has no custom title. + */ + private async _readExtensionHostCliCustomTitle(sessionId: string): Promise { + try { + const raw = await fs.readFile(this._extensionHostCliMarkerPath(sessionId), 'utf8'); + const title = (JSON.parse(raw) as { customTitle?: unknown }).customTitle; + return typeof title === 'string' && title.trim() ? title : undefined; + } catch { + return undefined; + } + } + + /** + * Adopt-on-open for legacy extension-host Copilot CLI sessions. If `session` + * has an on-disk SDK event log (`~/.copilot/session-state//`) but no + * agent-host VS Code-layer metadata yet, seed that metadata in place — reusing + * the event log verbatim — so the normal restore flow can resume it as editable + * turns. Returns `true` iff it newly adopted the session (so the caller can run + * the one-time checkpoint bridge); `false` when already migrated / native or + * not an adoptable on-disk session. + */ + async ensureSessionAdopted(session: URI): Promise { + const sessionId = AgentSession.id(session); + return this._sessionSequencer.queue(sessionId, async () => { + // A genuine native / already-adopted session always has a persisted + // working directory. The session DB FILE can also exist without any + // real metadata (checkpoint / changeset / git services create it via + // `openDatabase`), so gate on `workingDirectory` — not mere DB + // existence — to avoid falsely treating an empty DB as migrated. + const existing = await this._readStoredSessionMetadata(session); + if (existing?.workingDirectory) { + return false; // already native / adopted + } + // Only migrate legacy EH Copilot CLI sessions — never other Copilot SDK + // sessions (standalone CLI, Local agent, …) that share `~/.copilot`. + if (!(await this._isExtensionHostCliSession(sessionId))) { + return false; + } + const client = await this._ensureClient(); + const sdkMetadata = await client.getSessionMetadata(sessionId).catch(() => undefined); + const workingDirectory = typeof sdkMetadata?.context?.workingDirectory === 'string' ? URI.file(sdkMetadata.context.workingDirectory) : undefined; + if (!workingDirectory) { + return false; // no adoptable on-disk session + } + this._logService.info(`[Copilot] Adopting legacy session ${sessionId} in place (reusing on-disk events.jsonl)`); + // Resolve the project from the SDK-derived cwd (authoritative) — the + // caller may not have supplied a working directory (e.g. the chat + // editor), so we cannot trust a hint. + const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + // Carry over the user-chosen session name (EH `customTitle`) so the + // adopted session keeps its title instead of regenerating one. + const customTitle = await this._readExtensionHostCliCustomTitle(sessionId); + // Seed VS Code-layer metadata only — the SDK event log on disk is + // untouched. Writing `agentSessionData//session.db` here + // is also what makes the legacy extension-host Copilot CLI list stop + // showing this session (it dedups against agent-host-owned session ids). + // `isolation: 'folder'` keeps the session in place in the reused cwd — + // a git repo would otherwise default to worktree and show a spurious + // "Creating worktree…". + await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle); + return true; + }); + } + /** * Promotes a {@link IProvisionalSession} into a real Copilot SDK session * by performing the work that {@link createSession} previously did @@ -3712,7 +3854,7 @@ export class CopilotAgent extends Disposable implements IAgent { } - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, customTitle?: string): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -3741,6 +3883,18 @@ export class CopilotAgent extends Disposable implements IAgent { work.push(db.setMetadata(CopilotAgent._META_PROJECT_URI, project.uri.toString())); work.push(db.setMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME, project.displayName)); } + // Persisted the same way `AgentService._persistConfigValues` writes them, + // so restore's config resolution overlays them (used by adopt to force + // folder isolation) — folded into this write to avoid a second DB open. + if (configValues) { + work.push(db.setMetadata('configValues', JSON.stringify(configValues))); + } + // Overlaid as the session's display title on restore (see the + // `customTitle` overlay in `AgentService`); used by adopt to carry + // over the legacy extension-host session name. + if (customTitle) { + work.push(db.setMetadata('customTitle', customTitle)); + } await Promise.all(work); } finally { dbRef.dispose(); @@ -3997,7 +4151,15 @@ class SessionDiscoveredEntry extends Disposable { } throw err; }); - }, delay); + }, delay).catch(err => { + // The delayer rejects a pending trigger with `CancellationError` when + // cancelled or disposed (session teardown). Swallow it so the stored + // `_settled` promise never surfaces an unhandled rejection. + if (err instanceof CancellationError) { + return; + } + throw err; + }); } private async _refresh(token: CancellationToken): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 74c061544b3..35fb86e564a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -33,11 +33,11 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; -import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -51,6 +51,9 @@ import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, r import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; +import { getCopilotHomePath } from '../../common/copilotHome.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { join } from '../../../../base/common/path.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; @@ -1151,6 +1154,9 @@ suite('CopilotAgent', () => { const sessionDataService = disposables.add(new TestSessionDataService()); const ownedSession = AgentSession.uri('copilotcli', 'owned-before-auth'); const ownedDb = sessionDataService.openDatabase(ownedSession); + // A genuinely owned session persists a working directory at materialize; + // listing gates on that (an empty DB is a ghost / un-migrated session). + await ownedDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); ownedDb.dispose(); const client = new TestCopilotClient([sdkSession('owned-before-auth')]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); @@ -2778,9 +2784,16 @@ suite('CopilotAgent', () => { const sessionDataService = disposables.add(new TestSessionDataService()); const ownedSession = AgentSession.uri('copilotcli', 'owned'); const ownedDb = sessionDataService.openDatabase(ownedSession); + // A genuinely owned session persists a working directory at materialize; + // listing gates on that (an empty DB is a ghost / un-migrated session). + await ownedDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); ownedDb.dispose(); + // A ghost DB exists (created empty by checkpoint / git services) but has no + // stored working directory, so it must be excluded from the list. + const ghostSession = AgentSession.uri('copilotcli', 'ghost'); + sessionDataService.openDatabase(ghostSession).dispose(); - const client = new TestCopilotClient([sdkSession('owned'), sdkSession('external')]); + const client = new TestCopilotClient([sdkSession('owned'), sdkSession('ghost'), sdkSession('external')]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { await agent.authenticate('https://api.github.com', 'token'); @@ -2900,6 +2913,104 @@ suite('CopilotAgent', () => { } }); + suite('listSessions legacy-CLI surfacing (migration)', () => { + + async function writeExtensionHostMarker(userHome: URI, sessionId: string): Promise { + const dir = join(getCopilotHomePath(userHome.fsPath, process.env), 'session-state', sessionId); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(join(dir, 'vscode.metadata.json'), '{}', 'utf8'); + } + + test('surfaces an un-adopted extension-host CLI session as adoptable when migrate is ON', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/surface-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/surface-cwd-`); + const sessionId = 'ehcli-surface'; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + assert.deepStrictEqual( + (await agent.listSessions()).map(s => ({ id: AgentSession.id(s.session), adoptable: readSessionEhcliAdoptable(s._meta), cwd: s.workingDirectories?.map(d => d.fsPath) })), + [{ id: sessionId, adoptable: true, cwd: [URI.file(workingDirectory).fsPath] }], + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not surface the legacy CLI session when migrate is OFF', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/surface-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/surface-cwd-`); + const sessionId = 'ehcli-off'; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + // migrate flag left at its default (OFF): the un-owned legacy session is not listed. + + assert.deepStrictEqual(await agent.listSessions(), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not surface an un-owned SDK session without the extension-host marker', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/surface-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/surface-cwd-`); + const sessionId = 'no-marker'; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + // No marker written: a standalone Copilot SDK session, not an EH CLI session. + + assert.deepStrictEqual(await agent.listSessions(), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('lists an already-adopted (native) session normally, not as adoptable', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/surface-home-`)); + const sessionId = 'native'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + // A native / already-adopted session persists a working directory. + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); + db.dispose(); + const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); + const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); // marker present but already adopted + configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + assert.deepStrictEqual( + (await agent.listSessions()).map(s => ({ id: AgentSession.id(s.session), adoptable: readSessionEhcliAdoptable(s._meta) })), + [{ id: sessionId, adoptable: false }], + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + }); + suite('createSession activeClient eager-claim', () => { class SpyingPluginManager extends TestAgentPluginManager { @@ -6044,4 +6155,125 @@ suite('CopilotAgent', () => { }); }); + + suite('ensureSessionAdopted (legacy Copilot CLI migration)', () => { + + async function writeExtensionHostMarker(userHome: URI, sessionId: string, metadata: Record = {}): Promise { + const dir = join(getCopilotHomePath(userHome.fsPath, process.env), 'session-state', sessionId); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(join(dir, 'vscode.metadata.json'), JSON.stringify(metadata), 'utf8'); + } + + test('adopts a legacy extension-host session in place and seeds folder isolation', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); + const sessionId = 'legacy-adopt'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + + const first = await agent.ensureSessionAdopted(session); + // A second call is a no-op: the first adopt persisted a working + // directory, which now reads as an already-native session. + const second = await agent.ensureSessionAdopted(session); + + const db = await sessionDataService.tryOpenDatabase(session); + const configValues = await db?.object.getMetadata('configValues'); + db?.dispose(); + + assert.deepStrictEqual( + { first, second, configValues }, + { first: true, second: false, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('carries over the legacy custom title on adoption', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); + const sessionId = 'legacy-titled'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { customTitle: 'My Legacy Session' }); + + const adopted = await agent.ensureSessionAdopted(session); + + const db = await sessionDataService.tryOpenDatabase(session); + const customTitle = await db?.object.getMetadata('customTitle'); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, customTitle }, + { adopted: true, customTitle: 'My Legacy Session' }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not adopt a Copilot SDK session without the extension-host marker', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const sessionId = 'not-extension-host'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + // No `vscode.metadata.json` marker -> not an adoptable EH CLI session. + const adopted = await agent.ensureSessionAdopted(session); + + assert.deepStrictEqual( + { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, + { adopted: false, getSessionMetadataCalls: [], openedDatabases: [] }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not re-adopt a session that already has stored working-directory metadata', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const sessionId = 'already-adopted'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + // Seed as if already native / adopted: a persisted working directory. + const seed = sessionDataService.openDatabase(session); + await seed.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); + seed.dispose(); + const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); // even with a marker present + + const adopted = await agent.ensureSessionAdopted(session); + + assert.deepStrictEqual( + { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls }, + { adopted: false, getSessionMetadataCalls: [] }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index d280300d8c1..00fabd85f7d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -27,7 +27,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, StateComponents, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, StateComponents, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -4136,6 +4136,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!rawId) { return; } + // A surfaced-but-un-adopted legacy Copilot CLI session must NOT be + // subscribed passively: subscribing its session/chat channel triggers an + // agent-host restore, which adopts (migrates) it. Migration must happen + // only when the user explicitly opens the session. It renders read-only + // from its summary until then; the marker clears once it is adopted. + if (readSessionEhcliAdoptable(this._metaByRawId.get(rawId)?._meta)) { + return; + } const cached = this._sessionCache.get(rawId); if (!cached) { return; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 681a942cabe..0a606309a76 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -16,7 +16,7 @@ import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/r import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { IChatWidgetHistoryService } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; -import { buildHostLocalEventsPath, getCopilotCliSessionRawId } from '../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; +import { buildHostLocalEventsPath, COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId } from '../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -186,6 +186,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } getSessions(): ISession[] { + // Dedup only affects the displayed list; lookups (`getSession`, + // `getSessionForChatResource`) use the raw merged set so an EH row that is + // hidden here can still be resolved and migrated when clicked. + return this._dedupeMigratedCopilotCliSessions(this._getMergedSessions()); + } + + private _getMergedSessions(): ISession[] { const sessions: ISession[] = []; for (const provider of this.sessionsProvidersService.getProviders()) { sessions.push(...provider.getSessions()); @@ -193,14 +200,52 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return sessions; } + /** + * A legacy Copilot CLI session migrated in place to the agent host is briefly + * listed by BOTH the extension-host provider (`copilotcli:/`) and the + * agent-host provider (`agent-host-copilotcli:/`) for the same underlying + * SDK session id — the workbench agent-session model caches the stale legacy + * entry even after the extension stops reporting it. Drop the legacy entry so + * exactly one row shows per session. + */ + private _dedupeMigratedCopilotCliSessions(sessions: ISession[]): ISession[] { + let migratedRawIds: Set | undefined; + for (const session of sessions) { + if (session.resource.scheme === COPILOT_CLI_LOCAL_AH_SCHEME) { + const rawId = getCopilotCliSessionRawId(session.resource); + if (rawId) { + (migratedRawIds ??= new Set()).add(rawId); + } + } + } + if (!migratedRawIds) { + return sessions; + } + const result = sessions.filter(session => { + // Only the legacy extension-host scheme (`copilotcli:`) denotes a stale + // entry to drop. Remote agent-host Copilot sessions + // (`remote--copilotcli:`) share the `copilotcli` session type but + // are distinct sessions that must never be deduped against a local migrated + // id, and the migrated entry itself uses `agent-host-copilotcli:`. + if (session.resource.scheme === COPILOT_CLI_EH_SCHEME) { + const rawId = getCopilotCliSessionRawId(session.resource); + if (rawId && migratedRawIds!.has(rawId)) { + return false; + } + } + return true; + }); + return result; + } + getSession(resource: URI): ISession | undefined { - return this.getSessions().find(s => + return this._getMergedSessions().find(s => this.uriIdentityService.extUri.isEqual(s.resource, resource) ); } getSessionForChatResource(resource: URI): { session: ISession; chat: IChat } | undefined { - for (const session of this.getSessions()) { + for (const session of this._getMergedSessions()) { const chat = session.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, resource)); if (chat) { return { session, chat }; diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 82f2df42590..98286224f8f 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -706,6 +706,7 @@ export class SessionsService extends Disposable implements ISessionsService { throw new Error(`Session with resource ${sessionResource.toString()} not found`); } this.logService.trace(`[SessionsView] openSession start uri=${sessionResource.toString()} provider=${sessionData.providerId}`); + this._activate(sessionData, options?.preserveFocus); if (!await this._waitForSessionToLoad(sessionData, token)) { this.logService.trace(`[SessionsView] openSession cancelled while waiting for session to load uri=${sessionResource.toString()}`); 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 9d487d16551..840efd95898 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -14,6 +14,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; @@ -37,6 +39,7 @@ import { ISessionsPartService } from '../../browser/sessionsPartService.js'; import { CustomViewService, ICustomViewService } from '../../../customView/browser/customViewService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; const stubChat = { resource: URI.parse('test:///chat'), @@ -239,6 +242,7 @@ function createView(instantiationService: TestInstantiationService, service: ISe instantiationService.stub(ISessionsManagementService, service); instantiationService.stub(ISessionsPartService, new TestSessionsPartService()); instantiationService.stub(ICustomViewService, disposables.add(new CustomViewService(new NullLogService()))); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); return disposables.add(instantiationService.createInstance(SessionsService)); } @@ -2554,6 +2558,74 @@ suite('SessionsManagementService', () => { ); }); }); + + suite('legacy Copilot CLI migration', () => { + + const RAW_ID = 'sess-abc'; + + function legacyCliSession(): ISession { + return stubSession({ + sessionId: `legacy-${RAW_ID}`, + providerId: 'default-copilot', + sessionType: COPILOT_CLI_EH_SCHEME, + resource: URI.from({ scheme: COPILOT_CLI_EH_SCHEME, path: `/${RAW_ID}` }), + }); + } + + function migratedCliSession(): ISession { + return stubSession({ + sessionId: `migrated-${RAW_ID}`, + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + sessionType: COPILOT_CLI_EH_SCHEME, + resource: URI.from({ scheme: COPILOT_CLI_LOCAL_AH_SCHEME, path: `/${RAW_ID}` }), + }); + } + + function serviceWithSessions(sessions: readonly ISession[]): ISessionsManagementService { + const provider = new class extends TestSessionsProvider { + constructor() { super(sessions[0]); } + override getSessions(): ISession[] { return [...sessions]; } + }; + return createSessionsManagementService(sessions[0], disposables, provider).service; + } + + test('getSessions hides the legacy entry once its migrated agent-host entry exists', () => { + const legacy = legacyCliSession(); + const migrated = migratedCliSession(); + const service = serviceWithSessions([legacy, migrated]); + + assert.deepStrictEqual( + service.getSessions().map(s => s.sessionId), + [migrated.sessionId], + ); + }); + + test('getSessions keeps the legacy entry visible when no migrated entry exists', () => { + const legacy = legacyCliSession(); + const service = serviceWithSessions([legacy]); + + assert.deepStrictEqual( + service.getSessions().map(s => s.sessionId), + [legacy.sessionId], + ); + }); + + test('getSession still resolves the hidden legacy entry so it can be migrated on open', () => { + const legacy = legacyCliSession(); + const migrated = migratedCliSession(); + const service = serviceWithSessions([legacy, migrated]); + + // Hidden from the displayed list, yet still resolvable by resource so + // an explicit open can trigger migration. + assert.deepStrictEqual( + { + listed: service.getSessions().some(s => s.sessionId === legacy.sessionId), + resolved: service.getSession(legacy.resource)?.sessionId ?? null, + }, + { listed: false, resolved: legacy.sessionId }, + ); + }); + }); }); /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index e85f8c92d2c..38e717aa357 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -133,7 +133,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver; this._register(autorun(reader => { - if (agentHostEnablementService.enabled.read(reader)) { + const enabled = agentHostEnablementService.enabled.read(reader); + if (enabled) { this._initialize(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index e5ef6d8eace..e17a4fa808c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChangesSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionStatus, readSessionEhcliAdoptable, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; @@ -192,6 +192,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS createdAt: Date.parse(summary.createdAt), modifiedAt: Date.parse(summary.modifiedAt), changesSummary: summary.changes, + adoptable: readSessionEhcliAdoptable(summary._meta), }); } @@ -205,9 +206,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS createdAt: number; modifiedAt: number; changesSummary?: ChangesSummary; + /** Un-adopted legacy Copilot CLI session surfaced as adoptable; must not be passively restored. */ + adoptable?: boolean; }): IChatSessionItem { const inProgress = opts.status !== undefined && (opts.status & SessionStatus.InProgress) !== 0; const description = inProgress && opts.activity ? opts.activity : this._description; + const metadata = opts.adoptable + ? { ...(this._buildMetadata(opts.workingDirectory) ?? {}), [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } + : this._buildMetadata(opts.workingDirectory); return { resource: this._resource(rawId), label: opts.title || `Session ${rawId.substring(0, 8)}`, @@ -221,7 +227,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS isRead: opts.status !== undefined && opts.statusKnown !== false ? (opts.status & SessionStatus.IsRead) === SessionStatus.IsRead : undefined, - metadata: this._buildMetadata(opts.workingDirectory), + metadata, timing: { created: opts.createdAt, lastRequestStarted: opts.modifiedAt, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index d2554ea5928..5e2d7101e0c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -348,6 +348,9 @@ export class AgentHostSessionListStore extends Disposable { modifiedAt: new Date(session.modifiedTime).toISOString(), changes: session.changes, workingDirectories: session.workingDirectories?.map(d => d.toString()), + // Carry `_meta` so the adoptable-legacy marker survives into the list + // item; consumers use it to avoid passively restoring (and thereby + // migrating) an un-adopted legacy Copilot CLI session. ...(session._meta !== undefined ? { _meta: session._meta } : {}), }, }; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts index 4a769538b76..c38aa780f06 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts @@ -15,6 +15,7 @@ import { autorun } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { SESSION_META_EHCLI_ADOPTABLE_KEY } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { IChatModel } from '../../common/model/chatModel.js'; @@ -86,6 +87,16 @@ export class AgentSessionHoverWidget extends Disposable { } private async loadModel() { + // A surfaced-but-un-adopted legacy Copilot CLI session must NOT be loaded here: + // loading its model subscribes/restores it on the agent host, which adopts + // (migrates) it. Migration must happen only on explicit open, so render the + // fallback tooltip from the summary instead of loading the model. + if (this.session.metadata?.[SESSION_META_EHCLI_ADOPTABLE_KEY] === true) { + this.loadingElement.remove(); + const tooltip = this.buildFallbackTooltip(this.session); + this.domNode.textContent = typeof tooltip === 'string' ? tooltip : tooltip.value; + return; + } const modelRef = await this.chatService.acquireOrLoadSession(this.session.resource, ChatAgentLocation.Chat, this.cts.token, 'AgentSessionHoverWidget#loadModel'); if (this._store.isDisposed) { modelRef?.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9c63476b29c..65bb1d1bb03 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -29,6 +29,7 @@ import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../ import { ChatSessionStatus as AgentSessionStatus, IChatSessionFileChange, IChatSessionFileChange2, IChatSessionItem, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatWidgetService } from '../chat.js'; +import { COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId } from '../copilotCliEventsUri.js'; import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName, isAgentHostTarget, isBuiltInAgentSessionProvider } from './agentSessions.js'; //#region Interfaces, Types @@ -513,7 +514,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode get resolved(): boolean { return this._resolved; } private _sessions: ResourceMap; - get sessions(): IAgentSession[] { return Array.from(this._sessions.values()); } + get sessions(): IAgentSession[] { return this._dedupeMigratedCopilotCliSessions(Array.from(this._sessions.values())); } private readonly resolvers = this._register(new DisposableMap>()); @@ -591,6 +592,37 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode return this._sessions.get(resource); } + /** + * Hide the extension-host `copilotcli:` row when its agent-host + * `agent-host-copilotcli:` twin is present, so the list shows a single entry + * per legacy Copilot CLI session — the agent-host one, which migrates on open. + * Only display is deduped; {@link getSession} and the cache use the full map so + * a hidden row can still resolve. + */ + private _dedupeMigratedCopilotCliSessions(sessions: IAgentSession[]): IAgentSession[] { + let migratedRawIds: Set | undefined; + for (const session of sessions) { + if (session.resource.scheme === COPILOT_CLI_LOCAL_AH_SCHEME) { + const rawId = getCopilotCliSessionRawId(session.resource); + if (rawId) { + (migratedRawIds ??= new Set()).add(rawId); + } + } + } + if (!migratedRawIds) { + return sessions; + } + return sessions.filter(session => { + if (session.resource.scheme === COPILOT_CLI_EH_SCHEME) { + const rawId = getCopilotCliSessionRawId(session.resource); + if (rawId && migratedRawIds!.has(rawId)) { + return false; + } + } + return true; + }); + } + private _changedSignal: IObservable | undefined; private readonly _sessionObservables = new ResourceMap>(); private readonly _resolvedResources = new ResourceSet(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index f4ec8c8c5f7..1998f566e49 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -367,6 +367,15 @@ configurationRegistry.registerConfiguration({ default: false, tags: ['experimental'], }, + [ChatConfiguration.MigrateLegacyCopilotCliSessions]: { + type: 'boolean', + markdownDescription: nls.localize('chat.agentSessions.migrateLegacyCopilotCli', "Controls whether legacy extension host Copilot CLI chat sessions are migrated in place to the Agent host when opened, so their history becomes editable. When disabled, legacy sessions open as before."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'startup' + }, + }, 'chat.implicitContext.enabled': { type: 'object', description: nls.localize('chat.implicitContext.enabled.1', "Enables automatically using the active editor as chat context for specified chat locations."), diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index e122636543b..23275df2e11 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -43,6 +43,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { SESSION_META_EHCLI_ADOPTABLE_KEY } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { VoiceFirstConnectClassification, VoiceFirstConnectEvent, @@ -6006,6 +6007,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._eagerModelRefs.has(key) || this._eagerModelLoading.has(key) || this.chatService.getSession(resource)) { return; } + // A surfaced-but-un-adopted legacy Copilot CLI session must NOT be eagerly + // loaded: loading its model subscribes/restores it on the agent host, which + // adopts (migrates) it. Migration must happen only on explicit user open. + if (this.agentSessionsService.model.getSession(resource)?.metadata?.[SESSION_META_EHCLI_ADOPTABLE_KEY] === true) { + return; + } this.logService.trace(`[voice] eagerly loading model for session ${key.slice(-32)}`); this._eagerModelLoading.add(key); const cts = new CancellationTokenSource(); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 825559536db..8ee189fbf76 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -45,6 +45,7 @@ export enum ChatConfiguration { EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', + MigrateLegacyCopilotCliSessions = 'chat.agentSessions.migrateLegacyCopilotCli', ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests',