agentHost: fix legacy Copilot CLI migration issues (opening, worktrees, archived state) (#331896)

* Handle archive session and add telemetry

* agentHost: keep migrated legacy CLI sessions matching by project root

* worktree fix and log update

* feedback updates

* Feedback updates

* test fix
This commit is contained in:
Vijay Upadya
2026-08-21 18:51:40 +00:00
committed by GitHub
parent e6676d08ad
commit eb95feb5ae
18 changed files with 1257 additions and 114 deletions
+28
View File
@@ -1026,6 +1026,30 @@ export interface IActiveClient {
customizations: readonly ClientPluginCustomization[];
}
/** Worktree identity a predecessor recorded for a chat, so a missing checkout can be recreated on resume. */
export interface IAgentAdoptedWorktree {
readonly branchName: string;
readonly baseBranch: string | undefined;
readonly worktreePath: URI;
readonly repositoryRoot: URI;
}
/**
* Why an adoption attempt ended the way it did. Reported in logs and telemetry so
* a session that did not migrate can be diagnosed without reproducing it.
*/
export type AgentChatAdoptionReason =
/** Already has Agent Host metadata — native or previously adopted. */
| 'alreadyNative'
/** Not a legacy extension-host Copilot CLI chat (e.g. standalone CLI, Local agent). */
| 'notLegacyChat'
/** A legacy chat whose recorded working directory no longer exists and could not be resolved. */
| 'workingDirectoryMissing'
/** A legacy chat whose extension-host marker could not be re-read, leaving its archived state unknown. */
| 'markerUnavailable'
/** Newly adopted. */
| 'adopted';
/** Outcome of attempting to adopt a legacy provider-native chat. */
export interface IAgentChatAdoptionResult {
/** Whether this call newly seeded Agent Host metadata. */
@@ -1034,6 +1058,10 @@ export interface IAgentChatAdoptionResult {
readonly eligible: boolean;
/** Whether the chat already has Agent Host metadata, i.e. it is ours regardless of adoption. */
readonly native?: boolean;
/** Set when the adopted chat ran in a worktree that no longer exists and can be recreated. */
readonly worktree?: IAgentAdoptedWorktree;
/** Diagnostic reason behind {@link adopted}. */
readonly reason?: AgentChatAdoptionReason;
}
/**
@@ -1904,6 +1904,44 @@ export function withSessionEhcliAdoptable(meta: SessionSummaryMeta | undefined):
return { ...meta, [SESSION_META_EHCLI_ADOPTABLE_KEY]: true };
}
/**
* Session-DB key recording that a session was adopted from a legacy Copilot CLI
* (extension-host) chat. Unlike {@link SESSION_META_EHCLI_ADOPTABLE_KEY} this
* survives adoption, so consumers can keep treating the session as legacy for
* the rest of its life — a migrated session must not change how it is listed.
*/
export const AH_META_EHCLI_ADOPTED_DB_KEY = 'agentHost.ehcliAdopted';
/** `_meta` key mirroring {@link AH_META_EHCLI_ADOPTED_DB_KEY} on a summary. */
export const SESSION_META_EHCLI_ADOPTED_KEY = 'ehcliAdopted';
/** Whether the session was adopted from a legacy Copilot CLI chat. */
export function readSessionEhcliAdopted(meta: SessionSummaryMeta | undefined): boolean {
return meta?.[SESSION_META_EHCLI_ADOPTED_KEY] === true;
}
/** Returns a copy of `meta` with the adopted-legacy provenance marker updated. */
export function withSessionEhcliAdopted(meta: SessionSummaryMeta | undefined, adopted: boolean): SessionSummaryMeta | undefined {
const next: { [key: string]: unknown } = { ...meta };
if (adopted) {
next[SESSION_META_EHCLI_ADOPTED_KEY] = true;
} else {
delete next[SESSION_META_EHCLI_ADOPTED_KEY];
}
return Object.keys(next).length > 0 ? next : undefined;
}
/**
* Whether a session should be matched against a workspace folder by its project
* (repository) root in addition to its working directories. True only for
* legacy Copilot CLI sessions, which run out of a worktree outside the
* repository; agent-host-native worktree sessions are deliberately not surfaced
* in a window opened on their source repository.
*/
export function readSessionMatchesByProjectRoot(meta: SessionSummaryMeta | undefined): boolean {
return readSessionEhcliAdoptable(meta) || readSessionEhcliAdopted(meta);
}
// ---- RootState _meta accessors ---------------------------------------------
/**
@@ -941,7 +941,8 @@ export class AgentHostStateManager extends Disposable {
// adoptable-legacy session) is already known to clients with a different
// summary. Emit the delta so they update the entry in place — clearing the
// adoptable marker — rather than dropping the just-opened session on the
// next list reconcile. Never-announced sessions record the summary silently.
// next list reconcile. Never-announced sessions record the summary silently
// and stay hidden until {@link setSessionSummaryPublished}.
if (this._summaryNotifier.isAnnounced(key)) {
this._summaryNotifier.flush(key);
} else {
+147 -50
View File
@@ -20,7 +20,7 @@ import { hasKey } from '../../../base/common/types.js';
import { localize } from '../../../nls.js';
import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js';
import { AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js';
import { AgentHostSessionReleaseGraceMsEnvVar, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js';
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js';
@@ -35,7 +35,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 Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js';
import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js';
import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, 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, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, 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, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_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, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, 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 { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js';
import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js';
@@ -116,6 +116,7 @@ type AgentHostLegacyMigrationEvent = {
hasWorktree: boolean;
workingDirectoryCount: number;
errorMessage: string | undefined;
reason: string;
};
type AgentHostLegacyMigrationClassification = {
@@ -128,6 +129,7 @@ type AgentHostLegacyMigrationClassification = {
hasWorktree: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session ran in a pre-existing git worktree that was bridged during adoption.' };
workingDirectoryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of working directories associated with the migrated session.' };
errorMessage: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'Error message when the migration failed; absent for migrated/skipped outcomes.' };
reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why adoption ended as it did: adopted, alreadyNative, notLegacyChat, workingDirectoryMissing, or unknown. Separates a skipped session that was never ours from one whose working directory vanished, which need different fixes.' };
owner: 'vijayupadya';
comment: 'Tracks one-time adopt-on-open migration of legacy extension-host Copilot CLI sessions into the agent host to measure attempt, success, failure, and skipped rates.';
};
@@ -1579,7 +1581,9 @@ export class AgentService extends Disposable implements IAgentService {
*/
private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise<boolean> {
const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external]));
// Keys only: discovery arrives in batches, and the full listing re-runs the
// per-row provenance migration for every registered session each time.
const registeredKeys = new Set(await this._sessionRegistry.listSessionKeys());
const discoveryLimiter = new Limiter<boolean>(4);
let suppressed = 0;
let skippedAsStale = 0;
@@ -1591,8 +1595,7 @@ export class AgentService extends Disposable implements IAgentService {
const session = sessionMetadata.session;
try {
// Matching registry entries need no per-session I/O.
const known = existing.get(session.toString());
if (known !== undefined) {
if (registeredKeys.has(session.toString())) {
alreadyRegistered++;
return false;
}
@@ -1611,10 +1614,12 @@ export class AgentService extends Disposable implements IAgentService {
);
if (registered) {
registryChanged = true;
if (external && existing.get(session.toString()) !== true) {
// Only reached for a session the registry did not already hold, so its
// external read state has never been seeded.
if (external) {
await this._initializeExternalSessionReadState(session);
}
existing.set(session.toString(), external);
registeredKeys.add(session.toString());
if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) {
registeredExternal = true;
} else {
@@ -1657,10 +1662,14 @@ export class AgentService extends Disposable implements IAgentService {
const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external]));
const migrationLimiter = new Limiter<IRegisteredSession | undefined>(4);
const identities = await Promise.all(sessions.map(s => migrationLimiter.queue(async (): Promise<IRegisteredSession | undefined> => {
if (isSubagentSession(s.session.toString()) || await this._isChatBacking(s.session)) {
if (isSubagentSession(s.session.toString())) {
return undefined;
}
const external = await this._isExternalProviderChat(s.session);
const facts = await this._readSessionRegistrationFacts(s.session);
if (facts.chatBacking) {
return undefined;
}
const external = !facts.hostCreated;
return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' };
})));
let registeredExternal = false;
@@ -1716,6 +1725,32 @@ export class AgentService extends Disposable implements IAgentService {
}
}
/**
* Both facts registry backfill needs about a session, from a single database
* open it asks for both per session, and a large catalogue makes the second
* open the dominant cost of the pass.
*/
private async _readSessionRegistrationFacts(session: URI): Promise<{ readonly chatBacking: boolean; readonly hostCreated: boolean }> {
if (this._unpersistedChatBackings.has(session.toString())) {
return { chatBacking: true, hostCreated: false };
}
// A read failure is deliberately not caught: registering on a guess would
// durably mark a host-created session external, whereas failing the pass
// leaves it unmarked and retried.
const ref = await this._sessionDataService.tryOpenDatabase(session);
if (!ref) {
return { chatBacking: false, hostCreated: false };
}
try {
const metadata = await ref.object.getMetadataObject({ [CHAT_BACKING_METADATA_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true });
// The workspace-less marker is written when the host creates a session,
// so its presence is what identifies a host-created session.
return { chatBacking: !!metadata[CHAT_BACKING_METADATA_KEY], hostCreated: metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined };
} finally {
ref.dispose();
}
}
private async _migrateRegisteredSession(entry: IStoredRegisteredSession): Promise<IRegisteredSession | undefined> {
if (entry.external !== undefined) {
return undefined;
@@ -1880,8 +1915,8 @@ export class AgentService extends Disposable implements IAgentService {
const sessionStr = s.session.toString();
const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr);
const metadataKeys: Record<string, true> = changesetKeys
? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys }
: { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS };
? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys }
: { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS };
const m = await ref.object.getMetadataObject(metadataKeys);
// This session is an internal peer-chat backing (e.g. a
// Claude peer chat's SDK session, enumerated by the agent's
@@ -1935,6 +1970,9 @@ export class AgentService extends Disposable implements IAgentService {
if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) {
updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') };
}
if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) {
updated = { ...updated, _meta: withSessionEhcliAdopted(updated._meta, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true') };
}
const multiRoot = parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY]);
if (multiRoot) {
updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) };
@@ -2188,6 +2226,9 @@ export class AgentService extends Disposable implements IAgentService {
/** Adoptable keys retracted in this window; re-enabling also recovers earlier ones from the catalog. */
private readonly _retractedAdoptableKeys = new Set<string>();
/** Serializes adoptable re-surfacing, kept off the external-reconciliation chain. */
private _adoptableResurface: Promise<void> = Promise.resolve();
private _isMigrateLegacyEnabled(): boolean {
return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true;
}
@@ -2205,8 +2246,11 @@ export class AgentService extends Disposable implements IAgentService {
this._lastMigrateLegacyEnabled = enabled;
if (enabled) {
// Discovery skips chats already in the registry, so it cannot re-announce
// what disabling retracted — restore them from the registry instead.
this._sessionListReconciliation = this._sessionListReconciliation
// what disabling retracted. `_retractedAdoptableKeys` is process-local, so
// after a restart the catalog is the only record of them. Runs on its own
// chain: this scan on `_sessionListReconciliation` would stall external
// session reconciliation behind it.
this._adoptableResurface = this._adoptableResurface
.then(() => this._resurfaceAdoptableSessions())
.catch(error => this._logService.warn('[AgentService] Re-surfacing adoptable legacy sessions failed', error));
return;
@@ -2226,9 +2270,10 @@ export class AgentService extends Disposable implements IAgentService {
}
/**
* Re-announces adoptable-legacy sessions that are not currently surfaced
* those this window retracted, plus any the catalog still reports as adoptable,
* so rows retracted before a restart are recovered too.
* A key is forgotten only once it is confirmed surfaced, so a failed listing
* or migration being disabled again before this runs leaves it restorable.
* Covers both what this process retracted and what the catalog still reports as
* adoptable, so rows retracted before a restart are recovered too.
*/
private async _resurfaceAdoptableSessions(): Promise<void> {
if (!this._isMigrateLegacyEnabled()) {
@@ -2236,10 +2281,6 @@ export class AgentService extends Disposable implements IAgentService {
}
for (const metadata of await this.listSessions()) {
const key = metadata.session.toString();
if (this._announcedSurfacedKeys.has(key) || this._stateManager.getSessionState(key)) {
this._retractedAdoptableKeys.delete(key);
continue;
}
if (!this._retractedAdoptableKeys.has(key) && !readSessionEhcliAdoptable(metadata._meta)) {
continue;
}
@@ -4730,7 +4771,7 @@ export class AgentService extends Disposable implements IAgentService {
provider: string,
outcome: AgentHostLegacyMigrationEvent['outcome'],
startTime: number,
extra: { turnCount?: number; hasProject?: boolean; hasWorktree?: boolean; workingDirectoryCount?: number; errorMessage?: string },
extra: { turnCount?: number; hasProject?: boolean; hasWorktree?: boolean; workingDirectoryCount?: number; errorMessage?: string; reason?: AgentChatAdoptionReason },
): void {
this._telemetryService.publicLog2<AgentHostLegacyMigrationEvent, AgentHostLegacyMigrationClassification>('agentHost.legacyCopilotCliMigration', {
provider,
@@ -4742,6 +4783,7 @@ export class AgentService extends Disposable implements IAgentService {
hasWorktree: extra.hasWorktree ?? false,
workingDirectoryCount: extra.workingDirectoryCount ?? 0,
errorMessage: extra.errorMessage,
reason: extra.reason ?? 'unknown',
});
}
@@ -4762,22 +4804,25 @@ export class AgentService extends Disposable implements IAgentService {
if (await this._sessionRegistry.isTombstoned(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
// Wait for the provider's one-time catalog migration before reading
// metadata, mirroring `listSessions`, so restore does not misread an
// unwarmed catalog as a missing session (#331648). A catalog that stays
// unavailable is non-fatal: fall through, but remember it so a resulting
// miss is classified as unavailable rather than absent.
let catalogReadable = true;
try {
await this._awaitInitialProviderMigrationForProvider(agent);
} catch (err) {
catalogReadable = false;
this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err);
}
// Re-check after the (possibly lengthy) wait so a delete that landed meanwhile is not resurrected.
if (await this._sessionRegistry.isTombstoned(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
// Warming the provider catalogue is O(catalogue) — ~48s on a large
// `~/.copilot` — and the only decision that needs it is whether a metadata
// miss is authoritative (#331648). Defer it so a session that resolves from
// its own per-session lookup never pays for the whole catalogue.
let catalogReadable: Promise<boolean> | undefined;
const awaitCatalogReadable = () => catalogReadable ??= (async () => {
let readable = true;
try {
await this._awaitInitialProviderMigrationForProvider(agent);
} catch (err) {
readable = false;
this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err);
}
// This wait can be lengthy, so re-check that a delete has not landed meanwhile.
if (await this._sessionRegistry.isTombstoned(session)) {
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`);
}
return readable;
})();
const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr);
const external = registeredSession?.external ?? false;
this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`);
@@ -4805,25 +4850,49 @@ export class AgentService extends Disposable implements IAgentService {
// created, hidden while `showExternalSessions` is `none`) would be
// materialized here and thereby claimed away from the extension host's list.
if (!registeredSession && migrateLegacyEnabled && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) {
this._logService.info(`[AgentService] restore refused for unregistered ${sessionStr}: not an adoptable legacy chat (reason=${adoption.reason ?? 'unknown'})`);
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session is not an adoptable legacy chat: ${sessionStr}`);
}
// From here the whole restore is wrapped so `migrated` is reported only
// after every required step succeeds, and any failure after a successful
// adoption is surfaced as a migration failure.
let registeredAfterAdoption = !!registeredSession;
try {
const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', catalogReadable, !!registeredSession);
// Adoption has already claimed the chat on disk, which is what stops the
// extension host listing it. Register it before restoring so a later restore
// failure (e.g. a worktree whose branch is gone) leaves a session that
// reports an error like any native one, instead of one that exists in no
// list at all. A registration that cannot be made durable fails the
// migration: continuing would leave exactly the orphan this prevents.
if (adopted && !registeredSession) {
await this._retryRegistryMutation(
() => this._sessionRegistry.register(session, { provider: agent.id, startTime: Date.now(), source: 'restore' }, { checkTombstone: true }),
`adoption registration for ${sessionStr}`,
);
registeredAfterAdoption = true;
this._invalidateSessionList();
}
const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', awaitCatalogReadable, !!registeredSession, adoption.worktree);
await this._restoreAnnotations(session);
if (adopted) {
this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, facts);
// Discovery never surfaced this chat when migration was enabled after
// startup, so clients have no entry for it and a restore alone stays
// silent. Publishing announces it with the adopted summary.
this._stateManager.setSessionSummaryPublished(sessionStr, true);
this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, { ...facts, reason: adoption.reason });
} else if (adoption.eligible) {
// Migrate setting on and a genuine legacy candidate, but not adopted
// this pass (e.g. its on-disk working directory could not be resolved).
this._reportLegacyMigration(agent.id, 'skipped', migrationStartTime, { hasProject: facts.hasProject, workingDirectoryCount: facts.workingDirectoryCount });
this._logService.info(`[AgentService] legacy session ${sessionStr} was a migration candidate but was not adopted (reason=${adoption.reason ?? 'unknown'})`);
this._reportLegacyMigration(agent.id, 'skipped', migrationStartTime, { hasProject: facts.hasProject, workingDirectoryCount: facts.workingDirectoryCount, reason: adoption.reason });
}
} catch (err) {
if (adopted) {
this._reportLegacyMigration(agent.id, 'failed', migrationStartTime, { errorMessage: toErrorMessage(err) });
this._logService.error(registeredAfterAdoption
? `[AgentService] legacy session ${sessionStr} was adopted but its restore failed; it is registered so it surfaces with an error rather than disappearing`
: `[AgentService] legacy session ${sessionStr} was adopted but could not be registered; the extension host no longer lists it, so it will not appear until the next successful restore`, err);
this._reportLegacyMigration(agent.id, 'failed', migrationStartTime, { errorMessage: toErrorMessage(err), reason: adoption.reason });
}
throw err;
}
@@ -4909,17 +4978,26 @@ export class AgentService extends Disposable implements IAgentService {
* Returns the facts used for migration telemetry; throws if any required step
* fails so the caller can report the outcome accurately.
*/
private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], catalogReadable: boolean, sessionKnownToRegistry: boolean): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> {
private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], awaitCatalogReadable: () => Promise<boolean>, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> {
this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`);
let meta = await this._getSessionMetadataForRestore(agent, session, external);
if (!meta) {
// Authoritative absence only when the catalog was readable this run and
// the registry has no record of the session; a miss for a known
// (registered) session, or while the catalog was unavailable, is
// transient — e.g. a provider whose SDK is not downloaded yet (#331648).
throw catalogReadable && !sessionKnownToRegistry
? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`)
: new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`);
// Only a miss needs the catalogue: it decides whether the session is
// genuinely absent, and warming it may enumerate thousands of sessions.
const catalogReadable = await awaitCatalogReadable();
meta = await this._getSessionMetadataForRestore(agent, session, external);
// The registry is backfilled by that same pass, so re-read it before
// concluding the session is unknown.
const knownToRegistry = sessionKnownToRegistry || (await this._listRegisteredSessions()).some(entry => entry.session.toString() === sessionStr);
if (!meta) {
// Authoritative absence only when the catalog was readable this run and
// the registry has no record of the session; a miss for a known
// (registered) session, or while the catalog was unavailable, is
// transient — e.g. a provider whose SDK is not downloaded yet (#331648).
throw catalogReadable && !knownToRegistry
? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`)
: new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`);
}
}
this._logService.trace(`[AgentService] restore: provider metadata resolved for ${sessionStr}`);
@@ -4930,8 +5008,23 @@ export class AgentService extends Disposable implements IAgentService {
// worktree-isolated sessions. No-op for folder / primary-checkout cwds.
let adoptedWorktree = false;
if (adopted && this._worktree) {
// The predecessor recorded this worktree but its checkout is gone, so it
// cannot be probed; seed the same metadata a native session persists at
// creation and let resume recreate it.
if (adoptionWorktree) {
try {
await this._worktree.recordAdoptedWorktreeMetadata(session, adoptionWorktree);
adoptedWorktree = true;
const worktreeProject = await this._worktree.resolveWorktreeProject(session);
if (worktreeProject) {
meta = { ...meta, project: worktreeProject };
}
} catch (err) {
this._logService.warn(`[AgentService] adopt: recording recorded worktree metadata failed for ${sessionStr}`, err);
}
}
const adoptedWorkingDirectory = meta.workingDirectories?.[0];
if (adoptedWorkingDirectory) {
if (!adoptedWorktree && adoptedWorkingDirectory) {
try {
if (await this._worktree.adoptExistingWorktreeMetadata(session, adoptedWorkingDirectory)) {
adoptedWorktree = true;
@@ -5016,6 +5109,7 @@ export class AgentService extends Disposable implements IAgentService {
[AH_META_IS_DONE_DB_KEY]: true,
configValues: true,
[AH_META_WORKSPACELESS_DB_KEY]: true,
[AH_META_EHCLI_ADOPTED_DB_KEY]: true,
[AH_META_ORCHESTRATION_DB_KEY]: true,
[SESSION_META_MULTI_ROOT_KEY]: true,
[SESSION_ARTIFACTS_KEY]: true,
@@ -5077,6 +5171,9 @@ export class AgentService extends Disposable implements IAgentService {
if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) {
sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true');
}
if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) {
sessionMetadata = withSessionEhcliAdopted(sessionMetadata, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true');
}
const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]);
if (orchestration) {
sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration);
@@ -14,7 +14,7 @@ import { CancellationError, getErrorMessage } from '../../../../base/common/erro
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableMap, DisposableStore, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../base/common/map.js';
import { FileAccess } from '../../../../base/common/network.js';
import { FileAccess, Schemas } from '../../../../base/common/network.js';
import { formatTokenCount } from '../../../../base/common/numbers.js';
import { equals } from '../../../../base/common/objects.js';
import { autorun, observableValue, observableValueOpts, type IObservable, type ISettableObservable } from '../../../../base/common/observable.js';
@@ -43,7 +43,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabl
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js';
import { prepareSideChatPrompt, sliceSideChatTurns } from '../agentPeerChats.js';
import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js';
import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js';
import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js';
import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
@@ -57,7 +57,7 @@ import type { ErrorInfo } from '../../common/state/protocol/common/state.js';
import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js';
import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js';
import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js';
import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js';
import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js';
import { isCustomizationEnabled } from '../../common/customizationEnablement.js';
import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js';
@@ -577,10 +577,12 @@ const EXTENSION_HOST_CLI_MARKER_FILE = 'vscode.metadata.json';
interface IExtensionHostCliMarker {
readonly origin?: string;
readonly customTitle?: string;
/** Whether the user archived the session in the extension host list. */
readonly archived?: boolean;
/** Folder-mode repository root recorded by the extension host. */
readonly repositoryProperties?: { readonly repositoryPath?: string };
/** Worktree-mode checkout; `worktreePath` is the directory the session ran in. */
readonly worktreeProperties?: { readonly worktreePath?: string; readonly repositoryPath?: string };
readonly worktreeProperties?: { readonly worktreePath?: string; readonly repositoryPath?: string; readonly branchName?: string; readonly baseBranchName?: string };
readonly workspaceFolder?: { readonly folderPath?: string };
}
@@ -628,6 +630,15 @@ function extensionHostCliWorkingDirectoryPaths(marker: IExtensionHostCliMarker |
].filter((path): path is string => typeof path === 'string' && path.length > 0);
}
/**
* The local repository root the extension host recorded for a chat. Survives a
* deleted worktree checkout, unlike resolving git from the working directory.
*/
function extensionHostCliRepositoryPath(marker: IExtensionHostCliMarker | undefined): string | undefined {
const path = marker?.worktreeProperties?.repositoryPath ?? marker?.repositoryProperties?.repositoryPath;
return typeof path === 'string' && path.length > 0 ? path : undefined;
}
/**
* Shape of the extension-host Copilot CLI `vscode.requests.metadata.json`
* sidecar written next to a session's SDK event log. Only the fields adoption
@@ -2418,6 +2429,7 @@ export class CopilotAgent extends Disposable implements IAgent {
let outsideImportWindow = 0;
let withoutRepository = 0;
let suppressedAdoptable = 0;
let suppressedArchived = 0;
let failed = 0;
let discovered = 0;
let external = 0;
@@ -2433,6 +2445,13 @@ export class CopilotAgent extends Disposable implements IAgent {
suppressedAdoptable++;
return undefined;
}
// A chat the user archived in the extension host list stays archived:
// surfacing it here would resurface everything they filed away. It is
// still adoptable once unarchived there.
if (adoptable && await this._isExtensionHostCliSessionArchived(s.sessionId)) {
suppressedArchived++;
return undefined;
}
// A legacy chat the SDK reports without a cwd is still reachable: the
// extension host records its own directory in the marker, and that is
// the only source once the extension is retired.
@@ -2465,7 +2484,10 @@ export class CopilotAgent extends Disposable implements IAgent {
modifiedTime,
// Always key the project off the resolved working directory: a worktree
// session's context repository/gitRoot would resolve to the repo root.
project: await this._resolveSessionProject({ ...s.context, cwd: workingDirectory.fsPath }, projectLimiter, projectByContext),
project: await this._localProject(
await this._resolveSessionProject({ ...s.context, cwd: workingDirectory.fsPath }, projectLimiter, projectByContext),
adoptable ? s.sessionId : undefined,
),
summary: s.summary,
workingDirectories: [workingDirectory],
_meta: adoptable ? withSessionEhcliAdoptable(undefined) : undefined,
@@ -2489,7 +2511,7 @@ export class CopilotAgent extends Disposable implements IAgent {
publish(chats);
}
}
this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${discovered - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`);
this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${discovered - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${suppressedArchived} suppressed archived legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`);
return true;
}
@@ -3075,12 +3097,50 @@ export class CopilotAgent extends Disposable implements IAgent {
return isExtensionHostCliMarker(await this._readExtensionHostCliMarker(sessionId));
}
/** Reads the marker from disk, bypassing the cache, for its mutable fields. */
private async _readExtensionHostCliMarkerUncached(sessionId: string): Promise<IExtensionHostCliMarker | undefined> {
try {
const marker = parseExtensionHostCliMarker(await fs.readFile(this._extensionHostCliSidecarPath(sessionId, EXTENSION_HOST_CLI_MARKER_FILE), 'utf8'));
if (marker) {
this._extensionHostCliMarkerCache.set(sessionId, Promise.resolve(marker));
}
return marker;
} catch {
return undefined;
}
}
/** Reads a legacy extension-host Copilot CLI custom title, if present. */
private async _readExtensionHostCliCustomTitle(sessionId: string): Promise<string | undefined> {
const title = (await this._readExtensionHostCliMarker(sessionId))?.customTitle;
return typeof title === 'string' && title.trim() ? title : undefined;
}
/**
* Whether the user archived this session in the extension host list, or
* `undefined` when the current state cannot be established (unreadable or
* malformed marker, or one that no longer identifies a VS Code legacy chat).
* Callers that would commit to the state must not treat that as unarchived.
*/
private async _isExtensionHostCliSessionArchived(sessionId: string): Promise<boolean | undefined> {
// Archive state is toggled in the extension host while this agent runs, so it
// cannot be served from the marker cache, which memoizes successful reads.
const marker = await this._readExtensionHostCliMarkerUncached(sessionId);
if (!isExtensionHostCliMarker(marker)) {
return undefined;
}
return marker?.archived === true;
}
/** Whether `path` is a directory that still exists on disk. */
private async _isExistingDirectory(path: string): Promise<boolean> {
try {
return (await fs.stat(path)).isDirectory();
} catch {
return false;
}
}
/**
* Working directory recorded in the extension host's own marker, used when the
* SDK reports no `workingDirectory` for a legacy chat. The extension host
@@ -3091,17 +3151,61 @@ export class CopilotAgent extends Disposable implements IAgent {
// Adoption is durable and one-way, so never persist a recorded path that no
// longer exists (a deleted worktree is the common case).
for (const candidate of extensionHostCliWorkingDirectoryPaths(await this._readExtensionHostCliMarker(sessionId))) {
try {
if ((await fs.stat(candidate)).isDirectory()) {
return URI.file(candidate);
}
} catch {
// Missing or unreadable; fall through to the next candidate.
if (await this._isExistingDirectory(candidate)) {
return URI.file(candidate);
}
}
return undefined;
}
/**
* Worktree identity the extension host recorded, when its checkout is gone but
* the repository remains. Resume recreates the worktree from this, matching how
* a natively worktree-isolated session recovers.
*/
private async _extensionHostCliAdoptedWorktree(sessionId: string): Promise<IAgentAdoptedWorktree | undefined> {
const worktree = (await this._readExtensionHostCliMarker(sessionId))?.worktreeProperties;
if (!worktree?.worktreePath || !worktree.repositoryPath || !worktree.branchName) {
return undefined;
}
if (await this._isExistingDirectory(worktree.worktreePath) || !(await this._isExistingDirectory(worktree.repositoryPath))) {
return undefined;
}
return {
branchName: worktree.branchName,
baseBranch: worktree.baseBranchName,
worktreePath: URI.file(worktree.worktreePath),
repositoryRoot: URI.file(worktree.repositoryPath),
};
}
/**
* Records the durable adopted-legacy marker on a session adopted by a build
* that predates it. Without this those sessions keep the extension-host marker
* but no provenance, so a worktree one stays filtered out of the window opened
* on its repository. Keyed off the marker, so it never claims a native session.
*/
private async _backfillAdoptedLegacyMarker(session: URI, sessionId: string): Promise<void> {
const ref = await this._sessionDataService.tryOpenDatabase(session);
if (!ref) {
return;
}
try {
if (await ref.object.getMetadata(AH_META_EHCLI_ADOPTED_DB_KEY) !== undefined) {
return;
}
if (!(await this._isExtensionHostCliSession(sessionId))) {
return;
}
await ref.object.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true');
this._logService.info(`[Copilot] Backfilled the adopted-legacy marker for ${sessionId}, migrated before it was recorded`);
} catch (err) {
this._logService.warn(`[Copilot] Failed to backfill the adopted-legacy marker for ${sessionId}`, err);
} finally {
ref.dispose();
}
}
/** Adopts a legacy extension-host Copilot CLI session in place when it is eligible on disk. */
async ensureChatAdopted(chat: URI, context: URI | IAgentChatContext): Promise<IAgentChatAdoptionResult> {
const session = resolveAgentChatContext(context, chat).configurationResource;
@@ -3114,30 +3218,54 @@ export class CopilotAgent extends Disposable implements IAgent {
// existence — to avoid falsely treating an empty DB as migrated.
const existing = await this._readStoredSessionMetadata(session);
if (existing?.workingDirectory) {
return { adopted: false, eligible: false, native: true }; // already native / adopted
await this._backfillAdoptedLegacyMarker(session, sessionId);
this._logService.trace(`[Copilot] Adoption skipped for ${sessionId}: already has Agent Host metadata (cwd=${existing.workingDirectory.fsPath})`);
return { adopted: false, eligible: false, native: true, reason: 'alreadyNative' };
}
// 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 { adopted: false, eligible: false };
this._logService.info(`[Copilot] Adoption declined for ${sessionId}: not a legacy extension-host Copilot CLI chat (no VS Code marker in its SDK session directory)`);
return { adopted: false, eligible: false, reason: 'notLegacyChat' };
}
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)
// The SDK reports the directory recorded when the session ran, which may since
// have been deleted (a removed worktree). Adopting it anyway commits the claim
// and then fails to resume, leaving the session in neither list.
const sdkWorkingDirectory = typeof sdkMetadata?.context?.workingDirectory === 'string' ? sdkMetadata.context.workingDirectory : undefined;
// A deleted worktree is recoverable the same way a native session recovers
// one: keep it as the working directory and let resume recreate it from the
// recorded branch.
const adoptedWorktree = await this._extensionHostCliAdoptedWorktree(sessionId);
const workingDirectory = adoptedWorktree?.worktreePath
?? (sdkWorkingDirectory && await this._isExistingDirectory(sdkWorkingDirectory) ? URI.file(sdkWorkingDirectory) : undefined)
?? await this._extensionHostCliWorkingDirectory(sessionId);
if (!workingDirectory) {
// An eligible legacy session whose on-disk working directory could not
// be resolved: a genuine migration candidate that did not migrate.
return { adopted: false, eligible: true };
this._logService.warn(`[Copilot] Adoption skipped for ${sessionId}: no usable working directory (sdk='${sdkWorkingDirectory ?? '(none)'}' exists=${sdkWorkingDirectory ? await this._isExistingDirectory(sdkWorkingDirectory) : false}, no recorded worktree, no marker fallback). The session stays on the legacy provider.`);
return { adopted: false, eligible: true, reason: 'workingDirectoryMissing' };
}
this._logService.info(`[Copilot] Adopting legacy session ${sessionId} in place (reusing on-disk events.jsonl)`);
this._logService.info(`[Copilot] Adopting legacy session ${sessionId} in place (reusing on-disk events.jsonl): cwd=${workingDirectory.fsPath}${adoptedWorktree ? ` worktree=${adoptedWorktree.worktreePath.fsPath} branch=${adoptedWorktree.branchName} base=${adoptedWorktree.baseBranch ?? '(none)'} repo=${adoptedWorktree.repositoryRoot.fsPath} (checkout missing, will be recreated on resume)` : ''}`);
// 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);
const project = await this._localProject(
await projectFromCopilotContext({ cwd: (adoptedWorktree?.repositoryRoot ?? workingDirectory).fsPath }, this._gitService),
sessionId,
);
// 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);
const archived = await this._isExtensionHostCliSessionArchived(sessionId);
if (archived === undefined) {
// Adoption commits the archived state, and the extension host stops listing
// the chat once it does. Guessing `false` here would resurface a session the
// user had filed away, so leave it for the next open instead.
this._logService.warn(`[Copilot] Adoption skipped for ${sessionId}: its extension-host marker could not be re-read, so the archived state is unknown`);
return { adopted: false, eligible: true, reason: 'markerUnavailable' };
}
// Seed VS Code-layer metadata only — the SDK event log on disk is
// untouched. Writing `agentSessionData/<sanitizedId>/session.db` here
// is also what makes the legacy extension-host Copilot CLI list stop
@@ -3145,9 +3273,10 @@ export class CopilotAgent extends Disposable implements IAgent {
// `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, /* markRead */ true);
await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle, /* markRead */ true, archived, /* ehcliAdopted */ true);
await this._adoptLegacyTurnUsage(session, sessionId);
return { adopted: true, eligible: true };
this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} customTitle=${customTitle !== undefined} worktreeBridged=${!!adoptedWorktree}`);
return { adopted: true, eligible: true, reason: 'adopted', ...(adoptedWorktree ? { worktree: adoptedWorktree } : {}) };
});
}
@@ -4751,7 +4880,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, configValues?: Record<string, unknown>, customTitle?: string, markRead?: boolean): Promise<void> {
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<string, unknown>, customTitle?: string, markRead?: boolean, archived?: boolean, ehcliAdopted?: boolean): Promise<void> {
const dbRef = this._sessionDataService.openDatabase(session);
const db = dbRef.object;
try {
@@ -4763,6 +4892,16 @@ export class CopilotAgent extends Disposable implements IAgent {
if (markRead) {
work.push(db.setMetadata(AH_META_IS_READ_DB_KEY, 'true'));
}
// Archiving is user-curated state; losing it on adoption would resurface
// everything the user filed away in the extension host list.
if (archived) {
work.push(db.setMetadata(AH_META_IS_ARCHIVED_DB_KEY, 'true'));
}
// Outlives the transient `ehcliAdoptable` summary marker so the session
// keeps being listed like the legacy session it was migrated from.
if (ehcliAdopted) {
work.push(db.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true'));
}
if (workingDirectory) {
work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString()));
}
@@ -4921,6 +5060,25 @@ export class CopilotAgent extends Disposable implements IAgent {
await this._storeSessionMetadata(session, undefined, undefined, undefined, undefined, project, true);
}
/**
* Git resolution runs in the session's working directory, so a legacy session
* whose worktree checkout was deleted falls back to the remote (e.g.
* `https://github.com/owner/repo`). That is not a location on disk, so the
* session could never be matched to the repository folder a window has open.
* The extension host recorded the local repository root prefer it.
*/
private async _localProject(project: IAgentSessionProjectInfo | undefined, adoptableSessionId: string | undefined): Promise<IAgentSessionProjectInfo | undefined> {
if (project?.uri.scheme === Schemas.file || adoptableSessionId === undefined) {
return project;
}
const repositoryPath = extensionHostCliRepositoryPath(await this._readExtensionHostCliMarker(adoptableSessionId));
if (!repositoryPath) {
return project;
}
const uri = URI.file(repositoryPath);
return { uri, displayName: resourceBasename(uri) || project?.displayName || uri.toString() };
}
private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter<IAgentSessionProjectInfo | undefined>, projectByContext: Map<string, Promise<IAgentSessionProjectInfo | undefined>>): Promise<IAgentSessionProjectInfo | undefined> {
const key = this._projectContextKey(context);
if (!key) {
@@ -957,6 +957,17 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
return true;
}
/**
* Records worktree identity supplied by a predecessor for an adopted session whose
* checkout is gone, so resume recreates it exactly like a native worktree session.
* Values come from the predecessor's own record rather than probing the (missing)
* directory, which is what {@link adoptExistingWorktreeMetadata} requires.
*/
async recordAdoptedWorktreeMetadata(sessionUri: URI, metadata: { readonly branchName: string; readonly baseBranch: string | undefined; readonly worktreePath: URI; readonly repositoryRoot: URI }): Promise<void> {
this._logService.info(`[${this._logLabel}:${AgentSession.id(sessionUri)}] Recorded adopted worktree metadata: worktree='${metadata.worktreePath.fsPath}' branch='${metadata.branchName}' base='${metadata.baseBranch ?? '(none)'}' repo='${metadata.repositoryRoot.fsPath}'`);
await this._writeWorktreeMetadata(sessionUri, metadata);
}
/**
* Records repository identity for an externally-owned linked worktree without taking ownership of its lifecycle.
*/
@@ -713,6 +713,22 @@ suite('AgentHostStateManager', () => {
assert.strictEqual(readSessionEhcliAdoptable(changed[0].changes._meta), false);
});
test('publishing a restored session announces it to clients that never saw it', () => {
// A legacy chat adopted after startup was never surfaced by discovery, so
// restore records it silently and clients have no entry. Publishing is what
// makes an adopted session appear instead of existing only on the host.
manager.restoreSession(makeSessionSummary(), []);
const notifications: INotification[] = [];
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
manager.setSessionSummaryPublished(sessionUri, true);
assert.deepStrictEqual(
notifications.filter(n => n.type === NotificationType.SessionAdded).map(n => (n as { summary: { resource: string } }).summary.resource),
[sessionUri],
);
});
suite('unused-draft tracking', () => {
test('reports draft status by origin, addressable by session or chat URI', () => {
@@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js';
import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
import { AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js';
import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js';
import { IProductService } from '../../../product/common/productService.js';
@@ -4966,6 +4966,25 @@ suite('AgentService (node dispatcher)', () => {
assert.deepStrictEqual(sessions[0]._meta, { 'vscode.external': true, workspaceless: true });
});
test('listSessions overlays the adopted-legacy marker so a migrated session keeps its legacy listing', async () => {
const db = new TestSessionDatabase();
await db.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true');
const sessionId = 'test-session-ehcli-adopted';
const sessionUri = AgentSession.uri('copilot', sessionId);
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days });
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.deepStrictEqual(
{ count: sessions.length, adopted: readSessionEhcliAdopted(sessions[0]?._meta) },
{ count: 1, adopted: true },
);
});
test('listSessions restores persisted multi-root metadata', async () => {
const db = new TestSessionDatabase();
const multiRoot = {
@@ -6662,7 +6681,10 @@ suite('AgentService (node dispatcher)', () => {
let rejected: unknown;
const restore = svc.restoreSession(session).catch(err => { rejected = err; });
await advanceUntil(() => agent.listChatsToMigrateCalls > 0);
// The gated catalogue migration starts from `registerProvider`, so waiting
// on it alone would sample the counters before restore's own (independent)
// metadata read has landed.
await advanceUntil(() => agent.listChatsToMigrateCalls > 0 && agent.getChatMetadataCalls > 0);
const beforeGate = {
metadataRead: agent.getChatMetadataCalls,
hydrated: !!svc.stateManager.getSessionState(session.toString()),
@@ -6676,7 +6698,7 @@ suite('AgentService (node dispatcher)', () => {
rejected,
hydratedAfter: !!svc.stateManager.getSessionState(session.toString()),
}, {
beforeGate: { metadataRead: 0, hydrated: false },
beforeGate: { metadataRead: 1, hydrated: false },
rejected: undefined,
hydratedAfter: true,
});
@@ -6691,7 +6713,9 @@ suite('AgentService (node dispatcher)', () => {
let rejected: unknown;
const restore = svc.restoreSession(session).catch(err => { rejected = err; });
await advanceUntil(() => agent.listChatsToMigrateCalls > 0);
// Wait until restore is parked on the catalogue: deleting before it reads
// metadata would trip the early tombstone check instead of the one after.
await advanceUntil(() => agent.listChatsToMigrateCalls > 0 && agent.getChatMetadataCalls > 0);
await svc.disposeSession(session);
agent.migrationGate.complete();
await restore;
@@ -6704,11 +6728,33 @@ suite('AgentService (node dispatcher)', () => {
}, {
isProtocolError: true,
code: AHP_SESSION_NOT_FOUND,
metadataRead: 0,
// Restore reads per-session metadata before waiting on the catalogue,
// so one read happens even for a session deleted during the wait.
metadataRead: 1,
hydrated: false,
});
});
test('restores a session the provider can describe without waiting for the catalogue', async () => {
// Warming the catalogue is O(catalogue) — ~48s on a large `~/.copilot` —
// so a session that resolves from its own metadata must not pay for it.
const svc = makeService();
const agent = disposables.add(new StartupRaceAgent('copilot'));
const session = AgentSession.uri('copilot', 'describable-session');
seedSession(agent, session);
// Describable immediately, while the catalogue migration stays gated.
agent.sdkReady = true;
svc.registerProvider(agent);
await svc.restoreSession(session);
assert.deepStrictEqual(
{ hydrated: !!svc.stateManager.getSessionState(session.toString()), catalogueSettled: agent.migrationGate.isSettled },
{ hydrated: true, catalogueSettled: false },
);
agent.migrationGate.complete();
});
test('reports a genuinely missing session as not found once migration completes', async () => {
const svc = makeService();
const agent = disposables.add(new StartupRaceAgent('copilot'));
@@ -7227,6 +7273,7 @@ suite('AgentService (node dispatcher)', () => {
);
});
test('adopts a surfaced legacy session on open only when the migrate setting is on', async () => {
// Open-adoption is strictly gated on the live migrate setting.
class AdoptOnOpenAgent extends MockAgent {
@@ -7276,6 +7323,63 @@ suite('AgentService (node dispatcher)', () => {
);
});
test('an adopted chat whose restore fails is still registered, not lost from every list', async () => {
// Adoption claims the chat on disk, which stops the extension host listing
// it. If restore then fails (e.g. a worktree whose branch is gone) and the
// chat was never registered, it exists in no list at all.
class AdoptThenFailAgent extends MockAgent {
constructor() { super('copilot'); }
// Absent from the catalogue, so only the adoption path can register it.
override async listChatsToMigrate(): Promise<IAgentChatMetadata[]> {
return [];
}
async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise<IAgentChatAdoptionResult> {
return { adopted: true, eligible: true };
}
override async materializeChat(): Promise<never> {
throw new Error('working directory no longer exists');
}
}
const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new AdoptThenFailAgent());
localService.registerProvider(agent);
localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true });
const session = AgentSession.uri('copilot', 'adopted-restore-fails');
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(session), session);
await assert.rejects(() => localService.restoreSession(session));
const registry = (localService as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry;
assert.strictEqual((await registry.get(session))?.session.toString(), session.toString());
});
test('an adopted chat whose registration cannot be made durable fails the migration', async () => {
// Continuing unregistered would leave exactly the orphan the registration is
// there to prevent: adopted on disk, so the extension host stops listing it,
// but present in no Agent Host list either.
class AdoptAgent extends MockAgent {
constructor() { super('copilot'); }
override async listChatsToMigrate(): Promise<IAgentChatMetadata[]> {
return [];
}
async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise<IAgentChatAdoptionResult> {
return { adopted: true, eligible: true };
}
}
const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new AdoptAgent());
localService.registerProvider(agent);
localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true });
const session = AgentSession.uri('copilot', 'adopted-registration-fails');
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(AgentSession.id(session), session);
const registry = (localService as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry;
registry.register = async () => { throw new Error('registry unavailable'); };
await assert.rejects(() => localService.restoreSession(session));
});
test('does not materialize state for an unregistered chat that is not adoptable', async () => {
// An external chat (e.g. created by the GitHub app) is hidden while
// `showExternalSessions` is `none`, so it is absent from the registered
@@ -43,7 +43,7 @@ import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type
import { AgentHostClientType } from '../../common/agentHostClientInfo.js';
import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js';
import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js';
import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js';
import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
@@ -5569,6 +5569,27 @@ suite('CopilotAgent', () => {
}
});
test('does not surface a legacy chat the user archived in the extension host', async () => {
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/archived-discovery-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/archived-discovery-cwd-`);
const sessionDataService = disposables.add(new TestSessionDataService());
const client = new TestCopilotClient([sdkSession('ehcli-archived', workingDirectory)]);
await writeExtensionHostMarker(userHome, 'ehcli-archived', { origin: 'vscode', archived: true });
const { agent } = createTestAgentContext(disposables, {
sessionDataService,
copilotClient: client,
userHome,
rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true },
});
try {
assert.deepStrictEqual(await collectDiscoveredChats(agent), []);
} 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 a session Agent Host owns or one the SDK reports without a working directory', async () => {
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/owned-discovery-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/owned-discovery-cwd-`);
@@ -11088,6 +11109,248 @@ suite('CopilotAgent', () => {
await fs.writeFile(join(dir, 'vscode.requests.metadata.json'), JSON.stringify(details), 'utf8');
}
test('keeps a deleted worktree as the working directory so resume can recreate it', async () => {
// Parity with native worktree sessions: the checkout is recreated from the
// recorded branch rather than the session being re-rooted at the repository.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const repositoryRoot = await fs.mkdtemp(`${os.tmpdir()}/adopt-repo-`);
const worktreePath = join(repositoryRoot, '..', 'gone.worktrees', 'feature-x');
const sessionId = 'legacy-worktree-gone';
const session = AgentSession.uri('copilotcli', sessionId);
const sessionDataService = disposables.add(new TestSessionDataService());
// The SDK still reports the deleted checkout, exactly as it does on disk.
const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]);
const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome });
try {
await agent.authenticate('https://api.github.com', 'token');
await writeExtensionHostMarker(userHome, sessionId, {
origin: 'vscode',
worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' },
});
const adopted = await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory');
db?.dispose();
assert.deepStrictEqual(
{
adopted: adopted.adopted,
worktree: adopted.worktree && {
branchName: adopted.worktree.branchName,
baseBranch: adopted.worktree.baseBranch,
worktreePath: adopted.worktree.worktreePath.fsPath,
repositoryRoot: adopted.worktree.repositoryRoot.fsPath,
},
persistedCwd,
},
{
adopted: true,
worktree: { branchName: 'feature/x', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath },
persistedCwd: URI.file(worktreePath).toString(),
},
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(repositoryRoot, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('adopts a deleted worktree with the local repository as its project, not the remote', async () => {
// Git resolution runs in the (missing) checkout and falls back to the
// remote, whose URI is not a path — the session could then never be
// matched to the repository folder a window has open.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const repositoryRoot = await fs.mkdtemp(`${os.tmpdir()}/adopt-repo-`);
const worktreePath = join(repositoryRoot, '..', 'gone.worktrees', 'feature-y');
const sessionId = 'legacy-worktree-remote-project';
const session = AgentSession.uri('copilotcli', sessionId);
const sessionDataService = disposables.add(new TestSessionDataService());
const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]);
// No git root resolves for a checkout that is gone, so the project would
// otherwise come from `context.repository`.
const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome });
try {
await agent.authenticate('https://api.github.com', 'token');
await writeExtensionHostMarker(userHome, sessionId, {
origin: 'vscode',
worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/y', baseBranchName: 'main' },
});
await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const projectUri = await db?.object.getMetadata('copilot.project.uri');
db?.dispose();
assert.strictEqual(projectUri, URI.file(repositoryRoot).toString());
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(repositoryRoot, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('backfills the adopted-legacy marker for a session migrated by an older build', async () => {
// Those sessions already have a working directory, so adoption short-circuits
// as `alreadyNative` and never reaches the write. Without the backfill a
// migrated worktree session stays filtered out of its repository window.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-old-`);
const sessionId = 'legacy-already-adopted';
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);
// Metadata an older build wrote: adopted, but without the provenance marker.
const seed = sessionDataService.openDatabase(session);
await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString());
seed.dispose();
const adopted = await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const marker = await db?.object.getMetadata('agentHost.ehcliAdopted');
db?.dispose();
assert.deepStrictEqual(
{ reason: adopted.reason, marker },
{ reason: 'alreadyNative', marker: 'true' },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('does not backfill the adopted-legacy marker onto a native session', async () => {
// No extension-host marker means the session was never a legacy chat.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-native-`);
const sessionId = 'native-session';
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');
const seed = sessionDataService.openDatabase(session);
await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString());
seed.dispose();
await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const marker = await db?.object.getMetadata('agentHost.ehcliAdopted');
db?.dispose();
assert.strictEqual(marker, undefined);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('sees an archive toggled in the extension host after the marker was cached', async () => {
// The marker cache memoizes successful reads for the agent's lifetime, but
// `archived` is user-toggled while both hosts run, so it must be re-read.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-archive-`);
const sessionId = 'legacy-archived-later';
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, { origin: 'vscode', archived: false });
// Populate the marker cache, as discovery does when it classifies the chat.
await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise<boolean> })._isExtensionHostCliSession(sessionId);
// The user archives it in the extension host list afterwards.
await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true });
await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const archived = await db?.object.getMetadata('isArchived');
db?.dispose();
assert.strictEqual(archived, 'true');
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('declines adoption when the archived state can no longer be read', async () => {
// Adoption commits the archived state and makes the extension host stop
// listing the chat, so guessing "not archived" would resurface a session the
// user had filed away. Leave it for the next open instead.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-marker-gone-`);
const sessionId = 'legacy-marker-unreadable';
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);
// Classify it as legacy while the marker is readable, then corrupt it.
await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise<boolean> })._isExtensionHostCliSession(sessionId);
await fs.writeFile(join(getCopilotHomePath(userHome.fsPath, process.env), 'session-state', sessionId, 'vscode.metadata.json'), '{ not json', 'utf8');
const adopted = await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory');
db?.dispose();
assert.deepStrictEqual(
{ adopted, persistedCwd },
{ adopted: { adopted: false, eligible: true, reason: 'markerUnavailable' }, persistedCwd: undefined },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('reports no recorded worktree when the checkout still exists', async () => {
// A live worktree is handled by the existing probe-the-directory bridge.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-live-`);
const sessionId = 'legacy-worktree-live';
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, {
origin: 'vscode',
worktreeProperties: { worktreePath: workingDirectory, repositoryPath: workingDirectory, branchName: 'feature/y' },
});
const adopted = await ensureDefaultChatAdopted(agent, session);
assert.deepStrictEqual({ adopted: adopted.adopted, worktree: adopted.worktree }, { adopted: true, worktree: undefined });
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
await disposeAgent(agent);
}
});
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-`);
@@ -11111,7 +11374,70 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ first, second, configValues },
{ first: { adopted: true, eligible: true }, second: { adopted: false, eligible: false, native: true }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) },
{ first: { adopted: true, eligible: true, reason: 'adopted' }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, 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('does not adopt a session whose recorded working directory no longer exists', async () => {
// A months-old session may have run in a worktree that has since been
// deleted. Adopting it commits the claim (the extension host list stops
// showing it) and then fails to resume, leaving it in neither list.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const deletedWorkingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-gone-`);
await fs.rm(deletedWorkingDirectory, { recursive: true, force: true });
const sessionId = 'legacy-missing-cwd';
const session = AgentSession.uri('copilotcli', sessionId);
const sessionDataService = disposables.add(new TestSessionDataService());
const client = new TestCopilotClient([sdkSession(sessionId, deletedWorkingDirectory)]);
const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome });
try {
await agent.authenticate('https://api.github.com', 'token');
await writeExtensionHostMarker(userHome, sessionId);
const adopted = await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory');
db?.dispose();
assert.deepStrictEqual(
{ adopted, persistedCwd },
{ adopted: { adopted: false, eligible: true, reason: 'workingDirectoryMissing' }, persistedCwd: undefined },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await disposeAgent(agent);
}
});
test('carries over the legacy archived state on adoption', async () => {
// Archiving is user-curated: adopting must not resurface a session the
// user filed away in the extension host list.
const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`));
const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`);
const sessionId = 'legacy-archived';
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, { origin: 'vscode', archived: true });
const adopted = await ensureDefaultChatAdopted(agent, session);
const db = await sessionDataService.tryOpenDatabase(session);
const archived = await db?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY);
db?.dispose();
assert.deepStrictEqual(
{ adopted, archived },
{ adopted: { adopted: true, eligible: true, reason: 'adopted' }, archived: 'true' },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11149,7 +11475,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, usages },
{
adopted: { adopted: true, eligible: true },
adopted: { adopted: true, eligible: true, reason: 'adopted' },
usages: [
['evt-1', JSON.stringify({ model: 'gpt-5.4', _meta: { copilotUsage: { totalNanoAiu: 1_500_000_000 } } })],
['evt-2', JSON.stringify({ model: 'gpt-5.4-mini', _meta: { copilotUsage: { totalNanoAiu: 0 } } })],
@@ -11183,7 +11509,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, customTitle },
{ adopted: { adopted: true, eligible: true }, customTitle: 'My Legacy Session' },
{ adopted: { adopted: true, eligible: true, reason: 'adopted' }, customTitle: 'My Legacy Session' },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11212,7 +11538,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, isRead },
{ adopted: { adopted: true, eligible: true }, isRead: 'true' },
{ adopted: { adopted: true, eligible: true, reason: 'adopted' }, isRead: 'true' },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11236,7 +11562,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions },
{ adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] },
{ adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11266,7 +11592,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions },
{ adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] },
{ adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11291,7 +11617,7 @@ suite('CopilotAgent', () => {
const adopted = await ensureDefaultChatAdopted(agent, session);
assert.deepStrictEqual(adopted, { adopted: true, eligible: true });
assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted' });
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
await fs.rm(workingDirectory, { recursive: true, force: true });
@@ -11317,7 +11643,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions },
{ adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] },
{ adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -11353,7 +11679,7 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual(
{ adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, usages },
{ adopted: { adopted: false, eligible: false, native: true }, getSessionMetadataCalls: [], usages: [] },
{ adopted: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, getSessionMetadataCalls: [], usages: [] },
);
} finally {
await fs.rm(userHome.fsPath, { recursive: true, force: true });
@@ -21,6 +21,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js';
import { AutomationStore } from '../../../automations/browser/automationService.js';
@@ -102,7 +103,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
// Startup restore reopens persisted slots against a cold host, where the
// first catalog pass is far slower than an interactive open.
const timeoutMs = reason === 'restore' ? LEGACY_MIGRATION_RESTORE_TIMEOUT_MS : LEGACY_MIGRATION_TIMEOUT_MS;
return adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, timeoutMs);
return adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, this._telemetryService, reason ?? 'open', timeoutMs);
}
constructor(
@@ -113,6 +114,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
@ILanguageModelsService languageModelsService: ILanguageModelsService,
@ILabelService private readonly _labelService: ILabelService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@ILogService logService: ILogService,
@IGitHubService gitHubService: IGitHubService,
@IInstantiationService instantiationService: IInstantiationService,
@@ -8,6 +8,7 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
import { ChatConfiguration } from '../../../common/constants.js';
import { AgentSession, IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js';
@@ -27,6 +28,25 @@ import { COPILOT_CLI_AGENT_PROVIDER, getCopilotCliSessionRawId, migratedCopilotC
export const LEGACY_MIGRATION_TIMEOUT_MS = 10_000;
export const LEGACY_MIGRATION_RESTORE_TIMEOUT_MS = 60_000;
/** Where a probe was triggered from, so outcomes can be attributed per entry point. */
export type LegacyMigrationProbeSource = 'open' | 'restore';
type LegacyMigrationProbeEvent = {
source: string;
outcome: 'adopted' | 'declined' | 'timedOut' | 'settingDisabled' | 'noConnection' | 'failed';
durationMs: number;
timeoutMs: number;
};
type LegacyMigrationProbeClassification = {
source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Entry point that probed: open (user opened a session) or restore (startup/editor restore).' };
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Probe outcome: adopted (the host migrated the session; the caller may still fall back if it does not surface — see agentHost.legacyCopilotCliMigrationOpen), declined (host refused, e.g. not an adoptable legacy chat), timedOut (no answer within the budget), settingDisabled, noConnection, or failed (probe threw).' };
durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent probing before the outcome was known.' };
timeoutMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The probe budget that applied, so timeouts can be correlated with the entry point.' };
owner: 'vijayupadya';
comment: 'Counts adopt-on-open probe attempts for legacy extension-host Copilot CLI sessions. The host-side agentHost.legacyCopilotCliMigration event only fires once migration starts, so without this there is no denominator for a success rate and a silently-unmigrated open is indistinguishable from a user having no legacy sessions.';
};
/**
* Redirects a legacy extension-host Copilot CLI resource to its agent-host twin,
* adopting it on the way, or `undefined` to leave the caller's resource alone.
@@ -47,10 +67,27 @@ export async function adoptLegacyCopilotCliResource(
resource: URI,
logService: ILogService,
configurationService: IConfigurationService,
telemetryService: ITelemetryService,
source: LegacyMigrationProbeSource,
timeoutMs: number = LEGACY_MIGRATION_TIMEOUT_MS,
): Promise<URI | undefined> {
const twin = migratedCopilotCliResource(resource);
if (!twin || !connection) {
if (!twin) {
return undefined;
}
const startedAt = Date.now();
// Reported only for resources that are actually legacy sessions, so the event
// counts migration opportunities rather than every open in the product.
const report = (outcome: LegacyMigrationProbeEvent['outcome']) => {
telemetryService.publicLog2<LegacyMigrationProbeEvent, LegacyMigrationProbeClassification>('agentHost.legacyCopilotCliMigrationProbe', {
source,
outcome,
durationMs: Date.now() - startedAt,
timeoutMs,
});
};
if (!connection) {
report('noConnection');
return undefined;
}
// The host restores a session whether or not it adopts it, so a successful
@@ -58,6 +95,7 @@ export async function adoptLegacyCopilotCliResource(
// without it we would move sessions onto the agent host for users who never
// opted in — including external ones, which are never adopted at all.
if (configurationService.getValue<boolean>(ChatConfiguration.MigrateLegacyCopilotCliSessions) !== true) {
report('settingDisabled');
return undefined;
}
const rawId = getCopilotCliSessionRawId(twin);
@@ -67,18 +105,20 @@ export async function adoptLegacyCopilotCliResource(
// AHP channels are backend session URIs (`<provider>:/<id>`); the
// `agent-host-` scheme is a client-side naming that the host does not know.
const backendSession = AgentSession.uri(COPILOT_CLI_AGENT_PROVIDER, rawId);
const startedAt = Date.now();
const store = new DisposableStore();
try {
const ref = store.add(connection.getSubscription(StateComponents.Session, backendSession, 'AgentHostLegacyMigration'));
const settled = await raceTimeout(whenSubscriptionSettles(ref.object as IAgentSubscription<SessionState>, store), timeoutMs);
if (settled === true) {
logService.trace(`[AgentHost] adopted legacy session ${resource.toString()} in ${Date.now() - startedAt}ms`);
report('adopted');
logService.info(`[AgentHost] adopted legacy session ${resource.toString()} in ${Date.now() - startedAt}ms`);
return twin;
}
report(settled === false ? 'declined' : 'timedOut');
logService.info(`[AgentHost] legacy session ${resource.toString()} not adopted (${settled === false ? 'declined by host' : `no answer within ${timeoutMs}ms`}); opening it unmigrated`);
return undefined;
} catch (err) {
report('failed');
logService.warn(`[AgentHost] legacy migration probe failed for ${resource.toString()}`, err);
return undefined;
} finally {
@@ -86,6 +126,27 @@ export async function adoptLegacyCopilotCliResource(
}
}
type LegacyMigrationOpenEvent = {
source: string;
surfaced: boolean;
};
type LegacyMigrationOpenClassification = {
source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Entry point that opened: open (user opened a session) or restore (startup/editor restore).' };
surfaced: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session was found and opened. False means the open fell back to the legacy session the host had just migrated away from.' };
owner: 'vijayupadya';
comment: 'Reports whether an adopted legacy session was actually opened as its migrated agent-host session. The probe event only reports that the host adopted it, so without this a silent fallback to the legacy session is invisible.';
};
/**
* Records whether an adopted session was opened as its migrated twin. Adoption
* succeeding does not mean the caller could open it, and that fallback is the
* failure this telemetry exists to catch.
*/
export function reportLegacyMigrationOpen(telemetryService: ITelemetryService, source: LegacyMigrationProbeSource, surfaced: boolean): void {
telemetryService.publicLog2<LegacyMigrationOpenEvent, LegacyMigrationOpenClassification>('agentHost.legacyCopilotCliMigrationOpen', { source, surfaced });
}
/** Resolves `true` once the subscription has state, `false` if it errors. */
function whenSubscriptionSettles(subscription: IAgentSubscription<SessionState>, store: DisposableStore): Promise<boolean> {
const current = subscription.value;
@@ -10,8 +10,10 @@ import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resour
import { URI } from '../../../../../../base/common/uri.js';
import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js';
import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { readSessionEhcliAdoptable, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { Schemas } from '../../../../../../base/common/network.js';
/**
* Minimal agent-host connection surface needed by the session list store.
@@ -88,9 +90,13 @@ export class AgentHostSessionListStore extends Disposable {
*/
private _mutationGeneration = 0;
/** Sessions already reported as having an unusable (non-local) project root. */
private readonly _reportedNonLocalProjects = new Set<string>();
constructor(
private readonly _connection: IAgentHostSessionListConnection,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
@ILogService private readonly _logService: ILogService,
) {
super();
@@ -371,6 +377,19 @@ export class AgentHostSessionListStore extends Disposable {
/** Uses workspace-file provenance for multi-root workspaces and path containment otherwise. */
private _isSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean {
const inWorkspace = this._computeSessionInWorkspace(entry);
// A legacy session is matched by its repository root, which must be a local
// path; a remote project (e.g. an `https://` repo URL) silently matches
// nothing. Excluding one is legitimate, so only report the broken input, and
// only once — this runs for every session on every refresh.
if (!inWorkspace && readSessionMatchesByProjectRoot(entry.summary._meta) && entry.summary.project && URI.parse(entry.summary.project.uri).scheme !== Schemas.file && !this._reportedNonLocalProjects.has(entry.summary.resource)) {
this._reportedNonLocalProjects.add(entry.summary.resource);
this._logService.warn(`[AgentHost] legacy session ${entry.summary.resource} has a non-local project '${entry.summary.project.uri}' and cannot be matched to a workspace folder`);
}
return inWorkspace;
}
private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean {
const workingDirectories = this._containmentCandidates(entry.summary);
const workspace = this._workspaceContextService.getWorkspace();
const folders = workspace.folders;
@@ -420,11 +439,18 @@ export class AgentHostSessionListStore extends Disposable {
* server-owned project (repository) root. Those legacy sessions run out of a
* `copilot-worktrees/` directory outside the repository, so working
* directories alone would hide them from a window opened on that repository.
* The marker has to outlive adoption: a migrated session is still a legacy
* session and must not drop out of the list the moment it migrates.
*/
private _containmentCandidates(summary: SessionSummary): readonly URI[] {
const candidates = summary.workingDirectories?.map(directory => URI.parse(directory)) ?? [];
if (summary.project?.uri && readSessionEhcliAdoptable(summary._meta)) {
candidates.push(URI.parse(summary.project.uri));
if (summary.project?.uri && readSessionMatchesByProjectRoot(summary._meta)) {
const project = URI.parse(summary.project.uri);
// A project can be a remote (e.g. `https://github.com/owner/repo`), whose
// `fsPath` is not a location on disk and would silently never match.
if (project.scheme === Schemas.file) {
candidates.push(project);
}
}
return candidates;
}
@@ -22,7 +22,8 @@ import { URI } from '../../../../../base/common/uri.js';
import { IAgentSessionsService } from './agentSessionsService.js';
import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { adoptLegacyCopilotCliResource } from './agentHost/agentHostLegacyMigration.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { adoptLegacyCopilotCliResource, reportLegacyMigrationOpen } from './agentHost/agentHostLegacyMigration.js';
//#region Session Opener Registry
@@ -59,18 +60,46 @@ export const sessionOpenerRegistry = new SessionOpenerRegistry();
//#endregion
/**
* The agent-host session a legacy chat was just migrated into is not in the list
* until its provider is refreshed, so a lookup straight after adoption misses and
* the caller would fall back to opening the legacy session it just migrated away
* from. Refresh that one provider and look again.
*/
async function resolveMigratedSession(agentSessionsService: IAgentSessionsService, migrated: URI): Promise<IAgentSession | undefined> {
const existing = agentSessionsService.getSession(migrated);
if (existing) {
return existing;
}
await agentSessionsService.model.resolve(getChatSessionType(migrated));
return agentSessionsService.getSession(migrated);
}
export async function openSessionByResource(accessor: ServicesAccessor, resource: URI, openOptions?: ISessionOpenOptions): Promise<IChatWidget | undefined> {
const instantiationService = accessor.get(IInstantiationService);
const logService = accessor.get(ILogService);
const agentSessionsService = accessor.get(IAgentSessionsService);
const telemetryService = accessor.get(ITelemetryService);
// A superseded legacy resource is redirected (and adopted) before anything
// looks it up, so opening by URI migrates instead of reaching the old provider.
resource = await adoptLegacyCopilotCliResource(
const migrated = await adoptLegacyCopilotCliResource(
accessor.get(IAgentHostConnectionsService).ambientConnection,
resource,
logService,
accessor.get(IConfigurationService),
) ?? resource;
accessor.get(ITelemetryService),
'open',
);
if (migrated) {
const surfaced = await resolveMigratedSession(agentSessionsService, migrated);
reportLegacyMigrationOpen(telemetryService, 'open', !!surfaced);
if (surfaced) {
resource = migrated;
} else {
logService.warn(`[AgentHost] migrated ${resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`);
}
}
for (const participant of sessionOpenerRegistry.getParticipants()) {
if (!participant.handleOpenSessionResource) {
@@ -98,6 +127,8 @@ export async function openSessionByResource(accessor: ServicesAccessor, resource
export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions, alreadyResolved?: boolean): Promise<IChatWidget | undefined> {
const instantiationService = accessor.get(IInstantiationService);
const logService = accessor.get(ILogService);
const agentSessionsService = accessor.get(IAgentSessionsService);
const telemetryService = accessor.get(ITelemetryService);
logService.trace(`[AgentSessions] openSession start: ${session.resource.toString()}`);
@@ -110,9 +141,17 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes
session.resource,
logService,
accessor.get(IConfigurationService),
accessor.get(ITelemetryService),
'open',
);
if (migrated) {
session = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService).getSession(migrated)) ?? session;
const migratedSession = await resolveMigratedSession(agentSessionsService, migrated);
reportLegacyMigrationOpen(telemetryService, 'open', !!migratedSession);
if (migratedSession) {
session = migratedSession;
} else {
logService.warn(`[AgentHost] migrated ${session.resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`);
}
}
}
@@ -17,6 +17,7 @@ import { ConfirmResult, IDialogService } from '../../../../../../platform/dialog
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
import { IStorageService } from '../../../../../../platform/storage/common/storage.js';
import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js';
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
@@ -76,6 +77,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService,
@IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
) {
super();
@@ -250,6 +252,8 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
this._sessionResource,
this.logService,
this.configurationService,
this.telemetryService,
'restore',
LEGACY_MIGRATION_RESTORE_TIMEOUT_MS,
);
if (migrated) {
@@ -34,7 +34,7 @@ import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSy
import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
import { ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js';
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
@@ -2522,6 +2522,82 @@ suite('AgentHostChatContribution', () => {
});
});
test('a worktree session adopted mid-window survives the post-adoption summary change', async () => {
// Repro of the migration symptom: the session is listed while adoptable,
// the user opens it, adoption clears `ehcliAdoptable`, and the summary
// change that follows must not evict it from the window's list.
const { instantiationService, agentHostService } = createTestServices(disposables);
const folder = URI.file('/src/repo');
instantiationService.stub(IWorkspaceContextService, {
getWorkbenchState: () => WorkbenchState.FOLDER,
getWorkspace: () => ({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] }),
getWorkspaceFolder: () => null,
onDidChangeWorkspaceFolders: Event.None,
});
const backendSession = AgentSession.uri('copilot', 'adopted-midflight');
agentHostService.addSession({
session: backendSession,
startTime: 1000,
modifiedTime: 2000,
summary: 'Worktree session',
workingDirectories: [URI.file('/src/repo.worktrees/feature')],
project: { uri: folder, displayName: 'repo' },
_meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true },
});
const listController = createSessionListController(disposables, instantiationService, agentHostService);
await listController.refresh(CancellationToken.None);
const beforeAdoption = listController.items.map(item => item.label);
// Post-adoption the host reports the session with the durable adopted
// marker in place of the transient adoptable one, so it keeps matching
// its repository folder even though it runs out of a sibling worktree.
agentHostService.fireNotification({
type: 'root/sessionSummaryChanged',
channel: ROOT_STATE_URI,
session: backendSession.toString(),
changes: { status: SessionStatus.Idle, _meta: { [SESSION_META_EHCLI_ADOPTED_KEY]: true } },
});
assert.deepStrictEqual({
beforeAdoption,
afterAdoption: listController.items.map(item => item.label),
}, {
beforeAdoption: ['Worktree session'],
afterAdoption: ['Worktree session'],
});
});
test('a summary change still clears host-cleared markers on a non-legacy session', async () => {
// The adoption carry-forward must not turn `_meta` into an append-only bag:
// a session that was never adoptable keeps the host's replacement verbatim.
const { instantiationService, agentHostService } = createTestServices(disposables);
const backendSession = AgentSession.uri('copilot', 'clearable-meta');
agentHostService.addSession({
session: backendSession,
startTime: 1000,
modifiedTime: 2000,
summary: 'Plain session',
_meta: { workspaceless: true },
});
const listController = createSessionListController(disposables, instantiationService, agentHostService);
await listController.refresh(CancellationToken.None);
agentHostService.fireNotification({
type: 'root/sessionSummaryChanged',
channel: ROOT_STATE_URI,
session: backendSession.toString(),
changes: { _meta: {} },
});
const store = (listController as unknown as { _sessionListStore: { getSessions(provider: string): readonly { summary: SessionSummary }[] } })._sessionListStore;
assert.deepStrictEqual(
store.getSessions('copilot').map(entry => entry.summary._meta),
[{}],
);
});
test('archive mutations dispatch through AHP and reconcile server summaries', async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);
const backendSession = AgentSession.uri('copilot', 'archivable');
@@ -3436,6 +3512,35 @@ suite('AgentHostChatContribution', () => {
assert.deepStrictEqual(listController.items.map(item => item.label), ['Legacy worktree session']);
});
test('a migrated legacy worktree session stays listed after adoption clears the adoptable marker', async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);
const folder = URI.file('/src/repo');
instantiationService.stub(IWorkspaceContextService, {
getWorkbenchState: () => WorkbenchState.FOLDER,
getWorkspace: () => ({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] }),
getWorkspaceFolder: () => null,
onDidChangeWorkspaceFolders: Event.None,
});
// Adoption drops `ehcliAdoptable` and leaves the durable adopted marker
// behind; the session must not fall out of the list on migration.
agentHostService.addSession({
session: AgentSession.uri('copilot', 'adopted-worktree'),
startTime: 1000,
modifiedTime: 2000,
summary: 'Adopted worktree session',
workingDirectories: [URI.file('/src/repo.worktrees/feature')],
project: { uri: folder, displayName: 'repo' },
_meta: { [SESSION_META_EHCLI_ADOPTED_KEY]: true },
});
const listController = createSessionListController(disposables, instantiationService, agentHostService);
await listController.refresh(CancellationToken.None);
assert.deepStrictEqual(listController.items.map(item => item.label), ['Adopted worktree session']);
});
test('sessionAdded notification filters out sessions outside the workspace', async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);
@@ -15,6 +15,7 @@ import { mock } from '../../../../../../base/test/common/mock.js';
import { adoptLegacyCopilotCliResource } from '../../../browser/agentSessions/agentHost/agentHostLegacyMigration.js';
import { COPILOT_CLI_AGENT_PROVIDER, COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME } from '../../../browser/copilotCliEventsUri.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { ChatConfiguration } from '../../../common/constants.js';
@@ -24,6 +25,17 @@ const migrationOn: IConfigurationService = new TestConfigurationService({ [ChatC
suite('AgentHost legacy Copilot CLI migration', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
/** Records probe outcomes so each path's telemetry can be asserted. */
let outcomes: string[];
let telemetry: ITelemetryService;
setup(() => {
outcomes = [];
telemetry = new class extends mock<ITelemetryService>() {
override publicLog2<E, C>(_name: string, data?: E): void {
outcomes.push((data as { outcome: string }).outcome);
}
};
});
const RAW_ID = 'sess-abc';
const legacyResource = URI.from({ scheme: COPILOT_CLI_EH_SCHEME, path: `/${RAW_ID}` });
const twinResource = URI.from({ scheme: COPILOT_CLI_LOCAL_AH_SCHEME, path: `/${RAW_ID}` });
@@ -58,11 +70,11 @@ suite('AgentHost legacy Copilot CLI migration', () => {
test('redirects to the agent-host twin once the subscription carries state', async () => {
const { connection, subscribed } = createConnection('adopted');
const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn);
const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open');
assert.deepStrictEqual(
{ resolved: resolved?.toString(), subscribed: subscribed.map(s => s.toString()) },
{ resolved: twinResource.toString(), subscribed: [backendChannel.toString()] },
{ resolved: resolved?.toString(), subscribed: subscribed.map(s => s.toString()), outcomes },
{ resolved: twinResource.toString(), subscribed: [backendChannel.toString()], outcomes: ['adopted'] },
);
});
@@ -88,43 +100,44 @@ suite('AgentHost legacy Copilot CLI migration', () => {
}
};
assert.strictEqual(await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn), undefined);
assert.strictEqual(await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'), undefined);
});
test('retries after a refusal instead of pinning the session to the legacy path', async () => {
const { connection, subscribed } = createConnection('refused');
const first = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn);
const second = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn);
const first = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open');
const second = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open');
// The host reports every restore failure as SessionNotFound, so a refusal
// cannot be told apart from a transient one and must not be remembered.
assert.deepStrictEqual(
{ first, second, subscribes: subscribed.length },
{ first: undefined, second: undefined, subscribes: 2 },
{ first, second, subscribes: subscribed.length, outcomes },
{ first: undefined, second: undefined, subscribes: 2, outcomes: ['declined', 'declined'] },
);
});
test('never probes a resource that is not a legacy Copilot CLI session', async () => {
const { connection, subscribed } = createConnection('adopted');
const resolved = await adoptLegacyCopilotCliResource(connection, twinResource, new NullLogService(), migrationOn);
const resolved = await adoptLegacyCopilotCliResource(connection, twinResource, new NullLogService(), migrationOn, telemetry, 'open');
assert.deepStrictEqual({ resolved, subscribed }, { resolved: undefined, subscribed: [] });
// Not a migration opportunity at all, so it must not even be counted.
assert.deepStrictEqual({ resolved, subscribed, outcomes }, { resolved: undefined, subscribed: [], outcomes: [] });
});
test('does nothing while the migration setting is off', async () => {
const { connection, subscribed } = createConnection('adopted');
const migrationOff: IConfigurationService = new TestConfigurationService();
const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOff);
const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOff, telemetry, 'open');
// The host restores a session whether or not it adopts it, so without this
// gate a user who never opted in would still be moved onto the agent host.
assert.deepStrictEqual({ resolved, subscribed }, { resolved: undefined, subscribed: [] });
assert.deepStrictEqual({ resolved, subscribed, outcomes }, { resolved: undefined, subscribed: [], outcomes: ['settingDisabled'] });
});
test('declines without probing when there is no connection', async () => {
assert.strictEqual(await adoptLegacyCopilotCliResource(undefined, legacyResource, new NullLogService(), migrationOn), undefined);
assert.strictEqual(await adoptLegacyCopilotCliResource(undefined, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'), undefined);
});
});
@@ -4,14 +4,23 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { Event } from '../../../../../../base/common/event.js';
import { IReference } from '../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../base/common/uri.js';
import { upcastPartial } from '../../../../../../base/test/common/mock.js';
import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.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 { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js';
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js';
import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js';
import { ChatConfiguration } from '../../../common/constants.js';
import { IAgentSession } from '../../../browser/agentSessions/agentSessionsModel.js';
import { openSessionByResource, ISessionOpenerParticipant, sessionOpenerRegistry } from '../../../browser/agentSessions/agentSessionsOpener.js';
import { openSession, openSessionByResource, ISessionOpenerParticipant, sessionOpenerRegistry } from '../../../browser/agentSessions/agentSessionsOpener.js';
import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js';
suite('AgentSessionsOpener', () => {
@@ -77,4 +86,106 @@ suite('AgentSessionsOpener', () => {
assert.deepStrictEqual({ resolvedResource, handledSession }, { resolvedResource: resource, handledSession: session });
});
test('surfaces a just-migrated session before opening it', async () => {
// Adoption registers the twin with the host, but the list only learns about
// it on the next provider refresh — without that refresh the open reverts to
// the legacy session it just migrated away from.
const legacy = URI.parse('copilotcli:/sess-1');
const twin = URI.parse('agent-host-copilotcli:/sess-1');
const twinSession = upcastPartial<IAgentSession>({ resource: twin });
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(ITelemetryService, NullTelemetryService);
instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true }));
// A host that answers the adoption probe with state, i.e. migration succeeded.
instantiationService.stub(IAgentHostConnectionsService, upcastPartial<IAgentHostConnectionsService>({
ambientConnection: new class extends mock<IAgentConnection>() {
override getSubscription<T>(): IReference<IAgentSubscription<T>> {
return {
object: upcastPartial<IAgentSubscription<T>>({ value: {} as T, onDidChange: Event.None, onDidError: Event.None }),
dispose: () => { },
};
}
},
}));
const resolvedProviders: (string | string[] | undefined)[] = [];
let surfaced = false;
instantiationService.stub(IAgentSessionsService, upcastPartial<IAgentSessionsService>({
getSession: candidate => (surfaced && candidate.toString() === twin.toString()) ? twinSession : undefined,
model: upcastPartial<IAgentSessionsService['model']>({
resolve: async provider => {
resolvedProviders.push(provider);
surfaced = true;
},
}),
}));
let handledSession: IAgentSession | undefined;
const participant: ISessionOpenerParticipant = {
handleOpenSession: async (_accessor, candidate) => {
handledSession = candidate;
return true;
},
handleOpenSessionResource: async () => false,
};
const registration = sessionOpenerRegistry.registerParticipant(participant);
try {
await instantiationService.invokeFunction(openSessionByResource, legacy);
} finally {
registration.dispose();
}
assert.deepStrictEqual(
{ handled: handledSession?.resource.toString(), resolvedProviders },
{ handled: twin.toString(), resolvedProviders: ['agent-host-copilotcli'] },
);
});
test('a list click opens the migrated session, not the legacy one it came from', async () => {
// The path Rob hit: adoption succeeded, but the twin was not in the list yet,
// so the open silently reverted to the legacy session.
const legacy = URI.parse('copilotcli:/sess-2');
const twin = URI.parse('agent-host-copilotcli:/sess-2');
const legacySession = upcastPartial<IAgentSession>({ resource: legacy });
const twinSession = upcastPartial<IAgentSession>({ resource: twin });
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(ITelemetryService, NullTelemetryService);
instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true }));
instantiationService.stub(IAgentHostConnectionsService, upcastPartial<IAgentHostConnectionsService>({
ambientConnection: new class extends mock<IAgentConnection>() {
override getSubscription<T>(): IReference<IAgentSubscription<T>> {
return {
object: upcastPartial<IAgentSubscription<T>>({ value: {} as T, onDidChange: Event.None, onDidError: Event.None }),
dispose: () => { },
};
}
},
}));
let surfaced = false;
instantiationService.stub(IAgentSessionsService, upcastPartial<IAgentSessionsService>({
getSession: candidate => (surfaced && candidate.toString() === twin.toString()) ? twinSession : undefined,
model: upcastPartial<IAgentSessionsService['model']>({ resolve: async () => { surfaced = true; } }),
}));
let handledSession: IAgentSession | undefined;
const registration = sessionOpenerRegistry.registerParticipant({
handleOpenSession: async (_accessor, candidate) => {
handledSession = candidate;
return true;
},
});
try {
await instantiationService.invokeFunction(openSession, legacySession);
} finally {
registration.dispose();
}
assert.strictEqual(handledSession?.resource.toString(), twin.toString());
});
});
@@ -18,6 +18,7 @@ import { IAgentHostConnectionsService } from '../../../../../../../platform/agen
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js';
import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js';
import { IStorageService } from '../../../../../../../platform/storage/common/storage.js';
import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js';
import { isResourceEditorInput } from '../../../../../../common/editor.js';
@@ -71,6 +72,7 @@ suite('ChatEditorInput', () => {
new TestContextService(),
{ _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) },
{ ambientConnection: undefined } as unknown as IAgentHostConnectionsService,
NullTelemetryService,
);
try {
@@ -127,6 +129,7 @@ suite('ChatEditorInput', () => {
new TestContextService(),
{ _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) },
{ ambientConnection: undefined } as unknown as IAgentHostConnectionsService,
NullTelemetryService,
);
try {