From 202b97eee2ce384aeab7ae28839efd18388651d4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 17 Sep 2026 22:51:24 +0200 Subject: [PATCH 1/4] sessions: support chat-scoped multi-folder workspaces Track effective workspaces and changesets per chat, compute branch changes across repositories, and open complete session workspaces in the editor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 3 + .../state/protocol/action-origin.generated.ts | 5 +- .../state/protocol/channels-chat/actions.ts | 14 ++ .../state/protocol/channels-chat/reducer.ts | 7 + .../state/protocol/channels-chat/state.ts | 3 + .../common/state/protocol/common/actions.ts | 4 +- .../common/state/protocol/version/registry.ts | 1 + .../node/agentHostCatalogProjection.ts | 11 +- .../node/agentHostCatalogSourceResolver.ts | 2 + .../node/agentHostChangesetService.ts | 39 ++++- .../agentHost/node/agentHostStateManager.ts | 56 +++++- .../platform/agentHost/node/agentService.ts | 164 +++++++++++++++--- .../agentHost/node/agentServiceFoundation.ts | 1 + .../agentHost/node/copilot/copilotAgent.ts | 12 +- .../agentHost/node/protocolServerHandler.ts | 5 +- .../node/shared/sessionServerTools.ts | 35 ++-- .../agentHostProtocolClient.test.ts | 21 +++ .../node/agentHostChangesetService.test.ts | 39 ++++- .../test/node/agentHostStateManager.test.ts | 31 ++++ .../agentHost/test/node/agentService.test.ts | 104 ++++++++++- .../agentHost/test/node/copilotAgent.test.ts | 103 ++++++++++- .../platform/agentHost/test/node/mockAgent.ts | 7 +- .../test/node/protocolServerHandler.test.ts | 5 +- .../agentHost/test/node/reducers.test.ts | 31 ++++ .../test/node/sessionServerTools.test.ts | 71 +++++++- src/vs/sessions/SESSIONS.md | 2 +- .../common/agentHostSessionWorkspace.ts | 35 ++++ .../contrib/changes/browser/changesView.ts | 6 +- .../changes/browser/changesViewService.ts | 17 +- .../test/browser/changesViewService.test.ts | 31 +++- .../test/browser/sessionsTaskService.test.ts | 2 + .../test/browser/githubContribution.test.ts | 1 + .../test/browser/layoutControllerTestUtils.ts | 1 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 + .../browser/agentHostSessionChangesets.ts | 60 ++++--- .../browser/baseAgentHostSessionsProvider.ts | 91 +++++++++- .../agentHostSessionChangesets.test.ts | 34 ++-- .../browser/agentHostSkillButtons.test.ts | 1 + .../localAgentHostSessionsProvider.test.ts | 110 +++++++++++- .../browser/copilotChatSessionsProvider.ts | 2 + .../sessionsTelemetry.contribution.test.ts | 1 + .../sessionsTerminalContribution.test.ts | 2 + .../browser/workspaceFolderManagement.ts | 76 ++++---- .../browser/workspaceFolderManagement.test.ts | 109 +++++++++++- .../electron-browser/actions/vscodeActions.ts | 36 ++-- .../services/sessions/common/session.ts | 4 + .../test/browser/sessionNavigation.test.ts | 2 + .../browser/sessionsManagementService.test.ts | 1 + .../test/browser/visibleSessions.test.ts | 1 + .../test/common/sessionContextKeys.test.ts | 1 + .../electron-browser/vscodeActions.test.ts | 68 +++++++- 51 files changed, 1269 insertions(+), 201 deletions(-) diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index d6309f1952214..0f3aaa92a7c40 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1650,6 +1650,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect ...(options.sideChat.selection ? { selection: options.sideChat.selection } : {}), } } : {}), + ...(options?.workingDirectories !== undefined && !options.fork + ? { workingDirectories: options.workingDirectories.map(directory => fromAgentHostUri(directory).toString()) } + : {}), }); } diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index e1e55844dc100..2ad53113d14ed 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatChangesetsChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -123,6 +123,7 @@ export type ChatAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction + | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -168,6 +169,7 @@ export type ServerChatAction = | ChatTurnCompleteAction | ChatErrorAction | ChatActivityChangedAction + | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction @@ -374,6 +376,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatActivityChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, + [ActionType.ChatChangesetsChanged]: false, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index ba619a4bd39eb..c6825c055b610 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -9,6 +9,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; +import type { Changeset } from '../channels-changeset/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -567,6 +568,18 @@ export interface ChatWorkingDirectoryRemovedAction { directory: URI; } +/** + * Replaces the catalogue of changesets exposed for this chat. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatChangesetsChangedAction { + type: ActionType.ChatChangesetsChanged; + /** New catalogue, or `undefined` to clear it. */ + changesets: Changeset[] | undefined; +} + /** * Token usage report for a turn. * @@ -833,6 +846,7 @@ export type ChatAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction + | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 5cf707cfc1f3c..d2c31363a1d2d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -454,6 +454,13 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return { ...state, workingDirectories: updated }; } + case ActionType.ChatChangesetsChanged: { + const { changesets: _omit, ...stateWithoutChangesets } = state; + return action.changesets + ? { ...stateWithoutChangesets, changesets: action.changesets } + : stateWithoutChangesets; + } + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index dbae61c6f3899..cef356eff086f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -8,6 +8,7 @@ import type { ModelSelection } from '../channels-root/state.js'; import type { AgentSelection, McpAuthRequirement, SessionStatus } from '../channels-session/state.js'; +import type { Changeset } from '../channels-changeset/state.js'; import type { ContentRef, ErrorInfo, FileEdit, StringOrMarkdown, TextRange, TextSelection, URI, UsageInfo } from '../common/state.js'; // ─── Chat State ────────────────────────────────────────────────────────────── @@ -63,6 +64,8 @@ export interface ChatState { * update the subset on a running chat. */ workingDirectories?: URI[]; + /** Catalogue of changesets the server exposes for this chat. */ + changesets?: Changeset[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 97ef944059b92..d00523f60b0d4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatChangesetsChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -60,6 +60,7 @@ export const enum ActionType { ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', + ChatChangesetsChanged = 'chat/changesetsChanged', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', @@ -216,6 +217,7 @@ export type StateAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction + | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 86a7dfeb64226..e954c67cd0770 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -130,6 +130,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', + [ActionType.ChatChangesetsChanged]: '0.9.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 4f25f874f2012..264f687e91b2f 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -299,6 +299,11 @@ const metadataValidator = plainObject(vObj({ [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: vOptionalProp(devContainerWorktreeValidator), })); +const workingDirectoriesValidator = new RefinedValidator( + boundedArray(uriString(), AGENT_HOST_CATALOG_CHILD_LIMIT), + value => hasUniqueValues(value, directory => directory) ? value : { message: 'Working directories must be unique.' }, +); + const chatValidator = plainObject(vObj({ uri: uriString(), order: safeInteger(), @@ -307,6 +312,7 @@ const chatValidator = plainObject(vObj({ titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), origin: vOptionalProp(jsonValue()), inheritedTurnId: vOptionalProp(boundedString(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT)), + workingDirectories: vOptionalProp(workingDirectoriesValidator), })); const chatsValidator = new RefinedValidator( @@ -323,11 +329,6 @@ const chatsValidator = new RefinedValidator( }, ); -const workingDirectoriesValidator = new RefinedValidator( - boundedArray(uriString(), AGENT_HOST_CATALOG_CHILD_LIMIT), - value => hasUniqueValues(value, directory => directory) ? value : { message: 'Working directories must be unique.' }, -); - export const agentHostCatalogDataValidator = plainObject(vObj({ modifiedTime: safeInteger(), summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 4be443418854f..dc10708777346 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -31,6 +31,7 @@ export interface ICatalogSourceState { readonly title?: string; readonly origin?: ChatOrigin; readonly inheritedTurnId?: string; + readonly workingDirectories?: readonly string[]; }[]; } @@ -225,6 +226,7 @@ export class AgentHostCatalogSourceResolver { titleSource: normalizeCatalogTitleSource(titleSource), origin: toCatalogChatOrigin(chat.origin), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + ...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}), }; }), }; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index ccfe2ad1ea955..615cecb5a60bb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -1137,7 +1137,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC try { let diffs: readonly ISessionFileDiff[] | undefined; - if (kind === 'session' && isMultiRootSession(workingDirectories)) { + if (kind === 'branch' && isMultiRootSession(workingDirectories)) { + diffs = await this._computeMultiFolderBranchDiffs(session, ref.object, workingDirectories!); + } else if (kind === 'session' && isMultiRootSession(workingDirectories)) { const result = await this._computeMultiFolderSessionDiffs(session, ref.object, workingDirectories!); diffs = result.diffs; usedEditTrackerFallback = result.usedFallback; @@ -1387,6 +1389,41 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return bestTurnId; } + /** Computes Branch Changes across every unique Git repository in the session. */ + private async _computeMultiFolderBranchDiffs(session: ProtocolURI, db: ISessionDatabase, workingDirectories: readonly string[]): Promise { + const workingDirectoryUris = this._parseWorkingDirectoryUris(session, workingDirectories); + const { gitRepositories } = await resolveSessionRepositories(workingDirectoryUris, this._gitService); + if (gitRepositories.length === 0) { + return undefined; + } + + const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); + const primaryBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName; + const primaryWorkingDirectory = workingDirectoryUris[0]; + const limiter = new Limiter(MAX_DIFF_REPOSITORY_CONCURRENCY); + try { + const perRepositoryDiffs = await Promise.all(gitRepositories.map(repository => limiter.queue(async () => { + const isPrimaryRepository = extUriBiasedIgnorePathCase.isEqualOrParent(primaryWorkingDirectory, repository); + const baseBranch = isPrimaryRepository + ? resolveDiffBaseBranchName(persistedBaseBranch, primaryBaseBranch) + : (await this._gitService.getSessionGitState(repository))?.baseBranchName; + return this._gitService.computeSessionFileDiffs(repository, { + sessionUri: session, + baseBranch, + }); + }))); + if (perRepositoryDiffs.some(diffs => diffs === undefined)) { + return undefined; + } + return dedupeSessionFileDiffs(perRepositoryDiffs.filter((diffs): diffs is readonly ISessionFileDiff[] => diffs !== undefined)); + } catch (err) { + this._logService.warn(`[AgentHostChangesetService] Failed to compute multi-folder branch diffs for ${session}; preserving cached changeset.`, err); + return undefined; + } finally { + limiter.dispose(); + } + } + /** * Computes diffs for a static changeset by shelling out to git. * Returns the diff list when the session has a working directory and diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index eae8b077cf9c5..40bc7e70b068f 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -88,6 +88,7 @@ interface IChatEntry { readonly session: string; summary: ChatSummary; state?: ChatState; + changesets?: readonly Changeset[]; providerData?: string; inheritedTurnId?: string; draft?: Message; @@ -552,7 +553,12 @@ export class AgentHostStateManager extends Disposable { throw new Error(`Restored chat was invalidated while resolving: ${chat}`); } if (!entry.state) { - entry.state = { ...createChatState(entry.summary), turns: restored.turns, draft: restored.draft ?? entry.draft }; + entry.state = { + ...createChatState(entry.summary), + turns: restored.turns, + draft: restored.draft ?? entry.draft, + changesets: entry.changesets ? [...entry.changesets] : undefined, + }; entry.resolver = undefined; if (restored.turns.length > 0) { this._markSessionUsed(entry.session); @@ -1041,7 +1047,7 @@ export class AgentHostStateManager extends Disposable { * summary (e.g. adoptable-legacy), a `sessionSummaryChanged` delta is emitted * so clients update the entry in place instead of dropping it. */ - restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string }): SessionState { + restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string; readonly defaultChatWorkingDirectories?: readonly string[] }): SessionState { const key = summary.resource; const existing = this._sessionStates.get(key); if (existing) { @@ -1054,7 +1060,7 @@ export class AgentHostStateManager extends Disposable { lifecycle: SessionLifecycle.Ready, }; this._sessionStates.set(key, this._newEntry(state, summary, SessionUse.Used)); - this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle); + this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle, options?.defaultChatWorkingDirectories); // A session that was previously surfaced (e.g. announced as an // adoptable-legacy session) is already known to clients with a different // summary. Emit the delta so they update the entry in place — clearing the @@ -1084,15 +1090,25 @@ export class AgentHostStateManager extends Disposable { * at creation/restore time, so the snapshot a client later receives on * subscribe already reflects the default chat. */ - private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string): void { + private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string, workingDirectories?: readonly string[]): void { const chatUri = buildDefaultChatUri(sessionKey); // Empty title means "inherit the session title"; a persisted independent // rename (`defaultChatTitle`) is seeded back here so it survives restore. - const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: defaultChatTitle ?? '' }; + const chatSummary: ChatSummary = { + ...createDefaultChatSummary(summary, chatUri), + title: defaultChatTitle ?? '', + ...(workingDirectories !== undefined ? { workingDirectories: [...workingDirectories] } : {}), + }; this._chatEntries.set(chatUri, { session: sessionKey, summary: chatSummary, - state: { ...createChatState(chatSummary), turns: turns ?? [], draft }, + state: { + ...createChatState(chatSummary), + turns: turns ?? [], + draft, + changesets: this._sessionStates.get(sessionKey)?.state.changesets?.slice(), + }, + changesets: this._sessionStates.get(sessionKey)?.state.changesets?.slice(), valid: true, }); const entry = this._sessionStates.get(sessionKey); @@ -1126,7 +1142,7 @@ export class AgentHostStateManager extends Disposable { * chat, tool spawn). Omitting it defaults to {@link ChatOriginKind.User} * via {@link createDefaultChatSummary}, so every catalog chat has an origin. */ - addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly inheritedTurnId?: string; readonly interactivity?: ChatInteractivity }): ChatSummary | undefined { + addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly inheritedTurnId?: string; readonly interactivity?: ChatInteractivity; readonly workingDirectories?: URI[] }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); @@ -1151,11 +1167,17 @@ export class AgentHostStateManager extends Disposable { status: SessionStatus.Idle, ...(options?.origin ? { origin: options.origin } : {}), interactivity: options?.interactivity, + ...(options?.workingDirectories !== undefined ? { workingDirectories: options.workingDirectories } : {}), }; this._chatEntries.set(chatUri, { session, summary: chatSummary, - state: { ...createChatState(chatSummary), turns: options?.turns ?? [] }, + state: { + ...createChatState(chatSummary), + turns: options?.turns ?? [], + changesets: sessionState.changesets?.slice(), + }, + changesets: sessionState.changesets?.slice(), providerData: options?.providerData, inheritedTurnId: options?.inheritedTurnId, valid: true, @@ -1169,7 +1191,7 @@ export class AgentHostStateManager extends Disposable { * creating conversation state. The state-manager-owned resolver installs a * complete state only through {@link resolveChatState}. */ - registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined { + registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly title?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] registerRestoredChatSummary for unknown session: ${session}`); @@ -1197,11 +1219,13 @@ export class AgentHostStateManager extends Disposable { // without provenance. ...(options.origin ? { origin: options.origin } : {}), interactivity: options.interactivity, + ...(options.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}), }; entry.state.chats = [...entry.state.chats, chatSummary]; this._chatEntries.set(chatUri, { session, summary: chatSummary, + changesets: sessionState.changesets?.slice(), providerData: options.providerData, inheritedTurnId: options.inheritedTurnId, draft: options.draft, @@ -1525,6 +1549,20 @@ export class AgentHostStateManager extends Disposable { type: ActionType.SessionChangesetsChanged, changesets: next, }); + + for (const chat of entry.state.chats) { + const chatEntry = this._chatEntries.get(chat.resource); + if (!chatEntry) { + continue; + } + chatEntry.changesets = next?.slice(); + if (chatEntry.state) { + this.dispatchServerAction(chat.resource, { + type: ActionType.ChatChangesetsChanged, + changesets: next?.slice(), + }); + } + } } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 46f52f5df94bd..cf9ba9908c198 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -331,6 +331,7 @@ interface ICatalogChat { readonly title?: string; readonly origin?: ChatOrigin; readonly inheritedTurnId?: string; + readonly workingDirectories?: readonly string[]; } interface ILegacyRegisteredSessionMetadata { @@ -1019,21 +1020,27 @@ export class AgentService extends Disposable implements IAgentService { /** * Host-owned first-send hook (invoked by {@link AgentSideEffects} before the - * agent locks its subprocess cwd). Resolves the working directories the session - * will actually run in and hands them to the agent at send time: + * agent locks its subprocess cwd). Resolves the working directories the + * addressed chat will actually run in and hands them to the agent at send time: * - index 0 is the process root: for `worktree` isolation the isolated * worktree (created here on the first send, see * {@link _resolveWorktreeBeforeSend}); for `folder` isolation the picked * folder; `undefined` (whole result) for workspace-less sessions. - * - the tail carries any additional session roots as-is (only index 0 is - * worktree-remapped; additional roots are passed through unchanged). + * - an explicit peer-chat subset is already resolved and must not be + * remapped through the owning session's worktree. */ private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise { const sessionId = AgentSession.id(params.session); - const pickedFolders = this._configurationService.getEffectiveWorkingDirectories(params.session); + const chatWorkingDirectories = this._stateManager.getChatState(params.chat)?.workingDirectories; + const pickedFolders = chatWorkingDirectories + ?? this._configurationService.getEffectiveWorkingDirectories(params.session); const pickedFolderUri = pickedFolders?.[0] ? URI.parse(pickedFolders[0]) : undefined; const tail = (pickedFolders ?? []).slice(1).map(d => URI.parse(d)); + if (!isDefaultChatUri(params.chat) && chatWorkingDirectories !== undefined) { + return pickedFolderUri ? [pickedFolderUri, ...tail] : undefined; + } + // Only worktree-isolation sessions defer directory resolution to the first // send (so the prompt can name the branch); folder / workspace-less // sessions run directly in the picked folder. @@ -1285,9 +1292,8 @@ export class AgentService extends Disposable implements IAgentService { }, getCreationDefaults: source => this._getServerToolCreationDefaults(source), startPrompt: (session, chat, prompt, delegation) => this._startSessionPrompt(session, chat, prompt, delegation), - createChat: (session, chat, options) => this.createChat(session, chat, (options?.title !== undefined || options?.model !== undefined) - ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) } - : undefined), + createChat: (session, chat, options) => this.createChat(session, chat, options), + addSessionWorkingDirectory: (session, directory) => this._addSessionWorkingDirectoryForChat(session, directory), renameChat: (session, chat, title) => this._renameChatFromTool(session, chat, title), reportToolError: (toolName, error) => this._logService.error(`[AgentService] ${toolName} failed after the tool returned: ${toErrorMessage(error)}`), deleteSession: session => this.disposeSession(session), @@ -2099,6 +2105,7 @@ export class AgentService extends Disposable implements IAgentService { title: chat.title, origin: chat.origin, inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource), + workingDirectories: chat.workingDirectories, })); } @@ -4177,6 +4184,12 @@ export class AgentService extends Disposable implements IAgentService { let forkedSourceTitle: string | undefined; const { sideChat, ...providerOptions } = options ?? {}; let createOptions: IAgentCreateChatOptions | undefined = providerOptions; + if (createOptions.workingDirectories !== undefined && !createOptions.fork) { + createOptions = { + ...createOptions, + workingDirectories: this._resolveChatWorkingDirectories(session, provider, createOptions.workingDirectories), + }; + } // Persist exhaustive provenance for peer chats. Fresh user-created chats // leave this undefined and default to `ChatOriginKind.User`. let peerChatOrigin: ChatOrigin | undefined; @@ -4274,6 +4287,7 @@ export class AgentService extends Disposable implements IAgentService { kind: 'peer' as const, ...(title !== undefined ? { title } : {}), ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), + ...(createOptions?.workingDirectories !== undefined ? { workingDirectories: createOptions.workingDirectories.map(directory => directory.toString()) } : {}), }; const existingIndex = existingCatalogChats.findIndex(existing => existing.uri === newCatalogChat.uri); const catalogChats = existingIndex < 0 @@ -4300,6 +4314,7 @@ export class AgentService extends Disposable implements IAgentService { ...(providerData !== undefined ? { providerData } : {}), ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), + ...(createOptions?.workingDirectories !== undefined ? { workingDirectories: createOptions.workingDirectories.map(directory => directory.toString()) } : {}), }); } catch (error) { const rollbackErrors: Error[] = []; @@ -4785,11 +4800,13 @@ export class AgentService extends Disposable implements IAgentService { */ private async _createChat(provider: IAgent, chat: URI, session: URI, options: IAgentCreateChatOptions | undefined): Promise { const placement = this._buildChatPlacement(session); - const convOptions: IAgentCreateChatOptions | undefined = (options?.title !== undefined || options?.model !== undefined || placement) + const convOptions: IAgentCreateChatOptions | undefined = (options?.title !== undefined || options?.model !== undefined || options?.workingDirectories !== undefined || placement) ? { ...(options?.title !== undefined ? { title: options.title } : {}), ...(options?.model !== undefined ? { model: options.model } : {}), - ...(placement?.workingDirectories ? { workingDirectories: placement.workingDirectories } : {}), + ...(options?.workingDirectories !== undefined + ? { workingDirectories: options.workingDirectories } + : placement?.workingDirectories ? { workingDirectories: placement.workingDirectories } : {}), ...(placement?.project ? { project: placement.project } : {}), ...(placement?.config ? { config: placement.config } : {}), } @@ -6071,15 +6088,16 @@ export class AgentService extends Disposable implements IAgentService { * of the primary directory for a provider that pins it — so the caller can * reject the action. Returns the canonicalized action on success. */ - private _prepareWorkingDirectoryAction(session: string, action: SessionWorkingDirectoryAction): SessionWorkingDirectoryAction { + private _prepareWorkingDirectoryAction(session: string, action: SessionWorkingDirectoryAction, options?: { readonly allowMultipleChats?: boolean; readonly allowWithoutMultiRootMetadata?: boolean; readonly allowWorktreeIsolation?: boolean }): SessionWorkingDirectoryAction { const state = this._stateManager.getSessionState(session); - if (!state || state.lifecycle !== SessionLifecycle.Ready || !state.workingDirectories?.length) { + const workingDirectories = this._stateManager.getSessionSummary(session)?.workingDirectories; + if (!state || state.lifecycle !== SessionLifecycle.Ready || !workingDirectories?.length) { throw new Error(`Session is not ready for working-directory changes: ${session}`); } - if (!readSessionMultiRootMetadata(state._meta) + if ((!options?.allowWithoutMultiRootMetadata && !readSessionMultiRootMetadata(state._meta)) || readSessionWorkspaceless(state._meta) - || state.config?.values[SessionConfigKey.Isolation] === 'worktree' - || state.chats.length !== 1 + || (!options?.allowWorktreeIsolation && state.config?.values[SessionConfigKey.Isolation] === 'worktree') + || (!options?.allowMultipleChats && state.chats.length !== 1) || !state.defaultChat || state.defaultChat !== state.chats[0].resource) { throw new Error(`Session does not support dynamic working-directory changes: ${session}`); @@ -6092,12 +6110,78 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`Provider does not support dynamic working-directory changes: ${AgentSession.provider(sessionUri) ?? '(unknown)'}`); } - return resolveSessionWorkingDirectoryAction(action, state.workingDirectories, { + return resolveSessionWorkingDirectoryAction(action, workingDirectories, { immutablePrimary: capability.immutablePrimary === true, primaryReplacement: capability.primaryReplacement === true, }); } + private async _addSessionWorkingDirectoryForChat(session: URI, directory: URI): Promise { + const sessionKey = session.toString(); + const state = this._stateManager.getSessionState(sessionKey); + const workingDirectories = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories; + if (!state || !workingDirectories?.length) { + throw new Error(`Session is not ready for working-directory changes: ${sessionKey}`); + } + const action = this._prepareWorkingDirectoryAction(sessionKey, { + type: ActionType.SessionWorkingDirectorySet, + directory: directory.toString(), + }, { allowMultipleChats: true, allowWithoutMultiRootMetadata: true, allowWorktreeIsolation: true }); + const canonicalDirectory = URI.parse(action.directory); + if (workingDirectories.includes(action.directory)) { + return canonicalDirectory; + } + + const inheritingChats = state.chats.filter(chat => chat.workingDirectories === undefined); + const resolvedChats = await Promise.all(inheritingChats.map(async chat => ({ + summary: chat, + state: await this._stateManager.resolveChatState(chat.resource), + }))); + const unresolvedChat = resolvedChats.find(chat => chat.state === undefined); + if (unresolvedChat) { + throw new Error(`Cannot preserve the working directories of chat ${unresolvedChat.summary.resource}.`); + } + + for (const chat of resolvedChats) { + if (chat.state?.workingDirectories !== undefined) { + continue; + } + for (const workingDirectory of workingDirectories) { + this._stateManager.dispatchServerAction(chat.summary.resource, { + type: ActionType.ChatWorkingDirectorySet, + directory: workingDirectory, + }); + } + this._stateManager.dispatchServerAction(sessionKey, { + type: ActionType.SessionChatUpdated, + chat: chat.summary.resource, + changes: { workingDirectories: [...workingDirectories] }, + }); + } + this._stateManager.dispatchServerAction(sessionKey, action); + return canonicalDirectory; + } + + private _resolveChatWorkingDirectories(session: URI, provider: IAgent, requested: readonly URI[]): readonly URI[] { + if (!provider.getDescriptor().capabilities?.multipleWorkingDirectories) { + throw new Error(`[AgentService] createChat: provider ${provider.id} does not support chat working directories`); + } + const sessionDirectories = this._stateManager.getSessionSummary(session.toString())?.workingDirectories ?? []; + const resolved: URI[] = []; + for (const directory of requested) { + const match = sessionDirectories.find(candidate => isEqual(URI.parse(candidate), directory)); + if (!match) { + throw new Error(`[AgentService] createChat: working directory ${directory.toString()} does not belong to session ${session.toString()}`); + } + const canonicalDirectory = URI.parse(match); + if (resolved.some(candidate => isEqual(candidate, canonicalDirectory))) { + throw new Error(`[AgentService] createChat: working directories must be unique`); + } + resolved.push(canonicalDirectory); + } + return resolved; + } + /** * Carries host-written session config through a client replacement. A client * may legitimately replace its own config wholesale, but omitting a host-owned @@ -7079,6 +7163,8 @@ export class AgentService extends Disposable implements IAgentService { _meta: restoredMeta, }; + const cachedChats = await this._readCachedChatCatalog(session); + const cachedDefaultChat = cachedChats?.find(chat => chat.kind === 'default'); const { draft: defaultDraft, title: defaultChatTitle } = await this._chatContributions.hydrateChat({ session: sessionStr, chat: defaultChatUri.toString(), @@ -7104,7 +7190,11 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } this._invalidateSessionList(); - this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); + this._stateManager.restoreSession(summary, mergedTurns, { + draft: restoredDraft, + defaultChatTitle, + defaultChatWorkingDirectories: cachedDefaultChat?.workingDirectories, + }); if (adoptionListVisible) { const adoptionMetadata: Record = {}; if (adoptionListVisible.title !== undefined) { @@ -7138,7 +7228,7 @@ export class AgentService extends Disposable implements IAgentService { // Register persisted peer-chat catalog metadata. Their provider backings // and histories are restored when a peer chat is first requested. - promises.push(this._restorePeerChats(agent, session)); + promises.push(this._restorePeerChats(agent, session, cachedChats)); // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue @@ -7206,8 +7296,8 @@ export class AgentService extends Disposable implements IAgentService { } /** Restores authoritative central peer membership after importing cooling-period legacy changes. */ - private async _restorePeerChats(agent: IAgent, session: URI): Promise { - const cached = await this._readCachedChatCatalog(session); + private async _restorePeerChats(agent: IAgent, session: URI, cachedChats?: readonly ICatalogChat[]): Promise { + const cached = cachedChats ?? await this._readCachedChatCatalog(session); let entries: readonly IPersistedPeerChat[]; try { entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); @@ -7265,6 +7355,7 @@ export class AgentService extends Disposable implements IAgentService { title: chat.summary, origin: fromCatalogChatOrigin(chat.origin), inheritedTurnId: chat.inheritedTurnId, + workingDirectories: chat.workingDirectories, })); } @@ -7328,18 +7419,19 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`); return undefined; } - const cachedTitle = cachedChats?.find(chat => chat.uri === entry.uri)?.title; + const cachedChat = cachedChats?.find(chat => chat.uri === entry.uri); + const cachedTitle = cachedChat?.title; const { title, draft } = await this._chatContributions.hydrateChat({ session: session.toString(), chat: chatUri.toString(), }, cachedTitle ? { title: cachedTitle } : {}); - return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId }; + return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId, workingDirectories: cachedChat?.workingDirectories }; })); for (const item of restored) { if (!item) { continue; } - const { chatUri, title, draft, providerData, origin, inheritedTurnId } = item; + const { chatUri, title, draft, providerData, origin, inheritedTurnId, workingDirectories } = item; if (this._stateManager.getChatState(chatUri.toString())) { continue; } @@ -7349,6 +7441,7 @@ export class AgentService extends Disposable implements IAgentService { providerData, origin, inheritedTurnId, + workingDirectories, resolver: currentProviderData => this._materializeRestoredPeerChat(session, chatUri, currentProviderData), }); } @@ -7691,13 +7784,13 @@ export class AgentService extends Disposable implements IAgentService { const chat = URI.parse(buildDefaultChatUri(session)); try { const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session), { activation: 'restore' }); - return await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata) : undefined); + return await this._withPersistedSessionWorkingDirectories(session, await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata) : undefined)); } catch (err) { if (err instanceof ProtocolError) { throw err; } try { - return await this._withWorktreeProject(session, await this._getSessionMetadataFromCatalog(agent, session, external)); + return await this._withPersistedSessionWorkingDirectories(session, await this._withWorktreeProject(session, await this._getSessionMetadataFromCatalog(agent, session, external))); } catch (fallbackErr) { if (fallbackErr instanceof ProtocolError) { const message = err instanceof Error ? err.message : String(err); @@ -7708,6 +7801,27 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _withPersistedSessionWorkingDirectories(session: URI, metadata: IAgentSessionMetadata | undefined): Promise { + if (!metadata) { + return undefined; + } + const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + if (!registered) { + return metadata; + } + const cached = await this._catalogListReader.read(registered); + if (!cached.eligible || cached.data.workingDirectories.length <= 1) { + return metadata; + } + const workingDirectories = [...(metadata.workingDirectories ?? [])]; + for (const directory of cached.data.workingDirectories.slice(workingDirectories.length > 0 ? 1 : 0)) { + if (!workingDirectories.some(candidate => isEqual(candidate, directory))) { + workingDirectories.push(directory); + } + } + return { ...metadata, workingDirectories }; + } + /** * Merges the repository project for a worktree-isolated session onto its * restored metadata so the session groups under the repository (not the diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 67ff77fae8ab7..c9c77ec181327 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -37,6 +37,7 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), startPrompt: (session, chat, prompt, delegation) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt, delegation), createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), + addSessionWorkingDirectory: (session, directory) => this.value.sessionServerToolAccessor.addSessionWorkingDirectory(session, directory), renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), deleteSession: session => this.value.sessionServerToolAccessor.deleteSession(session), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2259db12ea67b..1b3557b22110b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4209,6 +4209,7 @@ export class CopilotAgent extends Disposable implements IAgent { await this._activeClients.get(current.configurationResource)?.pluginController.retryFailedClientSyncIfNeeded(); let entry: CopilotAgentSession | undefined = current.target; + const hadCachedEntry = !!entry; if (!entry) { entry = await this._ensureResolvedChatSession(current, workingDirectories); } @@ -4216,8 +4217,8 @@ export class CopilotAgent extends Disposable implements IAgent { // If the active client's config changed (tools or plugins), // dispose this session so it gets resumed with the updated config. const activeClient = this._activeClients.get(current.configurationResource); - const hadCachedEntry = !!entry; this._logService.info(`[Copilot:${current.configurationId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); + const primaryWorkingDirectoryChanged = hadCachedEntry && workingDirectories?.[0] !== undefined && !isEqual(entry?.workingDirectory, workingDirectories[0]); const rootsChanged = !!entry && workingDirectories !== undefined && !areAdditionalWorkingDirectoriesEqual(entry.appliedAdditionalDirectories, this._additionalCustomizationDirectories(workingDirectories)); const currentSnapshot = entry && activeClient ? await activeClient.snapshot(current.chatKey) : undefined; const structuralRestartReason = entry && activeClient && currentSnapshot ? await activeClient.getRestartReason(entry.appliedSnapshot, current.chatKey, currentSnapshot) : undefined; @@ -4228,7 +4229,7 @@ export class CopilotAgent extends Disposable implements IAgent { [...new Set(entry.appliedDisabledRootMcpServers)].sort(), [...new Set(currentDisabledRootMcpServers)].sort(), ); - const refreshReason = (entry?.requiresRestartAfterWorkingDirectoryChange ? 'workingDirectoryChanged' : undefined) + const refreshReason = (entry?.requiresRestartAfterWorkingDirectoryChange || primaryWorkingDirectoryChanged ? 'workingDirectoryChanged' : undefined) ?? (rootsChanged ? 'additionalDirectoriesChanged' : undefined) ?? structuralRestartReason ?? (disabledRootMcpServersChanged ? 'disabledRootMcpServersChanged' : undefined) @@ -4973,6 +4974,9 @@ export class CopilotAgent extends Disposable implements IAgent { const storedMetadata = await this._readSessionMetadata(configurationResource); const resumeWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories); const parentEntry = this._findSessionBySdkId(configurationId); + const sessionWorkingDirectory = storedMetadata.workingDirectory + ?? parentEntry?.workingDirectory + ?? this._provisionalSessions.get(configurationId)?.workingDirectory; const persistedWorkingDirectory = resumeWorkingDirectories?.[0] ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory ?? storedMetadata.workingDirectory; @@ -4980,7 +4984,9 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } - const workingDirectory = await this._worktree.resolveWorkingDirectoryForResume(configurationResource, AgentSession.id(configurationResource), persistedWorkingDirectory); + const workingDirectory = resumeWorkingDirectories?.[0] && !isEqual(resumeWorkingDirectories[0], sessionWorkingDirectory) + ? resumeWorkingDirectories[0] + : await this._worktree.resolveWorkingDirectoryForResume(configurationResource, AgentSession.id(configurationResource), persistedWorkingDirectory); const launchWorkingDirectories = resumeWorkingDirectories ? [workingDirectory, ...resumeWorkingDirectories.slice(1)] : undefined; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index ab42627b849ee..99e04baa8898e 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1605,7 +1605,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return null; } const source = params.source; - let options: IAgentCreateChatRequestOptions | undefined; + let options: IAgentCreateChatRequestOptions | undefined = params.workingDirectories !== undefined + ? { workingDirectories: params.workingDirectories.map(directory => URI.parse(directory)) } + : undefined; if (source) { switch (source.kind) { case ChatSourceKind.Fork: @@ -1613,6 +1615,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien break; case ChatSourceKind.SideChat: options = { + ...options, sideChat: { source: URI.parse(source.chat), turnId: source.turnId, diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 8a8d0b36c8c1f..22cd9c05e7013 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -9,7 +9,7 @@ import { basename, isEqual } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; import { toAgentMessageDelegationMeta, type IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { localize } from '../../../../nls.js'; -import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; +import { AgentSession, type AgentProvider, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; @@ -73,11 +73,11 @@ const createSessionInputSchema: ToolDefinition['inputSchema'] = { relationship: { type: 'string', enum: [...createSessionRelationshipValues], - description: 'Whether this work belongs to the current session or is independently managed. Use `currentSession` for tasks from the current plan or deliverable, including parallel or delegated tasks, unless the user explicitly requests a worktree. Use `independent` for a separate deliverable that needs its own workspace, provider, or top-level lifecycle, or for an explicitly requested worktree.', + description: 'Whether this work belongs to the current session or is independently managed. Base this choice only on whether the work is related to the current session.', }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, - workspace: { type: 'string', description: 'For `independent` work: unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Required for `independent` and invalid for `currentSession`.' }, - worktree: { type: 'boolean', description: 'Override isolation for the new independent session. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior: inherit the creating session\'s isolation for the same project, otherwise use worktree isolation. Only valid with relationship `independent`; omit for `currentSession`.' }, + workspace: { type: 'string', description: 'Workspace for the delegated work: a unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, + worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior.' }, title: { type: 'string', maxLength: 200, description: 'Short title for the new chat or independent session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, }, @@ -174,7 +174,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.CreateSession, title: 'Create Session', - description: 'Create delegated work and start it with an initial prompt, either in a new chat sharing the current session\'s workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.', + description: 'Create delegated work and start it with an initial prompt. Set `relationship` to `currentSession` when the work is related to the current session, or to `independent` when it is not.', inputSchema: createSessionInputSchema, annotations: { readOnlyHint: false }, }, @@ -225,6 +225,7 @@ interface ICreateSessionArgs { export type IResolvedCreateSessionArgs = { readonly relationship: 'currentSession'; + readonly workspace?: URI; readonly prompt: string; readonly title: string; readonly model?: IAgentModelInfo; @@ -248,7 +249,8 @@ export interface IAgentServiceSessionServerToolAccessor { readonly getModels: () => readonly IAgentModelInfo[]; readonly getCreationDefaults: (source: URI) => ISessionCreationDefaults | undefined; readonly startPrompt: (session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta) => Promise; - readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => Promise; + readonly createChat: (session: URI, chat: URI, options?: IAgentCreateChatRequestOptions) => Promise; + readonly addSessionWorkingDirectory: (session: URI, directory: URI) => Promise; readonly renameChat: (session: URI, chat: URI, title: string) => Promise; readonly reportToolError: (toolName: SessionServerToolName, error: unknown) => void; readonly deleteSession: (session: URI) => Promise; @@ -464,9 +466,7 @@ function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMet async function getCreateSessionCatalog(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise { const args = (rawArgs ?? {}) as ICreateSessionArgs; - if (getCreateSessionRelationship(args) !== 'independent') { - return []; - } + getCreateSessionRelationship(args); const workspace = getOptionalString(args.workspace, 'workspace', SessionServerToolName.CreateSession); if (workspace === undefined) { return []; @@ -522,14 +522,12 @@ export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgent const worktree = getOptionalBoolean(args.worktree, 'worktree', SessionServerToolName.CreateSession); const model = resolveModel(modelName, models, relationship === 'currentSession' ? currentProvider : undefined); if (relationship === 'currentSession') { - if (workspace !== undefined) { - throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: workspace is only valid when relationship is "independent".`); - } if (worktree !== undefined) { - throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: worktree is only valid when relationship is "independent"; chats in the current session share its workspace.`); + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: worktree is only valid when relationship is "independent".`); } return { relationship, + ...(workspace !== undefined ? { workspace: resolveWorkspace(workspace, sessions) } : {}), prompt, title, ...(model !== undefined ? { model } : {}), @@ -813,11 +811,15 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso if (args.model !== undefined && args.model.provider !== currentProvider) { throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: model "${args.model.id}" belongs to provider "${args.model.provider}", but relationship "currentSession" targets provider "${currentProvider}".`); } + const workingDirectory = args.workspace !== undefined + ? await accessor.addSessionWorkingDirectory(currentSession, args.workspace) + : undefined; const result = await createChat(accessor, { session: currentSession, prompt: args.prompt, title: args.title, model: args.model, + ...(workingDirectory !== undefined ? { workingDirectories: [workingDirectory] } : {}), }, source, sourceTurnId); return { relationship: args.relationship, ...result }; } @@ -901,6 +903,7 @@ interface IResolvedCreateChatArgs { readonly prompt: string; readonly title?: string; readonly model?: IAgentModelInfo; + readonly workingDirectories?: readonly URI[]; } /** @@ -955,7 +958,11 @@ async function createChat(accessor: ISessionServerToolAccessor, args: IResolvedC const model = args.model !== undefined ? { id: args.model.id } : targetProvider === defaults?.provider ? defaults?.model : undefined; const chatId = generateUuid(); const chat = URI.parse(buildChatUri(args.session.toString(), chatId)); - await accessor.createChat(args.session, chat, { title: args.title, model }); + await accessor.createChat(args.session, chat, { + ...(args.title !== undefined ? { title: args.title } : {}), + ...(model !== undefined ? { model } : {}), + ...(args.workingDirectories !== undefined ? { workingDirectories: args.workingDirectories } : {}), + }); if (args.title !== undefined) { await accessor.renameChat(args.session, chat, args.title); } diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 7625e6708a54d..6436fab231539 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1020,6 +1020,27 @@ suite('AgentHostProtocolClient', () => { transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); await resultPromise; }); + + test('forwards a chat working-directory subset', async () => { + const { client, transport } = createClient(); + const workingDirectory = URI.parse('file:///workspace/other'); + + const resultPromise = client.createChat(sessionUri, chatUri, { workingDirectories: [workingDirectory] }); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { + channel: sessionUri.toString(), + chat: chatUri.toString(), + workingDirectories: [workingDirectory.toString()], + }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); }); test('preserves JSON-RPC error code and data', async () => { const { client, transport } = createClient(); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 6b55a599bb356..b64b3471ca1f7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -26,7 +26,7 @@ import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostCh import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { NULL_CHECKPOINT_SERVICE, type IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; -import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -2349,13 +2349,48 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { persisted: JSON.stringify(isolation === 'worktree' ? (primaryAvailable ? branchSummary : undefined) : { additions: 12, deletions: 6, files: 3 }), sessionFiles: ['/repoA/session.ts', '/repoA/sub/session.ts', '/repoB/session.ts'].map(path => URI.file(path).toString()), branchCallsBeforeRefresh: [], - branchCalls: ['file:///repoA'], + branchCalls: ['file:///repoA', 'file:///repoB'], sessionCalls: ['file:///repoA', 'file:///repoB'], }); }); } } + test('multi-root Branch Changes combines repositories using each repository base branch', async () => { + const calls: { root: string; baseBranch: string | undefined }[] = []; + const git = createNoopGitService(); + git.getRepositoryRoot = async workingDirectory => workingDirectory; + git.getSessionGitState = async workingDirectory => ({ + baseBranchName: workingDirectory.path === '/repoB' ? 'develop' : 'ignored-primary', + }); + git.computeSessionFileDiffs = async (workingDirectory, options) => { + calls.push({ root: workingDirectory.path, baseBranch: options.baseBranch }); + return [gitDiff(`${workingDirectory.path}/branch.ts`)]; + }; + const db = new TestSessionDatabase(); + await db.setMetadata(META_DIFF_BASE_BRANCH, 'main'); + const { svc, stateManager } = build({ + workingDirectories: ['file:///repoA', 'file:///repoB'], + git, + checkpoint: NULL_CHECKPOINT_SERVICE, + db, + }); + + svc.refreshBranchChangeset(sessionStr); + await waitForChangesetReady(stateManager, branchChangeset); + + assert.deepStrictEqual({ + calls, + files: stateManager.getChangesetState(branchChangeset)?.files.map(file => file.id), + }, { + calls: [ + { root: '/repoA', baseBranch: 'main' }, + { root: '/repoB', baseBranch: 'develop' }, + ], + files: ['/repoA/branch.ts', '/repoB/branch.ts'].map(path => URI.file(path).toString()), + }); + }); + test('multi-root Session Changes uses each repository baseline and the latest peer turn', async () => { const calls: { root: string; fromRef: string; toRef: string }[] = []; const turnCalls: { root: string | undefined; turnId: string }[] = []; diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index b6755d087f41d..4bd90b6c59adb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -1090,6 +1090,37 @@ suite('AgentHostStateManager', () => { ); }); + test('session changeset catalog is exposed by existing and newly added chat states', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat); + const changesets = [{ + label: 'Branch Changes', + uriTemplate: buildChangesetUri(sessionUri, 'branch'), + changeKind: 'branch', + }]; + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(envelope => envelopes.push(envelope))); + + manager.setSessionChangesets(sessionUri, changesets); + const peer2 = buildChatUri(sessionUri, 'peer-2'); + manager.addChat(sessionUri, peer2); + + assert.deepStrictEqual({ + defaultChangesets: manager.getDefaultChatState(sessionUri)?.changesets, + peerChangesets: manager.getChatState(peerChat)?.changesets, + newPeerChangesets: manager.getChatState(peer2)?.changesets, + changedChannels: envelopes + .filter(envelope => envelope.action.type === ActionType.ChatChangesetsChanged) + .map(envelope => envelope.channel) + .sort(), + }, { + defaultChangesets: changesets, + peerChangesets: changesets, + newPeerChangesets: changesets, + changedChannels: [buildDefaultChatUri(sessionUri), peerChat].sort(), + }); + }); + test('catalog-only SessionChatAdded does not create chat state', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 72fae94e4b04a..b062320dc27cb 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -13387,7 +13387,12 @@ suite('AgentService (node dispatcher)', () => { } } - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); const agent = disposables.add(new AdoptThenFailAgent()); registerTestAgentProvider(localService, agent); getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); @@ -17880,6 +17885,103 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('create_session scopes a current-session chat to an added working directory from a worktree session', async () => { + class ServerToolAgent extends MockAgent { + readonly createdChatWorkingDirectories: (readonly URI[] | undefined)[] = []; + serverToolHost: IAgentServerToolHost | undefined; + + constructor() { + super('copilot', { + multipleChats: { fork: true }, + multipleWorkingDirectories: { immutablePrimary: true }, + }); + } + + override async createChat(): Promise { } + + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + const result = await base.createChat(chat, context, options); + this.createdChatWorkingDirectories.push(options?.workingDirectories); + return result; + }, + })); + } + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new ServerToolAgent()); + registerTestAgentProvider(localService, agent); + const originalDirectory = URI.file('/workspace/original'); + const addedDirectory = URI.file('/workspace/added'); + const resumedSessionWorktree = URI.file('/workspace/resumed-session-worktree'); + const session = await localService.createSession({ + provider: 'copilot', + workingDirectories: [originalDirectory], + config: { [SessionConfigKey.Isolation]: 'worktree' }, + }); + setTestAgentHostWorktreeIsolation(localService, createTestAgentHostWorktreeIsolation({ + isWorkingDirectoryPending: () => false, + resolveWorkingDirectoryForResume: async () => resumedSessionWorktree, + })); + const sourceChat = URI.parse(buildDefaultChatUri(session)); + localService.dispatchAction(sourceChat.toString(), { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: new Date().toISOString(), + message: { text: 'Create folder-scoped work', origin: { kind: MessageKind.User } }, + }, 'test-client', 1); + + const peerSendPromise = Event.toPromise(Event.filter(agent.onDidSendMessage, call => call.prompt === 'work in the added folder')); + await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateSession, { + relationship: 'currentSession', + workspace: addedDirectory.toString(), + prompt: 'work in the added folder', + title: 'Added Folder', + }); + await peerSendPromise; + + const state = getStateManager(localService).getSessionState(session.toString()); + const peer = state?.chats.find(chat => chat.resource !== sourceChat.toString()); + const peerState = peer ? getStateManager(localService).getChatState(peer.resource) : undefined; + const peerSend = agent.sendMessageWorkingDirectories.find(call => call.chat.toString() === peer?.resource); + assert.ok(peer); + const beforeRestart = { + sessionWorkingDirectories: getStateManager(localService).getSessionSummary(session.toString())?.workingDirectories, + sourceWorkingDirectories: getStateManager(localService).getChatState(sourceChat.toString())?.workingDirectories, + peerWorkingDirectories: peerState?.workingDirectories, + providerWorkingDirectories: agent.createdChatWorkingDirectories.at(-1)?.map(directory => directory.toString()), + providerTurnWorkingDirectories: peerSend?.workingDirectories?.map(directory => directory.toString()), + peerPrompt: peerState?.activeTurn?.message.text, + }; + + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); + await localService.subscribe(URI.parse(peer.resource), 'restored-peer-reader'); + + assert.deepStrictEqual({ + beforeRestart, + restoredSessionWorkingDirectories: getStateManager(localService).getSessionSummary(session.toString())?.workingDirectories, + restoredSourceWorkingDirectories: getStateManager(localService).getChatState(sourceChat.toString())?.workingDirectories, + restoredPeerWorkingDirectories: getStateManager(localService).getChatState(peer.resource)?.workingDirectories, + }, { + beforeRestart: { + sessionWorkingDirectories: [originalDirectory.toString(), addedDirectory.toString()], + sourceWorkingDirectories: [originalDirectory.toString()], + peerWorkingDirectories: [addedDirectory.toString()], + providerWorkingDirectories: [addedDirectory.toString()], + providerTurnWorkingDirectories: [addedDirectory.toString()], + peerPrompt: 'work in the added folder', + }, + restoredSessionWorkingDirectories: [originalDirectory.toString(), addedDirectory.toString()], + restoredSourceWorkingDirectories: [originalDirectory.toString()], + restoredPeerWorkingDirectories: [addedDirectory.toString()], + }); + }); + test('session creation tools inherit pre-merge picker values when agent merge is enabled', async () => { class ServerToolAgent extends MockAgent { readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 334ed4fdad927..22dacf2c4d925 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -12282,6 +12282,7 @@ suite('CopilotAgent', () => { }); const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; return built.fake; }; @@ -12309,6 +12310,62 @@ suite('CopilotAgent', () => { } }); + test('sendMessage preserves an addressed backing working directory outside the parent session worktree across configuration refresh', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const worktreeIsolation = new NullAgentHostWorktreeIsolation(); + const { agent } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([]), + rootConfig: { [AgentHostCopilotMultiRootEnabledConfigKey]: true }, + worktreeIsolation, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'route-scoped-peer'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const sessionWorkingDirectory = URI.file('/session-worktree'); + const chatWorkingDirectory = URI.file('/peer-repository'); + const resolveCalls: string[] = []; + worktreeIsolation.resolveWorkingDirectoryForResume = async (_session, _sessionId, workingDirectory) => { + resolveCalls.push(workingDirectory.toString()); + return URI.file('/resolved-session-worktree'); + }; + await provisionSession(agent, { session, workingDirectories: [sessionWorkingDirectory] }); + await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); + + const internals = agent as unknown as ChatInternals; + const launches: { workingDirectory: string | undefined; customizationDirectory: string | undefined }[] = []; + internals._createAgentSession = (launchPlan, customizationDirectory, _activeClient, identity) => { + launches.push({ + workingDirectory: launchPlan.workingDirectory?.toString(), + customizationDirectory: customizationDirectory?.toString(), + }); + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; + return built.fake; + }; + + await agent.chats.sendMessage(chatUri, 'hello peer', [chatWorkingDirectory], undefined, undefined, undefined, exactChatContext(session, chatUri)); + + assert.deepStrictEqual({ + resolveCalls, + launches, + }, { + resolveCalls: [], + launches: [{ + workingDirectory: chatWorkingDirectory.toString(), + customizationDirectory: chatWorkingDirectory.toString(), + }, { + workingDirectory: chatWorkingDirectory.toString(), + customizationDirectory: chatWorkingDirectory.toString(), + }], + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage preserves provider-owned workspace-less working directories when the caller still supplies the scratch directory', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]), useRealResumePath: true }); @@ -12339,6 +12396,7 @@ suite('CopilotAgent', () => { const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); recorder = built.rec; (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; return built.fake; }; @@ -13788,6 +13846,7 @@ suite('CopilotAgent', () => { interface IRefreshSessionStub { sessionId: string; + workingDirectory: URI; appliedSnapshot: IActiveClientSnapshot; appliedAdditionalDirectories: readonly URI[]; appliedDisabledRootMcpServers: readonly string[]; @@ -13805,9 +13864,10 @@ suite('CopilotAgent', () => { resetTurnState(turnId: string): void; } - function refreshSessionStub(additionalDirectories: readonly URI[]): IRefreshSessionStub { + function refreshSessionStub(additionalDirectories: readonly URI[], workingDirectory = URI.file('/workspace/primary')): IRefreshSessionStub { return { sessionId: 'config-refresh-session', + workingDirectory, appliedSnapshot: { tools: [], plugins: [], mcpServers: {} }, appliedAdditionalDirectories: additionalDirectories, appliedDisabledRootMcpServers: [], @@ -13949,6 +14009,47 @@ suite('CopilotAgent', () => { } }); + test('refreshes a live session when the host corrects its primary working directory', async () => { + const client = new TestCopilotClient([]); + const logService = new RefreshLogService(); + const agent = createTestAgent(disposables, { copilotClient: client, logService }); + const sessionId = 'config-refresh-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const staleWorkingDirectory = URI.file('/workspace/stale'); + const correctedWorkingDirectory = URI.file('/workspace/corrected'); + const previousSession = refreshSessionStub([], staleWorkingDirectory); + const resumedSession = refreshSessionStub([], correctedWorkingDirectory); + const resumeCalls: string[][] = []; + const internals = agent as unknown as { + _resumeSession: (id: string, chatChannelUri?: URI, workingDirectories?: readonly URI[]) => Promise; + }; + + setDefaultSessionStub(agent, sessionId, previousSession); + internals._resumeSession = async (_id, _chatChannelUri, workingDirectories) => { + resumeCalls.push(workingDirectories?.map(directory => directory.toString()) ?? []); + setDefaultSessionStub(agent, sessionId, resumedSession); + return resumedSession as unknown as CopilotAgentSession; + }; + + try { + await agent.chats.sendMessage(defaultChatUri(session), 'hello', [correctedWorkingDirectory], undefined, 'corrected-root-turn'); + + assert.deepStrictEqual({ + previousDestroyCalls: previousSession.destroyCalls, + resumeCalls, + resumedSends: resumedSession.sendCalls, + logs: logService.messages, + }, { + previousDestroyCalls: 1, + resumeCalls: [[correctedWorkingDirectory.toString()]], + resumedSends: ['hello'], + logs: [`[Copilot:${sessionId}] Session configuration changed, refreshing session: operation=sendMessage, sdkSessionId=${sessionId}, chat=${defaultChatUri(session).toString()}, turnId=corrected-root-turn, reason=workingDirectoryChanged, clients=[(none)]`], + }); + } finally { + await disposeAgent(agent); + } + }); + test('does not refresh equivalent reordered additional roots', async () => { const client = new TestCopilotClient([]); const logService = new RefreshLogService(); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index e7f189b5c5255..82abfa6f1148f 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -78,6 +78,7 @@ export class MockAgent implements IAgent { readonly sendMessageCalls: IMockSendMessageCall[] = []; + readonly sendMessageWorkingDirectories: { chat: URI; workingDirectories: readonly URI[] | undefined }[] = []; readonly setPendingMessagesCalls: { chat: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; readonly disposeSessionCalls: URI[] = []; readonly releaseSessionCalls: URI[] = []; @@ -370,10 +371,14 @@ export class MockAgent implements IAgent { this._releaseSessionRecord(session); return Promise.resolve(); }, - sendMessage: (chatUri: URI, prompt: string, _workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise => { + sendMessage: (chatUri: URI, prompt: string, workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise => { const clientType = typeof clientTypeOrContext === 'string' ? clientTypeOrContext : AgentHostClientType.Unknown; const operationContext = context ?? (typeof clientTypeOrContext === 'string' ? undefined : clientTypeOrContext); this._recordContext('sendMessage', chatUri, operationContext); + this.sendMessageWorkingDirectories.push({ + chat: chatUri, + workingDirectories: URI.isUri(workingDirectoriesOrDirectory) ? [workingDirectoriesOrDirectory] : workingDirectoriesOrDirectory, + }); const { session, chat } = this._resolveChatTarget(chatUri, operationContext); return this.sendMessage(session, chat, prompt, attachments, turnId, senderClientId, clientType); }, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index a626a0ff35eef..94623e0521946 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -2061,8 +2061,9 @@ suite('ProtocolServerHandler', () => { const transport = connectClient('client-cc'); transport.sent.length = 0; const responsePromise = waitForResponse(transport, 2); + const workingDirectory = 'file:///workspace/other'; - transport.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat })); + transport.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat, workingDirectories: [workingDirectory] })); const resp = await responsePromise; assert.deepStrictEqual({ @@ -2071,7 +2072,7 @@ suite('ProtocolServerHandler', () => { inCatalog: stateManager.getSessionState(sessionUri)?.chats.some(c => c.resource === peerChat), }, { result: null, - created: [{ session: sessionUri, chat: peerChat }], + created: [{ session: sessionUri, chat: peerChat, options: { workingDirectories: [URI.parse(workingDirectory)] } }], inCatalog: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index dbb4eecd14284..40660caf279ad 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -54,6 +54,37 @@ function withActiveTurnAndToolCall(state: ChatState): ChatState { return state; } +suite('chatReducer - changesets', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('replaces and clears the chat changeset catalogue', () => { + const changesets = [{ + label: 'Branch Changes', + uriTemplate: 'ahp-session:/test/changeset/branch', + changeKind: 'branch', + }]; + const populated = chatReducer(makeChat(), { + type: ActionType.ChatChangesetsChanged, + changesets, + }); + const cleared = chatReducer(populated, { + type: ActionType.ChatChangesetsChanged, + changesets: undefined, + }); + + assert.deepStrictEqual({ + populated: populated.changesets, + cleared: cleared.changesets, + hasClearedProperty: Object.hasOwn(cleared, 'changesets'), + }, { + populated: changesets, + cleared: undefined, + hasClearedProperty: false, + }); + }); +}); + suite('chatReducer – summaryStatus with tool call confirmations and input requests', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index a7eae6c939a84..4694ed7aeb721 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -10,7 +10,7 @@ import { isWindows } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; +import type { IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; @@ -62,7 +62,7 @@ suite('SessionServerTools', () => { return { sessionUri, chatUri: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }; } - function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (...args: Parameters) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map }): ISessionServerToolAccessor { + function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (...args: Parameters) => void; onCreateChat?: (session: URI, chat: URI, options?: IAgentCreateChatRequestOptions) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map }): ISessionServerToolAccessor { const depths = overrides?.depths ?? new Map(); return { isActiveAgentTitleGenerationEnabled: overrides?.isActiveAgentTitleGenerationEnabled ?? (() => true), @@ -75,6 +75,7 @@ suite('SessionServerTools', () => { getCreationDefaults: overrides?.getCreationDefaults ?? (() => undefined), startPrompt: overrides?.startPrompt ?? (async (session, chat, prompt, delegation) => { overrides?.onPrompt?.(session, chat, prompt, delegation); }), createChat: overrides?.createChat ?? (async (session, chat, options) => { overrides?.onCreateChat?.(session, chat, options); }), + addSessionWorkingDirectory: overrides?.addSessionWorkingDirectory ?? (async (_session, directory) => directory), renameChat: overrides?.renameChat ?? (async (session, chat, title) => { overrides?.onRenameChat?.(session, chat, title); return { title }; }), reportToolError: overrides?.reportToolError ?? (() => { }), deleteSession: overrides?.deleteSession ?? (async session => { overrides?.onDelete?.(session); }), @@ -117,11 +118,11 @@ suite('SessionServerTools', () => { relationship: { type: 'string', enum: ['currentSession', 'independent'], - description: 'Whether this work belongs to the current session or is independently managed. Use `currentSession` for tasks from the current plan or deliverable, including parallel or delegated tasks, unless the user explicitly requests a worktree. Use `independent` for a separate deliverable that needs its own workspace, provider, or top-level lifecycle, or for an explicitly requested worktree.', + description: 'Whether this work belongs to the current session or is independently managed. Base this choice only on whether the work is related to the current session.', }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, - workspace: { type: 'string', description: 'For `independent` work: unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Required for `independent` and invalid for `currentSession`.' }, - worktree: { type: 'boolean', description: 'Override isolation for the new independent session. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior: inherit the creating session\'s isolation for the same project, otherwise use worktree isolation. Only valid with relationship `independent`; omit for `currentSession`.' }, + workspace: { type: 'string', description: 'Workspace for the delegated work: a unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, + worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior.' }, title: { type: 'string', maxLength: 200, description: 'Short title for the new chat or independent session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, }, @@ -647,10 +648,9 @@ suite('SessionServerTools', () => { }); }); - test('create_session guidance requires an explicit isolation choice and excludes currentSession', () => { + test('create_session guidance bases relationship only on relatedness', () => { const description = sessionServerToolDefinitions.find(definition => definition.name === SessionServerToolName.CreateSession)?.description ?? ''; - assert.match(description, /Only supply `worktree` when the user explicitly requests working with or without a new worktree/); - assert.match(description, /never combine it with `currentSession`/); + assert.strictEqual(description, 'Create delegated work and start it with an initial prompt. Set `relationship` to `currentSession` when the work is related to the current session, or to `independent` when it is not.'); }); test('getCreateSessionArgs resolves workspace by working directory and model by id/name', () => { @@ -875,7 +875,16 @@ suite('SessionServerTools', () => { assert.throws(() => getCreateSessionArgs({ workspace: workspace.toString(), prompt: 'hi', title: 'Task' }, [], []), /relationship/); assert.throws(() => getCreateSessionArgs({ relationship: 'other', prompt: 'hi', title: 'Task' }, [], []), /relationship/); assert.throws(() => getCreateSessionArgs({ relationship: 'independent', prompt: 'hi', title: 'Task' }, [], []), /workspace/); - assert.throws(() => getCreateSessionArgs({ relationship: 'currentSession', workspace: workspace.toString(), prompt: 'hi', title: 'Task' }, [], []), /workspace/); + const currentSessionArgs = getCreateSessionArgs({ relationship: 'currentSession', workspace: workspace.toString(), prompt: 'hi', title: 'Task' }, [], []); + assert.deepStrictEqual({ + ...currentSessionArgs, + workspace: currentSessionArgs.workspace?.toString(), + }, { + relationship: 'currentSession', + workspace: workspace.toString(), + prompt: 'hi', + title: 'Task', + }); assert.throws(() => getCreateSessionArgs({ relationship: 'independent', workspace: workspace.toString(), prompt: 'hi' }, [], []), /title/); assert.throws(() => getCreateSessionArgs({ relationship: 'independent', workspace: workspace.toString(), prompt: 'hi', title: ' ' }, [], []), /non-whitespace/); assert.throws(() => getCreateSessionArgs({ relationship: 'independent', workspace: workspace.toString(), prompt: 'hi', title: 'x'.repeat(201) }, [], []), /must not exceed 200/); @@ -1477,6 +1486,50 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('create_session with currentSession adds and scopes a requested workspace folder', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const requestedWorkspace = URI.file('/workspace/other'); + const operations: string[] = []; + let createdChat: { session: URI; options?: IAgentCreateChatRequestOptions } | undefined; + const accessor = createAccessor({ + listSessions: async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)], + addSessionWorkingDirectory: async (session, directory) => { + operations.push(`add:${session.toString()}:${directory.toString()}`); + return requestedWorkspace; + }, + onCreateChat: (session, _chat, options) => { + operations.push('create'); + createdChat = { session, options }; + }, + onRenameChat: () => operations.push('rename'), + onPrompt: () => operations.push('prompt'), + }); + + await createSessionServerToolGroup(accessor).execute( + stateManager, + executionContext('copilot:/s1'), + SessionServerToolName.CreateSession, + { + relationship: 'currentSession', + workspace: requestedWorkspace.toString(), + prompt: 'do it there', + title: 'Other Folder', + }, + ); + + assert.deepStrictEqual({ + session: createdChat?.session.toString(), + workingDirectories: createdChat?.options?.workingDirectories?.map(directory => directory.toString()), + operations, + }, { + session: 'copilot:/s1', + workingDirectories: [requestedWorkspace.toString()], + operations: [`add:copilot:/s1:${requestedWorkspace.toString()}`, 'create', 'rename', 'prompt'], + }); + store.dispose(); + }); + test('create_session with currentSession rejects a model from another provider', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 59bc9ae434560..ac5d142fd2452 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -101,7 +101,7 @@ Chat origin and interactivity describe whether a chat is user-created, tool-crea ### Workspaces and quick chats -`ISession.workspace` describes the workspace in which a session operates. A quick chat is workspace-less by product intent and is identified through `ISession.isQuickChat`. An absent workspace alone does not prove that a session is a quick chat because workspace state may still be hydrating. +`ISession.workspace` describes the complete workspace in which a session operates. `IChat.workspace` describes the effective workspace available to that chat and may be a subset of the session workspace. A quick chat is workspace-less by product intent and is identified through `ISession.isQuickChat`. An absent workspace alone does not prove that a session is a quick chat because workspace state may still be hydrating. ### Capabilities diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index ec5951aadd943..ab20a8ebdee32 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -97,6 +97,41 @@ export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | unde return [workspace.label, ...folderKeys].join('\n'); } +/** + * Projects a chat's working-directory scope onto its owning session workspace. + * Returns no workspace rather than exposing a partial scope when a required folder is unavailable. + */ +export function buildAgentHostChatWorkspace(sessionWorkspace: ISessionWorkspace | undefined, workingDirectories: readonly URI[] | undefined): ISessionWorkspace | undefined { + if (!sessionWorkspace || workingDirectories === undefined) { + return sessionWorkspace; + } + + const folders: ISessionFolder[] = []; + for (const workingDirectory of workingDirectories) { + const folder = sessionWorkspace.folders.find(candidate => extUri.isEqual(candidate.workingDirectory, workingDirectory)); + if (!folder) { + return undefined; + } + folders.push(folder); + } + + if (folders.length === 0) { + return undefined; + } + if (folders.length === sessionWorkspace.folders.length && folders.every((folder, index) => folder === sessionWorkspace.folders[index])) { + return sessionWorkspace; + } + + const primaryFolder = folders[0]; + const usesSessionPrimary = primaryFolder === sessionWorkspace.folders[0]; + return { + ...sessionWorkspace, + uri: usesSessionPrimary ? sessionWorkspace.uri : primaryFolder.root, + label: usesSessionPrimary ? sessionWorkspace.label : primaryFolder.name, + folders, + }; +} + export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectories: readonly URI[] | undefined, options: IAgentHostSessionWorkspaceOptions, gitHubInfo: IObservable, gitState?: ISessionGitState): ISessionWorkspace | undefined { const baseBranchName = gitState?.baseBranchName; const baseBranchProtected = baseBranchName !== undefined diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 1c6b1a75cfc50..e30907abc0c1d 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -1389,10 +1389,12 @@ export class ChangesViewPane extends ViewPane { } const activeSession = this.sessionsService.activeSession.get(); - const folder = activeSession?.workspace.get()?.folders[0]; - if (!folder) { + const activeChat = activeSession?.activeChat?.get(); + const folders = (activeChat?.workspace ?? activeSession?.workspace)?.get()?.folders; + if (!folders || folders.length !== 1) { return undefined; } + const folder = folders[0]; const workspaceFolderUri = folder.workingDirectory; if (workspaceFolderUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 941fde829ba18..6836545490380 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -119,7 +119,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic this.activeSessionIsVirtualWorkspaceObs = derived(reader => { const activeSession = this.sessionsService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.isVirtualWorkspace ?? false; + const activeChat = activeSession?.activeChat?.read(reader); + return (activeChat?.workspace ?? activeSession?.workspace)?.read(reader)?.isVirtualWorkspace ?? false; }); // Active session has git repository @@ -130,8 +131,9 @@ export class ChangesViewService extends Disposable implements IChangesViewServic } const activeSession = this.sessionsService.activeSession.read(reader); - const workspace = activeSession?.workspace.read(reader); - return workspace?.folders[0].gitRepository !== undefined; + const activeChat = activeSession?.activeChat?.read(reader); + const workspace = (activeChat?.workspace ?? activeSession?.workspace)?.read(reader); + return workspace?.folders.some(folder => folder.gitRepository !== undefined) ?? false; }); // Active session review comment count by file @@ -143,7 +145,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic // Changesets const activeSessionChangesetsObs = derived(reader => { const activeSession = this.sessionsService.activeSession.read(reader); - return activeSession?.changesets.read(reader); + const activeChat = activeSession?.activeChat?.read(reader); + return activeChat?.changesets?.read(reader) ?? activeSession?.changesets.read(reader); }); this.activeSessionChangesetsObs = derived(reader => { const changesets = activeSessionChangesetsObs.read(reader); @@ -201,7 +204,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic const activeSessionBaseBranchProtected = derived(reader => { const activeSession = this.sessionsService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.folders[0]?.gitRepository?.baseBranchProtected === true; + const activeChat = activeSession?.activeChat?.read(reader); + return (activeChat?.workspace ?? activeSession?.workspace)?.read(reader)?.folders[0]?.gitRepository?.baseBranchProtected === true; }); this.activeSessionChangesetOperationsObs = derived(reader => { @@ -238,7 +242,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic // Reset changeset selection this._register(autorun(reader => { - this.activeSessionResourceObs.read(reader); + const activeSession = this.sessionsService.activeSession.read(reader); + activeSession?.activeChat?.read(reader); this.setChangesetId(undefined); })); this._register(sessionsManagementService.onDidReplaceSession(({ from, to }) => { diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index 1ecd1750ea8e9..52d308406ed8d 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -14,7 +14,7 @@ import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platf import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ISession, ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus } from '../../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; import { ICodeReviewService, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; @@ -25,7 +25,7 @@ suite('ChangesViewService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean; readonly pullRequestState?: 'open' | 'closed' | 'merged'; readonly livePullRequestState?: 'open' | 'closed' | 'merged'; readonly pullRequestIcon?: { readonly id: string } }): IActiveSession { + function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly chatChangesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean; readonly pullRequestState?: 'open' | 'closed' | 'merged'; readonly livePullRequestState?: 'open' | 'closed' | 'merged'; readonly pullRequestIcon?: { readonly id: string } }): IActiveSession { const workspace = options?.baseBranchProtected === undefined && options?.pullRequestState === undefined && options?.livePullRequestState === undefined && options?.pullRequestIcon === undefined ? undefined : upcastPartial({ @@ -51,6 +51,11 @@ suite('ChangesViewService', () => { }), })], }); + const chat = upcastPartial({ + resource: URI.from({ scheme: 'test-chat', path: `/${id}` }), + workspace: constObservable(workspace), + changesets: options?.chatChangesets ? constObservable(options.chatChangesets) : undefined, + }); return upcastPartial({ resource: URI.from({ scheme: 'test-session', path: `/${id}` }), providerId: 'local-agent-host', @@ -59,6 +64,7 @@ suite('ChangesViewService', () => { changes: constObservable([]), changesets: constObservable(options?.changesets ?? []), workspace: constObservable(workspace), + activeChat: constObservable(chat), }); } @@ -317,6 +323,27 @@ suite('ChangesViewService', () => { }); }); + test('uses the active chat changesets when the provider exposes them', () => { + const sessionBranch = createChangeset([]); + const chatBranch = { + ...createChangeset([]), + id: 'chat-branch', + label: 'Chat Branch Changes', + }; + const { service } = createHarness(createSession('chat-changesets', { + changesets: [sessionBranch], + chatChangesets: [chatBranch], + })); + + assert.deepStrictEqual({ + changesets: service.activeSessionChangesetsObs.get()?.map(changeset => changeset.id), + selected: service.activeSessionChangesetObs.get()?.id, + }, { + changesets: ['chat-branch'], + selected: 'chat-branch', + }); + }); + test('hides checkout from generic changeset operations', () => { const changeset = createChangeset([ { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts index 5d30d93118901..32dc97c3141f9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts @@ -33,10 +33,12 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession gitRepository: { uri: opts.repository, workTreeUri: opts.worktree, baseBranchName: undefined, gitHubInfo: constObservable(undefined) }, } satisfies ISessionFolder], requiresWorkspaceTrust: false, + isVirtualWorkspace: false, } : undefined; const chat: IChat = { resource: URI.parse('file:///session'), createdAt: new Date(), + workspace: constObservable(workspace), title: observableValue('title', 'session'), updatedAt: observableValue('updatedAt', new Date()), status: observableValue('status', SessionStatus.Untitled), diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index df1fe6a140eb7..158244c81ffc0 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -518,6 +518,7 @@ class TestSession implements ISession { const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, + workspace: this.workspace, title: this.title, updatedAt: this.updatedAt, status: this.status, diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 2c49f8aefa154..c068d33aa9584 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -74,6 +74,7 @@ export function makeSession(resource: URI, opts?: { const chat: IChat = { resource, createdAt: new Date(), + workspace: constObservable(opts?.workspace), title: observableValue('title', 'Test'), updatedAt: observableValue('updatedAt', new Date()), status, diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 8cf337ad4eec3..a036ecbc0ec27 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -75,6 +75,8 @@ Agent provider names form logical session-type identifiers. Resource URI schemes - derives capabilities from the advertised agent and live host state; - updates observable state without replacing the facade when identity is stable. +The session working-directory set is authoritative for the folders projected through `ISessionWorkspace`. When an agent creates a peer chat with a different folder, the host first adds that folder to the session and then records the chat's working directories as a subset of the session set. Existing chats that inherited the full set are pinned to the previous set before expansion so they do not silently gain access to the new folder. The session set and per-chat subsets are persisted in the host catalog and restored together after restart. This flow requires the agent's multiple-working-directories capability; omitting a chat subset preserves full-session inheritance. + The provider cache owns adapter identity. Catalog notifications describe membership; adapter observables describe mutable state. Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 4f15c0f3be7bf..ff310730a9f80 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -32,6 +32,10 @@ export interface IAgentHostChangeset extends Changeset { * channel; an array, including an empty one, is used as-is. */ readonly changes?: IObservable; + /** Working directories used to scope the visible files for this adaptation. */ + readonly workingDirectories?: IObservable; + /** Whether operations from the backing channel must be hidden for this adaptation. */ + readonly suppressOperations?: boolean; } /** @@ -48,29 +52,35 @@ function sessionFileChangeUri(change: ISessionFileChange): URI { } /** - * For multi-root sessions, keeps only changes under the primary working - * directory (`workingDirectories[0]`); single-root/empty/`undefined` inputs are - * returned unchanged. Paths are compared on the primary's `file:` scheme so - * `agent-host:`-wrapped changes still match and OS path-casing is respected. + * For multi-root sessions, keeps changes under any declared working directory; + * single-root/empty/`undefined` inputs are returned unchanged. Paths are + * compared on each working directory's `file:` scheme so `agent-host:`-wrapped + * changes still match and OS path-casing is respected. */ -export function filterChangesToPrimaryWorkingDirectory( +export function filterChangesToWorkingDirectories( changes: readonly ISessionFileChange[], workingDirectories: readonly string[] | undefined ): readonly ISessionFileChange[] { - if (!isMultiRootSession(workingDirectories)) { + if (!workingDirectories || !isMultiRootSession(workingDirectories)) { return changes; } - const primary = workingDirectories?.[0]; - if (!primary) { + return filterChangesToWorkspaceDirectories(changes, workingDirectories); +} + +function filterChangesToWorkspaceDirectories( + changes: readonly ISessionFileChange[], + workingDirectories: readonly string[] | undefined +): readonly ISessionFileChange[] { + if (!workingDirectories || workingDirectories.length === 0) { return changes; } - const primaryWorkingDirectory = URI.parse(primary); - return changes.filter(change => + const workspaceFolders = workingDirectories.map(workingDirectory => URI.parse(workingDirectory)); + return changes.filter(change => workspaceFolders.some(workspaceFolder => extUriBiasedIgnorePathCase.isEqualOrParent( - primaryWorkingDirectory.with({ path: sessionFileChangeUri(change).path }), - primaryWorkingDirectory)); + workspaceFolder.with({ path: sessionFileChangeUri(change).path }), + workspaceFolder))); } export function createChangesets( @@ -230,6 +240,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { readonly capabilities: ISessionChangesetCapabilities; private readonly _locallyRunningOperationCounts = observableValue>(this, new Map()); + private readonly _workingDirectories: IObservable | undefined; protected abstract readonly channelUriObs: IObservable; protected abstract readonly changesetStateObs: IObservable>; @@ -240,8 +251,9 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { private readonly _options: IAgentHostAdapterOptions, private readonly _dialogService: IDialogService, ) { + this._workingDirectories = changeset.workingDirectories; this.capabilities = { - review: changeset.capabilities?.review !== undefined + review: !changeset.suppressOperations && changeset.capabilities?.review !== undefined } satisfies ISessionChangesetCapabilities; const providedChangesObs = derivedObservableWithCache(this, (reader, lastValue) => { @@ -342,6 +354,9 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { (operationId, metadata) => this._invokeOperation(operationId, undefined, metadata), ); this.operations = derivedOpts({ equalsFn: arrayEqualsC(structuralEquals) }, reader => { + if (changeset.suppressOperations) { + return []; + } const locallyRunningOperationCounts = this._locallyRunningOperationCounts.read(reader); return pullRequestCreation.mapOperations(operationsObs.read(reader)) .map(operation => locallyRunningOperationCounts.has(operation.id) && operation.status !== SessionChangesetOperationStatus.Running @@ -358,7 +373,9 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { * the {@link changes} derived, so overrides may read observables via `reader`. */ protected _filterChanges(changes: readonly ISessionFileChange[], reader: IReader): readonly ISessionFileChange[] { - return changes; + return this._workingDirectories + ? filterChangesToWorkspaceDirectories(changes, this._workingDirectories.read(reader)) + : changes; } async invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { @@ -510,9 +527,9 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { protected readonly changesetStateObs: IObservable>; /** - * The session's ordered working directories (index 0 is the primary), read - * from the existing session-state subscription. Used to filter the last-turn - * changes to the primary working directory for multi-root sessions. + * The session's ordered working directories, read from the existing + * session-state subscription. Used to keep last-turn changes within the + * session workspace. */ private readonly _workingDirectoriesObs: IObservable; @@ -598,13 +615,12 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { } /** - * For multi-root sessions, restrict the last-turn changes to files under the - * session's primary working directory so the single-root Changes tree in the - * Agents Window stays renderable. Single-root sessions are unaffected — - * {@link filterChangesToPrimaryWorkingDirectory} returns the input unchanged. + * For multi-root sessions, restrict the last-turn changes to files under any + * session working directory. Single-root sessions are unaffected — + * {@link filterChangesToWorkingDirectories} returns the input unchanged. */ protected override _filterChanges(changes: readonly ISessionFileChange[], reader: IReader): readonly ISessionFileChange[] { - return filterChangesToPrimaryWorkingDirectory(changes, this._workingDirectoriesObs.read(reader)); + return filterChangesToWorkingDirectories(changes, this._workingDirectoriesObs.read(reader)); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 31838aa7cd5a8..375323e6471af 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -59,7 +59,7 @@ import { isAutoApprovePolicyRestricted, normalizeSessionConfigValue } from '../. import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdentifier, resolveModelIdentifierFromLanguageModels } from '../../../../../workbench/contrib/chat/common/modelSelection.js'; import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, IAgentMergeClientState, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; -import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; +import { agentHostSessionWorkspaceKey, buildAgentHostChatWorkspace } from '../../../../common/agentHostSessionWorkspace.js'; import { USE_WORKTREE_SETTING, isSessionConfigComplete } from '../../../../common/sessionConfig.js'; import { linkKey } from '../../../../common/sessionLinks.js'; import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; @@ -563,6 +563,8 @@ export interface IAgentHostAdapterOptions { readonly backendSessionScheme?: string; /** Maps a backend session URI to the client resource used by this host. */ readonly mapBackendSessionResource: (resource: URI) => URI; + /** Maps a backend working-directory URI to the client resource used by this host. */ + readonly mapWorkingDirectoryUri?: AgentHostUriMapper; /** `Changeset.changeKind` the Changes view selects by default. Defaults to `branch`. */ readonly defaultChangesetKind?: ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; /** Connection state from the backing remote provider, when there is one. */ @@ -666,6 +668,7 @@ class AdditionalChat extends Disposable { private readonly _title: ISettableObservable; private readonly _status: ISettableObservable; private readonly _updatedAt: ISettableObservable; + private readonly _workingDirectories: ISettableObservable; private readonly _modelId: ISettableObservable; private readonly _modelSource: ISettableObservable; private readonly _mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -674,12 +677,13 @@ class AdditionalChat extends Disposable { private readonly _interactivity: ISettableObservable; private readonly _isNew: ISettableObservable; - constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable) { + constructor(resource: URI, summary: ChatSummary, sessionWorkspace: IObservable, mapWorkingDirectoryUri: AgentHostUriMapper, createChangesets: (workspace: IObservable) => IObservable, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable) { super(); const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : new Date(); this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); this._status = observableValue('chatStatus', mapProtocolStatus(summary.status)); this._updatedAt = observableValueOpts({ owner: this, debugName: 'chatUpdatedAt', equalsFn: dateEquals }, modifiedAt); + this._workingDirectories = observableValueOpts({ owner: this, debugName: 'chatWorkingDirectories', equalsFn: structuralEquals }, summary.workingDirectories); this._modelId = observableValue('chatModelId', undefined); this._modelSource = observableValue('chatModelSource', undefined); this._mode = observableValueOpts<{ readonly id: string; readonly kind: string } | undefined>({ owner: this, debugName: 'chatMode', equalsFn: structuralEquals }, undefined); @@ -688,13 +692,19 @@ class AdditionalChat extends Disposable { this._interactivity = observableValue('chatInteractivity', toChatInteractivity(summary.interactivity)); this._isNew = observableValue('chatIsNew', isNew); const status = derived(this, reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)); + const workspace = derived(this, reader => buildAgentHostChatWorkspace( + sessionWorkspace.read(reader), + this._workingDirectories.read(reader)?.map(directory => mapWorkingDirectoryUri(URI.parse(directory))) + )); this.chat = { resource, createdAt: modifiedAt, + workspace, title: this._title, updatedAt: this._updatedAt, status: toPresentedSessionStatus(this, status, connectionStatus), changes: constObservable([]), + changesets: createChangesets(workspace), lastTurnChanges: output?.lastTurnChanges, customizations: output?.customizations, checkpoints: observableValue(this, undefined), @@ -732,6 +742,7 @@ class AdditionalChat extends Disposable { this._title.set(summary.title || localize('newChatTab', "New Chat"), tx); this._status.set(mapProtocolStatus(summary.status), tx); this._updatedAt.set(modifiedAt, tx); + this._workingDirectories.set(summary.workingDirectories, tx); this._description.set(summary.activity ? new MarkdownString().appendText(summary.activity) : undefined, tx); this._lastTurnEnd.set(modifiedAt, tx); this._interactivity.set(toChatInteractivity(summary.interactivity), tx); @@ -871,6 +882,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * (which may have been promoted by a running peer chat). */ private readonly _defaultChatStatusOverride = observableValue('defaultChatStatusOverride', undefined); + private readonly _defaultChatWorkingDirectories = observableValueOpts({ owner: this, debugName: 'defaultChatWorkingDirectories', equalsFn: structuralEquals }, undefined); /** Whether this session was created with worktree isolation. */ private readonly _worktreeIsolation = observableValue('worktreeIsolation', false); /** Interactivity of the default chat. Driven from the default chat's protocol summary. */ @@ -1153,13 +1165,19 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._currentTurnChanges = this._createCurrentTurnChangesObservable(); const defaultChatStatus = derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)); + const defaultChatWorkspace = derived(this, reader => buildAgentHostChatWorkspace( + this.workspace.read(reader), + this._defaultChatWorkingDirectories.read(reader)?.map(directory => this._options.mapWorkingDirectoryUri?.(URI.parse(directory)) ?? URI.parse(directory)) + )); const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, + workspace: defaultChatWorkspace, title: derived(this, reader => this._defaultChatTitleOverride.read(reader) ?? this.title.read(reader)), updatedAt: this.updatedAt, status: toPresentedSessionStatus(this, defaultChatStatus, connectionStatus), changes: this.changes, + changesets: this._createChatChangesets(this.resource, URI.parse(buildDefaultChatUri(this.backendUri)), defaultChatWorkspace), lastTurnChanges: sessionOutput.getLastTurnChanges(URI.parse(buildDefaultChatUri(this.backendUri))), customizations: sessionOutput.getChatCustomizations(URI.parse(buildDefaultChatUri(this.backendUri))), checkpoints: observableValue(this, undefined), @@ -1240,6 +1258,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const defaultSummary = state.chats.find(isDefault); this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); this._defaultChatInteractivity.set(toChatInteractivity(defaultSummary?.interactivity), undefined); + this._defaultChatWorkingDirectories.set(defaultSummary?.workingDirectories, undefined); // Tool-origin subagents and user-created side (`/btw`) chats must reach // the peer-chat catalog even when the backing session type is otherwise @@ -1332,7 +1351,19 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { lastTurnChanges: this._sessionOutput.getLastTurnChanges(backendUri), customizations: this._sessionOutput.getChatCustomizations(backendUri), }; - const chat = new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), this.isArchived, output, this._options.readOnly, this._options.connectionStatus); + const chat = new AdditionalChat( + resource, + summary, + this.workspace, + this._options.mapWorkingDirectoryUri ?? (uri => uri), + workspace => this._createChatChangesets(resource, backendUri, workspace), + this._newChatIds.has(chatId), + this._resolveParentChatResource(summary.origin), + this.isArchived, + output, + this._options.readOnly, + this._options.connectionStatus + ); const selection = this._chatModelSelections.get(chatId); if (selection) { chat.setModelId(selection.modelId, selection.source); @@ -1340,6 +1371,56 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return chat; } + private _createChatChangesets(clientChatResource: URI, backendChatResource: URI, workspace: IObservable): IObservable { + const isActiveChatObs = derived(this, reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + return !!activeSession + && isEqual(activeSession.resource, this.resource) + && isEqual(activeSession.activeChat.read(reader).resource, clientChatResource); + }); + const chatStateObs = createActiveSessionSubscriptionObs( + this._options, + isActiveChatObs, + StateComponents.Chat, + constObservable(backendChatResource), + ); + const chatChangesetCatalog = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + const chatState = chatStateObs.read(reader).read(reader); + return chatState && !(chatState instanceof Error) ? chatState.changesets : undefined; + }); + const workingDirectories = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => + workspace.read(reader)?.folders.map(folder => folder.workingDirectory.toString())); + + return derived(this, reader => { + const sessionChangesets = this.changesets.read(reader); + const chatCatalog = chatChangesetCatalog.read(reader); + if (chatCatalog === undefined) { + return sessionChangesets; + } + + const branchChangesets = createChangesets( + backendChatResource, + this._options, + isActiveChatObs, + chatCatalog + .filter(changeset => changeset.changeKind === ChangesetKind.Branch) + .map(changeset => ({ + ...changeset, + workingDirectories, + suppressOperations: true, + })), + ); + if (!sessionChangesets) { + return branchChangesets; + } + + const branchChangeset = branchChangesets[0]; + return sessionChangesets.flatMap(changeset => changeset.id === ChangesetKind.Branch + ? branchChangeset ? [branchChangeset] : [] + : [changeset]); + }); + } + /** * Maps a protocol parent-chat URI (from a Tool/Fork {@link ChatSummary.origin}) * to this session's UI chat resource: the default chat maps to the session @@ -2169,8 +2250,10 @@ class NewSession extends Disposable { const mainChat: IChat = { resource, createdAt, title, updatedAt, + workspace: this._workspace, status: this._status, changes, + changesets: this._changesets, checkpoints, modelId: this._modelId, modelSource: this._modelSource, @@ -3139,6 +3222,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentCapabilities: this._agentCapabilities, backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + mapWorkingDirectoryUri: uri => this.mapWorkingDirectoryUri(uri), connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; @@ -3651,6 +3735,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + mapWorkingDirectoryUri: uri => this.mapWorkingDirectoryUri(uri), connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index f446cff4e4410..79ef48a50f9e5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -45,7 +45,7 @@ import { ISessionsService } from '../../../../../services/sessions/browser/sessi import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; import { SessionSyncChangesActionViewItem, SessionSyncChangesContribution } from '../../../../changes/browser/sessionSyncChanges.js'; import { isSessionPullRequestOperation } from '../../../../changes/common/pullRequestCreation.js'; -import { createChangesets, filterChangesToPrimaryWorkingDirectory, IAgentHostChangeset } from '../../browser/agentHostSessionChangesets.js'; +import { createChangesets, filterChangesToWorkingDirectories, IAgentHostChangeset } from '../../browser/agentHostSessionChangesets.js'; import { IAgentHostAdapterOptions } from '../../browser/baseAgentHostSessionsProvider.js'; suite('AgentHostSessionChangesets', () => { @@ -99,19 +99,21 @@ suite('AgentHostSessionChangesets', () => { }; } - suite('filterChangesToPrimaryWorkingDirectory', () => { - test('(a) multi-root: keeps only changes under the primary working directory', () => { + suite('filterChangesToWorkingDirectories', () => { + test('(a) multi-root: keeps changes under every working directory', () => { const changes = [ makeChange('file:///repo/primary/src/a.ts'), makeChange('file:///repo/primary/deep/nested/b.ts'), makeChange('file:///repo/other/c.ts'), + makeChange('file:///outside/d.ts'), ]; - const result = filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/primary', 'file:///repo/other']); + const result = filterChangesToWorkingDirectories(changes, ['file:///repo/primary', 'file:///repo/other']); assert.deepStrictEqual(uris(result), [ 'file:///repo/primary/src/a.ts', 'file:///repo/primary/deep/nested/b.ts', + 'file:///repo/other/c.ts', ]); }); @@ -121,19 +123,19 @@ suite('AgentHostSessionChangesets', () => { makeChange('file:///repo/other/b.ts'), ]; - assert.strictEqual(filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/primary']), changes); + assert.strictEqual(filterChangesToWorkingDirectories(changes, ['file:///repo/primary']), changes); }); test('(b) undefined working directories: returns the input list unchanged', () => { const changes = [makeChange('file:///repo/primary/a.ts')]; - assert.strictEqual(filterChangesToPrimaryWorkingDirectory(changes, undefined), changes); + assert.strictEqual(filterChangesToWorkingDirectories(changes, undefined), changes); }); test('(b) empty working directories: returns the input list unchanged', () => { const changes = [makeChange('file:///repo/primary/a.ts')]; - assert.strictEqual(filterChangesToPrimaryWorkingDirectory(changes, []), changes); + assert.strictEqual(filterChangesToWorkingDirectories(changes, []), changes); }); test('(c) boundary: a change exactly at the primary directory is kept; a sibling with a shared prefix is excluded', () => { @@ -143,7 +145,7 @@ suite('AgentHostSessionChangesets', () => { makeChange('file:///repo/primary-sibling/y.ts'), ]; - const result = filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/primary', 'file:///repo/second']); + const result = filterChangesToWorkingDirectories(changes, ['file:///repo/primary', 'file:///repo/second']); assert.deepStrictEqual(uris(result), [ 'file:///repo/primary', @@ -160,9 +162,12 @@ suite('AgentHostSessionChangesets', () => { // Guard the fixture itself: a real deletion must omit `modifiedUri`. assert.strictEqual((changes[0] as IChatSessionFileChange2).modifiedUri, undefined); - const result = filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/primary', 'file:///repo/other']); + const result = filterChangesToWorkingDirectories(changes, ['file:///repo/primary', 'file:///repo/other']); - assert.deepStrictEqual(uris(result), ['file:///repo/primary/gone.ts']); + assert.deepStrictEqual(uris(result), [ + 'file:///repo/primary/gone.ts', + 'file:///repo/other/gone.ts', + ]); }); test('compares mapped (agent-host) changes by their preserved file path', () => { @@ -175,9 +180,12 @@ suite('AgentHostSessionChangesets', () => { makeChange('agent-host://server/repo/other/b.ts'), ]; - const result = filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/primary', 'file:///repo/other']); + const result = filterChangesToWorkingDirectories(changes, ['file:///repo/primary', 'file:///repo/other']); - assert.deepStrictEqual(uris(result), ['agent-host://server/repo/primary/a.ts']); + assert.deepStrictEqual(uris(result), [ + 'agent-host://server/repo/primary/a.ts', + 'agent-host://server/repo/other/b.ts', + ]); }); test('case-differing sibling roots are not conflated (file-path case semantics)', () => { @@ -188,7 +196,7 @@ suite('AgentHostSessionChangesets', () => { if (isLinux) { const changes = [makeChange('agent-host://server/repo/app/x.ts')]; - const result = filterChangesToPrimaryWorkingDirectory(changes, ['file:///repo/App', 'file:///repo/other']); + const result = filterChangesToWorkingDirectories(changes, ['file:///repo/App', 'file:///repo/other']); assert.deepStrictEqual(uris(result), []); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index 5a22bad27932b..555ef180e8be2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -29,6 +29,7 @@ function makeActiveSession(providerId: string): IActiveSession { const chat = { resource: URI.parse('file:///session'), createdAt: new Date(), + workspace: constObservable(undefined), title: observableValue('t', 'Test'), updatedAt: observableValue('u', new Date()), status: observableValue('s', 0), diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 2c9e58bfc4a5a..dd6d2c40ac04b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -21,6 +21,7 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSes import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; +import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; @@ -428,7 +429,7 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; status?: ProtocolSessionStatus; activity?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; status?: ProtocolSessionStatus; activity?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; workingDirectories?: readonly URI[]; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { let _meta = opts?._meta; _meta = opts?.quickChat ? withSessionWorkspaceless(_meta, true) : _meta; _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); @@ -443,7 +444,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; status: opts?.status, activity: opts?.activity, project: opts?.project, - workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, + workingDirectories: opts?.workingDirectories ? [...opts.workingDirectories] : (opts?.workingDirectory ? [opts.workingDirectory] : undefined), _meta, }; } @@ -602,7 +603,7 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; workingDirectories?: readonly string[]; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -616,7 +617,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, - workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined, + workingDirectories: opts?.workingDirectories ? [...opts.workingDirectories] : (opts?.workingDirectory ? [opts.workingDirectory] : undefined), changes: opts?.changes, ...(opts?.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }, @@ -5744,8 +5745,8 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Multi-chat catalog (applyChatCatalog reconciliation) ---------------- suite('multi-chat catalog', () => { - function makeChatSummary(resource: string, title: string, status = ProtocolSessionStatus.Idle): ChatSummary { - return { resource, title, status, modifiedAt: new Date(0).toISOString() }; + function makeChatSummary(resource: string, title: string, status = ProtocolSessionStatus.Idle, workingDirectories?: readonly string[]): ChatSummary { + return { resource, title, status, modifiedAt: new Date(0).toISOString(), workingDirectories: workingDirectories ? [...workingDirectories] : undefined }; } function makeState(chats: ChatSummary[], opts?: { sessionTitle?: string; defaultChat?: string }): SessionState { @@ -5760,12 +5761,12 @@ suite('LocalAgentHostSessionsProvider', () => { }; } - function setupMultiChatSession(provider: ReturnType, rawId: string): ISession { + function setupMultiChatSession(provider: ReturnType, rawId: string, workingDirectories?: readonly URI[]): ISession { // Registered with the host as well as announced: `getSessions` starts a refresh, and an // authoritative empty list would evict the adapter the notification just created — // leaving later writes landing on an instance nothing reads. - agentHost.addSession(createSession(rawId, { summary: 'Session' })); - fireSessionAdded(agentHost, rawId, { title: 'Session' }); + agentHost.addSession(createSession(rawId, { summary: 'Session', workingDirectories })); + fireSessionAdded(agentHost, rawId, { title: 'Session', workingDirectories: workingDirectories?.map(uri => uri.toString()) }); const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === rawId); assert.ok(session); // Force a session-state subscription so pushed states reach the adapter. @@ -5798,6 +5799,97 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('chat workspaces expose explicit subsets and inherit the full session workspace', () => { + const provider = createProvider(disposables, agentHost); + const primaryDirectory = URI.file('/workspace-primary'); + const peerDirectory = URI.file('/workspace-peer'); + const session = setupMultiChatSession(provider, 'multi-workspaces', [primaryDirectory, peerDirectory]); + const sessionUri = AgentSession.uri('copilotcli', 'multi-workspaces').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const inheritedPeerChat = buildChatUri(sessionUri, 'peer-2'); + + agentHost.setSessionState('multi-workspaces', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle, [primaryDirectory.toString()]), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), + makeChatSummary(inheritedPeerChat, 'Inherited Peer'), + ], { defaultChat })); + + assert.deepStrictEqual({ + session: session.workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), + defaultChat: session.mainChat.get().workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), + peerChat: session.chats.get().find(chat => chat.resource.fragment === 'peer-1')?.workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), + inheritedPeerChat: session.chats.get().find(chat => chat.resource.fragment === 'peer-2')?.workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), + }, { + session: [primaryDirectory.toString(), peerDirectory.toString()], + defaultChat: [primaryDirectory.toString()], + peerChat: [peerDirectory.toString()], + inheritedPeerChat: [primaryDirectory.toString(), peerDirectory.toString()], + }); + }); + + test('active peer chat exposes branch changes from its workspace only', () => { + const primaryDirectory = URI.file('/workspace-primary'); + const peerDirectory = URI.file('/workspace-peer'); + const activeSession = observableValue('test.activeSession', undefined); + const provider = createProvider(disposables, agentHost, undefined, { activeSession }); + const session = setupMultiChatSession(provider, 'multi-chat-changesets', [primaryDirectory, peerDirectory]); + const sessionUri = AgentSession.uri('copilotcli', 'multi-chat-changesets').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const branchChangeset = { + label: 'Branch Changes', + uriTemplate: `${sessionUri}/changeset/branch`, + changeKind: 'branch', + capabilities: { review: {} }, + }; + + agentHost.setSessionState('multi-chat-changesets', 'copilotcli', { + ...makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle, [primaryDirectory.toString()]), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), + ], { defaultChat }), + workingDirectories: [primaryDirectory.toString(), peerDirectory.toString()], + changesets: [branchChangeset], + }); + const peer = session.chats.get().find(chat => chat.resource.fragment === 'peer-1'); + assert.ok(peer); + activeSession.set({ + resource: session.resource, + sessionId: session.sessionId, + activeChat: constObservable(peer), + } as IActiveSession, undefined); + agentHost.setChatState(peerChat, { + ...makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), + changesets: [branchChangeset], + turns: [], + }); + agentHost.setChangesetState(branchChangeset.uriTemplate, { + status: ChangesetStatus.Ready, + files: [primaryDirectory, peerDirectory].map(directory => { + const resource = URI.joinPath(directory, 'changed.ts'); + return { + id: resource.toString(), + edit: { + after: { uri: resource.toString(), content: { uri: resource.toString() } }, + diff: { added: 1, removed: 0 }, + }, + }; + }), + }); + + const branch = peer.changesets?.get()?.find(changeset => changeset.id === ChangesetKind.Branch); + assert.deepStrictEqual({ + changes: branch?.changes.get().map(change => isIChatSessionFileChange2(change) ? change.uri.toString() : change.modifiedUri.toString()), + operations: branch?.operations.get(), + review: branch?.capabilities?.review, + }, { + changes: [URI.joinPath(peerDirectory, 'changed.ts').toString()], + operations: [], + review: false, + }); + }); + test('equivalent chat catalogs do not notify chat observers', () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-stable'); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index bf428463def53..24192e452ec69 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -212,6 +212,7 @@ function buildChatFromSession(chat: Omit): ICha return { resource: chat.resource, createdAt: chat.createdAt, + workspace: chat.workspace, title: chat.title, updatedAt: chat.updatedAt, status: chat.status, @@ -3818,6 +3819,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return { resource: resource ?? chat.resource, createdAt: chat.createdAt, + workspace: chat.workspace, title: chat.title, updatedAt: chat.updatedAt, status: chat.status, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts index 89eae92571adf..47d69725e9731 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts @@ -105,6 +105,7 @@ class TestTelemetryService extends NullTelemetryServiceShape { const chat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(SessionStatus.Completed), diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index a2ca55ad79d69..4c888d4f02286 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -83,6 +83,7 @@ function makeAgentSession(opts: { const chat = { resource: URI.parse('file:///session'), createdAt: new Date(), + workspace: constObservable(undefined), title: observableValue('test.title', 'Test Session'), updatedAt: observableValue('test.updatedAt', new Date()), status: observableValue('test.status', 0), @@ -154,6 +155,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT const chat: IChat = { resource: URI.parse('file:///session'), createdAt: new Date(), + workspace: constObservable(undefined), title: observableValue('test.title', 'Test Session'), updatedAt: observableValue('test.updatedAt', new Date()), status: observableValue('test.status', 0), diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 5c2f00e486663..9e990ae2ea757 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -15,7 +15,7 @@ import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { Queue } from '../../../../base/common/async.js'; -import { ISession } from '../../../services/sessions/common/session.js'; +import { ISessionWorkspace } from '../../../services/sessions/common/session.js'; import { IWorkspaceFolderLabelService } from '../../../../workbench/services/workspaces/common/workspaceFolderLabelService.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { @@ -34,68 +34,67 @@ export class WorkspaceFolderManagementContribution extends Disposable implements super(); this._register(autorun(reader => { const activeSession = this.sessionsService.activeSession.read(reader); - activeSession?.workspace.read(reader); - this.queue.queue(() => this.updateWorkspaceFoldersForSession(activeSession)); + const activeChat = activeSession?.activeChat.read(reader); + const workspace = activeChat?.workspace.read(reader); + this.queue.queue(() => this.updateWorkspaceFolders(workspace)); })); } - private async updateWorkspaceFoldersForSession(session: ISession | undefined): Promise { + private async updateWorkspaceFolders(workspace: ISessionWorkspace | undefined): Promise { // Auto-trust an isolated worktree VS Code created off a trusted repo, so a // worktree session mounts without tripping the untrusted-folder backstop. - await ensureSessionWorktreesTrusted(session?.workspace.get(), this.workspaceTrustManagementService); - const activeSessionFolderData = this.getActiveSessionFolderData(session); - const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; + await ensureSessionWorktreesTrusted(workspace, this.workspaceTrustManagementService); + const activeSessionFolders = this.getWorkspaceFolderData(workspace); + const currentFolders = this.workspaceContextService.getWorkspace().folders; - // Never mount an untrusted folder: mounting it would flip the whole Agents + // Never mount untrusted folders: mounting one would flip the whole Agents // Window into Restricted Mode. Sessions opened from the list are already // gated on trust (see `ISessionsService.canOpenSession`); this backstop - // keeps paths that bypass that gate (e.g. startup restore) safe too by - // leaving the folder unmounted rather than mounting it untrusted. - if (activeSessionFolderData && !await this.isFolderMountable(session, activeSessionFolderData.uri)) { - if (currentRepo) { - await this.workspaceEditingService.removeFolders([currentRepo], true); + // keeps paths that bypass that gate (e.g. startup restore) safe too. + const mountable = await Promise.all(activeSessionFolders.map(folder => this.isFolderMountable(workspace, folder.uri))); + if (mountable.some(isMountable => !isMountable)) { + if (currentFolders.length > 0) { + await this.workspaceEditingService.removeFolders(currentFolders.map(folder => folder.uri), true); } return; } - if (!activeSessionFolderData) { - if (currentRepo) { - await this.workspaceEditingService.removeFolders([currentRepo], true); + if (activeSessionFolders.length === 0) { + if (currentFolders.length > 0) { + await this.workspaceEditingService.removeFolders(currentFolders.map(folder => folder.uri), true); } return; } - if (!currentRepo) { - await this.workspaceEditingService.addFolders([activeSessionFolderData], true); + if (currentFolders.length === 0) { + await this.workspaceEditingService.addFolders(activeSessionFolders, true); return; } - if (this.uriIdentityService.extUri.isEqual(currentRepo, activeSessionFolderData.uri)) { + const foldersMatch = currentFolders.length === activeSessionFolders.length + && currentFolders.every((folder, index) => this.uriIdentityService.extUri.isEqual(folder.uri, activeSessionFolders[index].uri)); + if (foldersMatch) { return; } - await this.workspaceEditingService.updateFolders(0, 1, [activeSessionFolderData], true); + await this.workspaceEditingService.updateFolders(0, currentFolders.length, activeSessionFolders, true); } - private getActiveSessionFolderData(session: ISession | undefined): IWorkspaceFolderCreationData | undefined { - if (!session) { - return undefined; + private getWorkspaceFolderData(workspace: ISessionWorkspace | undefined): IWorkspaceFolderCreationData[] { + if (!workspace) { + return []; } - const workspace = session.workspace.get(); - const folder = workspace?.folders[0]; - - if (!folder) { - return undefined; - } - - return { - uri: folder.workingDirectory, - name: this.workspaceFolderLabelService.getWorkspaceFolderLabel( - new WorkspaceFolder({ uri: folder.workingDirectory, name: workspace.label, index: 0 }), - true - ) ?? workspace.label - }; + return workspace.folders.map((folder, index) => { + const name = index === 0 ? workspace.label : folder.name; + return { + uri: folder.workingDirectory, + name: this.workspaceFolderLabelService.getWorkspaceFolderLabel( + new WorkspaceFolder({ uri: folder.workingDirectory, name, index }), + true + ) ?? name + }; + }); } /** @@ -104,8 +103,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements * left unmounted so the window never enters Restricted Mode behind the user's * back. Sessions that don't require trust (e.g. virtual/cloud) always mount. */ - private async isFolderMountable(session: ISession | undefined, uri: URI): Promise { - const workspace = session?.workspace.get(); + private async isFolderMountable(workspace: ISessionWorkspace | undefined, uri: URI): Promise { if (!workspace?.requiresWorkspaceTrust) { return true; } diff --git a/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts b/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts index b80d492e3e2b3..efaa943d84341 100644 --- a/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts +++ b/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts @@ -24,6 +24,7 @@ import { WorkspaceFolderManagementContribution } from '../../browser/workspaceFo const stubChat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(0), @@ -61,18 +62,21 @@ function worktreeFolder(repoPath: string, worktreePath: string): ISessionFolder return { root, workingDirectory, name: worktreePath, description: undefined, gitRepository }; } -function makeWorkspace(folder: ISessionFolder, requiresWorkspaceTrust: boolean): ISessionWorkspace { +function makeWorkspace(folderOrFolders: ISessionFolder | readonly ISessionFolder[], requiresWorkspaceTrust: boolean): ISessionWorkspace { + const folders = Array.isArray(folderOrFolders) ? folderOrFolders : [folderOrFolders]; + const folder = folders[0]; return { uri: folder.root, label: folder.name, icon: Codicon.folder, - folders: [folder], + folders, requiresWorkspaceTrust, isVirtualWorkspace: false, }; } -function makeActiveSession(sessionId: string, workspace: ISessionWorkspace | undefined): IActiveSession { +function makeActiveSession(sessionId: string, workspace: ISessionWorkspace | undefined, chatWorkspace = workspace): IActiveSession { + const activeChat: IChat = { ...stubChat, workspace: constObservable(chatWorkspace) }; return { resource: URI.parse(`test:///${sessionId}`), sessionId, @@ -94,9 +98,9 @@ function makeActiveSession(sessionId: string, workspace: ISessionWorkspace | und description: constObservable(undefined), lastTurnEnd: constObservable(undefined), chats: constObservable([]), - mainChat: constObservable(stubChat), + mainChat: constObservable(activeChat), capabilities: constObservable({ supportsMultipleChats: false }), - activeChat: constObservable(stubChat), + activeChat: constObservable(activeChat), isCreated: constObservable(true), sticky: constObservable(false), openChats: constObservable([]), @@ -211,6 +215,81 @@ suite('WorkspaceFolderManagementContribution', () => { }); }); + test('mounts every folder from a multi-folder session workspace', async () => { + const { activeSession, workspaceEditing } = createContribution(); + const primary = localFolder('/repo-primary'); + const secondary = localFolder('/repo-secondary'); + + activeSession.set(makeActiveSession('a', makeWorkspace([primary, secondary], false)), undefined); + await settle(); + + assert.deepStrictEqual( + workspaceEditing.addFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + [[primary.workingDirectory.toString(), secondary.workingDirectory.toString()]], + ); + }); + + test('mounts only the active chat workspace subset', async () => { + const { activeSession, workspaceEditing } = createContribution(); + const primary = localFolder('/repo-primary'); + const secondary = localFolder('/repo-secondary'); + + activeSession.set(makeActiveSession( + 'a', + makeWorkspace([primary, secondary], false), + makeWorkspace(secondary, false), + ), undefined); + await settle(); + + assert.deepStrictEqual( + workspaceEditing.addFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + [[secondary.workingDirectory.toString()]], + ); + }); + + test('replaces workspace folders when the active chat changes', async () => { + const { activeSession, workspaceEditing } = createContribution(); + const primary = localFolder('/repo-primary'); + const secondary = localFolder('/repo-secondary'); + const firstChat: IChat = { ...stubChat, resource: URI.parse('test:///chat-first'), workspace: constObservable(makeWorkspace(primary, false)) }; + const secondChat: IChat = { ...stubChat, resource: URI.parse('test:///chat-second'), workspace: constObservable(makeWorkspace(secondary, false)) }; + const activeChat = observableValue('activeChat', firstChat); + + activeSession.set({ ...makeActiveSession('a', makeWorkspace([primary, secondary], false)), activeChat }, undefined); + await settle(); + activeChat.set(secondChat, undefined); + await settle(); + + assert.deepStrictEqual({ + added: workspaceEditing.addFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + updated: workspaceEditing.updateFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + }, { + added: [[primary.workingDirectory.toString()]], + updated: [[secondary.workingDirectory.toString()]], + }); + }); + + test('replaces the complete workspace folder set when switching sessions', async () => { + const { activeSession, workspaceEditing } = createContribution(); + const firstPrimary = localFolder('/repo-a'); + const firstSecondary = localFolder('/repo-b'); + const secondPrimary = localFolder('/repo-c'); + const secondSecondary = localFolder('/repo-d'); + + activeSession.set(makeActiveSession('a', makeWorkspace([firstPrimary, firstSecondary], false)), undefined); + await settle(); + activeSession.set(makeActiveSession('b', makeWorkspace([secondPrimary, secondSecondary], false)), undefined); + await settle(); + + assert.deepStrictEqual({ + added: workspaceEditing.addFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + updated: workspaceEditing.updateFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + }, { + added: [[firstPrimary.workingDirectory.toString(), firstSecondary.workingDirectory.toString()]], + updated: [[secondPrimary.workingDirectory.toString(), secondSecondary.workingDirectory.toString()]], + }); + }); + test('mounts an already-trusted folder without granting trust', async () => { const { activeSession, workspaceEditing, workspaceTrust } = createContribution(); const folder = localFolder('/repo-trusted'); @@ -228,6 +307,26 @@ suite('WorkspaceFolderManagementContribution', () => { }); }); + test('does not partially mount a multi-folder session when one folder is untrusted', async () => { + const { activeSession, workspaceEditing, workspaceTrust } = createContribution(); + const primary = localFolder('/repo-trusted'); + const secondary = localFolder('/repo-untrusted'); + workspaceTrust.trust(primary.workingDirectory); + + activeSession.set(makeActiveSession('a', makeWorkspace([primary, secondary], true)), undefined); + await settle(); + + assert.deepStrictEqual({ + added: workspaceEditing.addFoldersCalls.length, + updated: workspaceEditing.updateFoldersCalls.length, + granted: workspaceTrust.setUrisTrustCalls, + }, { + added: 0, + updated: 0, + granted: [], + }); + }); + test('does not mount an untrusted folder and never silently grants trust', async () => { const { activeSession, workspaceEditing, workspaceTrust } = createContribution(); const folder = localFolder('/repo-untrusted'); diff --git a/src/vs/sessions/electron-browser/actions/vscodeActions.ts b/src/vs/sessions/electron-browser/actions/vscodeActions.ts index 188a45b48de52..db8a3be3a756a 100644 --- a/src/vs/sessions/electron-browser/actions/vscodeActions.ts +++ b/src/vs/sessions/electron-browser/actions/vscodeActions.ts @@ -59,29 +59,25 @@ export class OpenSessionInVSCodeAction extends Action2 { const remoteAgentHostService = accessor.get(IRemoteAgentHostService); const nativeHostService = accessor.get(INativeHostService); - const folderUri = this.getFolderUriToOpen(sessionsService, sessionsProvidersService, remoteAgentHostService); - if (!folderUri) { - return nativeHostService.openWindow(); - } - - const chatSessionToOpen = getChatSessionToOpenInEditor(sessionsService.activeSession.get()); - return nativeHostService.openWindow([{ folderUri }], { forceNewWindow: true, chatSessionToOpen }); + return openSessionInVSCode(nativeHostService, sessionsService.activeSession.get(), sessionsProvidersService, remoteAgentHostService); } +} - private getFolderUriToOpen(sessionsService: ISessionsService, sessionsProvidersService: ISessionsProvidersService, remoteAgentHostService: IRemoteAgentHostService): URI | undefined { - const activeSession = sessionsService.activeSession.get(); - if (!activeSession) { - return undefined; - } - - const workspace = activeSession.workspace.get(); - const rawFolderUri = workspace?.folders[0]?.workingDirectory; - if (!rawFolderUri) { - return undefined; - } - - return resolveRemoteFolderUri(rawFolderUri, activeSession.providerId, sessionsProvidersService, remoteAgentHostService); +export async function openSessionInVSCode( + nativeHostService: INativeHostService, + session: IActiveSession | undefined, + sessionsProvidersService: ISessionsProvidersService, + remoteAgentHostService: IRemoteAgentHostService, +): Promise { + const folderUris = session?.workspace.get()?.folders.map(folder => + resolveRemoteFolderUri(folder.workingDirectory, session.providerId, sessionsProvidersService, remoteAgentHostService) + ); + if (!folderUris?.length) { + return nativeHostService.openWindow(); } + + const chatSessionToOpen = getChatSessionToOpenInEditor(session); + return nativeHostService.openWindow(folderUris.map(folderUri => ({ folderUri })), { forceNewWindow: true, chatSessionToOpen }); } /** diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 98f4d6b8c6ee7..2c251670afff4 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -649,6 +649,8 @@ export interface IChat { // Reactive properties + /** The effective workspace available to this chat. */ + readonly workspace: IObservable; /** Chat display title (changes when auto-titled or renamed). */ readonly title: IObservable; /** When the chat was last updated. */ @@ -657,6 +659,8 @@ export interface IChat { readonly status: IObservable; /** File changes produced by the chat. */ readonly changes: IObservable; + /** Changesets exposed for this chat. Providers that do not support chat-scoped changesets may omit this. */ + readonly changesets?: IObservable; /** * File changes produced by the chat's **last turn** only (as opposed to the * cumulative chat {@link changes}). Derived from the chat's live output diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index e9842a4fcde68..3cf40416056c1 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -22,6 +22,7 @@ import { ISendRequestOptions } from '../../common/sessionsProvider.js'; const stubChat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(SessionStatus.Completed), @@ -41,6 +42,7 @@ function stubChatWithId(id: string, status: SessionStatus = SessionStatus.Comple return { resource: URI.parse(`test:///chat-${id}`), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable(`Chat ${id}`), updatedAt: constObservable(new Date()), status: constObservable(status), diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 7fc92128a291c..a3d0c5fd302a1 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -54,6 +54,7 @@ import type { SessionView } from '../../../../browser/parts/sessionView.js'; const stubChat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(0), diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 2a89a9d2f08a0..a0fa8358347b0 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -17,6 +17,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionRemoteConnec const stubChat: IChat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(0), diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index 1820760360bbf..52bf107dc3ef4 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -37,6 +37,7 @@ function createSession(hasGitRepository: ISettableObservable): ISession const stubChat: IChat = { resource: URI.parse('test:///chat'), createdAt: new Date(), + workspace: constObservable(undefined), title: constObservable('Chat'), updatedAt: constObservable(new Date()), status: constObservable(0), diff --git a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts index 10267fdeb4334..5ee6916aeb7e1 100644 --- a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts +++ b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts @@ -7,11 +7,14 @@ import assert from 'assert'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; -import { IOpenedMainWindow } from '../../../platform/window/common/window.js'; +import { IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IWindowOpenable, isFolderToOpen } from '../../../platform/window/common/window.js'; import { constObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import { getChatSessionToOpenInEditor, returnToVSCodeEditor, shouldShowReturnToVSCodeEditor } from '../../electron-browser/actions/vscodeActions.js'; +import { getChatSessionToOpenInEditor, openSessionInVSCode, returnToVSCodeEditor, shouldShowReturnToVSCodeEditor } from '../../electron-browser/actions/vscodeActions.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; +import { IRemoteAgentHostService } from '../../../platform/agentHost/common/remoteAgentHostService.js'; +import { Codicon } from '../../../base/common/codicons.js'; suite('VS Code Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -63,6 +66,51 @@ suite('VS Code Actions', () => { missing: undefined, }); }); + + test('opens the complete session workspace in one editor window', async () => { + const calls: { folders?: string[]; forceNewWindow?: boolean; chatSessionToOpen?: string }[] = []; + const nativeHostService = new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { + calls.push({ + folders: Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => openable.folderUri.toString()) : undefined, + forceNewWindow: options?.forceNewWindow, + chatSessionToOpen: options?.chatSessionToOpen?.toString(), + }); + } + }(); + const session = createSession('multi-folder', true, [URI.file('/repo-a'), URI.file('/repo-b')]); + + await openSessionInVSCode( + nativeHostService, + session, + new class extends mock() { }(), + new class extends mock() { }(), + ); + + assert.deepStrictEqual(calls, [{ + folders: ['file:///repo-a', 'file:///repo-b'], + forceNewWindow: true, + chatSessionToOpen: 'test:/multi-folder', + }]); + }); + + test('opens an empty editor window when the session has no workspace', async () => { + const calls: { empty: boolean }[] = []; + const nativeHostService = new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[]): Promise { + calls.push({ empty: !Array.isArray(toOpen) }); + } + }(); + + await openSessionInVSCode( + nativeHostService, + undefined, + new class extends mock() { }(), + new class extends mock() { }(), + ); + + assert.deepStrictEqual(calls, [{ empty: true }]); + }); }); function createWindow(id: number): IOpenedMainWindow { @@ -73,9 +121,23 @@ function createWindow(id: number): IOpenedMainWindow { }; } -function createSession(id: string, isCreated: boolean): IActiveSession { +function createSession(id: string, isCreated: boolean, folders?: URI[]): IActiveSession { return new class extends mock() { override readonly resource = URI.from({ scheme: 'test', path: `/${id}` }); + override readonly providerId = 'test'; override readonly isCreated = constObservable(isCreated); + override readonly workspace = constObservable(folders ? { + uri: URI.from({ scheme: 'test', path: `/${id}/workspace` }), + label: id, + icon: Codicon.folder, + folders: folders.map((folder, index) => ({ + root: folder, + workingDirectory: folder, + name: `Repository ${index + 1}`, + description: undefined, + })), + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + } : undefined); }(); } From 06cf20a198c63d703ff64088d54b9cfb90a23e3c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 18 Sep 2026 04:33:01 +0200 Subject: [PATCH 2/4] sessions: support multi-repository chats Keep chat workspaces, changesets, artifacts, pull requests, and Agent Merge operations scoped to their owning repository and worktree while preserving a session-wide aggregate view. Add explicit fresh-worktree creation, relationship experiment gating, multi-PR presentation, and compact multi-project folder labels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChangesetOperationService.ts | 10 + .../common/agentHostGitStateService.ts | 3 + .../agentHost/common/agentHostSchema.ts | 9 + .../agentHostStarter.config.contribution.ts | 11 + .../platform/agentHost/common/agentMerge.ts | 13 + .../platform/agentHost/common/agentService.ts | 3 + .../platform/agentHost/common/changesetUri.ts | 11 +- .../common/sessionArtifactCollection.ts | 7 +- .../agentHost/common/sessionArtifacts.ts | 4 + .../agentHost/common/worktreePaths.ts | 16 + .../node/agentConfigurationService.ts | 27 +- .../node/agentHostCatalogProjection.ts | 1 + .../node/agentHostChangesetCoordinator.ts | 72 +++-- .../agentHostChangesetOperationService.ts | 67 ++++- .../node/agentHostChangesetService.ts | 160 +++++++++- .../node/agentHostCheckoutOperationHandler.ts | 8 +- .../node/agentHostCommitOperationHandler.ts | 18 +- ...agentHostDiscardChangesOperationHandler.ts | 13 +- .../node/agentHostGitStateService.ts | 62 ++++ .../node/agentHostMergeOperationHandler.ts | 27 +- .../agentHost/node/agentHostPeerChatStore.ts | 63 +++- ...ostPullRequestLifecycleOperationHandler.ts | 7 +- .../agentHostPullRequestOperationHandler.ts | 73 +++-- .../agentHostPullRequestOperationProvider.ts | 15 +- .../agentHost/node/agentHostStateManager.ts | 41 ++- .../node/agentHostSyncOperationHandler.ts | 16 +- .../agentHost/node/agentMergeController.ts | 166 ++++++++--- .../agentHost/node/agentMergeTools.ts | 77 ++++- .../platform/agentHost/node/agentService.ts | 207 +++++++++++-- .../agentHost/node/agentServiceComposition.ts | 9 +- .../agentHost/node/agentServiceFoundation.ts | 2 +- .../agentHost/node/agentSideEffects.ts | 4 + .../node/shared/agentMergeServerTools.ts | 4 +- .../node/shared/artifactServerTools.ts | 22 +- .../agentHost/node/shared/serverToolGroups.ts | 4 +- .../node/shared/sessionAdditionalWorktrees.ts | 69 +++++ .../node/shared/sessionServerTools.ts | 73 +++-- .../node/shared/worktreeIsolation.ts | 30 +- .../test/common/sessionArtifacts.test.ts | 25 +- .../test/common/sessionTestHelpers.ts | 1 + .../node/agentConfigurationService.test.ts | 21 +- .../node/agentHostCatalogProjection.test.ts | 21 ++ .../agentHostChangesetCoordinator.test.ts | 97 +++++- ...agentHostChangesetOperationService.test.ts | 136 ++++++++- .../node/agentHostChangesetService.test.ts | 146 ++++++++- .../test/node/agentHostContributions.test.ts | 2 + ...HostDiscardChangesOperationHandler.test.ts | 31 +- .../node/agentHostGitStateService.test.ts | 42 +++ .../agentHostMergeOperationProvider.test.ts | 1 + .../test/node/agentHostPeerChatStore.test.ts | 6 +- ...entHostPullRequestOperationHandler.test.ts | 135 ++++++++- ...ntHostPullRequestOperationProvider.test.ts | 46 ++- .../test/node/agentHostStateManager.test.ts | 21 +- .../test/node/agentMergeCITools.test.ts | 11 +- .../test/node/agentMergeController.test.ts | 171 ++++++++++- .../test/node/agentMergeRerun.test.ts | 16 +- .../test/node/agentMergeServerTools.test.ts | 2 + .../agentHost/test/node/agentService.test.ts | 280 ++++++++++++++++-- .../test/node/agentSideEffects.test.ts | 1 + .../test/node/artifactServerTools.test.ts | 49 ++- .../test/node/chatContributions.test.ts | 1 + .../test/node/sessionServerTools.test.ts | 122 +++++++- .../node/shared/worktreeIsolation.test.ts | 7 +- src/vs/sessions/SESSIONS.md | 8 +- .../common/agentHostSessionWorkspace.ts | 19 +- .../contrib/chat/browser/sessionArtifacts.ts | 26 +- .../chat/browser/sessionChatInputToolbar.ts | 70 ++++- .../test/browser/sessionArtifacts.test.ts | 60 +++- .../browser/sessionChatInputToolbar.test.ts | 120 +++++++- .../github/browser/pullRequestActions.ts | 66 ++++- .../test/browser/pullRequestActions.test.ts | 77 ++++- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 12 +- .../browser/agentHostSessionArtifacts.ts | 18 +- .../browser/agentHostSessionChangesets.ts | 15 +- .../browser/baseAgentHostSessionsProvider.ts | 142 +++++---- .../agentHostSessionChangesets.test.ts | 38 ++- .../localAgentHostSessionsProvider.test.ts | 151 +++++++++- .../sessions/browser/media/sessionsList.css | 10 + .../sessions/browser/sessionsActions.ts | 5 +- .../sessions/browser/views/sessionsList.ts | 65 +++- .../test/browser/sessionsList.test.ts | 104 ++++++- .../browser/workspaceFolderManagement.test.ts | 26 ++ .../sessions/browser/visibleSessions.ts | 2 + .../services/sessions/common/session.ts | 32 ++ .../sessions/common/sessionContextKeys.ts | 5 +- .../sessions/test/common/session.test.ts | 79 ++++- 86 files changed, 3461 insertions(+), 504 deletions(-) create mode 100644 src/vs/platform/agentHost/node/shared/sessionAdditionalWorktrees.ts diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index a8920cdf613a2..78a200a5c9de1 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -87,6 +87,14 @@ export interface IChangesetOperationContext { readonly gitHubState?: ISessionGitHubState; } +/** Host-owned repository context for one concrete changeset channel. */ +export interface IChangesetOperationTargetContext { + readonly ownerUri: URI; + readonly workingDirectories: readonly URI[]; + readonly gitState?: ISessionGitState; + readonly gitHubState?: ISessionGitHubState; +} + /** * Registration surface handed to changeset operation contributions. * @@ -142,6 +150,8 @@ export interface IAgentHostChangesetOperationService extends IDisposable { * unregisters the handlers and disposes the contribution. */ registerContribution(contribution: IChangesetOperationContribution): IDisposable; + /** Associates a concrete changeset channel with its trusted repository context. */ + setChangesetTarget(sessionKey: string, changeset: string, target: IChangesetOperationTargetContext | undefined): void; /** * Recomputes operations using the provided or current Git state. * Without Git state, clears cached operations but defers initial publication. diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index 5c95c12377f07..62c5bf3f988d6 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -38,6 +38,9 @@ export interface IAgentHostGitStateService { */ refreshSessionGitState(sessionKey: string, workingDirectory?: URI): Promise; + /** Finds the pull request for a working directory without changing session-level Git metadata. */ + findPullRequestForWorkingDirectory(sessionKey: string, workingDirectory: URI): Promise; + /** Merges the branch identity known when an isolated worktree materializes into session metadata. */ getMaterializedWorktreeMeta(sessionKey: string, branchName: string): SessionSummaryMeta | undefined; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 2df1b4c6f8e9d..095246a70d32c 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -551,6 +551,9 @@ export const agentHostProxyConfigSchema = createSchema(agentHostProxyConfigDefin /** Root config key forwarded from the renderer for active-agent title generation. */ export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGeneration'; +/** Root config key controlling whether create_session exposes its relationship choice. */ +export const AgentHostCreateSessionRelationshipConfigKey = 'createSessionRelationship'; + /** Root config key controlling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled'; @@ -881,6 +884,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.activeAgentTitleGeneration.description', "Whether the active agent names sessions and chats with rename tools instead of utility-model title generation."), default: false, }), + [AgentHostCreateSessionRelationshipConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.createSessionRelationship.title', "Create Session Relationship"), + description: localize('agentHost.config.createSessionRelationship.description', "Whether agents can choose between creating a chat in the current session and creating an independent session. When disabled, delegated work is always created as a chat in the current session."), + default: true, + }), [AgentHostMarkdownPlanRichLinksEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.markdownPlanRichLinks.title', "Markdown Plan Rich Links"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 495e2abd6282c..14032e4d12a36 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -15,6 +15,7 @@ import { AgentHostByokModelsEnabledSettingId, AgentHostGitHubMcpServerEnabledSettingId, AgentHostActiveAgentTitleGenerationSettingId, + AgentHostCreateSessionRelationshipSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, @@ -40,6 +41,7 @@ import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostByokModelsEnabledConfigKey, + AgentHostCreateSessionRelationshipConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, @@ -191,6 +193,15 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto' }, agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey }, }, + [AgentHostCreateSessionRelationshipSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.experimental.createSessionRelationship', "Controls whether agents can choose to create delegated work in the current session or as an independent session. When disabled, delegated work is always created as a chat in the current session."), + default: true, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostCreateSessionRelationshipConfigKey }, + }, ...artifactToolsConfigurationProperties, [AgentHostAutoAttachPullRequestsSettingId]: { type: 'boolean', diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index 0508b6f1259f1..f39d8f383eb55 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -80,6 +80,12 @@ export interface AgentMergeSessionOverrides { export interface AgentMergeTarget { readonly branchName: string; readonly pullRequestUrl?: string; + /** Chat whose repository and turns Agent Merge owns. Absent on legacy targets. */ + readonly chatUri?: string; + /** Working directory Agent Merge must inspect and mutate. Absent on legacy targets. */ + readonly workingDirectory?: string; + /** Whether the controller still needs to announce this newly enabled target. */ + readonly announcementPending?: boolean; readonly enabledAt: string; readonly commentWatermark: string; } @@ -283,6 +289,10 @@ export const agentMergeDisableReasons = { log: 'the associated pull request URL is invalid', notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was disabled because the associated pull request URL is invalid."), }), + targetUnavailable: (): AgentMergeDisableReason => ({ + log: 'the owning chat or working directory is no longer available', + notice: localize('agentMerge.disabled.targetUnavailable', "Agent Merge was disabled because its chat or working directory is no longer available in this session."), + }), differentGitHubHost: (): AgentMergeDisableReason => ({ log: 'the bound pull request belongs to a different GitHub host than the signed-in account', notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was disabled because its pull request belongs to a different GitHub host than the signed-in account."), @@ -597,6 +607,9 @@ function readTarget(value: unknown): AgentMergeTarget | undefined { enabledAt: value.enabledAt, commentWatermark: value.commentWatermark, ...(typeof value.pullRequestUrl === 'string' ? { pullRequestUrl: value.pullRequestUrl } : {}), + ...(typeof value.chatUri === 'string' ? { chatUri: value.chatUri } : {}), + ...(typeof value.workingDirectory === 'string' ? { workingDirectory: value.workingDirectory } : {}), + ...(value.announcementPending === true ? { announcementPending: true } : {}), }; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 898120e3592a4..699c4d2d983b1 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -115,6 +115,9 @@ export const AgentHostGitHubMcpServerEnabledSettingId = 'chat.agentHost.githubMc /** Configuration key gating active-agent session and chat title generation. */ export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.experimental.activeAgentTitleGeneration'; +/** Configuration key controlling whether agents choose how delegated work relates to the current session. */ +export const AgentHostCreateSessionRelationshipSettingId = 'chat.agentHost.experimental.createSessionRelationship'; + /** Configuration key enabling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledSettingId = 'chat.agentHost.experimental.markdownPlanRichLinks'; diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 4eb642465a9ff..59e8ad061edff 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -7,7 +7,7 @@ import { localize } from '../../../nls.js'; import { readAgentMergeSessionState } from './agentMerge.js'; import { isAgentMergeMessage } from './meta/agentMergeMessageMeta.js'; import { AgentSystemNotificationKind, readAgentSystemNotificationMeta } from './meta/agentSystemNotificationMeta.js'; -import { MessageKind, readSessionGitState, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js'; +import { isAhpChatChannel, MessageKind, parseDefaultChatUri, readSessionGitState, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, type Changeset, type ISessionGitState, type ISessionWithDefaultChat, type URI } from './state/sessionState.js'; /** * Helpers for building / parsing the URI clients subscribe to in order to @@ -282,6 +282,15 @@ export function parseChangesetUri(uri: URI): { sessionUri: URI; changesetId: str return { sessionUri, changesetId, kind: ChangesetKind.Unknown }; } +/** Resolves the parent session of either a session- or chat-owned changeset URI. */ +export function getChangesetSessionUri(uri: URI): URI | undefined { + const owner = parseChangesetUri(uri)?.sessionUri; + if (!owner) { + return undefined; + } + return isAhpChatChannel(owner) ? parseDefaultChatUri(owner) : owner; +} + /** Returns `true` iff `uri` looks like a changeset URI we recognise. */ export function isChangesetUri(uri: URI): boolean { return parseChangesetUri(uri) !== undefined; diff --git a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts index d264393e95410..24a9cbbe86102 100644 --- a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts +++ b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts @@ -12,6 +12,8 @@ export interface ISessionArtifactInput { readonly label: string; /** `true` for an artifact the session produced, `false` for a reference. */ readonly isArtifact: boolean; + /** Host-owned chat attribution. Model-provided inputs never populate this field. */ + readonly chat?: string; readonly link?: string; readonly uri?: string; readonly commitHash?: string; @@ -153,7 +155,8 @@ export class SessionArtifactCollection { add(input: ISessionArtifactInput, createId: () => string): IAddSessionArtifactResult { const artifact = this._create(input, createId); const value = getSessionArtifactValue(artifact); - const existing = this._artifacts.find(candidate => getSessionArtifactValue(candidate) === value); + const existing = this._artifacts.find(candidate => + candidate.chat === artifact.chat && getSessionArtifactValue(candidate) === value); if (existing) { return { artifacts: this._artifacts, artifact: existing, added: false }; } @@ -187,6 +190,7 @@ export class SessionArtifactCollection { id: string; type: SessionArtifactType; label: string; + chat?: string; isArtifact: boolean; link?: string; uri?: string; @@ -194,6 +198,7 @@ export class SessionArtifactCollection { isGitHub?: boolean; } = { id: createId(), type: input.type, label: input.label, isArtifact: input.isArtifact }; + if (input.chat !== undefined) { artifact.chat = input.chat; } if (input.link !== undefined) { artifact.link = input.link; } if (input.uri !== undefined) { artifact.uri = input.uri; } if (input.commitHash !== undefined) { artifact.commitHash = input.commitHash; } diff --git a/src/vs/platform/agentHost/common/sessionArtifacts.ts b/src/vs/platform/agentHost/common/sessionArtifacts.ts index 812c21ebef383..d722a4ef0feb5 100644 --- a/src/vs/platform/agentHost/common/sessionArtifacts.ts +++ b/src/vs/platform/agentHost/common/sessionArtifacts.ts @@ -34,6 +34,8 @@ export interface ISessionArtifact { readonly id: string; readonly type: SessionArtifactType; readonly label: string; + /** Chat that recorded this entry. Absent for session-wide entries. */ + readonly chat?: string; /** * `true` for an artifact — something this session produced — and `false` for * a reference, something it only points the user at. @@ -79,6 +81,7 @@ function parseSessionArtifact(value: unknown): ISessionArtifact | undefined { id: string; type: SessionArtifactType; label: string; + chat?: string; isArtifact: boolean; link?: string; uri?: string; @@ -95,6 +98,7 @@ function parseSessionArtifact(value: unknown): ISessionArtifact | undefined { if (typeof raw['uri'] === 'string') { artifact.uri = raw['uri']; } if (typeof raw['commitHash'] === 'string') { artifact.commitHash = raw['commitHash']; } if (typeof raw['isGitHub'] === 'boolean') { artifact.isGitHub = raw['isGitHub']; } + if (typeof raw['chat'] === 'string') { artifact.chat = raw['chat']; } return artifact; } diff --git a/src/vs/platform/agentHost/common/worktreePaths.ts b/src/vs/platform/agentHost/common/worktreePaths.ts index 4d07b36609701..7bf7b6c7b1eb1 100644 --- a/src/vs/platform/agentHost/common/worktreePaths.ts +++ b/src/vs/platform/agentHost/common/worktreePaths.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { basename } from '../../../base/common/path.js'; +import { Schemas } from '../../../base/common/network.js'; import { isEqual, isEqualOrParent, normalizePath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; @@ -37,3 +38,18 @@ export function isWorktreeUnderRepository(candidate: URI, repositoryRoot: URI): const normalizedCandidate = normalizePath(candidate); return isEqualOrParent(normalizedCandidate, worktreesRoot) && !isEqual(normalizedCandidate, worktreesRoot); } + +/** Derives the source repository from a worktree directly under its conventional `.worktrees` sibling. */ +export function deriveRepositoryRootFromWorktree(worktree: URI): URI | undefined { + if (worktree.scheme !== Schemas.file) { + return undefined; + } + const worktreesRoot = URI.joinPath(worktree, '..'); + const worktreesRootName = basename(worktreesRoot.fsPath); + const suffix = '.worktrees'; + if (!worktreesRootName.endsWith(suffix)) { + return undefined; + } + const repositoryName = worktreesRootName.slice(0, -suffix.length); + return repositoryName ? URI.joinPath(worktreesRoot, '..', repositoryName) : undefined; +} diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 4f59ef0df72c7..2cf42cba7b6af 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -27,29 +27,48 @@ import type { ISessionSandboxPolicy } from './sessionSandbox.js'; export const IAgentConfigurationService = createDecorator('agentConfigurationService'); +function getSessionWorkingDirectories(stateManager: AgentHostStateManager, session: ProtocolURI): string[] | undefined { + const summaryWorkingDirectories = stateManager.getSessionSummary(session)?.workingDirectories; + const stateWorkingDirectories = stateManager.getSessionState(session)?.workingDirectories; + if (!stateWorkingDirectories?.length) { + return summaryWorkingDirectories; + } + if (!summaryWorkingDirectories?.length) { + return stateWorkingDirectories; + } + + const workingDirectories = [...stateWorkingDirectories]; + for (const workingDirectory of summaryWorkingDirectories.slice(1)) { + if (!workingDirectories.includes(workingDirectory)) { + workingDirectories.push(workingDirectory); + } + } + return workingDirectories; +} + /** * @deprecated Use {@link getEffectiveWorkingDirectories} instead, which preserves every root instead of collapsing to the primary. */ export function getEffectiveWorkingDirectory(stateManager: AgentHostStateManager, session: ProtocolURI): string | undefined { - const own = stateManager.getSessionState(session)?.workingDirectories?.[0]; + const own = getSessionWorkingDirectories(stateManager, session)?.[0]; if (own !== undefined) { return own; } const parentInfo = parseSubagentSessionUri(session); if (parentInfo) { - return stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories?.[0]; + return getSessionWorkingDirectories(stateManager, parentInfo.parentSession.toString())?.[0]; } return undefined; } export function getEffectiveWorkingDirectories(stateManager: AgentHostStateManager, session: ProtocolURI): string[] | undefined { - const own = stateManager.getSessionState(session)?.workingDirectories; + const own = getSessionWorkingDirectories(stateManager, session); if (own !== undefined) { return own; } const parentInfo = parseSubagentSessionUri(session); if (parentInfo) { - return stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories; + return getSessionWorkingDirectories(stateManager, parentInfo.parentSession.toString()); } return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 264f687e91b2f..a8795b09fde21 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -252,6 +252,7 @@ const artifactValidator = plainObject(vObj({ id: boundedString(), type: vEnum('pullRequest', 'issue', 'commit', 'website', 'file', 'resource'), label: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT), + chat: vOptionalProp(uriString()), isArtifact: vOptionalProp(vBoolean()), link: vOptionalProp(boundedString()), uri: vOptionalProp(boundedString()), diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index d3cf049b172b0..35e3b5e474b26 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agent.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { buildBranchChangesetUri, buildSessionChangesetUri, ChangesetKind, getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetService, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../common/agentHostChangesetService.js'; @@ -101,13 +101,10 @@ export class AgentHostChangesetCoordinator extends Disposable { } /** - * Called when a provisional session is materialized (working directory - * becomes known). Recomputes every current changeset subscription. + * Called after a provisional session is materialized. */ onSessionMaterialized(sessionStr: string): void { this._changesets.refreshChangesetCatalog(sessionStr); - this._changesets.onWorkingDirectoryAvailable(sessionStr); - this._changesetFileMonitor.onSessionMaterialized(sessionStr); } @@ -159,40 +156,41 @@ export class AgentHostChangesetCoordinator extends Disposable { onFirstSubscriber(resource: URI): void { const resourceStr = resource.toString(); const parsed = parseChangesetUri(resourceStr); + const session = getChangesetSessionUri(resourceStr); if (!parsed && !isAhpChatChannel(resourceStr) && this._stateManager.getSessionState(resourceStr)) { this.ensureSessionSubscription(resourceStr); return; } - if (parsed?.kind === ChangesetKind.Branch) { - this._addSubscription(parsed.sessionUri, resourceStr); - this._changesets.refreshBranchChangeset(parsed.sessionUri); - this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); + if (parsed?.kind === ChangesetKind.Branch && session) { + this._addSubscription(session, resourceStr); + this._changesets.refreshBranchChangeset(session); + this._changesetFileMonitor.trackSessionChanges(resourceStr, session); return; } - if (parsed?.kind === ChangesetKind.Uncommitted) { - this._addSubscription(parsed.sessionUri, resourceStr); - void this._changesets.computeUncommittedChangeset(parsed.sessionUri); - this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); + if (parsed?.kind === ChangesetKind.Uncommitted && session) { + this._addSubscription(session, resourceStr); + void this._changesets.computeUncommittedChangeset(session); + this._changesetFileMonitor.trackSessionChanges(resourceStr, session); return; } - if (parsed?.kind === ChangesetKind.Session) { - this._addSubscription(parsed.sessionUri, resourceStr); - this._changesets.refreshSessionChangeset(parsed.sessionUri); - this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); + if (parsed?.kind === ChangesetKind.Session && session) { + this._addSubscription(session, resourceStr); + this._changesets.refreshSessionChangeset(session); + this._changesetFileMonitor.trackSessionChanges(resourceStr, session); return; } - if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { + if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined && session) { // Track the new subscriber so the service's per-turn recompute // gating starts including this turn. The initial snapshot is // already produced by `tryHandleSubscribe → computeTurnChangeset`; // subsequent deltas flow from `onToolCallEditsApplied` / // `onTurnComplete` once we've added this turn id here. - this._addSubscription(parsed.sessionUri, resourceStr); + this._addSubscription(session, resourceStr); return; } } @@ -226,23 +224,32 @@ export class AgentHostChangesetCoordinator extends Disposable { onLastSubscriber(resource: URI): void { const resourceStr = resource.toString(); const parsed = parseChangesetUri(resourceStr); + const session = getChangesetSessionUri(resourceStr); if (parsed?.kind === ChangesetKind.Branch) { - this._removeSubscription(parsed.sessionUri, resourceStr); + if (session) { + this._removeSubscription(session, resourceStr); + } this._changesetFileMonitor.untrackSessionChanges(resourceStr); return; } if (parsed?.kind === ChangesetKind.Uncommitted) { - this._removeSubscription(parsed.sessionUri, resourceStr); + if (session) { + this._removeSubscription(session, resourceStr); + } this._changesetFileMonitor.untrackSessionChanges(resourceStr); return; } if (parsed?.kind === ChangesetKind.Session) { - this._removeSubscription(parsed.sessionUri, resourceStr); + if (session) { + this._removeSubscription(session, resourceStr); + } this._changesetFileMonitor.untrackSessionChanges(resourceStr); return; } if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { - this._removeSubscription(parsed.sessionUri, resourceStr); + if (session) { + this._removeSubscription(session, resourceStr); + } return; } if (!parsed) { @@ -270,8 +277,9 @@ export class AgentHostChangesetCoordinator extends Disposable { if (parsed.kind === ChangesetKind.Unknown) { throw new Error(`Cannot subscribe to unknown changeset resource: ${resourceStr}`); } - if (!this._stateManager.getSessionState(parsed.sessionUri)) { - await restoreSession(URI.parse(parsed.sessionUri)); + const session = getChangesetSessionUri(resourceStr); + if (session && !this._stateManager.getSessionState(session)) { + await restoreSession(URI.parse(session)); } } @@ -300,21 +308,25 @@ export class AgentHostChangesetCoordinator extends Disposable { throw new Error(`Cannot subscribe to unknown changeset resource: ${resourceStr}`); } await this.restoreSessionIfChangesetSubscription(resource, restoreSession); + const session = getChangesetSessionUri(resourceStr); + if (!session) { + return false; + } if (parsed.kind === ChangesetKind.Turn && parsed.turnId) { - await this._changesets.computeTurnChangeset(parsed.sessionUri, parsed.turnId); + await this._changesets.computeTurnChangeset(session, parsed.turnId); } else if (parsed.kind === ChangesetKind.Compare && parsed.originalTurnId && parsed.modifiedTurnId) { // Compare-turns is computed once on subscribe. Both turns are // typically historical so the snapshot doesn't need to track // live edits; `onFirstSubscriber` / `onLastSubscriber` do not // need to participate. - await this._changesets.computeCompareTurnsChangeset(parsed.sessionUri, parsed.originalTurnId, parsed.modifiedTurnId); + await this._changesets.computeCompareTurnsChangeset(session, parsed.originalTurnId, parsed.modifiedTurnId); } else { // Static changesets are seeded by `onSessionRestored` / // `onSessionCreated`. Re-register defensively in case the // session was created in this process before the coordinator // existed. The uncommitted refresh itself is fired from // {@link onFirstSubscriber} on the 0→1 path. - this._changesets.registerStaticChangesets(parsed.sessionUri); + this._changesets.registerStaticChangesets(session); } return true; } @@ -385,14 +397,18 @@ export class AgentHostChangesetCoordinator extends Disposable { * external edits, so re-attach the file monitor for the session (and its * inheriting subagents) — otherwise a folder added or removed mid-session * would not start/stop being watched until an unrelated lifecycle event. + * Recompute subscribed changesets as well because their contents are scoped + * to the effective working-directory set. */ private onDidChangeSessionWorkingDirectories(sessionStr: string): void { this._changesetOperationService.updateOperations(sessionStr); this._changesetFileMonitor.onSessionWorkingDirectoriesChanged(sessionStr); + this._changesets.recomputeSubscribedChangesets(sessionStr); for (const candidate of this._stateManager.getSessionUris()) { if (parseSubagentSessionUri(candidate)?.parentSession.toString() === sessionStr) { this._changesetOperationService.updateOperations(candidate); this._changesetFileMonitor.onSessionWorkingDirectoriesChanged(candidate); + this._changesets.recomputeSubscribedChangesets(candidate); } } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 433ad5e4874e4..c5dd1f7227a83 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -7,13 +7,13 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; import { stableStringify } from '../../../base/common/objects.js'; -import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { ChangesetKind, getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import { isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, ISessionGitHubState, readSessionGitHubState, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; -import type { IChangesetOperationContribution, IAgentHostChangesetOperationService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetOperationTargetKind, ISessionGitHubState, isAhpChatChannel, readSessionGitHubState, readSessionGitState, type ChangesetOperation, type ErrorInfo, type ISessionGitState } from '../common/state/sessionState.js'; +import type { IChangesetOperationContribution, IAgentHostChangesetOperationService, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry, IChangesetOperationTargetContext } from '../common/agentHostChangesetOperationService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; @@ -28,6 +28,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA private readonly _changesetOperationHandlers = new Map(); private readonly _inFlightOperations = new Map>(); private readonly _operationLanes = new Map>(); + private readonly _operationTargets = new Map(); constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -42,6 +43,13 @@ export class AgentHostChangesetOperationService extends Disposable implements IA refreshSessionGitState: sessionKey => this._gitStateService.refreshSessionGitState(sessionKey), onDidChangeOperations: sessionKey => this.updateOperations(sessionKey), }; + this._register(this._stateManager.onDidRemoveSession(sessionKey => { + for (const [changeset, entry] of this._operationTargets) { + if (entry.sessionKey === sessionKey) { + this._operationTargets.delete(changeset); + } + } + })); } registerContribution(contribution: IChangesetOperationContribution): IDisposable { @@ -76,7 +84,18 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } + setChangesetTarget(sessionKey: string, changeset: string, target: IChangesetOperationTargetContext | undefined): void { + if (target) { + this._operationTargets.set(changeset, { sessionKey, target }); + } else { + this._operationTargets.delete(changeset); + } + } + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] { + const target = this._operationTargets.get(changeset)?.target; + gitState = target?.gitState ?? gitState; + gitHubState = target?.gitHubState ?? gitHubState; if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); @@ -105,13 +124,21 @@ export class AgentHostChangesetOperationService extends Disposable implements IA return []; } - return this._getOperations({ + const operations = this._getOperations({ sessionKey, changesetUri: changeset, changesetKind: parsed.kind, gitState, gitHubState }); + const workingDirectories = target?.workingDirectories ?? this._configurationService.getEffectiveWorkingDirectories(sessionKey); + if (!workingDirectories || workingDirectories.length <= 1) { + return operations; + } + return operations.flatMap(operation => { + const scopes = operation.scopes.filter(scope => scope !== ChangesetOperationScope.Changeset); + return scopes.length > 0 ? [{ ...operation, scopes }] : []; + }); } /** @@ -220,7 +247,23 @@ export class AgentHostChangesetOperationService extends Disposable implements IA // otherwise re-enable invocation mid-turn and let the working tree / // branch state be mutated. const parsed = parseChangesetUri(params.channel); - if (parsed && this._stateManager.hasActiveTurn(parsed.sessionUri)) { + const sessionKey = getChangesetSessionUri(params.channel); + const targetEntry = this._operationTargets.get(params.channel); + if (targetEntry) { + const targetOwner = parsed?.sessionUri; + if (!targetOwner || !sessionKey || targetEntry.sessionKey !== sessionKey || targetEntry.target.ownerUri !== targetOwner) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Changeset operation target is no longer valid: ${params.channel}`); + } + if (isAhpChatChannel(targetOwner) && !this._stateManager.getSessionChats(sessionKey).some(chat => chat.resource === targetOwner)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Changeset chat no longer exists: ${targetOwner}`); + } + const currentWorkingDirectories = this._stateManager.getChatWorkingDirectories(targetOwner) + ?? this._stateManager.getSessionState(sessionKey)?.workingDirectories; + if (!sameWorkingDirectories(currentWorkingDirectories, targetEntry.target.workingDirectories)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Changeset operation target has changed: ${params.channel}`); + } + } + if (sessionKey && this._stateManager.hasActiveTurn(sessionKey)) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' is disabled while a turn is active on changeset ${params.channel}`); } @@ -230,9 +273,10 @@ export class AgentHostChangesetOperationService extends Disposable implements IA // advertised operations can go stale if the session became multi-root // after they were published (e.g. the Editor Window added a root), so a // stale operation must not be invocable. + const targetWorkingDirectories = this._operationTargets.get(params.channel)?.target.workingDirectories; if (parsed && (parsed.kind === ChangesetKind.Turn || parsed.kind === ChangesetKind.Compare) - && isMultiRootSession(this._configurationService.getEffectiveWorkingDirectories(parsed.sessionUri))) { + && isMultiRootSession(targetWorkingDirectories ?? (sessionKey ? this._configurationService.getEffectiveWorkingDirectories(sessionKey) : undefined))) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Operation '${params.operationId}' is not available on a ${parsed.kind} changeset in a multi-root session: ${params.channel}`); } @@ -257,8 +301,11 @@ export class AgentHostChangesetOperationService extends Disposable implements IA handler: IChangesetOperationHandler, params: InvokeChangesetOperationParams, ): Promise { - const operationLane = `${params.channel}\x00${params.operationId}`; - const operationKey = `${operationLane}\x00${stableStringify({ target: params.target ?? null, _meta: params._meta ?? null })}`; + const target = this._operationTargets.get(params.channel)?.target; + const operationLane = target?.workingDirectories.length === 1 + ? target.workingDirectories[0] + : `${params.channel}\x00${params.operationId}`; + const operationKey = `${operationLane}\x00${params.channel}\x00${stableStringify({ target: params.target ?? null, _meta: params._meta ?? null })}`; const inFlightOperationResult = this._inFlightOperations.get(operationKey); if (inFlightOperationResult && this._operationLanes.get(operationLane) === inFlightOperationResult) { return inFlightOperationResult; @@ -328,6 +375,10 @@ export class AgentHostChangesetOperationService extends Disposable implements IA } } +function sameWorkingDirectories(left: readonly string[] | undefined, right: readonly string[]): boolean { + return left?.length === right.length && left.every((directory, index) => directory === right[index]); +} + function toChangesetOperationError(error: unknown): ErrorInfo { const message = toErrorMessage(error); return error instanceof Error diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 615cecb5a60bb..6bf9ded7d4b54 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -33,10 +33,13 @@ import { type SessionConfigState, type URI as ProtocolURI, readSessionGitState, + readSessionGitHubState, isDefaultChatUri, isHostNoticeTurn, lastAttributableTurnId, SessionLifecycle, + type ISessionGitHubState, + type ISessionGitState, } from '../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -59,6 +62,7 @@ import { AgentSession } from '../common/agent.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreePendingState } from './shared/worktreeIsolation.js'; import { getSummaryChangesetKind, resolveChangesetSubscriptions } from './agentHostChangesetSummary.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { parsePullRequestUrl } from './agentMergeController.js'; /** * Maximum number of per-repository git diffs a multi-folder fan-out runs at @@ -146,6 +150,33 @@ function tryParsePersistedDiffs(raw: string | undefined, sessionUri: string, kin } } +function filterGitHubStateForRepository(gitHubState: ISessionGitHubState | undefined, gitState: ISessionGitState | undefined): ISessionGitHubState | undefined { + const owner = gitState?.githubOwner; + const repo = gitState?.githubRepo; + if (!owner || !repo) { + return undefined; + } + const belongsToRepository = (url: string): boolean => { + const parsed = parsePullRequestUrl(url); + return parsed?.owner.toLowerCase() === owner.toLowerCase() && parsed.repo.toLowerCase() === repo.toLowerCase(); + }; + const pullRequestUrls = gitHubState?.pullRequestUrls?.filter(belongsToRepository); + const currentPullRequestMatches = gitHubState?.pullRequestUrls?.[0] !== undefined && belongsToRepository(gitHubState.pullRequestUrls[0]); + const pullRequestStateUrl = gitHubState?.pullRequestStateUrl && belongsToRepository(gitHubState.pullRequestStateUrl) + ? gitHubState.pullRequestStateUrl + : undefined; + return { + owner, + repo, + pullRequestUrls, + initialPullRequestUrls: gitHubState?.initialPullRequestUrls?.filter(belongsToRepository), + associatedPullRequestUrls: gitHubState?.associatedPullRequestUrls?.filter(belongsToRepository), + pullRequestBranchName: currentPullRequestMatches ? gitHubState?.pullRequestBranchName : undefined, + pullRequestStateUrl, + pullRequestState: pullRequestStateUrl ? gitHubState?.pullRequestState : undefined, + }; +} + export class AgentHostChangesetService extends Disposable implements IAgentHostChangesetService { declare readonly _serviceBrand: undefined; @@ -193,6 +224,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return this._changesetSubscriptions.getSessionSubscriptions(session).has(changeset); } + private _hasSubscriptionOfKind(session: ProtocolURI, kind: ChangesetKind): boolean { + return [...this._changesetSubscriptions.getSessionSubscriptions(session)] + .some(changeset => parseChangesetUri(changeset)?.kind === kind); + } + private _hasWorkingDirectory(session: ProtocolURI): boolean { return !this._worktree.isWorkingDirectoryPending(AgentSession.id(session)) && !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0]; @@ -562,12 +598,12 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private async _computeUncommittedChangeset(session: ProtocolURI, turnId: string | undefined, reportTelemetry: boolean, clientContext?: IAgentHostClientTelemetryContext): Promise { const uncommittedUri = this._stateManager.registerChangeset(buildUncommittedChangesetUri(session)); - if (!this._hasSubscription(session, uncommittedUri)) { + if (!this._hasSubscriptionOfKind(session, ChangesetKind.Uncommitted)) { return uncommittedUri; } - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; - if (!workingDirectory) { + const sessionWorkingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); + if (!sessionWorkingDirectories?.length) { return uncommittedUri; } @@ -578,6 +614,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC status: ChangesetStatus.Computing, }); } + this._dispatchChatRepositoryChangesetStatus(session, ChangesetKind.Uncommitted, ChangesetStatus.Computing); const stopWatch = StopWatch.create(); const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); @@ -595,11 +632,13 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC status: ChangesetStatus.Error, error: { errorType: 'computeFailed', message: 'Failed to compute uncommitted diff from git.' }, }); + this._dispatchChatRepositoryChangesetStatus(session, ChangesetKind.Uncommitted, ChangesetStatus.Error, 'Failed to compute uncommitted diff from git.'); outcome = 'gitUnavailable'; return uncommittedUri; } this._publishChangesetDiffs(session, uncommittedUri, diffs); + await this._publishChatRepositoryChangesets(session, ChangesetKind.Uncommitted, diffs); fileCount = diffs.length; outcome = 'computed'; } catch (err) { @@ -609,6 +648,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC status: ChangesetStatus.Error, error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) }, }); + this._dispatchChatRepositoryChangesetStatus(session, ChangesetKind.Uncommitted, ChangesetStatus.Error, err instanceof Error ? err.message : String(err)); outcome = 'error'; } finally { if (reportTelemetry) { @@ -627,21 +667,35 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private async _computeUncommittedDiffs(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; - if (!workingDirectory) { + const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); + if (!workingDirectories?.length) { return undefined; } - let workingDirectoryUri: URI; - try { - workingDirectoryUri = URI.parse(workingDirectory); - } catch { + const workingDirectoryUris = this._parseWorkingDirectoryUris(session, workingDirectories); + if (workingDirectoryUris.length === 1) { + return this._gitService.computeSessionFileDiffs(workingDirectoryUris[0], { + sessionUri: session, + }); + } + + const { gitRepositories } = await resolveSessionRepositories(workingDirectoryUris, this._gitService); + if (gitRepositories.length === 0) { return undefined; } - return this._gitService.computeSessionFileDiffs(workingDirectoryUri, { - sessionUri: session, - }); + const limiter = new Limiter(MAX_DIFF_REPOSITORY_CONCURRENCY); + try { + const perRepositoryDiffs = await Promise.all(gitRepositories.map(repository => limiter.queue(() => this._gitService.computeSessionFileDiffs(repository, { + sessionUri: session, + })))); + if (perRepositoryDiffs.some(diffs => diffs === undefined)) { + return undefined; + } + return dedupeSessionFileDiffs(perRepositoryDiffs.filter((diffs): diffs is readonly ISessionFileDiff[] => diffs !== undefined)); + } finally { + limiter.dispose(); + } } private async _computeTurnDiffsPreferCheckpoint(session: ProtocolURI, db: ISessionDatabase, turnId: string): Promise { @@ -976,7 +1030,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } - if (this._hasSubscription(session, buildUncommittedChangesetUri(session))) { + if (this._hasSubscriptionOfKind(session, ChangesetKind.Uncommitted)) { this._scheduleUncommittedRecompute(session, turnId, true, clientContext); } @@ -1206,6 +1260,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } this._publishChangesetDiffs(session, changesetUri, diffs, reviewed); + if (kind === ChangesetKind.Branch) { + await this._publishChatRepositoryChangesets(session, ChangesetKind.Branch, diffs, reviewed); + } fileCount = diffs.length; outcome = 'computed'; @@ -1279,11 +1336,18 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * (fileSet, fileRemoved) and moves the changeset to `ready` once the * fresh file list has been applied. */ - private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet }): void { + private _publishChangesetDiffs( + session: ProtocolURI, + changesetUri: ProtocolURI, + diffs: readonly ISessionFileDiff[], + reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet }, + gitState?: ISessionGitState, + gitHubState?: ISessionGitHubState, + ): void { // Get the available operations for this changeset. This call assumes that at this point // the git state of the session is up-to-date as it is being used to determine the available // operations. Long term this should be replaced with a more robust mechanism. - const operations = this._changesetOperationService.getOperations(session, changesetUri); + const operations = this._changesetOperationService.getOperations(session, changesetUri, gitState, gitHubState); const files: ChangesetFile[] = []; for (const edit of diffs) { @@ -1323,6 +1387,72 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + private async _publishChatRepositoryChangesets( + session: ProtocolURI, + kind: ChangesetKind.Branch | ChangesetKind.Uncommitted, + diffs: readonly ISessionFileDiff[], + reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet }, + ): Promise { + const sessionState = this._stateManager.getSessionState(session); + const sessionWorkingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); + if (!sessionState || !sessionWorkingDirectories) { + return; + } + const sessionGitState = readSessionGitState(sessionState._meta); + const sessionGitHubState = readSessionGitHubState(sessionState._meta); + for (const chat of this._stateManager.getSessionChats(session)) { + const changesetUri = kind === ChangesetKind.Branch + ? buildBranchChangesetUri(chat.resource) + : buildUncommittedChangesetUri(chat.resource); + this._stateManager.registerChangeset(changesetUri); + const workingDirectories = this._stateManager.getChatWorkingDirectories(chat.resource) ?? sessionWorkingDirectories; + const workingDirectoryUris = workingDirectories.map(directory => URI.parse(directory)); + const chatDiffs = diffs.filter(diff => { + const resource = diff.after?.uri ?? diff.before?.uri; + return resource !== undefined && workingDirectoryUris.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(URI.parse(resource), directory)); + }); + const usesSessionPrimary = workingDirectories.length === sessionWorkingDirectories.length + && workingDirectories.every((directory, index) => directory === sessionWorkingDirectories[index]); + const gitState = usesSessionPrimary + ? sessionGitState + : workingDirectoryUris.length === 1 + ? await this._gitService.getSessionGitState(workingDirectoryUris[0]) + : undefined; + const gitHubState = filterGitHubStateForRepository(sessionGitHubState, gitState); + this._changesetOperationService.setChangesetTarget(session, changesetUri, { + ownerUri: chat.resource, + workingDirectories, + gitState, + gitHubState, + }); + this._publishChangesetDiffs(session, changesetUri, chatDiffs, reviewed, gitState, gitHubState); + } + } + + private _dispatchChatRepositoryChangesetStatus( + session: ProtocolURI, + kind: ChangesetKind.Branch | ChangesetKind.Uncommitted, + status: ChangesetStatus, + errorMessage?: string, + ): void { + for (const chat of this._stateManager.getSessionChats(session)) { + const changesetUri = kind === ChangesetKind.Branch + ? buildBranchChangesetUri(chat.resource) + : buildUncommittedChangesetUri(chat.resource); + this._stateManager.registerChangeset(changesetUri); + this._stateManager.dispatchServerAction(changesetUri, errorMessage === undefined + ? { + type: ActionType.ChangesetStatusChanged, + status, + } + : { + type: ActionType.ChangesetStatusChanged, + status, + error: { errorType: 'computeFailed', message: errorMessage }, + }); + } + } + /** * Opens the databases for every non-default (peer) chat in a multi-chat * session. Each peer chat records its file edits into its own database diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts index 4dbfcb54c34a9..4991d4cffabff 100644 --- a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts @@ -9,7 +9,7 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { CheckoutBlockedByLocalChangesError, IAgentHostGitService } from '../common/agentHostGitService.js'; -import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { ChangesetKind, getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import { CheckoutOperationPreAction, checkoutOperationDirtyWorkingTreeErrorData, readCheckoutOperationPreAction, readCheckoutOperationTreeish } from '../common/meta/agentCheckoutOperationMeta.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; @@ -34,6 +34,10 @@ export class AgentHostCheckoutOperationHandler implements IChangesetOperationHan this._throwIfCancelled(token); const sessionUri = parsed.sessionUri; + const parentSessionUri = getChangesetSessionUri(params.channel); + if (!parentSessionUri) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Could not resolve session for changeset URI: ${params.channel}`); + } const sessionState = this._getSessionState(sessionUri); if (!sessionState) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); @@ -77,7 +81,7 @@ export class AgentHostCheckoutOperationHandler implements IChangesetOperationHan } try { - await this._onCheckedOut(sessionUri); + await this._onCheckedOut(parentSessionUri); } catch (error) { this._logService.warn(`[AgentHostCheckoutOperationHandler] Post-checkout refresh failed for session ${sessionUri}: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index 418bd94a23324..c30149728aa17 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -9,11 +9,11 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js'; +import { type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { CopilotApiError, ICopilotApiService } from './shared/copilotApiService.js'; @@ -55,6 +55,10 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl this._throwIfCancelled(token); const sessionUri = parsed.sessionUri; + const parentSessionUri = getChangesetSessionUri(params.channel); + if (!parentSessionUri) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Could not resolve session for changeset URI: ${params.channel}`); + } const sessionState = this._getSessionState(sessionUri); if (!sessionState) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); @@ -66,11 +70,6 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl } const workingDirectory = URI.parse(workingDirectoryStr); - const gitState = readSessionGitState(sessionState._meta); - if (!gitState) { - throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session's working directory is not a git repo: ${sessionUri}`); - } - const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory); if (!hasUncommitted) { return { message: { markdown: localize('agentHost.changeset.commit.noChanges', "No uncommitted changes to commit.") } }; @@ -98,8 +97,9 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl let message: string; try { + const branchName = await this._gitService.getCurrentBranch(workingDirectory); message = this._cleanCommitMessage(await this._copilotApiService.utilityChatCompletion(authToken, { - messages: this._buildCommitMessagePrompt(workingDirectory, gitState.branchName, diffs), + messages: this._buildCommitMessagePrompt(workingDirectory, branchName, diffs), }, { signal })); } catch (err) { this._throwIfCancelled(token); @@ -126,7 +126,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl } try { - await this._onCommitted(sessionUri); + await this._onCommitted(parentSessionUri); } catch (err) { this._logService.warn(`[AgentHostCommitOperationHandler] Post-commit refresh failed for session ${sessionUri}: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts index 3efc282b907e4..9c35b0d380fe2 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; -import { basename } from '../../../base/common/resources.js'; +import { basename, extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; @@ -57,13 +57,16 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat `Operation '${AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES}' requires a resource target.`); } - const workingDirectoryStr = sessionState.workingDirectories?.[0]; - if (!workingDirectoryStr) { + const resource = URI.parse(params.target.resource); + const workingDirectories = sessionState.workingDirectories; + if (!workingDirectories?.length) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } - + const workingDirectoryStr = workingDirectories.find(directory => extUriBiasedIgnorePathCase.isEqualOrParent(resource, URI.parse(directory))); + if (!workingDirectoryStr) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Resource is outside the session working directories: ${params.target.resource}`); + } const workingDirectory = URI.parse(workingDirectoryStr); - const resource = URI.parse(params.target.resource); this._logService.info(`[AgentHostDiscardChangesOperationHandler] Restoring '${resource.fsPath}' for session ${sessionUri}`); diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index d8319bf619be8..bcfadf5d82789 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -24,6 +24,7 @@ import { isCancellationError } from '../../../base/common/errors.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { AgentHostPullRequestAssociationResolver } from './agentHostPullRequestAssociationResolver.js'; +import { readSessionArtifacts, SessionArtifactType } from '../common/sessionArtifacts.js'; const PULL_REQUEST_CREATION_CLOCK_SKEW_MS = 5 * 60_000; @@ -84,6 +85,67 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi await this._queuePullRequestLookup(sessionKey); } + async findPullRequestForWorkingDirectory(sessionKey: string, workingDirectory: URI): Promise { + const state = this._stateManager.getSessionState(sessionKey); + if (!state || state.lifecycle !== SessionLifecycle.Ready) { + return undefined; + } + + try { + const baseBranchName = await this.resolveSessionBaseBranchName(sessionKey); + const gitState = await this._gitService.getSessionGitState(workingDirectory, baseBranchName); + const branchName = gitState?.branchName; + const owner = gitState?.githubOwner; + const repo = gitState?.githubRepo; + if (!branchName || branchName === gitState?.baseBranchName || !owner || !repo) { + return undefined; + } + + const authToken = this._getGitHubAuthToken(); + if (!authToken) { + return undefined; + } + + const allowedPullRequestUrls = this._isAutomaticPullRequestAttachmentEnabled() + ? undefined + : this._getRecordedPullRequestUrls(state); + if (allowedPullRequestUrls?.length === 0) { + return undefined; + } + + const pullRequest = await this._pullRequestAssociationResolver.resolveForCheckout( + { ...state, workingDirectories: [workingDirectory.toString()] }, + owner, + repo, + gitState, + branchName, + authToken, + allowedPullRequestUrls, + ); + return pullRequest?.url; + } catch (error) { + this._logService.warn(`[AgentHostGitStateService][findPullRequestForWorkingDirectory] Failed to find pull request for ${sessionKey}`, error); + return undefined; + } + } + + private _getRecordedPullRequestUrls(state: ISessionWithDefaultChat): string[] { + const urls = [ + ...readSessionArtifacts(state._meta) + .flatMap(artifact => artifact.type === SessionArtifactType.PullRequest && artifact.isArtifact && artifact.isGitHub === true && artifact.link ? [artifact.link] : []), + ...getSessionRelatedPullRequestUrls(readSessionGitHubState(state._meta)), + ]; + const seen = new Set(); + return urls.filter(url => { + const key = getSessionPullRequestUrlKey(url); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + /** * Queues a pull request lookup on the session's sequencer so overlapping * triggers (turn completion, session restore, a refresh observing a branch diff --git a/src/vs/platform/agentHost/node/agentHostMergeOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostMergeOperationHandler.ts index 9964e0ff6ce4f..3fc0a983d5ab9 100644 --- a/src/vs/platform/agentHost/node/agentHostMergeOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostMergeOperationHandler.ts @@ -10,10 +10,10 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { AGENT_HOST_MERGE_CHANGESET_OPERATION_ID, IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitService, tryResolvePrimaryWorktreeRoot } from '../common/agentHostGitService.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { hasSessionPullRequestForBranch, readSessionGitHubState, readSessionGitState, type SessionState } from '../common/state/sessionState.js'; +import { hasSessionPullRequestForBranch, isAhpChatChannel, readSessionGitHubState, readSessionGitState, type SessionState } from '../common/state/sessionState.js'; export class AgentHostMergeOperationHandler implements IChangesetOperationHandler { @@ -36,6 +36,10 @@ export class AgentHostMergeOperationHandler implements IChangesetOperationHandle this._throwIfCancelled(token); const sessionUri = parsed.sessionUri; + const parentSessionUri = getChangesetSessionUri(params.channel); + if (!parentSessionUri) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Could not resolve session for changeset URI: ${params.channel}`); + } const sessionState = this._getSessionState(sessionUri); if (!sessionState) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); @@ -53,12 +57,17 @@ export class AgentHostMergeOperationHandler implements IChangesetOperationHandle } this._throwIfCancelled(token); - const storedGitState = readSessionGitState(sessionState._meta); - const targetBranch = await this._resolveBaseBranchName(sessionUri); + const storedGitState = isAhpChatChannel(sessionUri) ? undefined : readSessionGitState(sessionState._meta); + const parentWorkingDirectory = this._getSessionState(parentSessionUri)?.workingDirectories?.[0]; + const configuredBaseBranch = workingDirectoryValue === parentWorkingDirectory + ? await this._resolveBaseBranchName(parentSessionUri) + : undefined; + const targetGitState = await this._gitService.getSessionGitState(worktreeRoot, configuredBaseBranch); + const targetBranch = configuredBaseBranch ?? targetGitState?.baseBranchName; if (!targetBranch) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.merge.targetBranchMissing', "Could not determine the branch to merge into.")); } - const gitState = await this._gitService.getSessionGitState(worktreeRoot, targetBranch) ?? storedGitState; + const gitState = targetGitState ?? storedGitState; const sourceBranch = gitState?.branchName ?? await this._gitService.getCurrentBranch(worktreeRoot); if (!sourceBranch) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.merge.sourceBranchMissing', "Could not determine the worktree branch.")); @@ -90,14 +99,14 @@ export class AgentHostMergeOperationHandler implements IChangesetOperationHandle let shouldRefresh = false; try { const hasUncommittedChanges = await this._gitService.hasUncommittedChanges(worktreeRoot); - this._throwIfPullRequestExists(sessionUri, sourceBranch); + this._throwIfPullRequestExists(parentSessionUri, sourceBranch); if (hasUncommittedChanges) { await this._gitService.commitAll(worktreeRoot, localize('agentHost.changeset.merge.commitMessage', "Agent Host changes for {0}", sourceBranch)); didCommit = true; shouldRefresh = true; } this._throwIfCancelled(token); - this._throwIfPullRequestExists(sessionUri, sourceBranch); + this._throwIfPullRequestExists(parentSessionUri, sourceBranch); this._logService.info(`[AgentHostMergeOperationHandler] Merging ${sourceBranch} into ${targetBranch} for session ${sessionUri}`); try { @@ -122,11 +131,11 @@ export class AgentHostMergeOperationHandler implements IChangesetOperationHandle if (!mergeCommit) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.merge.commitMissing', "Changes were merged into '{0}', but the resulting commit could not be recorded.", targetBranch)); } - await this._onMerged(sessionUri, mergeCommit); + await this._onMerged(parentSessionUri, mergeCommit); } finally { if (shouldRefresh) { try { - await this._onGitStateChanged(sessionUri); + await this._onGitStateChanged(parentSessionUri); } catch (error) { this._logService.warn(`[AgentHostMergeOperationHandler] Post-merge refresh failed for session ${sessionUri}: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 6665473056434..a73c5854528d1 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -19,6 +19,7 @@ export const PEER_CHATS_METADATA_KEY = 'peerChats'; export const CHAT_PROVIDER_DATA_METADATA_KEY = 'agentHost.chatProviderData'; export const CHAT_ORIGIN_METADATA_KEY = 'agentHost.chatOrigin'; export const CHAT_INHERITED_TURN_METADATA_KEY = 'agentHost.chatInheritedTurnId'; +export const CHAT_WORKING_DIRECTORIES_METADATA_KEY = 'agentHost.chatWorkingDirectories'; const CHAT_METADATA_CONCURRENCY = 4; const IMPORTED_PEER_CHAT_LIMIT = AGENT_HOST_CATALOG_CHILD_LIMIT - 1; @@ -27,6 +28,7 @@ export interface IPersistedPeerChat { readonly providerData?: string; readonly origin?: ChatOrigin; readonly inheritedTurnId?: string; + readonly workingDirectories?: readonly string[]; } interface IReplaceCentralOptions { @@ -131,7 +133,7 @@ export class AgentHostPeerChatStore { this._logService.error(error, `[AgentHostPeerChatStore] Failed to repair legacy peer-chat membership for ${session.toString()}`); }); } - return this._entriesFromCatalog(catalog.chats); + return this._readWorkingDirectories(this._entriesFromCatalog(catalog.chats)); } /** @@ -187,18 +189,20 @@ export class AgentHostPeerChatStore { }); } - upsert(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin, inheritedTurnId?: string): Promise { + upsert(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin, inheritedTurnId?: string, workingDirectories?: readonly string[]): Promise { const chatUri = chat.toString(); return this._enqueueWrite(session, entries => { const existing = entries.find(entry => entry.uri === chatUri); const effectiveOrigin = origin ?? existing?.origin; const effectiveInheritedTurnId = inheritedTurnId ?? existing?.inheritedTurnId; + const effectiveWorkingDirectories = workingDirectories ?? existing?.workingDirectories; const next = entries.filter(entry => entry.uri !== chatUri); next.push({ uri: chatUri, ...(providerData !== undefined ? { providerData } : {}), ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), ...(effectiveInheritedTurnId !== undefined ? { inheritedTurnId: effectiveInheritedTurnId } : {}), + ...(effectiveWorkingDirectories !== undefined ? { workingDirectories: [...effectiveWorkingDirectories] } : {}), }); return next; }); @@ -301,7 +305,8 @@ export class AgentHostPeerChatStore { ?? (legacyIsCurrentMirror ? central : legacy) ?? central ?? []; - const updated = this._parse(session, JSON.stringify(mutate(current))); + const currentWithMetadata = await this._readWorkingDirectories(current); + const updated = this._parse(session, JSON.stringify(mutate(currentWithMetadata))); const result = await this._replaceCentral(session, updated, catalog?.revision, { previousEntries: legacyIsCurrentMirror ? central : undefined, legacyMergeBase: legacy !== undefined && !legacyIsCurrentMirror ? legacy : undefined, @@ -366,7 +371,7 @@ export class AgentHostPeerChatStore { } if (current.revision !== revision) { previousEntries = entries; - entries = this._entriesFromCatalog(current.chats); + entries = await this._readWorkingDirectories(this._entriesFromCatalog(current.chats)); revision = current.revision; continue; } @@ -378,7 +383,7 @@ export class AgentHostPeerChatStore { return; } previousEntries = entries; - entries = this._entriesFromCatalog(superseding.chats); + entries = await this._readWorkingDirectories(this._entriesFromCatalog(superseding.chats)); revision = superseding.revision; } } @@ -515,10 +520,14 @@ export class AgentHostPeerChatStore { [CHAT_PROVIDER_DATA_METADATA_KEY]: true, [CHAT_ORIGIN_METADATA_KEY]: true, [CHAT_INHERITED_TURN_METADATA_KEY]: true, + [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: true, }); const origin = metadata[CHAT_ORIGIN_METADATA_KEY] ? this._parseOrigin(metadata[CHAT_ORIGIN_METADATA_KEY]) : metadata[CHAT_ORIGIN_METADATA_KEY] === '' ? undefined : entry.origin; + const workingDirectories = metadata[CHAT_WORKING_DIRECTORIES_METADATA_KEY] + ? this._parseWorkingDirectories(metadata[CHAT_WORKING_DIRECTORIES_METADATA_KEY]) + : metadata[CHAT_WORKING_DIRECTORIES_METADATA_KEY] === '' ? undefined : entry.workingDirectories; return { uri: entry.uri, ...(metadata[CHAT_PROVIDER_DATA_METADATA_KEY] !== undefined @@ -528,12 +537,42 @@ export class AgentHostPeerChatStore { ...(metadata[CHAT_INHERITED_TURN_METADATA_KEY] !== undefined ? metadata[CHAT_INHERITED_TURN_METADATA_KEY] ? { inheritedTurnId: metadata[CHAT_INHERITED_TURN_METADATA_KEY] } : {} : entry.inheritedTurnId !== undefined ? { inheritedTurnId: entry.inheritedTurnId } : {}), + ...(workingDirectories !== undefined ? { workingDirectories } : {}), }; } finally { ref.dispose(); } } + private async _readWorkingDirectories(entries: readonly IPersistedPeerChat[]): Promise { + const limiter = new Limiter(CHAT_METADATA_CONCURRENCY); + return Promise.all(entries.map(entry => limiter.queue(async () => { + const ref = await this._sessionDataService.tryOpenDatabase(URI.parse(entry.uri)); + if (!ref) { + return entry; + } + try { + const raw = await ref.object.getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY); + if (raw === undefined) { + return entry; + } + const workingDirectories = raw ? this._parseWorkingDirectories(raw) : undefined; + return { + uri: entry.uri, + ...(entry.providerData !== undefined ? { providerData: entry.providerData } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + ...(entry.inheritedTurnId !== undefined ? { inheritedTurnId: entry.inheritedTurnId } : {}), + ...(workingDirectories !== undefined ? { workingDirectories } : {}), + }; + } catch (error) { + this._logService.warn(`[AgentHostPeerChatStore] Failed to read chat working directories for ${entry.uri}: ${toErrorMessage(error)}`); + return entry; + } finally { + ref.dispose(); + } + }))); + } + private async _writeChatMetadata(entry: IPersistedPeerChat): Promise { const ref = this._sessionDataService.openDatabase(URI.parse(entry.uri)); try { @@ -541,6 +580,7 @@ export class AgentHostPeerChatStore { [CHAT_PROVIDER_DATA_METADATA_KEY]: entry.providerData ?? '', [CHAT_ORIGIN_METADATA_KEY]: entry.origin === undefined ? '' : this._stringifyOrigin(entry.origin), [CHAT_INHERITED_TURN_METADATA_KEY]: entry.inheritedTurnId ?? '', + [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: entry.workingDirectories === undefined ? '' : JSON.stringify(entry.workingDirectories), }); } finally { ref.dispose(); @@ -560,6 +600,14 @@ export class AgentHostPeerChatStore { return JSON.stringify(value); } + private _parseWorkingDirectories(raw: string): readonly string[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.every(directory => typeof directory === 'string')) { + throw new Error('expected an array of working-directory URIs'); + } + return parsed; + } + private _entriesFromCatalog(chats: readonly { readonly chat: string; readonly providerData?: string; @@ -615,6 +663,10 @@ export class AgentHostPeerChatStore { this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid inherited turn id`); continue; } + if (value.workingDirectories !== undefined && (!Array.isArray(value.workingDirectories) || !value.workingDirectories.every(directory => typeof directory === 'string'))) { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid working directories`); + continue; + } const originValue = toSerializableJsonValue(value.origin); const origin = fromCatalogChatOrigin(originValue); if (value.origin !== undefined && !origin) { @@ -626,6 +678,7 @@ export class AgentHostPeerChatStore { ...(typeof value.providerData === 'string' ? { providerData: value.providerData } : {}), ...(origin ? { origin } : {}), ...(typeof value.inheritedTurnId === 'string' ? { inheritedTurnId: value.inheritedTurnId } : {}), + ...(Array.isArray(value.workingDirectories) ? { workingDirectories: value.workingDirectories } : {}), }); } return result; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts index 189f3377fbe9b..91b32df2f710b 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -11,7 +11,7 @@ import { IGitHubService } from '../../github/common/githubService.js'; import { ILogService } from '../../log/common/log.js'; import { AgentHostPullRequestOperationId, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, resolveMergeMethod } from '../common/agentMerge.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -69,7 +69,10 @@ export class AgentHostPullRequestLifecycleOperationHandler implements IChangeset if (!parsed) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`); } - const sessionUri = parsed.sessionUri; + const sessionUri = getChangesetSessionUri(params.channel); + if (!sessionUri) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Could not resolve session for changeset URI: ${params.channel}`); + } const status = this._statusService.getPullRequestStatus(sessionUri); if (!status) { this._logService.warn(`[AgentHostPullRequestLifecycleOperationHandler] Rejected '${this._action}': session=${sessionUri}, reason=pull request state is not available`); diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 1f1ebf7aa0977..35a41f3edd014 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -9,9 +9,9 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import { buildDefaultChatUri, isAhpChatChannel, readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService, parseUpstreamBranchName } from '../common/agentHostGitService.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; @@ -44,6 +44,7 @@ type PullRequestCreationConfiguration = Pick { @@ -442,6 +451,8 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation pr: CreatedPullRequest, isExisting: boolean, sessionUri: string, + chatUri: string | undefined, + workingDirectory: URI, owner: string, repo: string, branchName: string, @@ -451,7 +462,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation options: PullRequestCreationConfiguration, ): Promise { if (!options.autoMergeMethod) { - await this._completePullRequestOperation(sessionUri, pr, branchName, options); + await this._completePullRequestOperation(sessionUri, chatUri, workingDirectory, pr, branchName, options); return this._createResult(pr, this._buildMessage(pr, isExisting, 'none', undefined, options)); } @@ -474,13 +485,14 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation this._logService.warn(`[AgentHostPullRequestOperationHandler] Cannot enable auto-merge for ${owner}/${repo}#${pr.number}: missing pull request node id`); } - await this._completePullRequestOperation(sessionUri, pr, branchName, options); + await this._completePullRequestOperation(sessionUri, chatUri, workingDirectory, pr, branchName, options); return this._createResult(pr, this._buildMessage(pr, isExisting, autoMergeOutcome, autoMergeError, options)); } - private async _completePullRequestOperation(sessionUri: string, pullRequest: CreatedPullRequest, branchName: string, options: PullRequestCreationConfiguration): Promise { + private async _completePullRequestOperation(sessionUri: string, chatUri: string | undefined, workingDirectory: URI, pullRequest: CreatedPullRequest, branchName: string, options: PullRequestCreationConfiguration): Promise { await this._onPullRequestCreated({ sessionKey: sessionUri, + chatUri, pullRequestUrl: pullRequest.url, pullRequestNumber: pullRequest.number, ...(pullRequest.title ? { pullRequestTitle: pullRequest.title } : {}), @@ -491,12 +503,23 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } const current = readAgentMergeSessionState(this._configurationService.getSessionConfigValues(sessionUri)); const overrides = options.agentMergeOptions ?? current?.overrides; + const enabledAt = new Date().toISOString(); this._configurationService.updateSessionConfig(sessionUri, { [SessionConfigKey.AgentMerge]: { enabled: true, ...(overrides ? { overrides } : {}), }, - [SessionConfigKey.AgentMergeController]: {}, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName, + pullRequestUrl: pullRequest.url, + chatUri: chatUri ?? buildDefaultChatUri(sessionUri).toString(), + workingDirectory: workingDirectory.toString(), + enabledAt, + commentWatermark: enabledAt, + announcementPending: true, + }, + }, }); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 4108f0c3cd919..9856906dfe375 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -24,6 +24,7 @@ import { ActionType } from '../common/state/sessionActions.js'; import { PREPARE_PULL_REQUEST_OPERATION_ID } from '../common/meta/agentPullRequestOperationMeta.js'; import { SESSION_ARTIFACTS_KEY, persistSessionMetadataValues } from './shared/persistSessionMetadata.js'; import { SessionArtifacts } from './shared/sessionArtifacts.js'; +import { ChangesetKind } from '../common/changesetUri.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { @@ -104,7 +105,10 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem return operations; } - private _computeOperations({ sessionKey, gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + private _computeOperations({ sessionKey, changesetKind, gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + if (changesetKind !== ChangesetKind.Branch) { + return undefined; + } // New Session const state = this._stateManager.getSessionState(sessionKey); if (state?.lifecycle === SessionLifecycle.Creating) { @@ -119,7 +123,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem // Pull request already exists for the currently checked out branch if (hasSessionPullRequestForBranch(gitHubState, gitState?.branchName)) { - return this._getPullRequestLifecycleOperations(sessionKey); + return this._getPullRequestLifecycleOperations(sessionKey, gitHubState?.pullRequestUrls); } const hasBranchChanges = gitState?.hasBaseBranchChanges ?? (gitState?.outgoingChanges ?? 0) > 0; @@ -167,12 +171,16 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem * the button bar stays hidden rather than flashing the wrong action, and * once the pull request is merged or closed, when nothing is left to do. */ - private _getPullRequestLifecycleOperations(sessionKey: string): ChangesetOperation[] | undefined { + private _getPullRequestLifecycleOperations(sessionKey: string, targetPullRequestUrls: readonly string[] | undefined): ChangesetOperation[] | undefined { const status = this._pullRequestStatusService.getPullRequestStatus(sessionKey); if (!status) { this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=pull request state has not resolved yet`); return undefined; } + if (!targetPullRequestUrls?.some(url => url.toLowerCase() === status.url.toLowerCase())) { + this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=pull request state belongs to a different repository target`); + return undefined; + } if (status.state !== 'open') { this._logService.trace(`[AgentHostPullRequestOperationContribution] No pull request operations: session=${sessionKey}, reason=pull request is ${status.state}`); return undefined; @@ -270,6 +278,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem label: event.pullRequestTitle ?? '', isArtifact: true, link: event.pullRequestUrl, + chat: event.chatUri, }, generateUuid)); const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 40bc7e70b068f..066b97948475b 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -16,7 +16,7 @@ import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotations import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { buildBranchChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { buildAnnotationsUri, isAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js'; @@ -512,6 +512,17 @@ export class AgentHostStateManager extends Disposable { return this._chatEntries.get(chat)?.state; } + /** Returns a chat's explicit working-directory subset, including before hydration. */ + getChatWorkingDirectories(chat: URI): readonly string[] | undefined { + const entry = this._chatEntries.get(chat); + return entry?.state?.workingDirectories ?? entry?.summary.workingDirectories; + } + + /** Returns the authoritative chat catalog for a session without merging a default chat state. */ + getSessionChats(session: URI): readonly ChatSummary[] { + return this._sessionStates.get(session)?.state.chats ?? []; + } + /** * Returns a chat's {@link ChatOrigin} from its catalog summary, not its * (lazily-materialized) {@link ChatState}: a restored chat registers its @@ -1106,9 +1117,9 @@ export class AgentHostStateManager extends Disposable { ...createChatState(chatSummary), turns: turns ?? [], draft, - changesets: this._sessionStates.get(sessionKey)?.state.changesets?.slice(), + changesets: this._changesetsForChat(chatUri, this._sessionStates.get(sessionKey)?.state.changesets), }, - changesets: this._sessionStates.get(sessionKey)?.state.changesets?.slice(), + changesets: this._changesetsForChat(chatUri, this._sessionStates.get(sessionKey)?.state.changesets), valid: true, }); const entry = this._sessionStates.get(sessionKey); @@ -1175,9 +1186,9 @@ export class AgentHostStateManager extends Disposable { state: { ...createChatState(chatSummary), turns: options?.turns ?? [], - changesets: sessionState.changesets?.slice(), + changesets: this._changesetsForChat(chatUri, sessionState.changesets), }, - changesets: sessionState.changesets?.slice(), + changesets: this._changesetsForChat(chatUri, sessionState.changesets), providerData: options?.providerData, inheritedTurnId: options?.inheritedTurnId, valid: true, @@ -1225,7 +1236,7 @@ export class AgentHostStateManager extends Disposable { this._chatEntries.set(chatUri, { session, summary: chatSummary, - changesets: sessionState.changesets?.slice(), + changesets: this._changesetsForChat(chatUri, sessionState.changesets), providerData: options.providerData, inheritedTurnId: options.inheritedTurnId, draft: options.draft, @@ -1555,16 +1566,30 @@ export class AgentHostStateManager extends Disposable { if (!chatEntry) { continue; } - chatEntry.changesets = next?.slice(); + const chatChangesets = this._changesetsForChat(chat.resource, next); + chatEntry.changesets = chatChangesets; if (chatEntry.state) { this.dispatchServerAction(chat.resource, { type: ActionType.ChatChangesetsChanged, - changesets: next?.slice(), + changesets: chatChangesets, }); } } } + private _changesetsForChat(chat: URI, changesets: readonly Changeset[] | undefined): Changeset[] | undefined { + return changesets?.map(changeset => { + switch (changeset.changeKind) { + case ChangesetKind.Branch: + return { ...changeset, uriTemplate: buildBranchChangesetUri(chat) }; + case ChangesetKind.Uncommitted: + return { ...changeset, uriTemplate: buildUncommittedChangesetUri(chat) }; + default: + return { ...changeset }; + } + }); + } + /** * Tear down a changeset. Dispatches {@link ActionType.ChangesetCleared} * so subscribers see an empty file list, then deletes the local state diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index d59a9fb89aedd..cbba77b13dc1b 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -6,10 +6,10 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { parseChangesetUri } from '../common/changesetUri.js'; +import { getChangesetSessionUri, parseChangesetUri } from '../common/changesetUri.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; -import { readSessionGitState, type SessionState } from '../common/state/sessionState.js'; +import { isAhpChatChannel, readSessionGitState, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { GitRefType, IAgentHostGitService } from '../common/agentHostGitService.js'; @@ -33,6 +33,10 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler this._throwIfCancelled(token); const sessionUri = parsed.sessionUri; + const parentSessionUri = getChangesetSessionUri(params.channel); + if (!parentSessionUri) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Could not resolve session for changeset URI: ${params.channel}`); + } const sessionState = this._getSessionState(sessionUri); if (!sessionState) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); @@ -44,15 +48,15 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler } const workingDirectory = URI.parse(workingDirectoryStr); - const gitState = readSessionGitState(sessionState._meta); + const cachedGitState = isAhpChatChannel(sessionUri) ? undefined : readSessionGitState(sessionState._meta); const branchName = await (this._gitService.getCurrentBranchName?.(workingDirectory) ?? this._gitService.getCurrentBranch(workingDirectory)); if (!branchName) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); } this._throwIfCancelled(token); - if (gitState?.branchName && gitState.branchName !== branchName) { - throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Current branch changed from ${gitState.branchName} to ${branchName} for ${workingDirectory}`); + if (cachedGitState?.branchName && cachedGitState.branchName !== branchName) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Current branch changed from ${cachedGitState.branchName} to ${branchName} for ${workingDirectory}`); } const branch = await this._gitService.getBranch(workingDirectory, branchName); @@ -89,7 +93,7 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler } try { - await this._onSynced(sessionUri); + await this._onSynced(parentSessionUri); } catch (err) { this._logService.warn(`[AgentHostSyncOperationHandler] Post-sync refresh failed for session ${sessionUri}: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 7cafb718529cb..1f2be5556149f 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -9,6 +9,7 @@ import { structuralEquals } from '../../../base/common/equals.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; +import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IGitHubService } from '../../github/common/githubService.js'; @@ -25,7 +26,7 @@ import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ActionType } from '../common/state/protocol/common/actions.js'; import { AuthRequiredReason } from '../common/state/sessionActions.js'; -import { getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, needsSessionGitStateRefresh, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; +import { buildDefaultChatUri, getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, needsSessionGitStateRefresh, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; @@ -43,13 +44,13 @@ const maximumIndeterminateDuration = 30 * 60_000; const indeterminateObservationGap = 2 * backstopInterval; export interface IAgentMergeControllerOptions { - readonly startTurn: (session: string, turnId: string, prompt: string) => boolean; - readonly cancelTurn: (session: string, turnId: string) => void; + readonly startTurn: (chatUri: string, turnId: string, prompt: string) => boolean; + readonly cancelTurn: (chatUri: string, turnId: string) => void; /** * Posts an Agent Merge state change into the session transcript. The notice * is client-visible only; it must never become part of the agent's context. */ - readonly postNotice: (session: string, kind: AgentSystemNotificationKind, content: string) => void; + readonly postNotice: (chatUri: string, kind: AgentSystemNotificationKind, content: string) => void; } /** The configuration a session was last told about, and the overrides it was resolved from. */ @@ -431,7 +432,16 @@ export class AgentMergeController extends Disposable { if (!runtime || !state || !agentMerge?.enabled || this._stateManager.hasActiveTurn(session)) { return; } - const branchName = await this._resolveCurrentBranch(session, runtime, state); + const targetContext = this._resolveTargetContext(session, agentMerge.target, state); + if (agentMerge.target && !targetContext) { + this._disable(session, agentMerge, agentMergeDisableReasons.targetUnavailable()); + return; + } + const primaryWorkingDirectory = state.workingDirectories?.[0] ? URI.parse(state.workingDirectories[0]) : undefined; + const targetWorkingDirectory = targetContext?.workingDirectory && (!primaryWorkingDirectory || !isEqual(targetContext.workingDirectory, primaryWorkingDirectory)) + ? targetContext.workingDirectory + : undefined; + const branchName = await this._resolveCurrentBranch(session, runtime, state, targetWorkingDirectory); if (!this._isCurrentRuntime(session, runtime)) { return; } @@ -447,7 +457,14 @@ export class AgentMergeController extends Disposable { const pullRequestUrl = currentGitHubState?.pullRequestBranchName === branchName ? getSessionRelatedPullRequestUrls(currentGitHubState)[0] : undefined; - target = { branchName, enabledAt: now, commentWatermark: now, ...(pullRequestUrl ? { pullRequestUrl } : {}) }; + const workingDirectory = state.workingDirectories?.[0]; + target = { + branchName, + enabledAt: now, + commentWatermark: now, + ...(workingDirectory ? { chatUri: buildDefaultChatUri(session).toString(), workingDirectory } : {}), + ...(pullRequestUrl ? { pullRequestUrl } : {}), + }; this._logService.info(`[AgentMergeController] Captured session branch and feedback watermark: session=${session}`); // Announce only on the first capture: a resumed session already has a // target, so restarting the host must not repeat the notice. @@ -459,33 +476,50 @@ export class AgentMergeController extends Disposable { this._disable(session, agentMerge, agentMergeDisableReasons.branchChanged(target.branchName, branchName)); return; } - - await this._gitStateService.attachSessionGitHubPullRequest(session, state.workingDirectories?.[0] ? URI.parse(state.workingDirectories[0]) : undefined); - if (!this._isCurrentRuntime(session, runtime)) { - return; - } - const refreshedState = this._stateManager.getSessionState(session); - if (!this._hasTargetBranch(refreshedState, target.branchName)) { - this._disable(session, agentMerge, agentMergeDisableReasons.branchChangedWhileRefreshing()); + if (target.announcementPending) { + const announcedTarget = { ...target, announcementPending: undefined }; + this._postEnabledNotice(session, { ...agentMerge, target: announcedTarget }); + this._updateAgentMergeState(session, agentMerge, { target: announcedTarget }); return; } - const gitHubState = readSessionGitHubState(refreshedState?._meta); - const pullRequestUrl = getSessionRelatedPullRequestUrls(gitHubState)[0]; + if (!target.pullRequestUrl) { - if (!pullRequestUrl) { + let resolvedPullRequestUrl: string | undefined; + if (targetWorkingDirectory) { + resolvedPullRequestUrl = await this._gitStateService.findPullRequestForWorkingDirectory(session, targetWorkingDirectory); + } else { + await this._gitStateService.attachSessionGitHubPullRequest(session, targetContext?.workingDirectory); + } + if (!this._isCurrentRuntime(session, runtime)) { + return; + } + if (targetWorkingDirectory) { + const currentBranch = await this._resolveCurrentBranch(session, runtime, state, targetWorkingDirectory); + if (!this._isCurrentRuntime(session, runtime)) { + return; + } + if (currentBranch !== target.branchName) { + this._disable(session, agentMerge, agentMergeDisableReasons.branchChangedWhileRefreshing()); + return; + } + } else { + const refreshedState = this._stateManager.getSessionState(session); + if (!this._hasTargetBranch(refreshedState, target.branchName)) { + this._disable(session, agentMerge, agentMergeDisableReasons.branchChangedWhileRefreshing()); + return; + } + resolvedPullRequestUrl = getSessionRelatedPullRequestUrls(readSessionGitHubState(refreshedState?._meta))[0]; + } + if (!resolvedPullRequestUrl) { this._logService.trace(`[AgentMergeController] Waiting for a pull request on the captured branch: session=${session}`); runtime.backstopScheduler.schedule(); return; } - target = { ...target, pullRequestUrl }; + target = { ...target, pullRequestUrl: resolvedPullRequestUrl }; this._logService.info(`[AgentMergeController] Bound session to its pull request: session=${session}`); this._updateAgentMergeState(session, agentMerge, { target }); return; } - if (pullRequestUrl && pullRequestUrl.toLowerCase() !== target.pullRequestUrl.toLowerCase()) { - this._disable(session, agentMerge, agentMergeDisableReasons.differentPullRequest()); - return; - } const parsed = parsePullRequestUrl(target.pullRequestUrl); if (!parsed) { @@ -576,9 +610,11 @@ export class AgentMergeController extends Disposable { // the worktree the agent is about to act on. An unreadable // worktree records a sentinel that no commit can match, so the // session fails closed rather than authorizing a later merge. - const repairBaseCommit = await this._resolveLocalCommit(session) ?? AGENT_MERGE_UNKNOWN_COMMIT; + const repairBaseCommit = await this._resolveLocalCommit(session, target) ?? AGENT_MERGE_UNKNOWN_COMMIT; + const chatUri = target.chatUri ?? buildDefaultChatUri(session).toString(); const context: IAgentMergeTurnContext = { session, + chatUri, turnId, ref, headSha: gate.context.headSha, @@ -602,7 +638,7 @@ export class AgentMergeController extends Disposable { if (!this._isCurrentRuntime(session, runtime) || !this._isTargetStillCurrent(session, target) || this._stateManager.hasActiveTurn(session) - || !this._options.startTurn(session, turnId, buildAgentMergePrompt(gate.actions, gate.context))) { + || !this._options.startTurn(chatUri, turnId, buildAgentMergePrompt(gate.actions, gate.context))) { this._logService.debug(`[AgentMergeController] Repair turn was not claimed because the session became busy, retargeted, or stopped: session=${session}`); runtime.backstopScheduler.schedule(); return; @@ -756,7 +792,53 @@ export class AgentMergeController extends Disposable { * runtime regardless, so any other checkout that cannot report a branch * costs a single git call rather than one per backstop. */ - private async _resolveCurrentBranch(session: string, runtime: AgentMergeRuntime, state: NonNullable>): Promise { + private _resolveTargetContext( + session: string, + target: AgentMergeTarget | undefined, + state: NonNullable>, + ): { readonly chatUri: string; readonly workingDirectory: URI | undefined } | undefined { + if (!target?.chatUri && !target?.workingDirectory) { + return { + chatUri: buildDefaultChatUri(session).toString(), + workingDirectory: state.workingDirectories?.[0] ? URI.parse(state.workingDirectories[0]) : undefined, + }; + } + if (!target.chatUri || !target.workingDirectory) { + return undefined; + } + let workingDirectory: URI; + try { + workingDirectory = URI.parse(target.workingDirectory); + if (parseRequiredSessionUriFromChatUri(target.chatUri).toString() !== session) { + return undefined; + } + } catch { + return undefined; + } + const defaultChat = buildDefaultChatUri(session).toString(); + if (target.chatUri !== defaultChat && !this._stateManager.getSessionChats(session).some(candidate => candidate.resource === target.chatUri)) { + return undefined; + } + const chatWorkingDirectories = this._stateManager.getChatWorkingDirectories(target.chatUri) + ?? (target.chatUri === defaultChat ? state.workingDirectories : undefined); + if (!chatWorkingDirectories?.includes(target.workingDirectory)) { + return undefined; + } + return { chatUri: target.chatUri, workingDirectory }; + } + + private async _resolveCurrentBranch( + session: string, + runtime: AgentMergeRuntime, + state: NonNullable>, + workingDirectory: URI | undefined, + ): Promise { + if (workingDirectory) { + const branchName = this._gitService.getCurrentBranchName + ? await this._gitService.getCurrentBranchName(workingDirectory) + : undefined; + return branchName ?? this._gitService.getCurrentBranch(workingDirectory); + } const gitState = readSessionGitState(state._meta); if (gitState?.branchName) { return gitState.branchName; @@ -906,14 +988,11 @@ export class AgentMergeController extends Disposable { /** Whether the session still sits on the branch and pull request this run was authorized for. */ private _isTargetStillCurrent(session: string, target: AgentMergeTarget): boolean { const state = this._stateManager.getSessionState(session); - if (!this._hasTargetBranch(state, target.branchName)) { - return false; - } - if (!target.pullRequestUrl) { - return true; - } - const pullRequestUrl = getSessionRelatedPullRequestUrls(readSessionGitHubState(state?._meta))[0]; - return !pullRequestUrl || pullRequestUrl.toLowerCase() === target.pullRequestUrl.toLowerCase(); + const currentTarget = readAgentMergeSessionState(state?.config?.values)?.target; + return !!state + && !!currentTarget + && structuralEquals(currentTarget, target) + && this._resolveTargetContext(session, currentTarget, state) !== undefined; } private async _merge(session: string, runtime: AgentMergeRuntime, ref: PullRequestRef, snapshot: PullRequestSnapshot, configuration: AgentMergeConfiguration, agentMerge: AgentMergeSessionState): Promise { @@ -1028,9 +1107,9 @@ export class AgentMergeController extends Disposable { * worktree cannot be read. Callers treat `undefined` as "changed" so an * unreadable worktree can never authorize an automatic merge. */ - private async _resolveLocalCommit(session: string): Promise { + private async _resolveLocalCommit(session: string, target: AgentMergeTarget): Promise { try { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; + const workingDirectory = target.workingDirectory ?? this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } @@ -1055,10 +1134,10 @@ export class AgentMergeController extends Disposable { agentMerge: AgentMergeSessionState, configuration: AgentMergeConfiguration, ): Promise { - if (configuration.mergePullRequest !== 'ifUnchanged' || agentMerge.repairBaseCommit === undefined) { + if (configuration.mergePullRequest !== 'ifUnchanged' || agentMerge.repairBaseCommit === undefined || !agentMerge.target) { return false; } - const currentCommit = await this._resolveLocalCommit(session); + const currentCommit = await this._resolveLocalCommit(session, agentMerge.target); if (!shouldStopMergingAfterAgentChanges(configuration, agentMerge, currentCommit)) { return false; } @@ -1140,7 +1219,13 @@ export class AgentMergeController extends Disposable { */ private _postNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { try { - this._options.postNotice(session, kind, content); + const target = readAgentMergeSessionState(this._configurationService.getSessionConfigValues(session))?.target; + const defaultChatUri = buildDefaultChatUri(session).toString(); + const chatUri = target?.chatUri + && (target.chatUri === defaultChatUri || this._stateManager.getSessionChats(session).some(candidate => candidate.resource === target.chatUri)) + ? target.chatUri + : defaultChatUri; + this._options.postNotice(chatUri, kind, content); } catch (error) { this._logService.warn(`[AgentMergeController] Failed to post an Agent Merge notice: session=${session}`, error); } @@ -1165,9 +1250,10 @@ export class AgentMergeController extends Disposable { const context = this._activeTurns.get(session); if (context) { this._activeTurns.delete(session); - if (this._stateManager.getSessionState(session)?.activeTurn?.id === context.turnId) { + const chatUri = context.chatUri ?? buildDefaultChatUri(session).toString(); + if (this._stateManager.getSessionState(chatUri)?.activeTurn?.id === context.turnId) { this._logService.info(`[AgentMergeController] Cancelling repair turn because Agent Merge stopped: session=${session}, turn=${context.turnId}`); - this._options.cancelTurn(session, context.turnId); + this._options.cancelTurn(chatUri, context.turnId); } } if (this._runtimes.has(session)) { diff --git a/src/vs/platform/agentHost/node/agentMergeTools.ts b/src/vs/platform/agentHost/node/agentMergeTools.ts index a351b99d92cef..6f5b900d79876 100644 --- a/src/vs/platform/agentHost/node/agentMergeTools.ts +++ b/src/vs/platform/agentHost/node/agentMergeTools.ts @@ -7,6 +7,7 @@ import { disposableTimeout, Queue, raceCancellationError } from '../../../base/c import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; import { IGitHubService } from '../../github/common/githubService.js'; import { GitHubWorkflowJob, GitHubWorkflowRerunOptions, GitHubWorkflowRun } from '../../github/common/githubPullRequestMutationService.js'; import { PullRequestCheck, PullRequestRef, PullRequestSnapshot } from '../../github/common/githubPullRequestService.js'; @@ -14,6 +15,7 @@ import { GitHubRequestError } from '../../github/common/githubTransport.js'; import { ILogService } from '../../log/common/log.js'; import { AgentMergeAction, AgentMergeConfiguration, classifyAgentMergeRequiredChecks, isAgentMergeFeedbackAuthor, readAgentMergeSessionState } from '../common/agentMerge.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { isSessionStatusArchived } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -22,6 +24,7 @@ import { AgentMergeCIRequest, IAgentMergeToolAccessor, parseAgentMergeCIRequest export interface IAgentMergeTurnContext { readonly session: string; + readonly chatUri?: string; readonly turnId: string; readonly ref: PullRequestRef; readonly headSha: string; @@ -50,6 +53,7 @@ export class AgentMergeTools extends Disposable implements IAgentMergeToolAccess @ILogService private readonly _logService: ILogService, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, ) { super(); this._register(toDisposable(() => this._abort.abort(new Error('Agent Merge tools disposed.')))); @@ -59,7 +63,20 @@ export class AgentMergeTools extends Disposable implements IAgentMergeToolAccess return this._isFeatureEnabled(); } - setEnabled(session: string, enabled: boolean): string { + setEnabled(session: string, chatUriOrEnabled: string | boolean, enabled?: boolean): string | Promise { + if (typeof chatUriOrEnabled === 'boolean') { + return this._setEnabledForSession(session, chatUriOrEnabled); + } + if (!this.isEnabled()) { + throw new Error('Agent Merge is disabled in the host configuration.'); + } + if (enabled === undefined) { + throw new Error('Agent Merge enablement is required.'); + } + return this._setEnabledForChat(session, chatUriOrEnabled, enabled); + } + + private _setEnabledForSession(session: string, enabled: boolean): string { if (!this.isEnabled()) { throw new Error('Agent Merge is disabled in the host configuration.'); } @@ -87,6 +104,64 @@ export class AgentMergeTools extends Disposable implements IAgentMergeToolAccess return JSON.stringify({ enabled }); } + private async _setEnabledForChat(session: string, chatUri: string, enabled: boolean): Promise { + if (!enabled) { + return this._setEnabledForSession(session, false); + } + if (!this.isEnabled()) { + throw new Error('Agent Merge is disabled in the host configuration.'); + } + const state = this._stateManager.getSessionState(chatUri); + if (!state) { + throw new Error(`Cannot update Agent Merge for unknown chat: ${chatUri}`); + } + if (isSessionStatusArchived(state.status)) { + throw new Error('Cannot enable Agent Merge for an archived session.'); + } + const values = this._configurationService.getSessionConfigValues(session); + if (!values) { + throw new Error('Cannot update Agent Merge before session configuration is available.'); + } + const current = readAgentMergeSessionState(values); + if (current?.enabled) { + return JSON.stringify({ enabled: true }); + } + const workingDirectories = this._stateManager.getChatWorkingDirectories(chatUri) ?? state.workingDirectories; + if (!workingDirectories?.length) { + return this._setEnabledForSession(session, true); + } + if (workingDirectories.length !== 1) { + throw new Error('Cannot enable Agent Merge unless the current chat has exactly one working directory.'); + } + const workingDirectory = URI.parse(workingDirectories[0]); + const currentBranchName = this._gitService.getCurrentBranchName + ? await this._gitService.getCurrentBranchName(workingDirectory) + : undefined; + const branchName = currentBranchName ?? await this._gitService.getCurrentBranch(workingDirectory); + if (!branchName) { + throw new Error('Cannot enable Agent Merge because the current branch could not be determined.'); + } + const enabledAt = new Date().toISOString(); + this._configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { + enabled: true, + ...(current?.overrides ? { overrides: current.overrides } : {}), + }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName, + chatUri, + workingDirectory: workingDirectories[0], + enabledAt, + commentWatermark: enabledAt, + announcementPending: true, + }, + }, + }); + this._logService.info(`[AgentMergeTools] Set chat-targeted session enablement: session=${session}, chat=${chatUri}`); + return JSON.stringify({ enabled: true }); + } + async readFailedCI(session: string, input: AgentMergeCIRequest = {}): Promise { const original = this._requireTurnAction(session, 'fixCI'); const request = parseAgentMergeCIRequest(input); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index cf9ba9908c198..81b45e6a52ead 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -64,10 +64,11 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentSessionResidency } from './agentSessionResidency.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; -import { type IAgentServiceSessionServerToolAccessor, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; +import { type IAddSessionWorkingDirectoryOptions, type IAgentServiceSessionServerToolAccessor, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { SessionArtifacts } from './shared/sessionArtifacts.js'; +import { readSessionAdditionalWorktrees, writeSessionAdditionalWorktrees, type ISessionAdditionalWorktree } from './shared/sessionAdditionalWorktrees.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; import { AgentHostCatalogDatabaseReference, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, decodeAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; @@ -99,12 +100,12 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsCompactPromptsConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsCompactPromptsConfigKey, AgentHostArtifactToolsConfigKey, AgentHostCreateSessionRelationshipConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, CHANGES_SUMMARY_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, fromCatalogChatOrigin } from './agentHostCatalogSourceResolver.js'; -import { AgentHostPeerChatStore, CHAT_PROVIDER_DATA_METADATA_KEY, IPersistedPeerChat } from './agentHostPeerChatStore.js'; +import { AgentHostPeerChatStore, CHAT_PROVIDER_DATA_METADATA_KEY, CHAT_WORKING_DIRECTORIES_METADATA_KEY, IPersistedPeerChat } from './agentHostPeerChatStore.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; import { IAgentHostTurnService } from './agentHostTurnService.js'; @@ -408,6 +409,7 @@ export interface IAgentServiceCallbacks { readonly postAgentMergeNotice: IAgentMergeControllerOptions['postNotice']; readonly resolveWorkingDirectoryBeforeSend: NonNullable; readonly resolveChatAttachmentTurns: NonNullable; + readonly setAdditionalWorktreesArchived: NonNullable; readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor; readonly artifactServerToolAccessor: IArtifactServerToolAccessor; /** Writes list-visible session metadata through the `sessions_v2` catalog and awaits the sync receipt. */ @@ -460,6 +462,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _resourceWriteQueue = this._register(new ResourceQueue()); private readonly _chatCatalogMutationSequencer = new SequencerByKey(); + private readonly _additionalWorktreeSequencer = new SequencerByKey(); /** Protocol: fires when state is mutated by an action. */ private readonly _onDidAction = this._register(new Emitter()); @@ -729,6 +732,7 @@ export class AgentService extends Disposable implements IAgentService { postAgentMergeNotice: (session, kind, content) => this._postAgentMergeNotice(session, kind, content), resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), + setAdditionalWorktreesArchived: (session, archived, strictCleanup) => this._setAdditionalWorktreesArchived(session, archived, strictCleanup), sessionServerToolAccessor: this._createSessionServerToolAccessor(), artifactServerToolAccessor: this._createArtifactServerToolAccessor(), persistListVisibleSessionState: (session, values) => this._persistListVisibleSessionState(URI.parse(session), values), @@ -786,6 +790,7 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); let externalSessionsMode = this._getExternalSessionsMode(); let agentMergeEnabled = this._isAgentMergeEnabled(); + let createSessionRelationshipEnabled = this._isCreateSessionRelationshipEnabled(); this._register(this._configurationService.onDidRootConfigChange(() => { const nextMode = this._getExternalSessionsMode(); if (nextMode !== externalSessionsMode) { @@ -813,6 +818,13 @@ export class AgentService extends Disposable implements IAgentService { .catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err)); } } + const nextCreateSessionRelationshipEnabled = this._isCreateSessionRelationshipEnabled(); + if (nextCreateSessionRelationshipEnabled !== createSessionRelationshipEnabled) { + createSessionRelationshipEnabled = nextCreateSessionRelationshipEnabled; + for (const session of this._stateManager.getSessionUris()) { + this._serverToolHost.advertise(session); + } + } })); this._register(this._gitHubEndpointService.onDidChange(() => { this._stateManager.emitAuthRequired({ @@ -1293,7 +1305,7 @@ export class AgentService extends Disposable implements IAgentService { getCreationDefaults: source => this._getServerToolCreationDefaults(source), startPrompt: (session, chat, prompt, delegation) => this._startSessionPrompt(session, chat, prompt, delegation), createChat: (session, chat, options) => this.createChat(session, chat, options), - addSessionWorkingDirectory: (session, directory) => this._addSessionWorkingDirectoryForChat(session, directory), + addSessionWorkingDirectory: (session, directory, options) => this._addSessionWorkingDirectoryForChat(session, directory, options), renameChat: (session, chat, title) => this._renameChatFromTool(session, chat, title), reportToolError: (toolName, error) => this._logService.error(`[AgentService] ${toolName} failed after the tool returned: ${toErrorMessage(error)}`), deleteSession: session => this.disposeSession(session), @@ -1337,6 +1349,10 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) === true; } + private _isCreateSessionRelationshipEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostCreateSessionRelationshipConfigKey) !== false; + } + /** * Reads a session's persisted artifacts and references, warning when any are * lost. A corrupt row would otherwise empty a session's artifacts pill with @@ -1413,11 +1429,11 @@ export class AgentService extends Disposable implements IAgentService { return true; } - private _startAgentMergePrompt(session: string, turnId: string, prompt: string): boolean { + private _startAgentMergePrompt(chat: string, turnId: string, prompt: string): boolean { + const session = parseRequiredSessionUriFromChatUri(chat); if (this._stateManager.hasActiveTurn(session)) { return false; } - const chat = buildDefaultChatUri(session).toString(); const message: Message = { text: prompt, origin: { kind: MessageKind.SystemNotification }, @@ -1445,18 +1461,19 @@ export class AgentService extends Disposable implements IAgentService { * notice on a turn the provider owns, so restore would replay that turn * without it. */ - private _postAgentMergeNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { + private _postAgentMergeNotice(chat: string, kind: AgentSystemNotificationKind, content: string): void { + const session = parseRequiredSessionUriFromChatUri(chat); if (this._stateManager.hasActiveTurn(session)) { const pending = this._pendingAgentMergeNotices.get(session); if (pending) { - pending.push({ kind, content }); + pending.push({ chat, kind, content }); } else { - this._pendingAgentMergeNotices.set(session, [{ kind, content }]); + this._pendingAgentMergeNotices.set(session, [{ chat, kind, content }]); } this._logService.debug(`[AgentService] Deferring an Agent Merge notice until the session is idle: session=${session}`); return; } - this._writeAgentMergeNotice(session, kind, content); + this._writeAgentMergeNotice(session, chat, kind, content); } /** Emits the notices that were waiting for a session's turn to end. */ @@ -1466,15 +1483,13 @@ export class AgentService extends Disposable implements IAgentService { return; } this._pendingAgentMergeNotices.delete(session); - for (const { kind, content } of pending) { - this._writeAgentMergeNotice(session, kind, content); + for (const { chat, kind, content } of pending) { + this._writeAgentMergeNotice(session, chat, kind, content); } } /** Writes one Agent Merge notice as a completed, host-owned local turn. */ - private _writeAgentMergeNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { - const chat = buildDefaultChatUri(session); - const channel = chat.toString(); + private _writeAgentMergeNotice(session: string, channel: string, kind: AgentSystemNotificationKind, content: string): void { const turnId = generateUuid(); this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatTurnStarted, @@ -1492,7 +1507,7 @@ export class AgentService extends Disposable implements IAgentService { }, }); this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatTurnComplete, turnId, duration: 0 }); - const turns = this._stateManager.getSessionState(chat)?.turns; + const turns = this._stateManager.getSessionState(channel)?.turns; const recorded = turns?.find(turn => turn.id === turnId); if (turns && recorded) { this._localTurns.record(session, channel, recorded, this._localTurns.findAnchorTurnId(channel, turns, turnId)); @@ -1503,8 +1518,7 @@ export class AgentService extends Disposable implements IAgentService { * Cancels a repair turn this host started for Agent Merge, so a stopped or * revoked controller cannot leave an autonomous turn running. */ - private _cancelAgentMergePrompt(session: string, turnId: string): void { - const chat = buildDefaultChatUri(session).toString(); + private _cancelAgentMergePrompt(chat: string, turnId: string): void { const action = { type: ActionType.ChatTurnCancelled, turnId, duration: 0 } as const; this._stateManager.dispatchServerAction(chat, action); this._sideEffects.handleAction(chat, action); @@ -2112,7 +2126,7 @@ export class AgentService extends Disposable implements IAgentService { private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); /** Agent Merge notices waiting for a session's in-flight turn to finish. */ - private readonly _pendingAgentMergeNotices = new Map(); + private readonly _pendingAgentMergeNotices = new Map(); /** Test surface: settles once the startup Agent Merge restore pass and the index writes it enqueued have run. */ async whenAgentMergeSessionsRestored(): Promise { @@ -4154,6 +4168,11 @@ export class AgentService extends Disposable implements IAgentService { return this._worktree.setDetachedWorktreeArchived(handle, archived); } + private async _setAdditionalWorktreesArchived(session: URI, archived: boolean, strictCleanup: boolean): Promise { + const worktrees = await readSessionAdditionalWorktrees(this._sessionDataService, session); + await Promise.all(worktrees.map(worktree => this._worktree.setDetachedWorktreeArchived(worktree.handle, archived, strictCleanup))); + } + claimDetachedWorktree(handle: string): Promise { return this._worktree.claimDetachedWorktree(handle); } @@ -4297,7 +4316,14 @@ export class AgentService extends Disposable implements IAgentService { : existing); this._catalogSyncSuppressedSessions.add(sessionKey); try { - await this._peerChatStore.upsert(session, chat, providerData, peerChatOrigin, createResult?.inheritedTurnId); + await this._peerChatStore.upsert( + session, + chat, + providerData, + peerChatOrigin, + createResult?.inheritedTurnId, + createOptions?.workingDirectories?.map(directory => directory.toString()), + ); if (title !== undefined) { await persistSessionMetadataValues(this._sessionDataService, chat.toString(), { [SESSION_CUSTOM_TITLE_KEY]: title, @@ -5023,9 +5049,6 @@ export class AgentService extends Disposable implements IAgentService { // Attach git state for the resolved process root (index 0), if present. void this._gitStateService.refreshSessionGitState(sessionKey, e.workingDirectories?.[0]); - // If a client subscribed to this session's uncommitted changeset - // before the working directory was known, recompute the current - // subscriptions now that the working directory is set. this._changesetCoordinator.onSessionMaterialized(sessionKey); } @@ -5327,9 +5350,12 @@ export class AgentService extends Disposable implements IAgentService { const sessionId = AgentSession.id(session); const persistedPeerChats = sessionChats.length === 0 ? await this._peerChatStore.tryRead(session) : undefined; const worktree = await this._worktree.prepareSessionDeletion(session, sessionId); - const cleanupWorkingDirectories = worktree?.repositoryRoot + const additionalWorktrees = await readSessionAdditionalWorktrees(this._sessionDataService, session); + let cleanupWorkingDirectories = worktree?.repositoryRoot ? [worktree.repositoryRoot.toString(), ...(workingDirectories?.slice(1) ?? [])] : workingDirectories; + cleanupWorkingDirectories = cleanupWorkingDirectories?.map(directory => + additionalWorktrees.find(candidate => candidate.workingDirectory === directory)?.repositoryRoot ?? directory); await this._peerChatStore.beginSessionDeletion(session); peerChatDeletionBegun = true; const provider = this._providerService.getProviderForSession(session); @@ -5369,6 +5395,9 @@ export class AgentService extends Disposable implements IAgentService { // session the working directory *is* the worktree, so once it is gone // the repository can no longer be resolved and the refs would leak // into the main repository (`refs/agents/*` is shared, not per-worktree). + for (const additionalWorktree of additionalWorktrees) { + await this._worktree.deleteDetachedWorktree(additionalWorktree.handle); + } await this._sessionDataService.deleteSessionData(session, cleanupWorkingDirectories); await this._worktree.removeSessionWorktree(sessionId, worktree); this._changesetCoordinator.onSessionDisposed(session.toString()); @@ -6116,7 +6145,71 @@ export class AgentService extends Disposable implements IAgentService { }); } - private async _addSessionWorkingDirectoryForChat(session: URI, directory: URI): Promise { + private async _addSessionWorkingDirectoryForChat(session: URI, directory: URI, options: IAddSessionWorkingDirectoryOptions): Promise { + return this._additionalWorktreeSequencer.queue(session.toString(), async () => { + const workingDirectories = this._stateManager.getSessionSummary(session.toString())?.workingDirectories ?? []; + const existing = workingDirectories.find(candidate => isEqual(URI.parse(candidate), directory)); + if (existing !== undefined && !(options.isolation === 'worktree' && options.forceNewWorktree)) { + return URI.parse(existing); + } + if (options.isolation !== 'worktree') { + return this._attachSessionWorkingDirectoryForChat(session, directory); + } + + const worktreeRoots = await this._gitService.getWorktreeRoots(directory); + const existingWorktree = workingDirectories.find(candidate => + worktreeRoots.some(root => isEqual(root, URI.parse(candidate)))); + if (existingWorktree !== undefined && !options.forceNewWorktree) { + return URI.parse(existingWorktree); + } + + const repositoryRoot = worktreeRoots[0] ?? directory; + const defaultBranch = await this._gitService.getDefaultBranch(repositoryRoot); + const selectedBranch = defaultBranch?.name + ?? await this._gitService.getCurrentBranch(repositoryRoot) + ?? 'HEAD'; + const sessionConfig = this._configurationService.getSessionConfigValues(session.toString()); + const detached = await this._worktree.createDetachedWorktree({ + workingDirectory: directory, + config: { + ...sessionConfig, + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: selectedBranch, + }, + prompt: options.prompt, + githubToken: this._authService.getAuthToken({ + resource: this._gitHubEndpointService.getCopilotResource().resource, + scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported, + }), + }); + const record: ISessionAdditionalWorktree = { + handle: detached.handle, + workingDirectory: detached.worktree.toString(), + repositoryRoot: repositoryRoot.toString(), + }; + let recorded = false; + try { + const records = await readSessionAdditionalWorktrees(this._sessionDataService, session); + await writeSessionAdditionalWorktrees(this._sessionDataService, session, [...records, record]); + recorded = true; + await this._worktree.claimDetachedWorktree(detached.handle); + return await this._attachSessionWorkingDirectoryForChat(session, detached.worktree); + } catch (error) { + try { + if (recorded) { + const records = await readSessionAdditionalWorktrees(this._sessionDataService, session); + await writeSessionAdditionalWorktrees(this._sessionDataService, session, records.filter(candidate => candidate.handle !== detached.handle)); + } + await this._worktree.deleteDetachedWorktree(detached.handle); + } catch (cleanupError) { + this._logService.error(`[AgentService] Failed to clean up additional worktree ${detached.handle}: ${toErrorMessage(cleanupError)}`); + } + throw error; + } + }); + } + + private async _attachSessionWorkingDirectoryForChat(session: URI, directory: URI): Promise { const sessionKey = session.toString(); const state = this._stateManager.getSessionState(sessionKey); const workingDirectories = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories; @@ -6142,6 +6235,18 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`Cannot preserve the working directories of chat ${unresolvedChat.summary.resource}.`); } + for (const chat of resolvedChats) { + if (chat.state?.workingDirectories !== undefined) { + continue; + } + if (isDefaultChatUri(chat.summary.resource)) { + await persistSessionMetadataValues(this._sessionDataService, chat.summary.resource, { + [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: JSON.stringify(workingDirectories), + }); + } else { + await this._peerChatStore.upsert(session, URI.parse(chat.summary.resource), undefined, undefined, undefined, workingDirectories); + } + } for (const chat of resolvedChats) { if (chat.state?.workingDirectories !== undefined) { continue; @@ -7165,6 +7270,8 @@ export class AgentService extends Disposable implements IAgentService { const cachedChats = await this._readCachedChatCatalog(session); const cachedDefaultChat = cachedChats?.find(chat => chat.kind === 'default'); + const defaultChatWorkingDirectories = await this._readDefaultChatWorkingDirectories(defaultChatUri) + ?? cachedDefaultChat?.workingDirectories; const { draft: defaultDraft, title: defaultChatTitle } = await this._chatContributions.hydrateChat({ session: sessionStr, chat: defaultChatUri.toString(), @@ -7193,7 +7300,7 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle, - defaultChatWorkingDirectories: cachedDefaultChat?.workingDirectories, + defaultChatWorkingDirectories, }); if (adoptionListVisible) { const adoptionMetadata: Record = {}; @@ -7311,22 +7418,37 @@ export class AgentService extends Disposable implements IAgentService { uri: chat.uri, ...(chat.origin !== undefined ? { origin: chat.origin } : {}), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + ...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}), }))); } - await this._restorePeerChatsFromCatalog(session, entries, cached); + const cachedWorkingDirectories = new Map(cached?.filter(chat => chat.kind === 'peer' && chat.workingDirectories !== undefined).map(chat => [chat.uri, chat.workingDirectories])); + const enrichedEntries = entries.map(entry => entry.workingDirectories === undefined && cachedWorkingDirectories.get(entry.uri) !== undefined + ? { ...entry, workingDirectories: cachedWorkingDirectories.get(entry.uri) } + : entry); + if (enrichedEntries.some((entry, index) => entry !== entries[index])) { + await this._peerChatStore.replace(session, enrichedEntries); + } + await this._restorePeerChatsFromCatalog(session, enrichedEntries, cached); await this._persistOrderedListVisibleSessionState(session, {}); } private async _readCentralChatCatalog(session: URI): Promise { const peers = await this._peerChatStore.tryRead(session); if (peers !== undefined) { + const defaultChatUri = buildDefaultChatUri(session.toString()); + const defaultChatWorkingDirectories = await this._readDefaultChatWorkingDirectories(URI.parse(defaultChatUri)); return [ - { uri: buildDefaultChatUri(session.toString()), kind: 'default' }, + { + uri: defaultChatUri, + kind: 'default', + ...(defaultChatWorkingDirectories !== undefined ? { workingDirectories: defaultChatWorkingDirectories } : {}), + }, ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, origin: peer.origin, inheritedTurnId: peer.inheritedTurnId, + workingDirectories: peer.workingDirectories, })), ]; } @@ -7370,6 +7492,7 @@ export class AgentService extends Disposable implements IAgentService { uri: chat.uri, ...(chat.origin !== undefined ? { origin: chat.origin } : {}), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + ...(chat.workingDirectories !== undefined ? { workingDirectories: chat.workingDirectories } : {}), })); let legacy: readonly IAgentLegacyChat[] = []; try { @@ -7425,7 +7548,7 @@ export class AgentService extends Disposable implements IAgentService { session: session.toString(), chat: chatUri.toString(), }, cachedTitle ? { title: cachedTitle } : {}); - return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId, workingDirectories: cachedChat?.workingDirectories }; + return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId, workingDirectories: entry.workingDirectories ?? cachedChat?.workingDirectories }; })); for (const item of restored) { if (!item) { @@ -7679,6 +7802,32 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _readDefaultChatWorkingDirectories(defaultChat: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase?.(defaultChat); + if (!ref) { + return undefined; + } + let raw: string | undefined; + try { + raw = await ref.object.getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY); + } finally { + ref.dispose(); + } + if (!raw) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.every(directory => typeof directory === 'string')) { + throw new Error('expected an array of working-directory URIs'); + } + return parsed; + } catch (error) { + this._logService.warn(`[AgentService] Ignoring malformed default-chat working directories for ${defaultChat.toString()}: ${toErrorMessage(error)}`); + return undefined; + } + } + private async _isWorkspaceConversionQuarantined(session: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 7f217d7b51b26..aaf9174e6e53c 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -36,6 +36,7 @@ import { AgentMergeController } from './agentMergeController.js'; import { AgentMergeTools } from './agentMergeTools.js'; import { AgentServerToolHost, IAgentHostServerToolService } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; +import { AgentHostCreateSessionRelationshipConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; @@ -138,6 +139,7 @@ export function createAgentServiceComposition( hostLaunchKind: options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, resolveWorkingDirectoryBeforeSend: params => callbackAdapter.value.resolveWorkingDirectoryBeforeSend(params), resolveChatAttachmentTurns: resource => callbackAdapter.value.resolveChatAttachmentTurns(resource), + setAdditionalWorktreesArchived: (session, archived, strictCleanup) => callbackAdapter.value.setAdditionalWorktreesArchived(session, archived, strictCleanup), }, )); const agentMergeTools = owned.add(instantiationService.createInstance( @@ -162,7 +164,12 @@ export function createAgentServiceComposition( }; const serverToolHost = new AgentServerToolHost( stateManager, - buildServerToolGroups(sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), + buildServerToolGroups( + sessionServerToolAccessor, + agentMergeTools, + callbackAdapter.artifactServerToolAccessor, + () => configurationService.getRootValue(platformRootSchema, AgentHostCreateSessionRelationshipConfigKey) !== false, + ), ); services.set(IAgentHostServerToolService, serverToolHost); workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService)); diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index c9c77ec181327..9b27cf3dc612f 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -37,7 +37,7 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), startPrompt: (session, chat, prompt, delegation) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt, delegation), createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), - addSessionWorkingDirectory: (session, directory) => this.value.sessionServerToolAccessor.addSessionWorkingDirectory(session, directory), + addSessionWorkingDirectory: (session, directory, options) => this.value.sessionServerToolAccessor.addSessionWorkingDirectory(session, directory, options), renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), deleteSession: session => this.value.sessionServerToolAccessor.deleteSession(session), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 8365e08cb28a9..140df93a83a0a 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -109,6 +109,8 @@ export interface IAgentSideEffectsOptions { readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise; /** Resolves a referenced chat's turns, hydrating its owning session when needed. */ readonly resolveChatAttachmentTurns?: (resource: ProtocolURI) => Promise; + /** Applies archive lifecycle changes to worktrees created for additional session folders. */ + readonly setAdditionalWorktreesArchived?: (session: URI, archived: boolean, strictCleanup: boolean) => Promise; /** Process launcher used when client-origin metadata is unavailable. */ readonly hostLaunchKind?: AgentHostLaunchKind; } @@ -1616,6 +1618,8 @@ export class AgentSideEffects extends Disposable { : this._worktree.cleanupWorktreeOnArchive(sessionUri, sessionId) : this._worktree.recreateWorktreeOnUnarchive(sessionUri, sessionId); worktreeOp.catch(err => this._logService.warn(`[AgentSideEffects] worktree ${action.isArchived ? 'cleanup' : 'recreate'} failed for ${channel}`, err)); + this._options.setAdditionalWorktreesArchived?.(sessionUri, action.isArchived, automaticArchive) + .catch(err => this._logService.warn(`[AgentSideEffects] additional worktree ${action.isArchived ? 'cleanup' : 'recreate'} failed for ${channel}`, err)); const agent = this._options.getAgent(channel); agent?.onArchivedChanged?.(URI.parse(channel), action.isArchived).catch(err => { this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${channel}`, err); diff --git a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts index 8765b5da76217..03148437d2e35 100644 --- a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts @@ -82,7 +82,7 @@ const definitions: readonly IAgentServerToolDefinition[] = [ export interface IAgentMergeToolAccessor { isEnabled(): boolean; - setEnabled(session: string, enabled: boolean): string; + setEnabled(session: string, chatUriOrEnabled: string | boolean, enabled?: boolean): string | Promise; readFailedCI(session: string, request?: AgentMergeCIRequest): Promise; replyToReviewThread(session: string, threadId: string, body: string, resolve: boolean): Promise; rerunFailedWorkflow(session: string, runId: string, failedJobsOnly: boolean): Promise; @@ -160,7 +160,7 @@ export function createAgentMergeServerToolGroup(accessor?: IAgentMergeToolAccess if (Object.keys(args).some(key => key !== 'enabled')) { throw new Error(`Invalid ${toolName} input: only enabled is supported.`); } - return accessor.setEnabled(context.sessionUri, requiredBoolean(args.enabled, 'enabled', toolName)); + return accessor.setEnabled(context.sessionUri.toString(), context.chatUri.toString(), requiredBoolean(args.enabled, 'enabled', toolName)); } case readAgentMergeCIToolName: return accessor.readFailedCI(context.sessionUri, parseAgentMergeCIRequest(rawArgs)); diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index b236cf04d0695..79aaa9165eb3e 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -68,7 +68,7 @@ export const artifactServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: ArtifactServerToolName.AddArtifactOrReference, title: 'Add Artifact or Reference', - description: `Record one or more artifacts or references so they are surfaced next to the chat input. Use \`items\` and batch related entries in one call when practical. Registration is optional, not an inventory of everything saved; default to no registration. ${artifactClassification} Other artifacts are deliverables the user requested or standalone results they are clearly likely to reopen, download, or reuse, such as a report or plan the user asked for. References are noteworthy existing resources the user will likely want to view. Do not record routine files, scratch files, caches, logs, intermediate results, or configuration snapshots unless the user asked for them as deliverables; persistence or location outside the workspace is not an eligibility signal. Do not record incidental resources, commits you create unless the user asks, or sessions and chats created with session-management tools. Never create, copy, or relocate a file solely to have an artifact to register. Adding an artifact promotes a matching reference, preserving its id; duplicates never downgrade artifacts.`, + description: `Record one or more artifacts or references for the current chat so they are surfaced next to its input. Use \`items\` and batch related entries in one call when practical. Registration is optional, not an inventory of everything saved; default to no registration. ${artifactClassification} Other artifacts are deliverables the user requested or standalone results they are clearly likely to reopen, download, or reuse, such as a report or plan the user asked for. References are noteworthy existing resources the user will likely want to view. Do not record routine files, scratch files, caches, logs, intermediate results, or configuration snapshots unless the user asked for them as deliverables; persistence or location outside the workspace is not an eligibility signal. Do not record incidental resources, commits you create unless the user asks, or sessions and chats created with session-management tools. Never create, copy, or relocate a file solely to have an artifact to register. Adding an artifact promotes a matching reference, preserving its id; duplicates never downgrade artifacts.`, inputSchema: createAddArtifactInputSchema(artifactInputSchema), annotations: { readOnlyHint: false }, deferLoading: false, @@ -76,7 +76,7 @@ export const artifactServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: ArtifactServerToolName.RemoveArtifactOrReference, title: 'Remove Artifact or Reference', - description: 'Remove an artifact or reference from this session by id.', + description: 'Remove an artifact or reference visible to the current chat by id.', inputSchema: removeArtifactInputSchema, annotations: { readOnlyHint: false, destructiveHint: true }, deferLoading: true, @@ -84,7 +84,7 @@ export const artifactServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: ArtifactServerToolName.ListArtifactsAndReferences, title: 'List Artifacts and References', - description: 'List the artifacts and references recorded on this session, with their ids.', + description: 'List the artifacts and references visible to the current chat, with their ids.', inputSchema: listArtifactsInputSchema, annotations: { readOnlyHint: true }, deferLoading: true, @@ -212,7 +212,7 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce const result = await artifacts.mutate(collection => { const messages: string[] = []; for (const input of inputs) { - const result = collection.addOrPromoteArtifact(input, generateUuid); + const result = collection.addOrPromoteArtifact({ ...input, chat: context.chatUri }, generateUuid); const status = result.added ? `Added ${entryNoun(result.artifact.isArtifact)}` : result.artifacts !== collection.artifacts ? 'Promoted artifact' : 'Already recorded'; @@ -228,7 +228,12 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce if (typeof id !== 'string' || id.length === 0) { throw new Error(`Invalid ${ArtifactServerToolName.RemoveArtifactOrReference} input: id must be a non-empty string.`); } - const result = await artifacts.mutate(collection => collection.remove(id)); + const result = await artifacts.mutate(collection => { + const artifact = collection.artifacts.find(artifact => artifact.id === id); + return artifact?.chat === undefined || artifact.chat === context.chatUri + ? collection.remove(id) + : { artifacts: collection.artifacts, removed: undefined }; + }); if (!result.removed) { return `No artifact or reference with id ${id}.`; } @@ -236,9 +241,10 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce return `${message}: ${result.removed.id}`; } case ArtifactServerToolName.ListArtifactsAndReferences: { - const current = artifacts.read().artifacts; + const current = artifacts.read().artifacts.filter(artifact => + artifact.chat === undefined || artifact.chat === context.chatUri); return current.length === 0 - ? 'No artifacts or references recorded for this session.' + ? 'No artifacts or references recorded for this chat.' : current.map(describeArtifact).join('\n'); } default: @@ -253,7 +259,7 @@ const artifactToolDiscoveryInstruction = `List/remove (discover if needed): \`${ /** Compact first-turn guidance for the prompt treatment. */ export const ARTIFACT_TOOLS_INSTRUCTION = `Artifact registration is optional; default to none. Follow \`${ArtifactServerToolName.AddArtifactOrReference}\` eligibility rules and batch related entries. ${artifactToolDiscoveryInstruction}`; -const ORIGINAL_ARTIFACT_TOOLS_INSTRUCTION = `Record notable artifacts and references with \`${ArtifactServerToolName.AddArtifactOrReference}\` so they are surfaced next to the chat input. Registration is optional, not an inventory of everything saved; default to no registration. ${artifactClassification} Other artifacts are deliverables the user explicitly requested or standalone results the user is clearly likely to reopen, download, or reuse; references are existing resources the user will likely want to view. Batch related entries in one call when practical. Do not record routine files, scratch files, caches, logs, intermediate results, or configuration snapshots unless the user asked for them as deliverables; persistence or location outside the workspace is not an eligibility signal. Do not record incidental resources, commits you create unless the user asks, or sessions and chats created with session-management tools. Never create, copy, or relocate a file solely to have an artifact to register. ${artifactToolDiscoveryInstruction}`; +const ORIGINAL_ARTIFACT_TOOLS_INSTRUCTION = `Record notable artifacts and references for the current chat with \`${ArtifactServerToolName.AddArtifactOrReference}\` so they are surfaced next to its input. Registration is optional, not an inventory of everything saved; default to no registration. ${artifactClassification} Other artifacts are deliverables the user explicitly requested or standalone results the user is clearly likely to reopen, download, or reuse; references are existing resources the user will likely want to view. Batch related entries in one call when practical. Do not record routine files, scratch files, caches, logs, intermediate results, or configuration snapshots unless the user asked for them as deliverables; persistence or location outside the workspace is not an eligibility signal. Do not record incidental resources, commits you create unless the user asks, or sessions and chats created with session-management tools. Never create, copy, or relocate a file solely to have an artifact to register. ${artifactToolDiscoveryInstruction}`; export function getArtifactToolsInstruction(useCompactPrompts: boolean): string { return useCompactPrompts ? ARTIFACT_TOOLS_INSTRUCTION : ORIGINAL_ARTIFACT_TOOLS_INSTRUCTION; diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index d0a4f156d7044..f5b391527decc 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -25,10 +25,10 @@ import { createArtifactServerToolGroup, type IArtifactServerToolAccessor } from * When omitted (the pure display path) the session group's `execute` is inert, * but its definitions and display remain available. */ -export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor): readonly IServerToolGroup[] { +export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor, isCreateSessionRelationshipEnabled?: () => boolean): readonly IServerToolGroup[] { return [ feedbackServerToolGroup, - createSessionServerToolGroup(sessionAccessor), + createSessionServerToolGroup(sessionAccessor, isCreateSessionRelationshipEnabled), createAgentMergeServerToolGroup(agentMergeAccessor), createArtifactServerToolGroup(artifactAccessor), ]; diff --git a/src/vs/platform/agentHost/node/shared/sessionAdditionalWorktrees.ts b/src/vs/platform/agentHost/node/shared/sessionAdditionalWorktrees.ts new file mode 100644 index 0000000000000..590f5bef17fb9 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/sessionAdditionalWorktrees.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { isAgentDevContainerWorktreeHandle } from '../../common/meta/agentDevContainerWorktreeMeta.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; + +const ADDITIONAL_WORKTREES_METADATA_KEY = 'agentHost.additionalWorktrees'; + +export interface ISessionAdditionalWorktree { + readonly handle: string; + readonly workingDirectory: string; + readonly repositoryRoot: string; +} + +/** Reads the additional repository worktrees owned by a multi-folder session. */ +export async function readSessionAdditionalWorktrees(sessionDataService: ISessionDataService, session: URI): Promise { + const ref = await sessionDataService.tryOpenDatabase(session); + if (!ref) { + return []; + } + try { + const raw = await ref.object.getMetadata(ADDITIONAL_WORKTREES_METADATA_KEY); + if (raw === undefined) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid additional worktree metadata for ${session.toString()}`, { cause: error }); + } + if (!Array.isArray(parsed) || parsed.some(value => !isSessionAdditionalWorktree(value))) { + throw new Error(`Invalid additional worktree metadata for ${session.toString()}`); + } + return parsed; + } finally { + ref.dispose(); + } +} + +/** Replaces the additional repository worktrees owned by a multi-folder session. */ +export async function writeSessionAdditionalWorktrees(sessionDataService: ISessionDataService, session: URI, worktrees: readonly ISessionAdditionalWorktree[]): Promise { + const ref = sessionDataService.openDatabase(session); + try { + if (worktrees.length === 0) { + await ref.object.deleteMetadata([ADDITIONAL_WORKTREES_METADATA_KEY]); + } else { + await ref.object.setMetadata(ADDITIONAL_WORKTREES_METADATA_KEY, JSON.stringify(worktrees)); + } + } finally { + ref.dispose(); + } +} + +function isSessionAdditionalWorktree(value: unknown): value is ISessionAdditionalWorktree { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const candidate = value as Partial; + return typeof candidate.handle === 'string' + && isAgentDevContainerWorktreeHandle(candidate.handle) + && typeof candidate.workingDirectory === 'string' + && candidate.workingDirectory.length > 0 + && typeof candidate.repositoryRoot === 'string' + && candidate.repositoryRoot.length > 0; +} diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 22cd9c05e7013..1cda200060d11 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -77,13 +77,25 @@ const createSessionInputSchema: ToolDefinition['inputSchema'] = { }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, workspace: { type: 'string', description: 'Workspace for the delegated work: a unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, - worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior.' }, + worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree; for `currentSession`, this always creates a fresh worktree even when the repository already has another session worktree. Set false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior, including inheriting and reusing it when a current-session chat adds a repository already represented in the session.' }, title: { type: 'string', maxLength: 200, description: 'Short title for the new chat or independent session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, }, required: ['relationship', 'prompt', 'title'], }; +const createSessionWithoutRelationshipInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Initial prompt to send to the new chat.' }, + workspace: { type: 'string', description: 'Optional workspace for the delegated work: a unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, + worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree; this always creates a fresh worktree even when the repository already has another session worktree. Set false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior, including inheriting and reusing it when the new chat adds a repository already represented in the session.' }, + title: { type: 'string', maxLength: 200, description: 'Short title for the new chat.' }, + model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model and must belong to the current session\'s provider.' }, + }, + required: ['prompt', 'title'], +}; + const getCurrentSessionInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: {}, @@ -208,6 +220,14 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ }, ]; +const sessionServerToolDefinitionsWithoutCreateSessionRelationship: readonly IAgentServerToolDefinition[] = sessionServerToolDefinitions.map(definition => definition.name === SessionServerToolName.CreateSession + ? { + ...definition, + description: 'Create delegated work as a new chat in the current session and start it with an initial prompt.', + inputSchema: createSessionWithoutRelationshipInputSchema, + } + : definition); + /** Resolves the owning backend session URI for the channel a tool call runs on. */ export function currentSessionUri(toolCallChannel: ProtocolURI): URI { const owning = parseChatUri(toolCallChannel) ?? undefined; @@ -226,6 +246,7 @@ interface ICreateSessionArgs { export type IResolvedCreateSessionArgs = { readonly relationship: 'currentSession'; readonly workspace?: URI; + readonly worktree?: boolean; readonly prompt: string; readonly title: string; readonly model?: IAgentModelInfo; @@ -238,6 +259,12 @@ export type IResolvedCreateSessionArgs = { readonly model?: IAgentModelInfo; }; +export interface IAddSessionWorkingDirectoryOptions { + readonly isolation?: 'folder' | 'worktree'; + readonly forceNewWorktree?: boolean; + readonly prompt: string; +} + /** AgentService-owned operations used by the session server-tool group. */ export interface IAgentServiceSessionServerToolAccessor { readonly isActiveAgentTitleGenerationEnabled: () => boolean; @@ -250,7 +277,7 @@ export interface IAgentServiceSessionServerToolAccessor { readonly getCreationDefaults: (source: URI) => ISessionCreationDefaults | undefined; readonly startPrompt: (session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta) => Promise; readonly createChat: (session: URI, chat: URI, options?: IAgentCreateChatRequestOptions) => Promise; - readonly addSessionWorkingDirectory: (session: URI, directory: URI) => Promise; + readonly addSessionWorkingDirectory: (session: URI, directory: URI, options: IAddSessionWorkingDirectoryOptions) => Promise; readonly renameChat: (session: URI, chat: URI, title: string) => Promise; readonly reportToolError: (toolName: SessionServerToolName, error: unknown) => void; readonly deleteSession: (session: URI) => Promise; @@ -464,9 +491,11 @@ function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMet return parsed; } -async function getCreateSessionCatalog(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise { +async function getCreateSessionCatalog(accessor: ISessionServerToolAccessor, rawArgs: unknown, relationshipEnabled: boolean): Promise { const args = (rawArgs ?? {}) as ICreateSessionArgs; - getCreateSessionRelationship(args); + if (relationshipEnabled) { + getCreateSessionRelationship(args); + } const workspace = getOptionalString(args.workspace, 'workspace', SessionServerToolName.CreateSession); if (workspace === undefined) { return []; @@ -511,9 +540,9 @@ function getCreateSessionRelationship(rawArgs: unknown): CreateSessionRelationsh } /** Validates and resolves create-session arguments against current sessions and models. */ -export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[], models: readonly IAgentModelInfo[], currentProvider?: AgentProvider): IResolvedCreateSessionArgs { +export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[], models: readonly IAgentModelInfo[], currentProvider?: AgentProvider, relationshipEnabled = true): IResolvedCreateSessionArgs { const args = (rawArgs ?? {}) as ICreateSessionArgs; - const relationship = getCreateSessionRelationship(args); + const relationship = relationshipEnabled ? getCreateSessionRelationship(args) : 'currentSession'; const prompt = getRequiredString(args.prompt, 'prompt', SessionServerToolName.CreateSession); const title = getRequiredString(args.title, 'title', SessionServerToolName.CreateSession); validateRenameTitle(title, SessionServerToolName.CreateSession); @@ -522,12 +551,10 @@ export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgent const worktree = getOptionalBoolean(args.worktree, 'worktree', SessionServerToolName.CreateSession); const model = resolveModel(modelName, models, relationship === 'currentSession' ? currentProvider : undefined); if (relationship === 'currentSession') { - if (worktree !== undefined) { - throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: worktree is only valid when relationship is "independent".`); - } return { relationship, ...(workspace !== undefined ? { workspace: resolveWorkspace(workspace, sessions) } : {}), + ...(worktree !== undefined ? { worktree } : {}), prompt, title, ...(model !== undefined ? { model } : {}), @@ -799,11 +826,12 @@ export interface ICreateSessionResult { /** * Creates work with the requested relationship and sends its initial prompt. */ -export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string): Promise { - const sessions = await getCreateSessionCatalog(accessor, rawArgs); +export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string, relationshipEnabled = true): Promise { + const sessions = await getCreateSessionCatalog(accessor, rawArgs, relationshipEnabled); const currentSession = source ? currentSessionUri(source.toString()) : undefined; const currentProvider = currentSession ? AgentSession.provider(currentSession) : undefined; - const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider); + const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider, relationshipEnabled); + const defaults = source ? accessor.getCreationDefaults(source) : undefined; if (args.relationship === 'currentSession') { if (!currentSession) { throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: relationship "currentSession" requires an invoking session.`); @@ -812,7 +840,11 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: model "${args.model.id}" belongs to provider "${args.model.provider}", but relationship "currentSession" targets provider "${currentProvider}".`); } const workingDirectory = args.workspace !== undefined - ? await accessor.addSessionWorkingDirectory(currentSession, args.workspace) + ? await accessor.addSessionWorkingDirectory(currentSession, args.workspace, { + isolation: args.worktree === undefined ? defaults?.isolation : args.worktree ? 'worktree' : 'folder', + ...(args.worktree === true ? { forceNewWorktree: true } : {}), + prompt: args.prompt, + }) : undefined; const result = await createChat(accessor, { session: currentSession, @@ -835,7 +867,6 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso workspace = primaryRoot; } } - const defaults = source ? accessor.getCreationDefaults(source) : undefined; const provider = args.model?.provider ?? defaults?.provider; const inheritsSourceProvider = provider !== undefined && provider === defaults?.provider; const inheritedProviderConfig = inheritsSourceProvider ? defaults?.config : undefined; @@ -1513,12 +1544,14 @@ function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServe * and never invokes {@link IServerToolGroup.execute}. `execute` throws when no * accessor was provided. */ -export function createSessionServerToolGroup(accessor?: ISessionServerToolAccessor): IServerToolGroup { +export function createSessionServerToolGroup(accessor?: ISessionServerToolAccessor, isCreateSessionRelationshipEnabled: () => boolean = () => true): IServerToolGroup { let createdSessionCount = 0; let createdChatCount = 0; let sentMessageCount = 0; const group: IServerToolGroup = { - definitions: sessionServerToolDefinitions, + get definitions() { + return isCreateSessionRelationshipEnabled() ? sessionServerToolDefinitions : sessionServerToolDefinitionsWithoutCreateSessionRelationship; + }, // Remove after 2026-10-26; self-mapped because its arguments differ from create_session. legacyToolNames: new Map([[SessionServerToolName.CreateChat, SessionServerToolName.CreateChat]]), materializeDefinitions: true, @@ -1532,6 +1565,9 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return sessionToolRequiresConfirmation(toolName); }, getDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { + if (toolName === SessionServerToolName.CreateSession && !isCreateSessionRelationshipEnabled()) { + return getSessionToolDisplay(toolName, { relationship: 'currentSession' }, result); + } return getSessionToolDisplay(toolName, args, result); }, async execute(stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown): Promise { @@ -1554,14 +1590,15 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return applySetWorkspaceTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); } case SessionServerToolName.CreateSession: { - const relationship = getCreateSessionRelationship(rawArgs); + const relationshipEnabled = isCreateSessionRelationshipEnabled(); + const relationship = relationshipEnabled ? getCreateSessionRelationship(rawArgs) : 'currentSession'; if (relationship === 'currentSession' && createdChatCount >= maxCreatedChats) { throw new Error(`Refusing to create more than ${maxCreatedChats} chats from server tools in this process.`); } if (relationship === 'independent' && createdSessionCount >= maxCreatedSessions) { throw new Error(`Refusing to create more than ${maxCreatedSessions} sessions from server tools in this process.`); } - const result = await applyCreateSessionTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); + const result = await applyCreateSessionTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId, relationshipEnabled); if (relationship === 'currentSession') { createdChatCount++; } else { diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index d7a04c225b173..f487875b9e1b1 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -23,7 +23,7 @@ import { ISchemaProperty, schemaProperty } from '../../common/agentHostSchema.js import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { DEV_CONTAINER_WORKTREE_DATA_ID_PREFIX, isAgentDevContainerWorktreeHandle } from '../../common/meta/agentDevContainerWorktreeMeta.js'; -import { getWorktreesRoot } from '../../common/worktreePaths.js'; +import { deriveRepositoryRootFromWorktree, getWorktreesRoot } from '../../common/worktreePaths.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, ResponsePart, ResponsePartKind, Turn } from '../../common/state/sessionState.js'; import { AGENT_BRANCH_PREFIX, IAgentBranchNameGenerator } from './agentBranchNameGenerator.js'; @@ -53,7 +53,7 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt resolveOnFirstSend(request: IResolveWorkingDirectoryRequest): Promise; createDetachedWorktree(request: Omit): Promise<{ handle: string; worktree: URI }>; claimDetachedWorktree(handle: string): Promise; - setDetachedWorktreeArchived(handle: string, archived: boolean): Promise; + setDetachedWorktreeArchived(handle: string, archived: boolean, strictCleanup?: boolean): Promise; deleteDetachedWorktree(handle: string): Promise; reconcileDetachedWorktrees(scope: string, activeHandles: readonly string[]): Promise; resolveIsolationConfig(request: IResolveIsolationConfigRequest): Promise; @@ -574,7 +574,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } } - async setDetachedWorktreeArchived(handle: string, archived: boolean): Promise { + async setDetachedWorktreeArchived(handle: string, archived: boolean, strictCleanup?: boolean): Promise { const record = detachedWorktreeRecordUri(handle); const ref = await this._sessionDataService.tryOpenDatabase(record); if (!ref) { @@ -587,7 +587,11 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI ref.dispose(); } if (archived) { - await this.cleanupWorktreeOnArchive(record, handle); + if (strictCleanup) { + await this.cleanupWorktree(record, handle); + } else { + await this.cleanupWorktreeOnArchive(record, handle); + } } else { await this.recreateWorktreeOnUnarchive(record, handle); } @@ -1561,7 +1565,7 @@ export class NullAgentHostWorktreeIsolation implements IAgentHostWorktreeIsolati async resolveOnFirstSend(_request: IResolveWorkingDirectoryRequest): Promise { return undefined; } async createDetachedWorktree(_request: Omit): Promise<{ handle: string; worktree: URI }> { throw new Error('Worktree isolation is not supported.'); } async claimDetachedWorktree(_handle: string): Promise { } - async setDetachedWorktreeArchived(_handle: string, _archived: boolean): Promise { } + async setDetachedWorktreeArchived(_handle: string, _archived: boolean, _strictCleanup?: boolean): Promise { } async deleteDetachedWorktree(_handle: string): Promise { } async reconcileDetachedWorktrees(_scope: string, _activeHandles: readonly string[]): Promise { } async resolveIsolationConfig(_request: IResolveIsolationConfigRequest): Promise { return undefined; } @@ -1596,22 +1600,8 @@ function projectFromRepositoryRoot(repositoryRoot: URI): IAgentSessionProjectInf return { uri: repositoryRoot, displayName: basename(repositoryRoot.fsPath) || repositoryRoot.toString() }; } -function deriveRepositoryRootFromWorktree(worktree: URI): URI | undefined { - if (worktree.scheme !== Schemas.file) { - return undefined; - } - const worktreesRoot = URI.joinPath(worktree, '..'); - const worktreesRootName = basename(worktreesRoot.fsPath); - const suffix = '.worktrees'; - if (!worktreesRootName.endsWith(suffix)) { - return undefined; - } - const repositoryName = worktreesRootName.slice(0, -suffix.length); - return repositoryName ? URI.joinPath(worktreesRoot, '..', repositoryName) : undefined; -} - /** - * Builds the repository {@link IAgentSessionProjectInfo} from a persisted + * Builds the repository {@link IAgentSessionProjectInfo} from a persisted * {@link WORKTREE_META_REPOSITORY_ROOT} value (a URI string), or `undefined` * when absent. Lets the host merge the repository project into a session's * catalog entry directly from a metadata batch it already read, without a diff --git a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts index 83f35a93b0ef2..82b861aa0c687 100644 --- a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts @@ -74,6 +74,26 @@ suite('Session Artifacts', () => { }); }); + test('deduplicates values independently for each owning chat', () => { + const input = parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md', isArtifact: true }, TOOL); + const first = new SessionArtifactCollection().add({ ...input, chat: 'ahp-chat://default/first' }, createId); + const second = new SessionArtifactCollection(first.artifacts).add({ ...input, chat: 'ahp-chat://peer/second' }, createId); + const duplicate = new SessionArtifactCollection(second.artifacts).add({ ...input, chat: 'ahp-chat://default/first' }, createId); + + assert.deepStrictEqual({ + artifacts: duplicate.artifacts, + added: duplicate.added, + duplicateId: duplicate.artifact.id, + }, { + artifacts: [ + { id: 'id-1', ...input, chat: 'ahp-chat://default/first' }, + { id: 'id-2', ...input, chat: 'ahp-chat://peer/second' }, + ], + added: false, + duplicateId: 'id-1', + }); + }); + test('promotes a duplicate reference to an artifact while preserving its id', () => { const reference = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'pullRequest', label: 'Referenced PR', link: 'https://github.com/microsoft/vscode/pull/1', isArtifact: false }, TOOL), createId); const promoted = new SessionArtifactCollection(reference.artifacts).addOrPromoteArtifact(parseSessionArtifactInput({ type: 'pullRequest', label: 'Pull Request #1', link: 'https://github.com/microsoft/vscode/pull/1', isArtifact: true }, TOOL), createId); @@ -145,7 +165,10 @@ suite('Session Artifacts', () => { }); test('round-trips artifacts through the meta bag and the session database', () => { - const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash', isArtifact: true }, TOOL), createId); + const added = new SessionArtifactCollection().add({ + ...parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash', isArtifact: true }, TOOL), + chat: 'ahp-chat://default/session', + }, createId); const meta = withSessionArtifacts({ other: 'kept' }, added.artifacts); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index ecab41eba15a9..529681a4ad4cb 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -578,6 +578,7 @@ export function createNoopGitStateService(): IAgentHostGitStateService { onDidRefreshSessionGitState: Event.None, onDidChangeSessionGitHubState: Event.None, refreshSessionGitState: async (_sessionKey: string, _workingDirectory?: URI) => { }, + findPullRequestForWorkingDirectory: async (_sessionKey: string, _workingDirectory: URI) => undefined, getMaterializedWorktreeMeta: (_sessionKey: string, _branchName: string) => undefined, resolveSessionBaseBranchName: async (_sessionKey: string) => undefined, setSessionGitHubState: async (_sessionKey: string, _state: ISessionGitHubState) => { }, diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 29c044ca8c251..3dcda5ddede82 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -16,7 +16,7 @@ import { AGENT_CUSTOMIZATION_SETTINGS_META_KEY, getAgentCustomizationSettingsEnt import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigState } from '../../common/state/protocol/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildChatUri, buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories, getEffectiveWorkingDirectory } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -176,6 +176,25 @@ suite('AgentConfigurationService', () => { assert.deepStrictEqual(getEffectiveWorkingDirectories(manager, uri), ['file:///work', 'file:///work-2']); }); + test('does not collapse the session set to the default chat scope', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri, 'file:///work', 'file:///work-2')); + manager.dispatchServerAction(buildDefaultChatUri(uri), { + type: ActionType.ChatWorkingDirectorySet, + directory: 'file:///work', + }); + + assert.deepStrictEqual({ + mergedSessionWorkingDirectories: manager.getSessionState(uri)?.workingDirectories, + primaryWorkingDirectory: getEffectiveWorkingDirectory(manager, uri), + workingDirectories: getEffectiveWorkingDirectories(manager, uri), + }, { + mergedSessionWorkingDirectories: ['file:///work'], + primaryWorkingDirectory: 'file:///work', + workingDirectories: ['file:///work', 'file:///work-2'], + }); + }); + test('falls back to the parent session set for subagents', () => { const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); manager.createSession(makeSummary(parent, 'file:///work/parent', 'file:///work/parent-2')); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts index 280d9d23f1987..2207ef16d684e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -149,6 +149,27 @@ suite('AgentHostCatalogProjection', () => { }); + test('preserves artifact chat attribution', () => { + const data = createData(); + const chat = 'ahp-chat://peer/c2Vzc2lvbg'; + const encoded = encode({ + ...data, + _meta: { + ...data._meta, + [SESSION_META_ARTIFACTS_KEY]: [{ + id: 'artifact-1', + type: 'website', + label: 'Chat artifact', + chat, + isArtifact: true, + link: 'https://example.com', + }], + }, + }); + + assert.strictEqual(encoded.data._meta?.[SESSION_META_ARTIFACTS_KEY]?.[0].chat, chat); + }); + test('retains detached-head state and the newest bounded artifact suffix', () => { const data = createData(); const artifacts = Array.from({ length: AGENT_HOST_CATALOG_ARTIFACT_LIMIT + 2 }, (_, index) => ({ diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 6ee6d2017fb12..711e8ffac8603 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -11,10 +11,10 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; -import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, ChangesetOperationScope, ChangesetOperationStatus, SessionStatus, withSessionGitState, type ISessionFileDiff, type ISessionGitHubState, type ISessionGitState } from '../../common/state/sessionState.js'; +import { buildChatUri, buildSubagentSessionUri, ChangesetOperationScope, ChangesetOperationStatus, SessionStatus, withSessionGitState, type ISessionFileDiff, type ISessionGitHubState, type ISessionGitState } from '../../common/state/sessionState.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostChangesetCoordinator } from '../../node/agentHostChangesetCoordinator.js'; import { resolveChangesetSubscriptions } from '../../node/agentHostChangesetSummary.js'; @@ -127,6 +127,27 @@ suite('ChangesetSessionCoordinator', () => { }); } + test('releases a peer chat turn subscription from its parent session', () => { + const { coordinator, stateManager, subscriptions } = createEnvironment(); + const session = AgentSession.uri('mock', 'peer-turn-subscription').toString(); + const chat = buildChatUri(session, 'peer'); + const changeset = buildTurnChangesetUri(chat, 'turn-1'); + createSession(stateManager, session); + stateManager.addChat(session, chat, { workingDirectories: [URI.file('/peer').toString()] }); + + coordinator.onFirstSubscriber(URI.parse(changeset)); + const subscribed = [...subscriptions.getSessionSubscriptions(session)]; + coordinator.onLastSubscriber(URI.parse(changeset)); + + assert.deepStrictEqual({ + subscribed, + released: [...subscriptions.getSessionSubscriptions(session)], + }, { + subscribed: [changeset], + released: [], + }); + }); + for (const previousIsolation of [undefined, 'folder', 'worktree']) { for (const isolation of ['folder', 'worktree']) { test(`restoring ${isolation} isolation from ${previousIsolation ?? 'unresolved'} refreshes only a changed summary source`, () => { @@ -329,24 +350,71 @@ suite('ChangesetSessionCoordinator', () => { } } - test('refreshes changeset operations when a session gains or loses a working directory', () => { + test('refreshes subscribed changesets and operations when a session gains or loses a working directory', () => { const session = AgentSession.uri('mock', 'session-wd').toString(); const environment = createEnvironment(); createSession(environment.stateManager, session, 'file:///repoA'); + environment.coordinator.onFirstSubscriber(URI.parse(buildBranchChangesetUri(session))); + environment.changesets.clearRefreshes(); const baseline = environment.updateOperationsCalls.length; - // Editor Window adds a second root -> multi-root: operations must refresh. environment.stateManager.dispatchServerAction(session, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///repoB' }); - assert.deepStrictEqual(environment.updateOperationsCalls.slice(baseline), [session], 'adding a root refreshes the session operations'); + assert.deepStrictEqual({ + operations: environment.updateOperationsCalls.slice(baseline), + recomputed: environment.changesets.recomputed, + branch: environment.changesets.branchRefreshes, + }, { + operations: [session], + recomputed: [session], + branch: [session], + }); - // A no-op working-directory action (same root) must not refresh again. const afterAdd = environment.updateOperationsCalls.length; + environment.changesets.clearRefreshes(); environment.stateManager.dispatchServerAction(session, { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///repoB' }); - assert.strictEqual(environment.updateOperationsCalls.length, afterAdd, 'a no-op working-directory action does not refresh'); + assert.deepStrictEqual({ + operations: environment.updateOperationsCalls.slice(afterAdd), + recomputed: environment.changesets.recomputed, + branch: environment.changesets.branchRefreshes, + }, { + operations: [], + recomputed: [], + branch: [], + }); - // Removing the second root -> back to single-root: operations refresh again (restore). environment.stateManager.dispatchServerAction(session, { type: ActionType.SessionWorkingDirectoryRemoved, directory: 'file:///repoB' }); - assert.deepStrictEqual(environment.updateOperationsCalls.slice(afterAdd), [session], 'removing a root refreshes the session operations'); + assert.deepStrictEqual({ + operations: environment.updateOperationsCalls.slice(afterAdd), + recomputed: environment.changesets.recomputed, + branch: environment.changesets.branchRefreshes, + }, { + operations: [session], + recomputed: [session], + branch: [session], + }); + }); + + test('chat-owned repository changeset subscriptions refresh their parent session', () => { + const session = AgentSession.uri('mock', 'session-chat-changesets').toString(); + const chat = buildChatUri(session, 'peer'); + const branchChangeset = buildBranchChangesetUri(chat); + const uncommittedChangeset = buildUncommittedChangesetUri(chat); + const environment = createEnvironment(); + createSession(environment.stateManager, session, 'file:///repoA'); + environment.stateManager.addChat(session, chat, { workingDirectories: ['file:///repoA'] }); + + environment.coordinator.onFirstSubscriber(URI.parse(branchChangeset)); + environment.coordinator.onFirstSubscriber(URI.parse(uncommittedChangeset)); + + assert.deepStrictEqual({ + branchRefreshes: environment.changesets.branchRefreshes, + uncommittedRefreshes: environment.changesets.uncommittedRefreshes, + subscriptions: [...environment.subscriptions.getSessionSubscriptions(session)].sort(), + }, { + branchRefreshes: [session], + uncommittedRefreshes: [session], + subscriptions: [branchChangeset, uncommittedChangeset].sort(), + }); }); test('refreshes the changeset catalogue when Agent Merge enablement changes', () => { @@ -476,13 +544,14 @@ suite('ChangesetSessionCoordinator', () => { }); }); - test('forwards session changeset refresh and recomputes current subscriptions on materialization', async () => { + test('recomputes current subscriptions once when a session is materialized', async () => { const session = AgentSession.uri('mock', 'session-1').toString(); const environment = createEnvironment(); createSession(environment.stateManager, session, undefined, false); environment.coordinator.onFirstSubscriber(URI.parse(buildSessionChangesetUri(session))); await tick(); + environment.changesets.clearRefreshes(); const summary = environment.stateManager.getSessionSummary(session)!; environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] }); @@ -491,10 +560,10 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual({ sessionRefreshes: environment.changesets.sessionRefreshes, - workingDirectoryAvailable: environment.changesets.workingDirectoryAvailable, + recomputed: environment.changesets.recomputed, }, { sessionRefreshes: [session], - workingDirectoryAvailable: [session], + recomputed: [session], }); }); @@ -1110,6 +1179,7 @@ class TestGitStateService extends Disposable implements IAgentHostGitStateServic this.refreshedWith.push({ sessionKey, workingDirectory: workingDirectory?.toString() }); this._onDidRefreshSessionGitState.fire(sessionKey); } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } async resolveSessionBaseBranchName(): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } @@ -1248,7 +1318,8 @@ class TestChangesetService implements IAgentHostChangesetService { } } async computeUncommittedChangeset(session: string): Promise { - if (this._subscriptions.getSessionSubscriptions(session).has(URI.parse(buildUncommittedChangesetUri(session)).toString())) { + if ([...this._subscriptions.getSessionSubscriptions(session)] + .some(changeset => parseChangesetUri(changeset)?.kind === ChangesetKind.Uncommitted)) { this.uncommittedRefreshes.push(session); } return `${session}/changeset/uncommitted`; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index e99aeedc18d4b..0e1f4b340f417 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -16,7 +16,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../common/state/protocol/channels-changeset/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { JsonRpcErrorCodes } from '../../common/state/sessionProtocol.js'; -import { ChangesetOperationScope, ChangesetOperationStatus, ISessionGitHubState, ISessionGitState, MessageKind, SessionStatus, buildDefaultChatUri, type ChangesetOperation, type SessionSummary } from '../../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ISessionGitHubState, ISessionGitState, MessageKind, SessionStatus, buildChatUri, buildDefaultChatUri, type ChangesetOperation, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentHostChangesetOperationService } from '../../node/agentHostChangesetOperationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; @@ -91,6 +91,7 @@ class TestGitStateService implements IAgentHostGitStateService { readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } async resolveSessionBaseBranchName(): Promise { return undefined; } @@ -446,17 +447,60 @@ suite('AgentHostChangesetOperationService', () => { assert.deepStrictEqual(compareOperations, sampleOperations); }); - test('multi-folder session keeps operations for branch and uncommitted changesets', () => { + test('multi-folder aggregate changesets keep only concretely targeted operations', () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; const service = createService(stateManager, new TestConfigurationService(['file:///a', 'file:///b'])); - disposables.add(service.registerContribution(new OperationsContribution(sampleOperations))); + const resourceOperation: ChangesetOperation = { + id: 'resource-operation', + label: 'Discard', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }; + disposables.add(service.registerContribution(new OperationsContribution([...sampleOperations, resourceOperation]))); const branchOperations = service.getOperations(sessionKey, buildBranchChangesetUri(sessionKey), sampleGitState); const uncommittedOperations = service.getOperations(sessionKey, buildUncommittedChangesetUri(sessionKey), sampleGitState); - assert.deepStrictEqual(branchOperations, sampleOperations); - assert.deepStrictEqual(uncommittedOperations, sampleOperations); + assert.deepStrictEqual(branchOperations, [resourceOperation]); + assert.deepStrictEqual(uncommittedOperations, [resourceOperation]); + }); + + test('chat-owned uncommitted changes use the registered repository target', () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const sessionKey = 'agent:/session'; + const chatUri = buildChatUri(sessionKey, 'peer'); + const changesetUri = buildUncommittedChangesetUri(chatUri); + const service = createService(stateManager, new TestConfigurationService(['file:///primary', 'file:///peer'])); + let observedContext: IChangesetOperationContext | undefined; + disposables.add(service.registerContribution(new class implements IChangesetOperationContribution { + registerHandlers(): IDisposable { + return { dispose() { } }; + } + getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] { + observedContext = context; + return sampleOperations; + } + dispose(): void { } + })); + const targetGitState: ISessionGitState = { ...sampleGitState, branchName: 'peer-branch' }; + service.setChangesetTarget(sessionKey, changesetUri, { + ownerUri: chatUri, + workingDirectories: ['file:///peer'], + gitState: targetGitState, + }); + + const operations = service.getOperations(sessionKey, changesetUri, { ...sampleGitState, branchName: 'primary-branch' }); + + assert.deepStrictEqual({ + operations, + changesetUri: observedContext?.changesetUri, + gitState: observedContext?.gitState, + }, { + operations: sampleOperations, + changesetUri, + gitState: targetGitState, + }); }); test('joins duplicate in-flight invocations for the same changeset operation', async () => { @@ -531,6 +575,54 @@ suite('AgentHostChangesetOperationService', () => { }); }); + test('serializes operations from different chat channels that share a worktree', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const service = createService(stateManager); + const handler = new TestHandler(); + disposables.add(service.registerContribution(new TestContribution(handler))); + const sessionKey = 'agent:/session'; + stateManager.createSession({ + resource: sessionKey, + provider: 'agent', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectories: ['file:///shared-worktree'], + }); + const chats = ['one', 'two'].map(chatId => buildChatUri(sessionKey, chatId)); + const channels = chats.map(chat => buildBranchChangesetUri(chat)); + for (let index = 0; index < channels.length; index++) { + const channel = channels[index]; + stateManager.addChat(sessionKey, chats[index], { workingDirectories: ['file:///shared-worktree'] }); + stateManager.registerChangeset(channel); + stateManager.dispatchServerAction(channel, { + type: ActionType.ChangesetOperationsChanged, + operations: [{ id: testOperationId, label: 'Commit', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle }], + }); + service.setChangesetTarget(sessionKey, channel, { + ownerUri: chats[index], + workingDirectories: ['file:///shared-worktree'], + gitState: sampleGitState, + }); + } + + const first = service.invokeChangesetOperation({ channel: channels[0], operationId: testOperationId }); + const second = service.invokeChangesetOperation({ channel: channels[1], operationId: testOperationId }); + assert.strictEqual(handler.calls, 1); + handler.complete({ message: { markdown: 'Done' } }); + + const results = await Promise.all([first, second]); + + assert.deepStrictEqual({ calls: handler.calls, results }, { + calls: 2, + results: [ + { message: { markdown: 'Done' } }, + { message: { markdown: 'Done' } }, + ], + }); + }); + test('publishes running and idle state around a successful changeset operation', async () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; @@ -636,6 +728,40 @@ suite('AgentHostChangesetOperationService', () => { assert.strictEqual(handler.calls, 0); }); + test('rejects a chat-owned operation after its chat is removed', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const sessionKey = 'agent:/session'; + const chat = buildChatUri(sessionKey, 'removed'); + const changesetUri = buildBranchChangesetUri(chat); + stateManager.createSession({ + resource: sessionKey, + provider: 'agent', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectories: ['file:///repo'], + }); + stateManager.addChat(sessionKey, chat, { workingDirectories: ['file:///repo'] }); + stateManager.registerChangeset(changesetUri); + stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetOperationsChanged, + operations: [{ id: testOperationId, label: 'Commit', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle }], + }); + const service = createService(stateManager); + const handler = new TestHandler(); + disposables.add(service.registerContribution(new TestContribution(handler))); + service.setChangesetTarget(sessionKey, changesetUri, { + ownerUri: chat, + workingDirectories: ['file:///repo'], + gitState: sampleGitState, + }); + stateManager.removeChat(sessionKey, chat); + + await assert.rejects(service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId }), /chat no longer exists/); + assert.strictEqual(handler.calls, 0); + }); + test('allows invocation of a turn operation in a single-root session', async () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index b64b3471ca1f7..a109969f778df 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -17,7 +17,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { toAgentWorkspaceContinuationMessageMeta } from '../../common/meta/agentWorkspaceContinuationMeta.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildChatUri, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildChatUri, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { CHANGES_SUMMARY_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../../common/agentHostChangesetService.js'; @@ -69,6 +69,7 @@ function createOperationService(): IAgentHostChangesetOperationService { return { _serviceBrand: undefined, registerContribution: () => toDisposable(() => { }), + setChangesetTarget: () => { }, updateOperations: () => { }, getOperations: () => undefined, invokeChangesetOperation: async () => { throw new Error('not implemented'); }, @@ -523,6 +524,7 @@ suite.skip('AgentHostChangesetService', () => { { after: { uri: 'file:///wd/b.ts', content: { uri: 'file:///wd/b.ts' } }, diff: { added: 2, removed: 1 } }, ]; const stubGit = { + getRepositoryRoot: async (workingDirectory: URI) => workingDirectory, computeSessionFileDiffs: async () => gitDiffs, } as unknown as IAgentHostGitService; @@ -597,6 +599,7 @@ suite.skip('AgentHostChangesetService', () => { test('git throws: state goes Error with original message', async () => { const stubGit = { + getRepositoryRoot: async (workingDirectory: URI) => workingDirectory, computeSessionFileDiffs: async () => { throw new Error('git command failed'); }, } as unknown as IAgentHostGitService; const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -1267,6 +1270,7 @@ suite('AgentHostChangesetService - materialization refresh', () => { const worktreeDirectory = 'file:///repo/worktree'; const computedWorkingDirectories: string[] = []; const gitService = createNoopGitService(); + gitService.getRepositoryRoot = async workingDirectory => workingDirectory; gitService.computeSessionFileDiffs = async workingDirectory => { computedWorkingDirectories.push(workingDirectory.toString()); return []; @@ -1439,7 +1443,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { } await timeout(1); } - assert.fail(`changeset ${changesetUri} never reached Ready`); + assert.fail(`changeset ${changesetUri} never reached Ready: ${JSON.stringify(stateManager.getChangesetState(changesetUri))}`); } function build(options: { @@ -1450,9 +1454,10 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { log?: RecordingLogService; telemetry?: ITelemetryService; review?: IAgentHostReviewService; + operationService?: IAgentHostChangesetOperationService; subscriptions?: string[]; isolation?: 'folder' | 'worktree'; - peer?: { resource: string; db: TestSessionDatabase; turnId: string; onDispose?: () => void; openError?: Error }; + peer?: { resource: string; db: TestSessionDatabase; turnId: string; workingDirectories?: readonly string[]; onDispose?: () => void; openError?: Error }; }): { svc: AgentHostChangesetService; stateManager: AgentHostStateManager; log: RecordingLogService } { const log = options.log ?? new RecordingLogService(); const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -1493,7 +1498,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { options.git, options.checkpoint, disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), - createOperationService(), + options.operationService ?? createOperationService(), createSubscriptionService(...(options.subscriptions ?? [])), options.review ?? NULL_REVIEW_SERVICE, options.telemetry ?? NullTelemetryService, @@ -1514,7 +1519,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }); } if (options.peer) { - stateManager.addChat(sessionStr, options.peer.resource); + stateManager.addChat(sessionStr, options.peer.resource, { workingDirectories: options.peer.workingDirectories ? [...options.peer.workingDirectories] : undefined }); stateManager.dispatchServerAction(options.peer.resource, { type: ActionType.ChatTurnStarted, turnId: options.peer.turnId, @@ -2391,6 +2396,137 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }); }); + test('publishes chat-owned Branch Changes with the chat repository target', async () => { + const peerChat = buildChatUri(sessionStr, 'peer-repository'); + const peerChangeset = buildBranchChangesetUri(peerChat); + const targets: { changeset: string; ownerUri: string; workingDirectories: readonly string[]; branchName: string | undefined }[] = []; + const operationService: IAgentHostChangesetOperationService = { + ...createOperationService(), + setChangesetTarget: (_session, changeset, target) => { + if (target) { + targets.push({ + changeset, + ownerUri: target.ownerUri, + workingDirectories: target.workingDirectories, + branchName: target.gitState?.branchName, + }); + } + }, + }; + const git = createNoopGitService(); + git.getRepositoryRoot = async workingDirectory => workingDirectory; + git.getSessionGitState = async workingDirectory => ({ + baseBranchName: 'main', + branchName: workingDirectory.path === '/repoB' ? 'peer-branch' : 'primary-branch', + hasGitHubRemote: true, + githubOwner: 'microsoft', + githubRepo: workingDirectory.path === '/repoB' ? 'peer' : 'primary', + }); + git.computeSessionFileDiffs = async workingDirectory => [gitDiff(`${workingDirectory.path}/branch.ts`)]; + const { svc, stateManager } = build({ + workingDirectories: ['file:///repoA', 'file:///repoB'], + git, + checkpoint: NULL_CHECKPOINT_SERVICE, + operationService, + subscriptions: [peerChangeset], + peer: { + resource: peerChat, + db: new TestSessionDatabase(), + turnId: 'peer-turn', + workingDirectories: ['file:///repoB'], + }, + }); + + svc.refreshBranchChangeset(sessionStr); + await waitForChangesetReady(stateManager, branchChangeset); + await timeout(20); + + assert.deepStrictEqual({ + chats: stateManager.getSessionChats(sessionStr).map(chat => chat.resource), + files: stateManager.getChangesetState(peerChangeset)?.files.map(file => file.id), + targets: targets.filter(target => target.changeset === peerChangeset), + }, { + chats: [buildDefaultChatUri(sessionStr), peerChat], + files: [URI.file('/repoB/branch.ts').toString()], + targets: [{ + changeset: peerChangeset, + ownerUri: peerChat, + workingDirectories: ['file:///repoB'], + branchName: 'peer-branch', + }], + }); + }); + + test('publishes chat-owned Uncommitted Changes with files and operations from the chat repository', async () => { + const peerChat = buildChatUri(sessionStr, 'peer-repository'); + const peerChangeset = buildUncommittedChangesetUri(peerChat); + const targets: { changeset: string; ownerUri: string; workingDirectories: readonly string[]; branchName: string | undefined }[] = []; + const operationService: IAgentHostChangesetOperationService = { + ...createOperationService(), + setChangesetTarget: (_session, changeset, target) => { + if (target) { + targets.push({ + changeset, + ownerUri: target.ownerUri, + workingDirectories: target.workingDirectories, + branchName: target.gitState?.branchName, + }); + } + }, + getOperations: (_session, changeset, gitState) => changeset === peerChangeset + ? [{ + id: `commit-${gitState?.branchName}`, + label: 'Commit', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] + : undefined, + }; + const git = createNoopGitService(); + git.getRepositoryRoot = async workingDirectory => workingDirectory; + git.getSessionGitState = async workingDirectory => ({ + baseBranchName: 'main', + branchName: workingDirectory.path === '/repoB' ? 'peer-branch' : 'primary-branch', + }); + git.computeSessionFileDiffs = async workingDirectory => [gitDiff(`${workingDirectory.path}/uncommitted.ts`)]; + const { svc, stateManager } = build({ + workingDirectories: ['file:///repoA', 'file:///repoB'], + git, + checkpoint: NULL_CHECKPOINT_SERVICE, + operationService, + subscriptions: [peerChangeset], + peer: { + resource: peerChat, + db: new TestSessionDatabase(), + turnId: 'peer-turn', + workingDirectories: ['file:///repoB'], + }, + }); + stateManager.dispatchServerAction(buildDefaultChatUri(sessionStr), { + type: ActionType.ChatWorkingDirectoryRemoved, + directory: 'file:///repoB', + }); + + await svc.computeUncommittedChangeset(sessionStr); + + assert.deepStrictEqual({ + aggregateFiles: stateManager.getChangesetState(buildUncommittedChangesetUri(sessionStr))?.files.map(file => file.id), + peerFiles: stateManager.getChangesetState(peerChangeset)?.files.map(file => file.id), + peerOperations: stateManager.getChangesetState(peerChangeset)?.operations?.map(operation => operation.id), + targets: targets.filter(target => target.changeset === peerChangeset), + }, { + aggregateFiles: ['/repoA/uncommitted.ts', '/repoB/uncommitted.ts'].map(path => URI.file(path).toString()), + peerFiles: [URI.file('/repoB/uncommitted.ts').toString()], + peerOperations: ['commit-peer-branch'], + targets: [{ + changeset: peerChangeset, + ownerUri: peerChat, + workingDirectories: ['file:///repoB'], + branchName: 'peer-branch', + }], + }); + }); + test('multi-root Session Changes uses each repository baseline and the latest peer turn', async () => { const calls: { root: string; fromRef: string; toRef: string }[] = []; const turnCalls: { root: string | undefined; turnId: string }[] = []; diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index 4a1c83a0dcad4..5494a6ba07d88 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -37,6 +37,7 @@ class FailingChangesetOperationService extends Disposable implements IAgentHostC }); } + setChangesetTarget(): void { } updateOperations(): void { } getOperations() { return []; } async invokeChangesetOperation(): Promise { throw new Error('Not implemented'); } @@ -47,6 +48,7 @@ const nullGitStateService: IAgentHostGitStateService = { onDidRefreshSessionGitState: Event.None, onDidChangeSessionGitHubState: Event.None, async refreshSessionGitState() { }, + async findPullRequestForWorkingDirectory() { return undefined; }, getMaterializedWorktreeMeta() { return undefined; }, async resolveSessionBaseBranchName() { return undefined; }, async setSessionGitHubState() { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index eac90b81084b7..6e884e8ab88b9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -12,7 +12,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { SessionStatus, buildChatUri, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostDiscardChangesOperationHandler } from '../../node/agentHostDiscardChangesOperationHandler.js'; import type { IAgentHostGitService, IBranch, IDefaultBranch } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -68,7 +68,7 @@ class TestGitService implements IAgentHostGitService { async getDiffPatchBetweenRefs(): Promise { return undefined; } } -function setup(disposables: Pick, opts?: { readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; session: URI } { +function setup(disposables: Pick, opts?: { readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean; readonly workingDirectories?: readonly URI[] }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; session: URI; stateManager: AgentHostStateManager } { const gitService = new TestGitService(); const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); @@ -80,7 +80,9 @@ function setup(disposables: Pick, opts?: { readonly with status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectories: opts?.withWorkingDirectory === false ? undefined : [URI.file('/repo').toString()], + workingDirectories: opts?.withWorkingDirectory === false + ? undefined + : (opts?.workingDirectories ?? [URI.file('/repo')]).map(directory => directory.toString()), }); } const handler = new AgentHostDiscardChangesOperationHandler( @@ -88,7 +90,7 @@ function setup(disposables: Pick, opts?: { readonly with gitService, new NullLogService(), ); - return { handler, gitService, session }; + return { handler, gitService, session, stateManager }; } function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] { @@ -119,6 +121,27 @@ suite('AgentHostDiscardChangesOperationHandler', () => { }); }); + test('restores a chat-owned resource through the chat working directory', async () => { + const primaryDirectory = URI.file('/primary'); + const peerDirectory = URI.file('/peer'); + const { handler, gitService, session, stateManager } = setup(disposables, { workingDirectories: [primaryDirectory, peerDirectory] }); + const chat = buildChatUri(session.toString(), 'peer'); + const target = URI.file('/peer/src/file.ts'); + stateManager.addChat(session.toString(), chat, { workingDirectories: [peerDirectory.toString()] }); + + await handler.invoke({ + channel: buildUncommittedChangesetUri(chat), + operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES, + target: makeResourceTarget(target), + }, CancellationToken.None); + + assert.deepStrictEqual(gitService.restoreCalls, [{ + workingDirectory: peerDirectory.toString(), + paths: [target.fsPath], + options: undefined, + }]); + }); + test('rejects channels that are not uncommitted-changeset URIs', async () => { const { handler, gitService, session } = setup(disposables); const target = URI.file('/repo/src/file.ts'); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 01624041fb667..e4d819ac928c4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -820,6 +820,48 @@ suite('AgentHostGitStateService', () => { }); }); + test('finds a pull request for another worktree without changing session Git metadata', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + const primaryGitState: ISessionGitState = { + branchName: 'primary', + baseBranchName: 'main', + githubOwner: 'microsoft', + githubRepo: 'vscode', + }; + const peerWorkingDirectory = URI.file('/peer-worktree'); + seedSession(h.stateManager, { + workingDirectory: WORKING_DIRECTORY, + gitState: primaryGitState, + gitHubState: { owner: 'microsoft', repo: 'vscode' }, + }); + h.setGitResult({ + branchName: 'peer-feature', + baseBranchName: 'main', + githubOwner: 'octo', + githubRepo: 'peer', + }); + h.setPullRequest('peer-feature', { + url: 'https://github.com/octo/peer/pull/1', + number: 1, + }); + + const pullRequestUrl = await h.service.findPullRequestForWorkingDirectory(SESSION, peerWorkingDirectory); + + assert.deepStrictEqual({ + pullRequestUrl, + gitCalls: h.gitCalls, + gitState: readSessionGitState(h.stateManager.getSessionState(SESSION)?._meta), + gitHubState: readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta), + }, { + pullRequestUrl: 'https://github.com/octo/peer/pull/1', + gitCalls: [peerWorkingDirectory.toString()], + gitState: primaryGitState, + gitHubState: { owner: 'microsoft', repo: 'vscode' }, + }); + }); + }); + test('falls back to the commit at HEAD when the branch name matches no pull request', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { // A branch checked out from a pull request head: no upstream, and a diff --git a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts index 53e52c058a65b..10d8b46762ec2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts @@ -30,6 +30,7 @@ const nullGitStateService = new class implements IAgentHostGitStateService { readonly onDidRefreshSessionGitState = Event.None; readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(): Promise { } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(): undefined { return undefined; } async resolveSessionBaseBranchName(): Promise { return 'main'; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 602d313f704e6..872699400c9b6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -425,15 +425,15 @@ suite('AgentHostPeerChatStore', () => { ); }); - test('refreshes provider data without dropping persisted origin or inherited turn', async () => { + test('refreshes provider data without dropping persisted origin, inherited turn, or working directories', async () => { const database = new TestSessionDatabase(); const store = createStore(database); - await store.upsert(session, first, 'old', origin, 'inherited-turn'); + await store.upsert(session, first, 'old', origin, 'inherited-turn', ['file:///workspace/first']); await store.upsert(session, first, 'refreshed'); assert.deepStrictEqual(await store.tryRead(session), [ - { uri: first.toString(), providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }, + { uri: first.toString(), providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn', workingDirectories: ['file:///workspace/first'] }, ]); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 5d937717dd0f3..a0f248155061a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -11,9 +11,9 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE } from '../../common/agent.js'; -import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, type ISessionGitState, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, withSessionGitHubState, withSessionGitState, type ISessionFileDiff, type ISessionGitState, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; import type { IAgentHostGitService, IBranch, IDefaultBranch, IPushOptions } from '../../common/agentHostGitService.js'; import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; @@ -25,7 +25,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import type { IAgentBranchNameGenerator, IAgentBranchNameGeneratorRequest } from '../../node/shared/agentBranchNameGenerator.js'; import { mock } from '../../../../base/test/common/mock.js'; -import { AgentMergeConfigKey, readAgentMergeSessionState, type AgentMergeConfiguration, type AgentMergeControllerState, type AgentMergeSessionOverrides } from '../../common/agentMerge.js'; +import { AgentMergeConfigKey, readAgentMergeSessionState, type AgentMergeConfiguration, type AgentMergeControllerState, type AgentMergeSessionOverrides, type AgentMergeTarget } from '../../common/agentMerge.js'; import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { createPullRequestOperationMeta, createPullRequestValidationMeta, PREPARE_PULL_REQUEST_OPERATION_ID, readPullRequestDetailsResult, type IPullRequestCreateOptions } from '../../common/meta/agentPullRequestOperationMeta.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -168,11 +168,13 @@ class TestOctoKitService implements IAgentHostOctoKitService { lastBody: string | undefined; lastHead: string | undefined; lastBase: string | undefined; + lastRepository: string | undefined; readonly findRequests: { branch: string; headOwner: string | undefined }[] = []; - async createPullRequest(_owner: string, _repo: string, title: string, body: string, head: string, base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { + async createPullRequest(owner: string, repo: string, title: string, body: string, head: string, base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { this.onMutation?.('createPullRequest'); this.calls.push(`createPullRequest:${draft}`); + this.lastRepository = `${owner}/${repo}`; this.lastTitle = title; this.lastBody = body; this.lastHead = head; @@ -231,10 +233,11 @@ function createAuthenticationService(withCopilotToken = false): IAgentHostAuthen }; } -function setup(disposables: Pick, gitService: TestGitService, octoKitService: IAgentHostOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; enableAgentMerge?: boolean; agentMergeAvailable?: boolean; sessionAgentMergeEnabled?: boolean; agentMergeDefaults?: Partial; agentMergeOverrides?: AgentMergeSessionOverrides; agentMergeControllerState?: AgentMergeControllerState; baseBranch?: string; branchPrefix?: string; workingDirectory?: string; logService?: ILogService }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; sessionConfigUpdates: Record[]; sessionConfigValues: Record; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { +function setup(disposables: Pick, gitService: TestGitService, octoKitService: IAgentHostOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; enableAgentMerge?: boolean; agentMergeAvailable?: boolean; sessionAgentMergeEnabled?: boolean; agentMergeDefaults?: Partial; agentMergeOverrides?: AgentMergeSessionOverrides; agentMergeControllerState?: AgentMergeControllerState; baseBranch?: string; branchPrefix?: string; workingDirectory?: string; logService?: ILogService }): { handler: AgentHostPullRequestOperationHandler; session: URI; stateManager: AgentHostStateManager; createdEvents: string[]; createdChats: Array; createdBranches: string[]; sessionConfigUpdates: Record[]; sessionConfigValues: Record; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); const createdEvents: string[] = []; + const createdChats: Array = []; const createdBranches: string[] = []; const sessionConfigUpdates: Record[] = []; stateManager.createSession({ @@ -315,11 +318,14 @@ function setup(disposables: Pick, gitService: TestGitSer async () => options?.baseBranch ?? 'main', async event => { createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`); + createdChats.push(event.chatUri); createdBranches.push(event.branchName); }, createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, configurationService, options?.logService ?? new NullLogService()), session, + stateManager, createdEvents, + createdChats, createdBranches, sessionConfigUpdates, sessionConfigValues, @@ -331,6 +337,20 @@ function setup(disposables: Pick, gitService: TestGitSer suite('AgentHostPullRequestOperationHandler', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + function assertAgentMergeTarget(target: AgentMergeTarget | undefined, expected: Omit): asserts target is AgentMergeTarget { + assert.ok(target); + assert.strictEqual(target.enabledAt, target.commentWatermark); + assert.deepStrictEqual({ + ...target, + enabledAt: '', + commentWatermark: '', + }, { + ...expected, + enabledAt: '', + commentWatermark: '', + }); + } + const submittedOptions: IPullRequestCreateOptions = { title: ' My edited title ', description: '\nMy edited description.\n', @@ -841,7 +861,7 @@ suite('AgentHostPullRequestOperationHandler', () => { const gitService = new TestGitService(); const octoKitService = new TestOctoKitService(); const overrides: AgentMergeSessionOverrides = { fixCI: false, mergePullRequest: 'never' }; - const { handler, session, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + const { handler, session, sessionConfigUpdates, sessionConfigValues } = setup(disposables, gitService, octoKitService, { agentMergeAvailable: true, agentMergeOverrides: overrides, agentMergeControllerState: { totalPromptCount: 9 }, @@ -852,12 +872,20 @@ suite('AgentHostPullRequestOperationHandler', () => { operationId: 'create-pr', _meta: createPullRequestOperationMeta({ ...submittedOptions, agentMerge: true, draft: true }), }, CancellationToken.None); + const target = readAgentMergeSessionState(sessionConfigValues)?.target; + assertAgentMergeTarget(target, { + branchName: 'feature/test', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', + chatUri: buildDefaultChatUri(session).toString(), + workingDirectory: URI.file('/repo').toString(), + announcementPending: true, + }); assert.deepStrictEqual({ message: result.message, sessionConfigUpdates }, { message: { markdown: 'Created draft pull request [#123](https://github.com/microsoft/vscode/pull/123) and enabled Agent Merge.' }, sessionConfigUpdates: [{ [SessionConfigKey.AgentMerge]: { enabled: true, overrides }, - [SessionConfigKey.AgentMergeController]: {}, + [SessionConfigKey.AgentMergeController]: { target }, }], }); }); @@ -881,13 +909,21 @@ suite('AgentHostPullRequestOperationHandler', () => { operationId: 'create-pr', _meta: createPullRequestOperationMeta({ ...submittedOptions, agentMerge: true, agentMergeOptions }), }, CancellationToken.None); + const target = readAgentMergeSessionState(sessionConfigValues)?.target; + assertAgentMergeTarget(target, { + branchName: 'feature/test', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', + chatUri: buildDefaultChatUri(session).toString(), + workingDirectory: URI.file('/repo').toString(), + announcementPending: true, + }); assert.deepStrictEqual({ stateAtCreation, sessionConfigUpdates, state: readAgentMergeSessionState(sessionConfigValues) }, { stateAtCreation: [false], sessionConfigUpdates: [{ [SessionConfigKey.AgentMerge]: { enabled: true, overrides: agentMergeOptions }, - [SessionConfigKey.AgentMergeController]: {}, + [SessionConfigKey.AgentMergeController]: { target }, }], - state: { enabled: true, overrides: agentMergeOptions }, + state: { enabled: true, overrides: agentMergeOptions, target }, }); }); } @@ -1000,11 +1036,68 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('creates a pull request from a peer chat repository and attributes it to that chat', async () => { + const gitService = new TestGitService(); + gitService.gitState = { + hasGitHubRemote: true, + githubOwner: 'octo', + githubRepo: 'peer', + branchName: 'feature/peer', + baseBranchName: 'develop', + }; + const octoKitService = new TestOctoKitService(); + const { handler, session, stateManager, createdEvents, createdChats } = setup(disposables, gitService, octoKitService); + const chat = buildChatUri(session.toString(), 'peer'); + stateManager.addChat(session.toString(), chat, { workingDirectories: [URI.file('/peer').toString()] }); + + await handler.invoke({ channel: buildBranchChangesetUri(chat), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + repository: octoKitService.lastRepository, + baseBranch: octoKitService.lastBase, + requestedBaseBranches: gitService.requestedBaseBranches, + createdEvents, + createdChats, + }, { + repository: 'octo/peer', + baseBranch: 'develop', + requestedBaseBranches: [undefined], + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], + createdChats: [chat], + }); + }); + + test('targets Agent Merge at the peer chat repository that created the pull request', async () => { + const gitService = new TestGitService(); + gitService.gitState = { + hasGitHubRemote: true, + githubOwner: 'octo', + githubRepo: 'peer', + branchName: 'feature/peer', + baseBranchName: 'develop', + }; + const octoKitService = new TestOctoKitService(); + const { handler, session, stateManager, sessionConfigValues } = setup(disposables, gitService, octoKitService, { enableAgentMerge: true }); + const chat = buildChatUri(session.toString(), 'peer'); + const workingDirectory = URI.file('/peer').toString(); + stateManager.addChat(session.toString(), chat, { workingDirectories: [workingDirectory] }); + + await handler.invoke({ channel: buildBranchChangesetUri(chat), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE }, CancellationToken.None); + + assertAgentMergeTarget(readAgentMergeSessionState(sessionConfigValues)?.target, { + branchName: 'feature/peer', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', + chatUri: chat, + workingDirectory, + announcementPending: true, + }); + }); + test('enables Agent Merge after creating the pull request, preserves overrides, and clears stale controller state', async () => { const gitService = new TestGitService(); const octoKitService = new TestOctoKitService(); const overrides: AgentMergeSessionOverrides = { fixCI: false }; - const { handler, session, createdEvents, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + const { handler, session, createdEvents, sessionConfigUpdates, sessionConfigValues } = setup(disposables, gitService, octoKitService, { enableAgentMerge: true, agentMergeOverrides: overrides, agentMergeControllerState: { @@ -1018,6 +1111,14 @@ suite('AgentHostPullRequestOperationHandler', () => { }); const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE }, CancellationToken.None); + const target = readAgentMergeSessionState(sessionConfigValues)?.target; + assertAgentMergeTarget(target, { + branchName: 'feature/test', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', + chatUri: buildDefaultChatUri(session).toString(), + workingDirectory: URI.file('/repo').toString(), + announcementPending: true, + }); assert.deepStrictEqual({ message: result.message, @@ -1031,7 +1132,7 @@ suite('AgentHostPullRequestOperationHandler', () => { enabled: true, overrides, }, - [SessionConfigKey.AgentMergeController]: {}, + [SessionConfigKey.AgentMergeController]: { target }, }], }); }); @@ -1039,12 +1140,20 @@ suite('AgentHostPullRequestOperationHandler', () => { test('creates a draft pull request and enables Agent Merge', async () => { const gitService = new TestGitService(); const octoKitService = new TestOctoKitService(); - const { handler, session, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + const { handler, session, sessionConfigUpdates, sessionConfigValues } = setup(disposables, gitService, octoKitService, { draft: true, enableAgentMerge: true, }); const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE }, CancellationToken.None); + const target = readAgentMergeSessionState(sessionConfigValues)?.target; + assertAgentMergeTarget(target, { + branchName: 'feature/test', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', + chatUri: buildDefaultChatUri(session).toString(), + workingDirectory: URI.file('/repo').toString(), + announcementPending: true, + }); assert.deepStrictEqual({ message: result.message, @@ -1060,7 +1169,7 @@ suite('AgentHostPullRequestOperationHandler', () => { [SessionConfigKey.AgentMerge]: { enabled: true, }, - [SessionConfigKey.AgentMergeController]: {}, + [SessionConfigKey.AgentMergeController]: { target }, }], }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index a65c92e3ec327..4e0e9839faeaa 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -35,6 +35,7 @@ const nullGitStateService = new class implements IAgentHostGitStateService { readonly onDidRefreshSessionGitState = Event.None; readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(): Promise { } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(): undefined { return undefined; } async resolveSessionBaseBranchName(): Promise { return undefined; } async getSessionGitHubState(): Promise { return undefined; } @@ -164,6 +165,7 @@ suite('AgentHostPullRequestOperationContribution', () => { await contribution.recordCreatedPullRequest({ sessionKey: 'agent:/session', + chatUri: 'agent:/session/chat/default', pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', pullRequestNumber: 123, pullRequestTitle: 'Improve archive nudges', @@ -171,12 +173,14 @@ suite('AgentHostPullRequestOperationContribution', () => { }); await contribution.recordCreatedPullRequest({ sessionKey: 'agent:/session', + chatUri: 'agent:/session/chat/default', pullRequestUrl: 'https://github.com/microsoft/vscode/pull/124', pullRequestNumber: 124, branchName: 'feature/test', }); await contribution.recordCreatedPullRequest({ sessionKey: 'agent:/session', + chatUri: 'agent:/session/chat/default', pullRequestUrl: 'https://github.com/microsoft/vscode/pull/123', pullRequestNumber: 123, pullRequestTitle: 'Improve archive nudges', @@ -190,30 +194,46 @@ suite('AgentHostPullRequestOperationContribution', () => { persisted: persistedArtifacts.map(({ id: _id, ...artifact }) => artifact), }, { live: [{ + type: SessionArtifactType.PullRequest, + label: 'Referenced PR', + isArtifact: false, + link: 'https://github.com/microsoft/vscode/pull/123', + isGitHub: true, + }, { type: SessionArtifactType.PullRequest, label: 'Improve archive nudges', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/123', isGitHub: true, + chat: 'agent:/session/chat/default', }, { type: SessionArtifactType.PullRequest, label: '', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/124', isGitHub: true, + chat: 'agent:/session/chat/default', }], persisted: [{ + type: SessionArtifactType.PullRequest, + label: 'Referenced PR', + isArtifact: false, + link: 'https://github.com/microsoft/vscode/pull/123', + isGitHub: true, + }, { type: SessionArtifactType.PullRequest, label: 'Improve archive nudges', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/123', isGitHub: true, + chat: 'agent:/session/chat/default', }, { type: SessionArtifactType.PullRequest, label: '', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/124', isGitHub: true, + chat: 'agent:/session/chat/default', }], }); }); @@ -221,15 +241,23 @@ suite('AgentHostPullRequestOperationContribution', () => { test('advertises PR operations for GitHub branches with uncommitted changes', () => { const provider = createContribution(); - const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Branch, changesetUri: '' }); assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', PREPARE_PULL_REQUEST_OPERATION_ID]); }); + test('does not advertise PR operations on Session Changes', () => { + const provider = createContribution(); + + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations, undefined); + }); + test('advertises only creation and preparation even when Agent Merge is enabled', () => { const provider = createContribution(undefined, undefined, Event.None, true); - const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Branch, changesetUri: '' }); assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [ { id: 'create-pr', label: 'Create PR' }, @@ -298,9 +326,9 @@ suite('AgentHostPullRequestOperationContribution', () => { const provider = createContribution(); const actual = [ - provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, hasGitHubRemote: false }, changesetKind: ChangesetKind.Session, changesetUri: '' }), - provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 0 }, changesetKind: ChangesetKind.Session, changesetUri: '' }), - provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 2, hasBaseBranchChanges: false }, changesetKind: ChangesetKind.Session, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, hasGitHubRemote: false }, changesetKind: ChangesetKind.Branch, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 0 }, changesetKind: ChangesetKind.Branch, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 2, hasBaseBranchChanges: false }, changesetKind: ChangesetKind.Branch, changesetUri: '' }), ]; assert.deepStrictEqual(actual, [undefined, undefined, undefined]); @@ -310,8 +338,8 @@ suite('AgentHostPullRequestOperationContribution', () => { const provider = createContribution(); const actual = [ - provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }), - provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: { ...pullRequestForBranch, pullRequestBranchName: 'feature/other' }, changesetKind: ChangesetKind.Session, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Branch, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: { ...pullRequestForBranch, pullRequestBranchName: 'feature/other' }, changesetKind: ChangesetKind.Branch, changesetUri: '' }), ]; assert.deepStrictEqual(actual.map(operations => operations?.map(op => op.id)), [undefined, ['create-pr', PREPARE_PULL_REQUEST_OPERATION_ID]]); @@ -319,7 +347,7 @@ suite('AgentHostPullRequestOperationContribution', () => { test('advertises lifecycle operations for a pull request on the current branch', () => { const operationsFor = (status?: IAgentHostPullRequestStatus, agentMergeEnabled = false) => createContribution(status, undefined, Event.None, agentMergeEnabled, agentMergeEnabled) - .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }) + .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Branch, changesetUri: '' }) ?.map(op => op.id); assert.deepStrictEqual({ @@ -349,7 +377,7 @@ suite('AgentHostPullRequestOperationContribution', () => { test('uses the same label for the Agent Merge Mark Ready operation', () => { const operations = createContribution(openPullRequest({ draft: true, agentMergeReadyForReview: false }), undefined, Event.None, true, true) - .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }); + .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Branch, changesetUri: '' }); assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [{ id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE, diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 4bd90b6c59adb..b9f4b49cec025 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -14,7 +14,7 @@ import { ActionType, NotificationType, type ActionEnvelope, type INotification } import { ChatInputQuestionKind, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; @@ -1097,6 +1097,10 @@ suite('AgentHostStateManager', () => { label: 'Branch Changes', uriTemplate: buildChangesetUri(sessionUri, 'branch'), changeKind: 'branch', + }, { + label: 'Uncommitted Changes', + uriTemplate: buildChangesetUri(sessionUri, 'uncommitted'), + changeKind: 'uncommitted', }]; const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(envelope => envelopes.push(envelope))); @@ -1114,9 +1118,18 @@ suite('AgentHostStateManager', () => { .map(envelope => envelope.channel) .sort(), }, { - defaultChangesets: changesets, - peerChangesets: changesets, - newPeerChangesets: changesets, + defaultChangesets: [ + { ...changesets[0], uriTemplate: buildBranchChangesetUri(buildDefaultChatUri(sessionUri)) }, + { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(buildDefaultChatUri(sessionUri)) }, + ], + peerChangesets: [ + { ...changesets[0], uriTemplate: buildBranchChangesetUri(peerChat) }, + { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(peerChat) }, + ], + newPeerChangesets: [ + { ...changesets[0], uriTemplate: buildBranchChangesetUri(peer2) }, + { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(peer2) }, + ], changedChannels: [buildDefaultChatUri(sessionUri), peerChat].sort(), }); }); diff --git a/src/vs/platform/agentHost/test/node/agentMergeCITools.test.ts b/src/vs/platform/agentHost/test/node/agentMergeCITools.test.ts index f6c42c084d428..16ac09be62c57 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeCITools.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeCITools.test.ts @@ -19,6 +19,7 @@ import { IPullRequestResources } from '../../../github/common/pullRequestResourc import { GitHubRequestError } from '../../../github/common/githubTransport.js'; import { NullLogService } from '../../../log/common/log.js'; import { defaultAgentMergeConfiguration } from '../../common/agentMerge.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentMergeCIEvidenceStore, agentMergeCIResponseBytes, ciJsonBytes, readCIRange, readCITail, searchCIEvidence } from '../../node/agentMergeCIEvidence.js'; @@ -569,7 +570,15 @@ class CIHarness extends Disposable { const logService = new NullLogService(); const stateManager = this._register(new AgentHostStateManager(logService)); const configurationService = this._register(new AgentConfigurationService(stateManager, logService)); - this.tools = this._register(new AgentMergeTools(() => this.enabled, session => session === this.context.session ? this.context : this.peerContexts.get(session), service, logService, stateManager, configurationService)); + this.tools = this._register(new AgentMergeTools( + () => this.enabled, + session => session === this.context.session ? this.context : this.peerContexts.get(session), + service, + logService, + stateManager, + configurationService, + new class extends mock() { }(), + )); } async read(request: AgentMergeCIRequest = {}, session = this.context.session): Promise { diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index f27d17722e5f8..ac9a9171c2f58 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -19,7 +19,7 @@ import { constObservable } from '../../../../base/common/observable.js'; import { AgentSystemNotificationKind } from '../../common/meta/agentSystemNotificationMeta.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionStatus, buildChatUri, buildDefaultChatUri, MessageKind, readSessionGitState, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; import { GitHubCredential, IGitHubCredentials } from '../../../github/common/githubCredentialService.js'; import { PullRequestSnapshot, PullRequestSubscription } from '../../../github/common/githubPullRequestService.js'; @@ -159,6 +159,7 @@ suite('AgentMergeController', () => { new NullLogService(), stateManager, configurationService, + noopGitService, )); configurationService.updateSessionConfig(session, { [SessionConfigKey.Mode]: 'interactive', @@ -543,11 +544,17 @@ suite('AgentMergeController', () => { }); }); - function createControllerHarness(disposables: ReturnType, snapshot?: PullRequestSnapshot): { + function createControllerHarness( + disposables: ReturnType, + snapshot?: PullRequestSnapshot, + gitService: IAgentHostGitService = noopGitService, + pullRequestLookup?: { readonly url: string; readonly workingDirectories: string[] }, + ): { readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService; readonly session: string; readonly notices: { readonly kind: AgentSystemNotificationKind; readonly content: string }[]; + readonly noticeChats: string[]; } { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -557,19 +564,27 @@ suite('AgentMergeController', () => { override readonly onDidRefreshSessionGitState = Event.None; override readonly onDidChangeSessionGitHubState = Event.None; override async attachSessionGitHubPullRequest(): Promise { } + override async findPullRequestForWorkingDirectory(_sessionKey: string, workingDirectory: URI): Promise { + pullRequestLookup?.workingDirectories.push(workingDirectory.toString()); + return pullRequestLookup?.url; + } }(); const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); const notices: { kind: AgentSystemNotificationKind; content: string }[] = []; + const noticeChats: string[] = []; disposables.add(new AgentMergeController( { startTurn: () => false, cancelTurn: () => { }, - postNotice: (_session, kind, content) => notices.push({ kind, content }), + postNotice: (chatUri, kind, content) => { + noticeChats.push(chatUri); + notices.push({ kind, content }); + }, }, stateManager, configurationService, gitStateService, - noopGitService, + gitService, new class extends mock() { override readonly credentials = new class extends mock() { override async getCredential(signal: AbortSignal): Promise { @@ -604,7 +619,7 @@ suite('AgentMergeController', () => { schema: platformSessionSchema.toProtocol(), values: {}, }); - return { stateManager, configurationService, session, notices }; + return { stateManager, configurationService, session, notices, noticeChats }; } for (const state of ['merged', 'closed'] as const) { @@ -663,6 +678,152 @@ suite('AgentMergeController', () => { }); } + test('restores monitoring and notices against a peer chat repository', async () => { + const pullRequestUrl = 'https://github.com/octo/peer/pull/1'; + const snapshot: PullRequestSnapshot = { + ref: { owner: 'octo', repo: 'peer', number: 1, host: 'api.github.com', accountId: 'account' }, + generation: 1, + headGeneration: 1, + core: { + status: 'ready', + complete: true, + value: { + repositoryNameWithOwner: 'octo/peer', + number: 1, title: 'Peer change', url: pullRequestUrl, state: 'merged', draft: false, + headSha: 'head', headRef: 'feature/peer', baseSha: 'base', baseRef: 'main', + }, + }, + topLevelComments: { status: 'missing', complete: false }, + submittedReviews: { status: 'missing', complete: false }, + inlineComments: { status: 'missing', complete: false }, + reviewThreads: { status: 'missing', complete: false }, + checks: { status: 'missing', complete: false }, + mergeability: { status: 'missing', complete: false }, + participants: { status: 'missing', complete: false }, + }; + const branchWorkingDirectories: string[] = []; + const gitService = new class extends mock() { + override async getCurrentBranchName(workingDirectory: URI): Promise { + branchWorkingDirectories.push(workingDirectory.toString()); + return 'feature/peer'; + } + }(); + const { stateManager, configurationService, session, notices, noticeChats } = createControllerHarness(disposables, snapshot, gitService); + const chat = buildChatUri(session, 'peer'); + const workingDirectory = URI.file('/peer').toString(); + stateManager.addChat(session, chat, { workingDirectories: [workingDirectory] }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'feature/primary', baseBranchName: 'main' })); + const disabled = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.enabled === false) { + resolve(); + } + })); + }); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature/peer', + pullRequestUrl, + chatUri: chat, + workingDirectory, + enabledAt: new Date(0).toISOString(), + commentWatermark: new Date(0).toISOString(), + }, + }, + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await disabled; + + assert.deepStrictEqual({ + branchWorkingDirectories, + noticeChats, + notices, + }, { + branchWorkingDirectories: [workingDirectory], + noticeChats: [chat], + notices: [{ + kind: AgentSystemNotificationKind.AgentMergePullRequestMerged, + content: 'Pull request [#1](https://github.com/octo/peer/pull/1) was merged. Agent Merge is now disabled.', + }], + }); + }); + + test('binds a peer chat pull request without replacing primary session Git state', async () => { + const pullRequestUrl = 'https://github.com/octo/peer/pull/1'; + const snapshot: PullRequestSnapshot = { + ref: { owner: 'octo', repo: 'peer', number: 1, host: 'api.github.com', accountId: 'account' }, + generation: 1, + headGeneration: 1, + core: { + status: 'ready', + complete: true, + value: { + repositoryNameWithOwner: 'octo/peer', + number: 1, title: 'Peer change', url: pullRequestUrl, state: 'open', draft: false, + headSha: 'head', headRef: 'feature/peer', baseSha: 'base', baseRef: 'main', + }, + }, + topLevelComments: { status: 'missing', complete: false }, + submittedReviews: { status: 'missing', complete: false }, + inlineComments: { status: 'missing', complete: false }, + reviewThreads: { status: 'missing', complete: false }, + checks: { status: 'missing', complete: false }, + mergeability: { status: 'missing', complete: false }, + participants: { status: 'missing', complete: false }, + }; + const gitService = new class extends mock() { + override async getCurrentBranchName(): Promise { + return 'feature/peer'; + } + }(); + const lookupWorkingDirectories: string[] = []; + const { stateManager, configurationService, session } = createControllerHarness( + disposables, + snapshot, + gitService, + { url: pullRequestUrl, workingDirectories: lookupWorkingDirectories }, + ); + const chat = buildChatUri(session, 'peer'); + const workingDirectory = URI.file('/peer').toString(); + const primaryGitState = { branchName: 'feature/primary', baseBranchName: 'main' }; + stateManager.addChat(session, chat, { workingDirectories: [workingDirectory] }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, primaryGitState)); + const bound = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (readAgentMergeSessionState(event.current?.values)?.target?.pullRequestUrl === pullRequestUrl) { + resolve(); + } + })); + }); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature/peer', + chatUri: chat, + workingDirectory, + enabledAt: new Date(0).toISOString(), + commentWatermark: new Date(0).toISOString(), + }, + }, + }); + + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await bound; + + assert.deepStrictEqual({ + lookupWorkingDirectories, + gitState: readSessionGitState(stateManager.getSessionState(session)?._meta), + pullRequestUrl: readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.target?.pullRequestUrl, + }, { + lookupWorkingDirectories: [workingDirectory], + gitState: primaryGitState, + pullRequestUrl, + }); + }); + test('announces enablement once it captures a branch, and again on the branch that turned it off', async () => { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); diff --git a/src/vs/platform/agentHost/test/node/agentMergeRerun.test.ts b/src/vs/platform/agentHost/test/node/agentMergeRerun.test.ts index 57decbf03154b..db3296b6036fe 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeRerun.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeRerun.test.ts @@ -419,9 +419,9 @@ class RerunTestHarness extends Disposable { }(); this.controller = this._register(new AgentMergeController( { - startTurn: (session, turnId, prompt) => { + startTurn: (chatUri, turnId, prompt) => { this.prompts.push(prompt); - this.stateManager.dispatchServerAction(buildDefaultChatUri(session), { + this.stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatTurnStarted, turnId, startedAt: new Date().toISOString(), @@ -429,7 +429,7 @@ class RerunTestHarness extends Disposable { }); return true; }, - cancelTurn: (session, turnId) => this.stateManager.dispatchServerAction(buildDefaultChatUri(session), { + cancelTurn: (chatUri, turnId) => this.stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatTurnCancelled, turnId, duration: 0, @@ -451,7 +451,15 @@ class RerunTestHarness extends Disposable { }(), this.logService, )); - this.tools = this._register(new AgentMergeTools(() => this.controller.isEnabled(), session => this.controller.getTurnContext(session), gitHubService, this.logService, this.stateManager, this.configurationService)); + this.tools = this._register(new AgentMergeTools( + () => this.controller.isEnabled(), + session => this.controller.getTurnContext(session), + gitHubService, + this.logService, + this.stateManager, + this.configurationService, + new class extends mock() { }(), + )); this.stateManager.dispatchServerAction(this.session, { type: ActionType.SessionReady }); } diff --git a/src/vs/platform/agentHost/test/node/agentMergeServerTools.test.ts b/src/vs/platform/agentHost/test/node/agentMergeServerTools.test.ts index 16b48fb53e87f..8fc1aca706741 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeServerTools.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IGitHubService } from '../../../github/common/githubService.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../../common/agentMerge.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { platformSessionSchema } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; @@ -49,6 +50,7 @@ suite('Agent Merge server tools', () => { logService, stateManager, configurationService, + new class extends mock() { }(), )); const host = new AgentServerToolHost(stateManager, [createAgentMergeServerToolGroup(tools)]); return { stateManager, configurationService, tools, host }; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b062320dc27cb..4eb167a7ef549 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -30,7 +30,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { AgentChatMigrationDeferred, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; import { IConnectionTrackerService } from '../../common/agentService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostArtifactToolsCompactPromptsConfigKey, AgentHostArtifactToolsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostArtifactToolsCompactPromptsConfigKey, AgentHostArtifactToolsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostCreateSessionRelationshipConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; @@ -51,7 +51,7 @@ import { AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionsV2ExclusionExpectation, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; -import { CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, type IPersistedPeerChat } from '../../node/agentHostPeerChatStore.js'; +import { CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, CHAT_WORKING_DIRECTORIES_METADATA_KEY, type IPersistedPeerChat } from '../../node/agentHostPeerChatStore.js'; import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_PAYLOAD_VERSION, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; import type { IAgentHostStorageService } from '../../node/agentHostStorageService.js'; import { AGENT_HOST_CATALOG_VERIFICATION_VERSION_STORAGE_KEY, CATALOG_VERIFICATION_VERSION } from '../../node/agentHostCatalogReconciliationService.js'; @@ -1410,7 +1410,7 @@ suite('AgentService (node dispatcher)', () => { databaseOpens, }, { repeatedClassification: !useCompactPrompts, - items, + items: items.map(item => ({ ...item, chat: buildDefaultChatUri(session) })), legacy: expectedArtifacts, central: expectedArtifacts, restarted: expectedArtifacts, @@ -3263,6 +3263,50 @@ suite('AgentService (node dispatcher)', () => { listener.dispose(); }); + test('restores the default chat workspace after expanding the session workspace', async () => { + const database = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(database); + const creating = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const creatingAgent = new DynamicWorkingDirectoryAgent('dynamic'); + disposables.add(toDisposable(() => creatingAgent.dispose())); + registerTestAgentProvider(creating, creatingAgent); + const primary = URI.file('/workspace/primary'); + const secondary = URI.file('/workspace/secondary'); + const session = await creating.createSession({ + provider: creatingAgent.id, + workingDirectories: [primary], + }); + const defaultChat = buildDefaultChatUri(session.toString()); + const internal = creating as unknown as { + _attachSessionWorkingDirectoryForChat(session: URI, directory: URI): Promise; + }; + + await internal._attachSessionWorkingDirectoryForChat(session, secondary); + const persistedWorkingDirectories = await database.getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY); + + const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const reopenedAgent = new DynamicWorkingDirectoryAgent('dynamic'); + reopenedAgent.sessionMetadataOverrides = { workingDirectories: [primary, secondary] }; + (reopenedAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + disposables.add(toDisposable(() => reopenedAgent.dispose())); + registerTestAgentProvider(reopened, reopenedAgent); + await reopened.restoreSession(session); + + assert.deepStrictEqual({ + persistedWorkingDirectories, + liveSession: getStateManager(creating).getSessionSummary(session.toString())?.workingDirectories, + liveDefaultChat: getStateManager(creating).getChatState(defaultChat)?.workingDirectories, + restoredSession: getStateManager(reopened).getSessionSummary(session.toString())?.workingDirectories, + restoredDefaultChat: getStateManager(reopened).getChatState(defaultChat)?.workingDirectories, + }, { + persistedWorkingDirectories: JSON.stringify([primary.toString()]), + liveSession: [primary.toString(), secondary.toString()], + liveDefaultChat: [primary.toString()], + restoredSession: [primary.toString(), secondary.toString()], + restoredDefaultChat: [primary.toString()], + }); + }); + test('rejects a failed review update and clears the client dispatch queue', async () => { const db = new TestSessionDatabase(); db.getMetadata = async () => { throw new Error('metadata unavailable'); }; @@ -17726,6 +17770,39 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('create_session relationship setting updates the live advertised schema', async () => { + class ServerToolAgent extends MockAgent { + serverToolHost: IAgentServerToolHost | undefined; + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + } + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new ServerToolAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const getCreateSessionSchema = () => getStateManager(localService).getSessionState(session.toString())?.serverTools + ?.find(tool => tool.name === SessionServerToolName.CreateSession)?.inputSchema; + + const enabledSchema = getCreateSessionSchema(); + getConfigurationService(localService).updateRootConfig({ [AgentHostCreateSessionRelationshipConfigKey]: false }); + const disabledSchema = getCreateSessionSchema(); + + assert.deepStrictEqual({ + enabledProperties: Object.keys(enabledSchema?.properties ?? {}), + enabledRequired: enabledSchema?.required, + disabledProperties: Object.keys(disabledSchema?.properties ?? {}), + disabledRequired: disabledSchema?.required, + hostProperties: Object.keys(agent.serverToolHost?.getDefinitionsForSession(session.toString()).find(tool => tool.name === SessionServerToolName.CreateSession)?.inputSchema?.properties ?? {}), + }, { + enabledProperties: ['relationship', 'prompt', 'workspace', 'worktree', 'title', 'model'], + enabledRequired: ['relationship', 'prompt', 'title'], + disabledProperties: ['prompt', 'workspace', 'worktree', 'title', 'model'], + disabledRequired: ['prompt', 'title'], + hostProperties: ['prompt', 'workspace', 'worktree', 'title', 'model'], + }); + }); + test('session creation tools inherit the calling chat model and permissions and isolate a different project', async () => { class ServerToolAgent extends MockAgent { readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; @@ -17885,7 +17962,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('create_session scopes a current-session chat to an added working directory from a worktree session', async () => { + test('create_session allocates explicit same-repository worktrees and preserves their lifecycle', async () => { class ServerToolAgent extends MockAgent { readonly createdChatWorkingDirectories: (readonly URI[] | undefined)[] = []; serverToolHost: IAgentServerToolHost | undefined; @@ -17912,20 +17989,63 @@ suite('AgentService (node dispatcher)', () => { })); } - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new ServerToolAgent()); - registerTestAgentProvider(localService, agent); + const sessionDataService = createSessionDataService(new TestSessionDatabase()); + const catalogDatabase = new TransientRegistryWriteDatabase(); const originalDirectory = URI.file('/workspace/original'); const addedDirectory = URI.file('/workspace/added'); + const addedWorktrees = [ + URI.file('/workspace/added.worktrees/peer-chat'), + URI.file('/workspace/added.worktrees/fresh-peer-chat'), + ]; + const addedWorktreeHandles = [generateUuid(), generateUuid()]; + const createdWorktrees: URI[] = []; + const gitService = { + ...createNoopGitService(), + getWorktreeRoots: async (directory: URI) => directory.toString() === addedDirectory.toString() + ? [addedDirectory, ...createdWorktrees] + : [], + }; + const createLocalService = () => disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionDataService, + { _serviceBrand: undefined } as IProductService, + gitService, + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, + catalogDatabase, + )); + let localService = createLocalService(); + const agent = disposables.add(new ServerToolAgent()); + registerTestAgentProvider(localService, agent); const resumedSessionWorktree = URI.file('/workspace/resumed-session-worktree'); + const detachedWorktreeRequests: (string | undefined)[] = []; + const detachedWorktreeBranches: (string | undefined)[] = []; + const claimedWorktreeHandles: string[] = []; const session = await localService.createSession({ provider: 'copilot', workingDirectories: [originalDirectory], - config: { [SessionConfigKey.Isolation]: 'worktree' }, + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'parent-only', + }, }); setTestAgentHostWorktreeIsolation(localService, createTestAgentHostWorktreeIsolation({ isWorkingDirectoryPending: () => false, resolveWorkingDirectoryForResume: async () => resumedSessionWorktree, + createDetachedWorktree: async request => { + const index = createdWorktrees.length; + const worktree = addedWorktrees[index]; + const handle = addedWorktreeHandles[index]; + if (!worktree || !handle) { + throw new Error('Unexpected additional worktree allocation'); + } + detachedWorktreeRequests.push(request.workingDirectory?.toString()); + const branch = request.config?.[SessionConfigKey.Branch]; + detachedWorktreeBranches.push(typeof branch === 'string' ? branch : undefined); + createdWorktrees.push(worktree); + return { handle, worktree }; + }, + claimDetachedWorktree: async handle => { claimedWorktreeHandles.push(handle); }, })); const sourceChat = URI.parse(buildDefaultChatUri(session)); localService.dispatchAction(sourceChat.toString(), { @@ -17944,41 +18064,99 @@ suite('AgentService (node dispatcher)', () => { }); await peerSendPromise; + const reusedPeerSendPromise = Event.toPromise(Event.filter(agent.onDidSendMessage, call => call.prompt === 'reuse the added worktree')); + await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateSession, { + relationship: 'currentSession', + workspace: addedDirectory.toString(), + prompt: 'reuse the added worktree', + title: 'Reused Worktree', + }); + await reusedPeerSendPromise; + + const freshPeerSendPromise = Event.toPromise(Event.filter(agent.onDidSendMessage, call => call.prompt === 'use a fresh added worktree')); + await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateSession, { + relationship: 'currentSession', + workspace: addedDirectory.toString(), + worktree: true, + prompt: 'use a fresh added worktree', + title: 'Fresh Worktree', + }); + await freshPeerSendPromise; + const state = getStateManager(localService).getSessionState(session.toString()); - const peer = state?.chats.find(chat => chat.resource !== sourceChat.toString()); - const peerState = peer ? getStateManager(localService).getChatState(peer.resource) : undefined; - const peerSend = agent.sendMessageWorkingDirectories.find(call => call.chat.toString() === peer?.resource); - assert.ok(peer); + const peer = state?.chats.find(chat => getStateManager(localService).getChatState(chat.resource)?.activeTurn?.message.text === 'work in the added folder'); + const reusedPeer = state?.chats.find(chat => getStateManager(localService).getChatState(chat.resource)?.activeTurn?.message.text === 'reuse the added worktree'); + const freshPeer = state?.chats.find(chat => getStateManager(localService).getChatState(chat.resource)?.activeTurn?.message.text === 'use a fresh added worktree'); + assert.ok(peer && reusedPeer && freshPeer); const beforeRestart = { sessionWorkingDirectories: getStateManager(localService).getSessionSummary(session.toString())?.workingDirectories, sourceWorkingDirectories: getStateManager(localService).getChatState(sourceChat.toString())?.workingDirectories, - peerWorkingDirectories: peerState?.workingDirectories, + peerWorkingDirectories: getStateManager(localService).getChatState(peer.resource)?.workingDirectories, + reusedPeerWorkingDirectories: getStateManager(localService).getChatState(reusedPeer.resource)?.workingDirectories, + freshPeerWorkingDirectories: getStateManager(localService).getChatState(freshPeer.resource)?.workingDirectories, providerWorkingDirectories: agent.createdChatWorkingDirectories.at(-1)?.map(directory => directory.toString()), - providerTurnWorkingDirectories: peerSend?.workingDirectories?.map(directory => directory.toString()), - peerPrompt: peerState?.activeTurn?.message.text, + detachedWorktreeRequests, + detachedWorktreeBranches, + claimedWorktreeHandles, }; - getStateManager(localService).deleteSession(session.toString()); + localService.dispose(); + localService = createLocalService(); + const deletedWorktreeHandles: string[] = []; + const archivedWorktreeResults: { handle: string; archived: boolean; strictCleanup: boolean | undefined }[] = []; + const archivedWorktrees = new DeferredPromise(); + setTestAgentHostWorktreeIsolation(localService, createTestAgentHostWorktreeIsolation({ + resolveWorkingDirectoryForResume: async (_session, _sessionId, workingDirectory) => workingDirectory, + setDetachedWorktreeArchived: async (handle, archived, strictCleanup) => { + archivedWorktreeResults.push({ handle, archived, strictCleanup }); + if (archivedWorktreeResults.length === addedWorktreeHandles.length) { + archivedWorktrees.complete(); + } + }, + deleteDetachedWorktree: async handle => { deletedWorktreeHandles.push(handle); }, + })); + registerTestAgentProvider(localService, agent); await localService.restoreSession(session); await localService.subscribe(URI.parse(peer.resource), 'restored-peer-reader'); + await localService.subscribe(URI.parse(reusedPeer.resource), 'restored-reused-peer-reader'); + await localService.subscribe(URI.parse(freshPeer.resource), 'restored-fresh-peer-reader'); + const restoredSessionWorkingDirectories = getStateManager(localService).getSessionSummary(session.toString())?.workingDirectories; + const restoredSourceWorkingDirectories = getStateManager(localService).getChatState(sourceChat.toString())?.workingDirectories; + const restoredPeerWorkingDirectories = getStateManager(localService).getChatState(peer.resource)?.workingDirectories; + const restoredReusedPeerWorkingDirectories = getStateManager(localService).getChatState(reusedPeer.resource)?.workingDirectories; + const restoredFreshPeerWorkingDirectories = getStateManager(localService).getChatState(freshPeer.resource)?.workingDirectories; + localService.archiveSession(session); + await archivedWorktrees.p; + await localService.disposeSession(session); assert.deepStrictEqual({ beforeRestart, - restoredSessionWorkingDirectories: getStateManager(localService).getSessionSummary(session.toString())?.workingDirectories, - restoredSourceWorkingDirectories: getStateManager(localService).getChatState(sourceChat.toString())?.workingDirectories, - restoredPeerWorkingDirectories: getStateManager(localService).getChatState(peer.resource)?.workingDirectories, + restoredSessionWorkingDirectories, + restoredSourceWorkingDirectories, + restoredPeerWorkingDirectories, + restoredReusedPeerWorkingDirectories, + restoredFreshPeerWorkingDirectories, + archivedWorktreeResults, + deletedWorktreeHandles, }, { beforeRestart: { - sessionWorkingDirectories: [originalDirectory.toString(), addedDirectory.toString()], + sessionWorkingDirectories: [originalDirectory.toString(), ...addedWorktrees.map(directory => directory.toString())], sourceWorkingDirectories: [originalDirectory.toString()], - peerWorkingDirectories: [addedDirectory.toString()], - providerWorkingDirectories: [addedDirectory.toString()], - providerTurnWorkingDirectories: [addedDirectory.toString()], - peerPrompt: 'work in the added folder', + peerWorkingDirectories: [addedWorktrees[0].toString()], + reusedPeerWorkingDirectories: [addedWorktrees[0].toString()], + freshPeerWorkingDirectories: [addedWorktrees[1].toString()], + providerWorkingDirectories: [addedWorktrees[1].toString()], + detachedWorktreeRequests: [addedDirectory.toString(), addedDirectory.toString()], + detachedWorktreeBranches: ['HEAD', 'HEAD'], + claimedWorktreeHandles: addedWorktreeHandles, }, - restoredSessionWorkingDirectories: [originalDirectory.toString(), addedDirectory.toString()], + restoredSessionWorkingDirectories: [originalDirectory.toString(), ...addedWorktrees.map(directory => directory.toString())], restoredSourceWorkingDirectories: [originalDirectory.toString()], - restoredPeerWorkingDirectories: [addedDirectory.toString()], + restoredPeerWorkingDirectories: [addedWorktrees[0].toString()], + restoredReusedPeerWorkingDirectories: [addedWorktrees[0].toString()], + restoredFreshPeerWorkingDirectories: [addedWorktrees[1].toString()], + archivedWorktreeResults: addedWorktreeHandles.map(handle => ({ handle, archived: true, strictCleanup: true })), + deletedWorktreeHandles: addedWorktreeHandles, }); }); @@ -21577,6 +21755,56 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a peer-targeted disable writes its notice to the owning chat', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sessionDb = new TestSessionDatabase(); + const { localService, sessionResource } = await createEnabledSession(sessionDb, orchestratorDb); + const session = sessionResource.toString(); + const peerChat = buildChatUri(session, 'peer'); + const workingDirectory = URI.file('/peer').toString(); + const stateManager = getStateManager(localService); + stateManager.addChat(session, peerChat, { workingDirectories: [workingDirectory] }); + const defaultTurnCount = stateManager.getSessionState(buildDefaultChatUri(session))?.turns.length; + const configurationService = getConfigurationService(localService); + const current = readAgentMergeSessionState(configurationService.getSessionConfigValues(session)); + assert.ok(current); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature/peer', + pullRequestUrl: 'https://github.com/octo/peer/pull/1', + chatUri: peerChat, + workingDirectory, + enabledAt: new Date(0).toISOString(), + commentWatermark: new Date(0).toISOString(), + }, + }, + }); + + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { + enabled: false, + ...(current.overrides ? { overrides: current.overrides } : {}), + }, + }); + await timeout(0); + + const peerTurns = stateManager.getSessionState(peerChat)?.turns ?? []; + assert.deepStrictEqual({ + defaultTurnCountAfterDisable: stateManager.getSessionState(buildDefaultChatUri(session))?.turns.length, + peerNotice: peerTurns.at(-1)?.responseParts, + persistedChatUris: (await sessionDb.getLocalTurns()).map(turn => turn.chatUri), + }, { + defaultTurnCountAfterDisable: defaultTurnCount, + peerNotice: [{ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge was disabled for this session.', + _meta: { kind: 'agentMergeDisabled' }, + }], + persistedChatUris: [peerChat], + }); + }); + test('a persisted Agent-Merge-enabled session begins monitoring on a fresh host and becomes MRU-eligible once disabled', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const orchestratorDb = new TestAgentHostOrchestratorDatabase(); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index a70a389b9b8c6..3d9cdc551058d 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -119,6 +119,7 @@ class NoopGitStateService implements IAgentHostGitStateService { readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } async resolveSessionBaseBranchName(_sessionKey: string): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } diff --git a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts index 98542c27e452a..1263373b32723 100644 --- a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { ArtifactServerToolName, LEGACY_ARTIFACT_SERVER_TOOL_NAMES } from '../../common/serverToolNames.js'; import { readSessionArtifacts, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; -import { buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentServerToolHost } from '../../node/shared/agentServerToolHost.js'; import { ARTIFACT_TOOLS_INSTRUCTION, artifactServerToolDefinitions, createArtifactServerToolGroup, getArtifactToolsInstruction, type IArtifactServerToolAccessor } from '../../node/shared/artifactServerTools.js'; @@ -263,8 +263,8 @@ suite('Artifact Server Tools', () => { messages: [`Added reference: ${artifacts[0].id}`, `Added artifact: ${artifacts[1].id}`, `Already recorded: ${artifacts[0].id}`], persistCalls: 1, artifacts: [ - { type: 'website', label: 'Docs', isArtifact: false, link: 'https://example.com/docs' }, - { type: 'file', label: 'Report', isArtifact: true, uri: 'file:///repo/report.md' }, + { type: 'website', label: 'Docs', chat: buildDefaultChatUri(sessionUri), isArtifact: false, link: 'https://example.com/docs' }, + { type: 'file', label: 'Report', chat: buildDefaultChatUri(sessionUri), isArtifact: true, uri: 'file:///repo/report.md' }, ], }); }); @@ -384,6 +384,49 @@ suite('Artifact Server Tools', () => { assert.deepStrictEqual({ artifacts: artifacts(), persisted }, { artifacts: [], persisted: [] }); }); + test('records, lists, and removes artifacts within the current chat', async () => { + const sessionUri = 'copilot:/chat-scoped'; + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer'); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: sessionUri, + provider: 'copilot', + title: 'Chat scoped', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + const group = createArtifactServerToolGroup({ isEnabled: () => true, persist: () => { } }); + const input = { + items: [{ type: 'website', label: 'Docs', isArtifact: false, link: 'https://example.com/docs' }], + }; + await group.execute(stateManager, { sessionUri, chatUri: defaultChat, turnId: 'turn-1' }, ArtifactServerToolName.AddArtifactOrReference, input); + await group.execute(stateManager, { sessionUri, chatUri: peerChat, turnId: 'turn-2' }, ArtifactServerToolName.AddArtifactOrReference, input); + const artifacts = readSessionArtifacts(stateManager.getSessionState(sessionUri)?._meta); + const defaultList = await group.execute(stateManager, { sessionUri, chatUri: defaultChat, turnId: 'turn-3' }, ArtifactServerToolName.ListArtifactsAndReferences, {}); + const peerId = artifacts.find(artifact => artifact.chat === peerChat)!.id; + const crossChatRemoval = await group.execute(stateManager, { sessionUri, chatUri: defaultChat, turnId: 'turn-4' }, ArtifactServerToolName.RemoveArtifactOrReference, { id: peerId }); + + assert.deepStrictEqual({ + artifacts: artifacts.map(({ id: _id, ...artifact }) => artifact), + defaultListIds: artifacts.map(artifact => [artifact.id, defaultList.includes(artifact.id)]), + crossChatRemoval, + remaining: readSessionArtifacts(stateManager.getSessionState(sessionUri)?._meta).length, + }, { + artifacts: [ + { type: 'website', label: 'Docs', chat: defaultChat, isArtifact: false, link: 'https://example.com/docs' }, + { type: 'website', label: 'Docs', chat: peerChat, isArtifact: false, link: 'https://example.com/docs' }, + ], + defaultListIds: [ + [artifacts[0].id, true], + [artifacts[1].id, false], + ], + crossChatRemoval: `No artifact or reference with id ${peerId}.`, + remaining: 2, + }); + }); + test('rejects session-management links during execution', async () => { const sessionUri = 'copilot:/caller'; const stateManager = store.add(new AgentHostStateManager(new NullLogService())); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index e2f68101cfe59..c4edfca9ca2e9 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -109,6 +109,7 @@ class RecordingGitStateService implements IAgentHostGitStateService { constructor(private readonly _observed: string[] | undefined) { } async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } + async findPullRequestForWorkingDirectory(): Promise { return undefined; } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } async resolveSessionBaseBranchName(_sessionKey: string): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 4694ed7aeb721..c7cfa1cfe7043 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -122,7 +122,7 @@ suite('SessionServerTools', () => { }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, workspace: { type: 'string', description: 'Workspace for the delegated work: a unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, - worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree, or false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior.' }, + worktree: { type: 'boolean', description: 'Whether to use an isolated Git worktree for the workspace. Set true only when the user explicitly asks to create a worktree; for `currentSession`, this always creates a fresh worktree even when the repository already has another session worktree. Set false only when the user explicitly asks to work without one. Omit to preserve the existing isolation behavior, including inheriting and reusing it when a current-session chat adds a repository already represented in the session.' }, title: { type: 'string', maxLength: 200, description: 'Short title for the new chat or independent session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, }, @@ -653,6 +653,42 @@ suite('SessionServerTools', () => { assert.strictEqual(description, 'Create delegated work and start it with an initial prompt. Set `relationship` to `currentSession` when the work is related to the current session, or to `independent` when it is not.'); }); + test('create_session omits relationship from its definition when the choice is disabled', () => { + let relationshipEnabled = true; + const group = createSessionServerToolGroup(undefined, () => relationshipEnabled); + const getDefinition = () => group.definitions.find(definition => definition.name === SessionServerToolName.CreateSession); + + const enabled = getDefinition(); + relationshipEnabled = false; + const disabled = getDefinition(); + + assert.deepStrictEqual({ + enabled: { + description: enabled?.description, + properties: Object.keys(enabled?.inputSchema?.properties ?? {}), + required: enabled?.inputSchema?.required, + }, + disabled: { + description: disabled?.description, + properties: Object.keys(disabled?.inputSchema?.properties ?? {}), + required: disabled?.inputSchema?.required, + staleIndependentDisplay: group.getDisplay?.(SessionServerToolName.CreateSession, { relationship: 'independent' })?.displayName, + }, + }, { + enabled: { + description: 'Create delegated work and start it with an initial prompt. Set `relationship` to `currentSession` when the work is related to the current session, or to `independent` when it is not.', + properties: ['relationship', 'prompt', 'workspace', 'worktree', 'title', 'model'], + required: ['relationship', 'prompt', 'title'], + }, + disabled: { + description: 'Create delegated work as a new chat in the current session and start it with an initial prompt.', + properties: ['prompt', 'workspace', 'worktree', 'title', 'model'], + required: ['prompt', 'title'], + staleIndependentDisplay: 'Create Chat in Current Session', + }, + }); + }); + test('getCreateSessionArgs resolves workspace by working directory and model by id/name', () => { const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace)]; const byId = getCreateSessionArgs({ relationship: 'independent', workspace: workspace.toString(), prompt: 'hi', title: 'Task', model: 'gpt-4o' }, sessions, [model]); @@ -1189,21 +1225,28 @@ suite('SessionServerTools', () => { }); }); - test(`create_session rejects currentSession with worktree=${worktree} before creating work`, async () => { - const operations: string[] = []; + test(`create_session honors explicit currentSession worktree=${worktree} over inherited isolation`, async () => { + let addOptions: Parameters[2] | undefined; const accessor = createAccessor({ - onCreate: () => operations.push('session'), - onCreateChat: () => operations.push('chat'), - onPrompt: () => operations.push('prompt'), + getCreationDefaults: () => ({ isolation: worktree ? 'folder' : 'worktree' }), + addSessionWorkingDirectory: async (_session, directory, options) => { + addOptions = options; + return directory; + }, }); - await assert.rejects(applyCreateSessionTool(accessor, { + await applyCreateSessionTool(accessor, { relationship: 'currentSession', + workspace: workspace.toString(), worktree, prompt: 'do it', title: 'Task', - }, URI.parse('copilot:/source')), /worktree is only valid when relationship is "independent"/); - assert.deepStrictEqual(operations, []); + }, URI.parse('copilot:/source')); + assert.deepStrictEqual(addOptions, { + isolation: worktree ? 'worktree' : 'folder', + ...(worktree ? { forceNewWorktree: true } : {}), + prompt: 'do it', + }); }); } @@ -1486,16 +1529,53 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('create_session forces a peer chat when relationship is disabled', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + let createdSession = false; + const createdChatSessions: URI[] = []; + const accessor = createAccessor({ + onCreate: () => { createdSession = true; }, + onCreateChat: session => { createdChatSessions.push(session); }, + }); + const group = createSessionServerToolGroup(accessor, () => false); + const context = executionContext('copilot:/s1'); + + const omittedResult = await group.execute(stateManager, context, SessionServerToolName.CreateSession, { + prompt: 'first', + title: 'First Task', + }); + const staleIndependentResult = await group.execute(stateManager, context, SessionServerToolName.CreateSession, { + relationship: 'independent', + prompt: 'second', + title: 'Second Task', + }); + + assert.deepStrictEqual({ + createdSession, + createdChatSessions: createdChatSessions.map(session => session.toString()), + results: [omittedResult, staleIndependentResult].map(result => result.split(' (', 1)[0]), + }, { + createdSession: false, + createdChatSessions: ['copilot:/s1', 'copilot:/s1'], + results: ['Chat created in the current session', 'Chat created in the current session'], + }); + store.dispose(); + }); + test('create_session with currentSession adds and scopes a requested workspace folder', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); const requestedWorkspace = URI.file('/workspace/other'); const operations: string[] = []; let createdChat: { session: URI; options?: IAgentCreateChatRequestOptions } | undefined; + let addOptions: Parameters[2] | undefined; const accessor = createAccessor({ listSessions: async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)], - addSessionWorkingDirectory: async (session, directory) => { + getCreationDefaults: () => ({ isolation: 'worktree', project: workspace }), + addSessionWorkingDirectory: async (session, directory, options) => { operations.push(`add:${session.toString()}:${directory.toString()}`); + addOptions = options; return requestedWorkspace; }, onCreateChat: (session, _chat, options) => { @@ -1521,15 +1601,37 @@ suite('SessionServerTools', () => { assert.deepStrictEqual({ session: createdChat?.session.toString(), workingDirectories: createdChat?.options?.workingDirectories?.map(directory => directory.toString()), + addOptions, operations, }, { session: 'copilot:/s1', workingDirectories: [requestedWorkspace.toString()], + addOptions: { isolation: 'worktree', prompt: 'do it there' }, operations: [`add:copilot:/s1:${requestedWorkspace.toString()}`, 'create', 'rename', 'prompt'], }); store.dispose(); }); + test('create_session with currentSession inherits folder isolation for a requested workspace', async () => { + let addOptions: Parameters[2] | undefined; + const accessor = createAccessor({ + getCreationDefaults: () => ({ isolation: 'folder', project: workspace }), + addSessionWorkingDirectory: async (_session, directory, options) => { + addOptions = options; + return directory; + }, + }); + + await applyCreateSessionTool(accessor, { + relationship: 'currentSession', + workspace: 'file:///workspace/other', + prompt: 'do it there', + title: 'Other Folder', + }, URI.parse('copilot:/s1')); + + assert.deepStrictEqual(addOptions, { isolation: 'folder', prompt: 'do it there' }); + }); + test('create_session with currentSession rejects a model from another provider', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 8a236206ac2c8..8f7d5d4867cae 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -16,6 +16,7 @@ import { NullLogService } from '../../../../log/common/log.js'; import { GitRefType, IAgentHostGitService, META_DIFF_BASE_BRANCH, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type ISessionGitState, type Turn } from '../../../common/state/sessionState.js'; +import { deriveRepositoryRootFromWorktree } from '../../../common/worktreePaths.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { buildWorktreeFailureNotification, normalizeWorktreeFailureDiagnostic, NullAgentHostWorktreeIsolation, SessionWorkingDirectoryMissingError, WorktreeIsolation, getWorktreeName, getWorktreesRoot } from '../../../node/shared/worktreeIsolation.js'; @@ -185,15 +186,19 @@ suite('WorktreeIsolation', () => { rmSync(worktreesRoot.fsPath, { recursive: true, force: true }); }); - test('getWorktreesRoot / getWorktreeName derive sibling paths and strip the agents/ prefix', () => { + test('worktree path helpers derive sibling paths and strip the agents/ prefix', () => { assert.deepStrictEqual({ root: getWorktreesRoot(URI.file('/src/vscode')).fsPath, + repository: deriveRepositoryRootFromWorktree(URI.file('/src/vscode.worktrees/add-config'))?.fsPath, + plainFolderRepository: deriveRepositoryRootFromWorktree(URI.file('/src/worktrees/add-config')), named: getWorktreeName('agents/add-config'), namedFlattened: getWorktreeName('agents/feature/sub-topic'), namedNoPrefix: getWorktreeName('plain-branch'), namedWithBranchPrefix: getWorktreeName('users/alice/agents/add-config', 'users/alice/'), }, { root: URI.file('/src/vscode.worktrees').fsPath, + repository: URI.file('/src/vscode').fsPath, + plainFolderRepository: undefined, named: 'add-config', namedFlattened: 'feature-sub-topic', namedNoPrefix: 'plain-branch', diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index ac5d142fd2452..9e3c83df80176 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -111,15 +111,21 @@ Capabilities describe operations supported by the backing provider and remain ob Sessions and chats expose provider-neutral file changes and changesets. Transport, reconciliation, and backend metadata stay in the provider. Presentation stays in the owning changes and layout contributions. +Changeset operations are scoped to the changeset channel that advertises them. In a multi-repository session, each chat's Branch Changes and Uncommitted Changes channels identify that chat's concrete repository/worktree and may advertise repository-mutating operations for that target. This lets repository-wide operations such as Commit and Create PR, as well as resource-scoped operations such as Discard Changes, resolve through the active chat's repository. Aggregate changesets keep session context but omit changeset-scoped mutations when more than one repository could be affected; resource- and range-scoped operations may remain when their target is unambiguous. + Features may extend individual changeset operation descriptors through contribution-owned contracts, keeping feature-specific capabilities out of `ISessionChangeset`. The Changes contribution defines the Create PR operation's preparation and submission contract and owns its form; providers attach that capability only to supported operations and own generation, creation, and transport. Preparation is read-only and returns repository and branch identity for submission to revalidate before mutations. Submission uses confirmed values, saving any Agent Merge configuration as session-only overrides after creation. +Agent Merge remains enabled and retained at session scope, but its durable target owns a specific chat, working directory, branch, and pull request. Monitoring validates that the chat still belongs to the session and still owns the working directory before acting. Git inspection runs against that worktree, autonomous repair turns and notices are written to that chat, and the Agent Merge compare changeset derives its turn range from the same chat. Legacy targets without chat/worktree identity retain the original default-chat behavior. + The form also supports requesting creation in the originating session's main chat through the normal send lifecycle, without invoking programmatic PR creation. The provider validates the same prepared identity without regenerating details before the message is sent. That message contains the PR details and GitHub merge instructions; submission choices travel separately as provider-owned request metadata. A host chat contribution applies Agent Merge choices only after the creation turn is admitted and while it is still active, so monitoring cannot capture the old branch during client-side request preparation. The Changes contribution remembers form options and the last-used submission method across sessions in profile storage; remembering choices does not itself change session configuration or retain PR content. Turn-level file changes route through `IChatResponseFileChangesService`. The editor workbench opens its standard multi-diff presentation; the Agents Window registers `SessionsChatResponseFileChangesService` to select its canonical Changes editor. Providers expose the data but do not choose the presentation. ### Artifacts, references, and customizations -Sessions may expose the artifacts and references recorded by the agent. Both share one session-scoped observable and are told apart by `isArtifact`: an artifact is something the session produced that is not an ordinary workspace edit, while a reference is something it only points the user at. Consumers that surface one category must filter on that field rather than assuming the observable holds artifacts alone. Chats may expose the customizations used or read during their turns; these are chat-scoped. Providers that cannot determine either may omit the corresponding observable. +Sessions may expose the artifacts and references recorded by the agent. Both share one canonical session observable and are told apart by `isArtifact`: an artifact is something the session produced that is not an ordinary workspace edit, while a reference is something it only points the user at. Entries may carry an originating `chat`; chat surfaces show entries owned by that chat plus unowned session-wide entries. Consumers must apply ownership filtering before partitioning generic entries or promoting pull requests and issues, so every representation has the same chat scope. Providers that cannot determine artifact ownership leave it unowned rather than assigning an arbitrary chat. Chats may expose the customizations used or read during their turns; these are chat-scoped. Providers that cannot determine either may omit the corresponding observable. + +Pull requests associated with a session form one ordered collection whose entries carry their own repository identity and may carry their originating chat. The collection may span repositories and worktrees; the legacy singular pull-request field remains a compatibility projection for the primary repository. Session-level status aggregates all entries rather than selecting a repository by recency. An action opens or copies the only pull request directly and requires an explicit picker choice when multiple pull requests are available. Providers may advertise `supportsRemoveArtifacts` and implement `removeSessionArtifact`. User-initiated removal routes through `ISessionsManagementService` to the owning provider, which persists and publishes the updated artifact list. Removing a record does not remove independent session associations or alter the linked resource. diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index ab20a8ebdee32..90362e64e582c 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -11,6 +11,7 @@ import { ThemeIcon } from '../../base/common/themables.js'; import { URI } from '../../base/common/uri.js'; import type { ISessionGitState } from '../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { deriveRepositoryRootFromWorktree } from '../../platform/agentHost/common/worktreePaths.js'; import { IGitHubInfo, ISessionFolder, ISessionWorkspace } from '../services/sessions/common/session.js'; export interface IAgentHostSessionProjectSummary { @@ -151,8 +152,22 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject // is not populated here. const primary = workingDirectories?.[0]; const additionalFolders: ISessionFolder[] = (workingDirectories ?? []).slice(1).map(dir => { - const name = basename(dir) || dir.path; - return { root: dir, workingDirectory: dir, name, description: options.description }; + const repositoryRoot = deriveRepositoryRootFromWorktree(dir); + const root = repositoryRoot ?? dir; + const name = basename(root) || root.path; + return { + root, + workingDirectory: dir, + name, + description: options.description, + gitRepository: repositoryRoot ? { + uri: repositoryRoot, + workTreeUri: dir, + isRepository: constObservable(true), + baseBranchName: undefined, + gitHubInfo: constObservable(undefined), + } : undefined, + }; }); if (project) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 8fc50c101c438..a9b92fe5b8cf3 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -30,7 +30,7 @@ import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/cha import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; import { linkKey } from '../../../common/sessionLinks.js'; -import { getGitHubPullRequestRefs, SessionArtifactKind, type ISessionArtifact } from '../../../services/sessions/common/session.js'; +import { getGitHubPullRequestRefs, isSessionArtifactVisibleInChat, SessionArtifactKind, type IChat, type ISessionArtifact } from '../../../services/sessions/common/session.js'; import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { parseGitHubPullRequestUrl } from '../../github/common/utils.js'; @@ -151,6 +151,18 @@ function isShownInGitHub(artifact: ISessionArtifact, surfacedLinks: ReadonlySet< return isGitHubLink && surfacedLinks.has(linkKey(link)); } +/** Filters GitHub refs promoted from artifacts while preserving refs discovered from other session state. */ +export function filterSessionArtifactGitHubRefsForChat(refs: readonly T[], artifacts: readonly ISessionArtifact[], kind: SessionArtifactKind.PullRequest | SessionArtifactKind.Issue, chat: URI | undefined): readonly T[] { + return refs.filter(ref => { + const artifactMatches = artifacts.filter(artifact => + artifact.isArtifact + && artifact.kind === kind + && artifact.link + && linkKey(artifact.link.toString()) === linkKey(ref.uri.toString())); + return artifactMatches.length === 0 || artifactMatches.some(artifact => isSessionArtifactVisibleInChat(artifact, chat)); + }); +} + function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions, labelService: Pick): IChatPillEntry | undefined { if (artifact.kind === SessionArtifactKind.File) { if (!artifact.uri) { @@ -285,6 +297,7 @@ export class SessionArtifacts extends Disposable { constructor( session: IObservable, + chat: IObservable, /** The URLs the browsers pill lists; website entries for them are left out. */ private readonly _browserUrls: IObservable>, @IClipboardService private readonly _clipboardService: IClipboardService, @@ -305,14 +318,19 @@ export class SessionArtifacts extends Disposable { if (!current) { return []; } + const chatResource = chat.read(reader)?.resource; + const artifacts = current.artifacts?.read(reader) ?? []; locationFormatting.read(reader); const gitHubInfo = current.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); const surfacedLinks = new Set([ - ...getGitHubPullRequestRefs(gitHubInfo), - ...(gitHubInfo?.issues ?? []), + ...filterSessionArtifactGitHubRefsForChat(getGitHubPullRequestRefs(gitHubInfo), artifacts, SessionArtifactKind.PullRequest, chatResource), + ...filterSessionArtifactGitHubRefsForChat(gitHubInfo?.issues ?? [], artifacts, SessionArtifactKind.Issue, chatResource), ].map(ref => linkKey(ref.uri.toString()))); return buildSessionArtifactSections( - (current.artifacts?.read(reader) ?? []).filter(artifact => artifact.isArtifact === isArtifact && !isShownInGitHub(artifact, surfacedLinks)), + artifacts.filter(artifact => + artifact.isArtifact === isArtifact + && isSessionArtifactVisibleInChat(artifact, chatResource) + && !isShownInGitHub(artifact, surfacedLinks)), this._actions(), this._labelService, imageCarouselEnabled.read(reader), diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 272a6e6bbca26..d20af0fbf0462 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -22,7 +22,7 @@ import { INotificationService } from '../../../../platform/notification/common/n import { ChatInputPills, StandardChatInputPillSources } from '../../../../workbench/contrib/chat/browser/chatInputPills.js'; import { createSessionPullRequestPillData, type IChatPullRequestPillEntry, type IChatPullRequestPillSection } from '../../../../workbench/contrib/chat/browser/sessionPullRequestPill.js'; import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; +import { filterSessionArtifactGitHubRefsForChat, SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; import { SessionCustomizations } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillResourceLocation, type ChatPillsCompactMode, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; @@ -31,7 +31,7 @@ import { IGitHubService } from '../../github/browser/githubService.js'; import { IResolvedSessionPullRequest, SessionPullRequestPresentationModel } from '../../github/browser/pullRequestIconStatus.js'; import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../../../workbench/contrib/chat/common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, getGitHubPullRequestRefs, IChat, SESSION_CHANGES_CHANGESET_ID, SessionArtifactKind, type ISessionArtifact, type IGitHubIssueRef, type IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; +import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, getGitHubPullRequestRefs, IChat, isSessionArtifactVisibleInChat, SESSION_CHANGES_CHANGESET_ID, SessionArtifactKind, type ISessionArtifact, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; @@ -242,12 +242,31 @@ export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue return entries.length > 0 ? [{ title: localize('sessionChatPills.issues', "Issues"), entries }] : []; } -/** Returns the session-scoped changes counts represented by the shared Changes pill. */ -export function computeSessionInputPillStats(session: IActiveSession | undefined, changesStatsCache: ISessionChangesStatsCache, reader: IReader): IDiffStats { +function getInputPillChangesetId(workspace: ISessionWorkspace | undefined): string { + return workspace?.folders.some(folder => folder.gitRepository?.workTreeUri !== undefined) + ? BRANCH_CHANGES_CHANGESET_ID + : SESSION_CHANGES_CHANGESET_ID; +} + +/** Returns the current chat's changes counts, falling back to session-level data when unavailable. */ +export function computeSessionInputPillStats(session: IActiveSession | undefined, chat: IChat | undefined, changesStatsCache: ISessionChangesStatsCache, reader: IReader): IDiffStats { if (session?.worktreePending?.read(reader)) { return EMPTY_DIFF_STATS; } - const workspace = session?.workspace.read(reader); + const workspace = chat?.workspace?.read(reader) ?? session?.workspace.read(reader); + const changesetId = getInputPillChangesetId(workspace); + const changeset = (chat?.changesets?.read(reader) ?? session?.changesets.read(reader)) + ?.find(candidate => candidate.id === changesetId && candidate.isEnabled.read(reader)); + if (changeset) { + let insertions = 0; + let deletions = 0; + const changes = changeset.changes.read(reader); + for (const change of changes) { + insertions += change.insertions; + deletions += change.deletions; + } + return { files: changes.length, insertions, deletions }; + } const stats = session && workspace ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) : undefined; @@ -333,7 +352,7 @@ export class SessionChatInputToolbar extends Disposable { if (debugData) { return debugData.stats; } - return computeSessionInputPillStats(this._session.read(reader), changesStatsCache, reader); + return computeSessionInputPillStats(this._session.read(reader), this._chat.read(reader), changesStatsCache, reader); }); const pillsEnabled = constObservable(true); @@ -341,7 +360,7 @@ export class SessionChatInputToolbar extends Disposable { // The browsers pill already offers the pages it lists, so the artifacts and // references pills leave those websites out. - const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session, this._browsers.urls)); + const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session, this._chat, this._browsers.urls)); this._artifactSections = derived(this, reader => { const debugData = this._debugData.read(reader); return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader); @@ -357,7 +376,21 @@ export class SessionChatInputToolbar extends Disposable { const workspace = session?.workspace.read(reader); return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); }); - const pullRequestRefs = derived(this, reader => getGitHubPullRequestRefs(gitHubInfo.read(reader))); + const artifactsForChat = derived(this, reader => { + const session = this._session.read(reader); + const chat = this._chat.read(reader); + return (session?.artifacts?.read(reader) ?? []).filter(artifact => isSessionArtifactVisibleInChat(artifact, chat?.resource)); + }); + const pullRequestRefs = derived(this, reader => { + const session = this._session.read(reader); + const chat = this._chat.read(reader); + return filterSessionArtifactGitHubRefsForChat( + getGitHubPullRequestRefs(gitHubInfo.read(reader)), + session?.artifacts?.read(reader) ?? [], + SessionArtifactKind.PullRequest, + chat?.resource, + ); + }); const agentMergeConfiguration = derived(this, reader => { const session = this._session.read(reader); return session ? getSessionAgentMergeConfigurationObservable(session, sessionsProvidersService, this._configurationService).read(reader) : undefined; @@ -366,7 +399,7 @@ export class SessionChatInputToolbar extends Disposable { const pullRequestSections = derived(this, reader => { const session = this._session.read(reader); const artifactActions = session?.capabilities.read(reader).supportsRemoveArtifacts ? { - artifacts: session.artifacts?.read(reader) ?? [], + artifacts: artifactsForChat.read(reader), remove: async (artifactIds: readonly string[]) => { try { for (const artifactId of artifactIds) { @@ -380,7 +413,16 @@ export class SessionChatInputToolbar extends Disposable { } : undefined; return buildSessionPullRequestSections(pullRequestPresentation.pullRequests.read(reader), session, commandService, clipboardService, openerService, this._sessionsService, artifactActions, this._pullRequestDropdownHoverCache); }); - const issueRefs = derived(this, reader => gitHubInfo.read(reader)?.issues ?? []); + const issueRefs = derived(this, reader => { + const session = this._session.read(reader); + const chat = this._chat.read(reader); + return filterSessionArtifactGitHubRefsForChat( + gitHubInfo.read(reader)?.issues ?? [], + session?.artifacts?.read(reader) ?? [], + SessionArtifactKind.Issue, + chat?.resource, + ); + }); const issues = derived(this, reader => issueRefs.read(reader).map(ref => { const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); return { ref, issue: reference.object.issue.read(reader) }; @@ -412,7 +454,7 @@ export class SessionChatInputToolbar extends Disposable { return computeAggregateIssueIcon(resolved.map(({ issue }) => issue)); }); const changesLabel = derived(this, reader => { - const workspace = this._session.read(reader)?.workspace.read(reader); + const workspace = this._chat.read(reader)?.workspace?.read(reader) ?? this._session.read(reader)?.workspace.read(reader); const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); return branch ? localize('sessionChatPills.allChangesOnBranch', "All Changes ({0})", branch) @@ -427,14 +469,12 @@ export class SessionChatInputToolbar extends Disposable { if (!session || this._debugData.get()) { return; } - const isWorktree = session.workspace.get()?.folders[0]?.gitRepository?.workTreeUri !== undefined; + const workspace = this._chat.get()?.workspace?.get() ?? session.workspace.get(); layoutService.revealEditorPartExplicitly(); void sessionChangesService.openChangesEditor(session.resource, { changesetSelection: { kind: 'id', - id: isWorktree - ? BRANCH_CHANGES_CHANGESET_ID - : SESSION_CHANGES_CHANGESET_ID + id: getInputPillChangesetId(workspace) } }); }, diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts index 18c094f994326..3f729d94b1494 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts @@ -17,8 +17,8 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { buildSessionArtifactSections, sessionArtifactLocationText, SessionArtifacts, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; -import { type IGitHubInfo, type ISessionArtifact, type ISessionWorkspace, SessionArtifactKind } from '../../../../services/sessions/common/session.js'; +import { buildSessionArtifactSections, filterSessionArtifactGitHubRefsForChat, sessionArtifactLocationText, SessionArtifacts, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; +import { type IChat, type IGitHubInfo, type ISessionArtifact, type ISessionWorkspace, SessionArtifactKind } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; suite('Session Artifacts', () => { @@ -42,6 +42,9 @@ suite('Session Artifacts', () => { function createPresentation(entries: readonly ISessionArtifact[], info?: IGitHubInfo) { const artifacts = observableValue('artifacts', entries); const gitHubInfo = observableValue('gitHubInfo', info); + const chat = observableValue('chat', new class extends mock() { + override readonly resource = URI.parse('test-chat:/session#chat-a'); + }()); const root = URI.file('/repo'); const workspace = observableValue('workspace', { uri: root, @@ -65,6 +68,7 @@ suite('Session Artifacts', () => { disposables.add(configurationService.onDidChangeConfigurationEmitter); const presentation = disposables.add(new SessionArtifacts( session, + chat, constObservable(new Set()), new class extends mock() { }(), new class extends mock() { }(), @@ -78,7 +82,7 @@ suite('Session Artifacts', () => { override readonly onDidChangeWorkspaceFolders = Event.None; }(), )); - return { presentation, session, artifacts, workspace, gitHubInfo }; + return { presentation, session, chat, artifacts, workspace, gitHubInfo }; } function visibleEntries(presentation: SessionArtifacts, reader?: IReader) { @@ -106,6 +110,56 @@ suite('Session Artifacts', () => { ]); }); + test('shows chat-owned artifacts and references only in their chat', () => { + const chatA = URI.parse('test-chat:/session#chat-a'); + const chatB = URI.parse('test-chat:/session#chat-b'); + const { presentation, chat } = createPresentation([ + { id: 'global-artifact', kind: SessionArtifactKind.Website, label: 'Global artifact', isArtifact: true, link: URI.parse('https://example.com/global') }, + { id: 'global-reference', kind: SessionArtifactKind.Resource, label: 'Global reference', isArtifact: false, uri: URI.parse('https://example.com/global-ref') }, + { id: 'chat-a-artifact', kind: SessionArtifactKind.File, label: 'Chat A artifact', chat: chatA, isArtifact: true, uri: URI.file('/repo/a.md') }, + { id: 'chat-a-reference', kind: SessionArtifactKind.Website, label: 'Chat A reference', chat: chatA, isArtifact: false, link: URI.parse('https://example.com/a') }, + { id: 'chat-b-artifact', kind: SessionArtifactKind.File, label: 'Chat B artifact', chat: chatB, isArtifact: true, uri: URI.file('/repo/b.md') }, + { id: 'chat-b-reference', kind: SessionArtifactKind.Website, label: 'Chat B reference', chat: chatB, isArtifact: false, link: URI.parse('https://example.com/b') }, + ]); + + const chatAEntries = visibleEntries(presentation); + chat.set(new class extends mock() { + override readonly resource = chatB; + }(), undefined); + const chatBEntries = visibleEntries(presentation); + + assert.deepStrictEqual({ chatAEntries, chatBEntries }, { + chatAEntries: { + artifacts: ['global-artifact', 'chat-a-artifact'], + references: ['chat-a-reference', 'global-reference'], + }, + chatBEntries: { + artifacts: ['global-artifact', 'chat-b-artifact'], + references: ['chat-b-reference', 'global-reference'], + }, + }); + }); + + test('filters promoted GitHub artifacts without hiding session-level refs', () => { + const chatA = URI.parse('test-chat:/session#chat-a'); + const chatB = URI.parse('test-chat:/session#chat-b'); + const refs = [1, 2, 3, 4].map(number => ({ number, uri: URI.parse(`https://github.com/microsoft/vscode/pull/${number}`) })); + const artifacts: readonly ISessionArtifact[] = [ + { id: 'a', kind: SessionArtifactKind.PullRequest, label: 'A', chat: chatA, isArtifact: true, link: refs[0].uri }, + { id: 'b', kind: SessionArtifactKind.PullRequest, label: 'B', chat: chatB, isArtifact: true, link: refs[1].uri }, + { id: 'global', kind: SessionArtifactKind.PullRequest, label: 'Global', isArtifact: true, link: refs[3].uri }, + ]; + const visible = (chat: URI) => filterSessionArtifactGitHubRefsForChat(refs, artifacts, SessionArtifactKind.PullRequest, chat).map(ref => ref.number); + + assert.deepStrictEqual({ + chatA: visible(chatA), + chatB: visible(chatB), + }, { + chatA: [1, 3, 4], + chatB: [2, 3, 4], + }); + }); + test('shows each artifact path or link beside its dropdown entry', () => { const fileUri = URI.file('/home/alice/artifacts/report.md'); const resourceUri = URI.parse('https://example.com/dashboard'); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index aa89cde637b30..1576f61c444c3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -27,7 +27,7 @@ import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; -import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, SESSION_CHANGES_CHANGESET_ID, SessionArtifactKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionArtifact, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, SESSION_CHANGES_CHANGESET_ID, SessionArtifactKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionArtifact, type ISessionChangeset, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangesEditorOptions, ISessionChangesService } from '../../../changes/common/sessionChangesService.js'; import { getGitHubHoverDate, getGitHubHoverDescription, getGitHubHoverTitle, getGitHubHoverTitleParts } from '../../../github/browser/githubHover.js'; @@ -59,10 +59,14 @@ suite('SessionChatInputToolbar', () => { return { instantiationService, visibility }; } - test('uses session-scoped changes rather than the last turn', () => { + test('uses current chat changes with session-level fallback', () => { + const root = URI.file('/repo'); + const sessionWorkspace = constObservable(upcastPartial({ + folders: [{ root, workingDirectory: root, name: 'repo', description: undefined }], + })); const session = upcastPartial({ sessionId: 'provider:session', - workspace: constObservable(upcastPartial({ folders: [] })), + workspace: sessionWorkspace, changesets: constObservable([]), changes: constObservable([{ modifiedUri: URI.file('/session-change.ts'), @@ -70,21 +74,54 @@ suite('SessionChatInputToolbar', () => { deletions: 4, }]), }); + const chatChanges = observableValue('chatChanges', [{ + modifiedUri: URI.file('/repo.worktrees/chat/chat-change.ts'), + insertions: 2, + deletions: 1, + }]); + const chat = upcastPartial({ + workspace: constObservable(upcastPartial({ + folders: [{ + root, + workingDirectory: URI.file('/repo.worktrees/chat'), + name: 'repo', + description: undefined, + gitRepository: { + uri: root, + workTreeUri: URI.file('/repo.worktrees/chat'), + baseBranchName: 'main', + gitHubInfo: constObservable(undefined), + }, + }], + })), + changesets: constObservable([upcastPartial({ + id: BRANCH_CHANGES_CHANGESET_ID, + isEnabled: constObservable(true), + changes: chatChanges, + })]), + }); const cache = upcastPartial({ get: () => ({ files: 2, insertions: 8, deletions: 3 }), }); - const stats = derived(reader => computeSessionInputPillStats(session, cache, reader)); + const stats = derived(reader => computeSessionInputPillStats(session, chat, cache, reader)); + const fallbackStats = derived(reader => computeSessionInputPillStats(session, undefined, cache, reader)); const pendingSession = upcastPartial({ ...session, worktreePending: constObservable(true), }); - const pendingStats = derived(reader => computeSessionInputPillStats(pendingSession, cache, reader)); + const pendingStats = derived(reader => computeSessionInputPillStats(pendingSession, chat, cache, reader)); assert.deepStrictEqual({ - session: stats.get(), + chat: stats.get(), + sessionFallback: fallbackStats.get(), pendingWorktree: pendingStats.get(), }, { - session: { + chat: { + files: 1, + insertions: 2, + deletions: 1, + }, + sessionFallback: { files: 1, insertions: 10, deletions: 4, @@ -170,6 +207,75 @@ suite('SessionChatInputToolbar', () => { } } + test('opens Branch Changes for the current chat worktree', () => { + const { instantiationService } = createServices(); + const root = URI.file('/repo'); + const worktree = URI.file('/repo.worktrees/chat'); + const chat = upcastPartial({ + resource: URI.parse('chat:peer'), + workspace: constObservable(upcastPartial({ + folders: [{ + root, + workingDirectory: worktree, + name: 'repo', + description: undefined, + gitRepository: { + uri: root, + workTreeUri: worktree, + baseBranchName: 'main', + gitHubInfo: constObservable(undefined), + }, + }], + })), + changesets: constObservable([upcastPartial({ + id: BRANCH_CHANGES_CHANGESET_ID, + isEnabled: constObservable(true), + changes: constObservable([{ + modifiedUri: URI.joinPath(worktree, 'changed.ts'), + insertions: 2, + deletions: 1, + }]), + })]), + }); + const session = upcastPartial({ + sessionId: 'provider:session', + capabilities: constObservable({ supportsMultipleChats: true }), + resource: URI.parse('session:1'), + chats: constObservable([chat]), + workspace: constObservable(upcastPartial({ + folders: [{ root, workingDirectory: root, name: 'repo', description: undefined }], + })), + changesets: constObservable([]), + changes: constObservable([]), + }); + const calls: { action: string; options?: ISessionChangesEditorOptions }[] = []; + instantiationService.stub(ISessionsService, 'setActive', () => { + calls.push({ action: 'activate' }); + }); + instantiationService.stub(IAgentWorkbenchLayoutService, upcastPartial({ + revealEditorPartExplicitly: () => { calls.push({ action: 'reveal' }); }, + })); + instantiationService.stub(ISessionChangesService, upcastPartial({ + openChangesEditor: async (_resource, options) => { + calls.push({ action: 'open', options }); + return undefined; + }, + })); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); + toolbar.setSession(session, chat); + + toolbar.element.querySelector('.chat-changes-pill-button')?.click(); + + assert.deepStrictEqual(calls, [{ + action: 'activate', + }, { + action: 'reveal', + }, { + action: 'open', + options: { changesetSelection: { kind: 'id', id: BRANCH_CHANGES_CHANGESET_ID } }, + }]); + }); + test('adds rich GitHub hovers only when live details are available', async () => { const commands: { readonly id: string; readonly args: readonly unknown[] }[] = []; const clipboardWrites: string[] = []; diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index f0a153eeff94b..aeee6d63aa61a 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -4,16 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize2 } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getGitHubPullRequestRefs, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; +import { getSessionGitHubPullRequestRefs, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; @@ -21,6 +23,10 @@ class PullRequestActionContext { constructor(readonly pullRequest: IGitHubPullRequestRef) { } } +interface IPullRequestPickItem extends IQuickPickItem { + readonly pullRequest: IGitHubPullRequestRef; +} + function isPullRequestActionContext(target: unknown): target is PullRequestActionContext { if (!target || typeof target !== 'object') { return false; @@ -54,21 +60,63 @@ class OpenPullRequestAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const sessionsService = accessor.get(ISessionsService); + const openerService = accessor.get(IOpenerService); const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); - const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); + const pullRequest = isPullRequestActionContext(target) + ? target.pullRequest + : await pickSessionPullRequest(accessor, target, localize('agentSessions.openPullRequest.pick', "Choose a pull request to open")); if (!pullRequest) { return; } - const openerService = accessor.get(IOpenerService); await openerService.open(pullRequest.uri, { openExternal: true, allowContributedOpeners: true }); } } registerAction2(OpenPullRequestAction); -function getSessionPullRequest(session: ISession | undefined): IGitHubPullRequestRef | undefined { - const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); - return getGitHubPullRequestRefs(gitHubInfo)[0]; +async function pickSessionPullRequest(accessor: ServicesAccessor, session: ISession | undefined, placeHolder: string): Promise { + const pullRequests = getSessionGitHubPullRequestRefs(session); + if (pullRequests.length <= 1) { + return pullRequests[0]; + } + + const chats = session?.chats.get() ?? []; + const items: IPullRequestPickItem[] = pullRequests.map(pullRequest => { + const chatTitle = pullRequest.chat + ? chats.find(chat => chat.resource.toString() === pullRequest.chat?.toString())?.title.get() + : undefined; + const state = pullRequest.liveState ?? pullRequest.state; + const stateLabel = state === 'open' + ? localize('agentSessions.pullRequestState.open', "Open") + : state === 'merged' + ? localize('agentSessions.pullRequestState.merged', "Merged") + : state === 'closed' + ? localize('agentSessions.pullRequestState.closed', "Closed") + : undefined; + const detail = stateLabel && chatTitle + ? localize('agentSessions.pullRequestPick.stateAndChat', "{0} · Chat: {1}", stateLabel, chatTitle) + : stateLabel + ? stateLabel + : chatTitle + ? localize('agentSessions.pullRequestPick.chat', "Chat: {0}", chatTitle) + : undefined; + const label = pullRequest.title ?? localize('agentSessions.pullRequestPick.untitled', "Pull Request #{0}", pullRequest.number); + const description = `${pullRequest.owner}/${pullRequest.repo}#${pullRequest.number}`; + return { + label, + description, + detail, + ariaLabel: [label, description, detail].filter(value => value !== undefined).join(', '), + iconClass: ThemeIcon.asClassName(pullRequest.icon ?? Codicon.gitPullRequest), + pullRequest, + }; + }); + const quickInputService = accessor.get(IQuickInputService); + return (await quickInputService.pick(items, { + placeHolder, + matchOnDescription: true, + matchOnDetail: true, + }))?.pullRequest; } class CopyPullRequestUrlAction extends Action2 { @@ -92,7 +140,9 @@ class CopyPullRequestUrlAction extends Action2 { const clipboardService = accessor.get(IClipboardService); const sessionsService = accessor.get(ISessionsService); const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); - const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); + const pullRequest = isPullRequestActionContext(target) + ? target.pullRequest + : await pickSessionPullRequest(accessor, target, localize('agentSessions.copyPullRequestUrl.pick', "Choose a pull request whose URL to copy")); if (!pullRequest) { return; } diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts index dcd82c0a337d6..282e9d6d64814 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts @@ -16,9 +16,10 @@ import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/commo import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasPullRequestContext } from '../../../../common/contextkeys.js'; -import { IGitHubPullRequestRef, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IChat, IGitHubPullRequestRef, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import '../../browser/pullRequestActions.js'; import { SessionPullRequestPresentationModel } from '../../browser/pullRequestIconStatus.js'; @@ -28,7 +29,7 @@ import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestMo import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; -function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullRequestRefs?: readonly IGitHubPullRequestRef[]): ISession { +function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullRequestRefs?: readonly IGitHubPullRequestRef[], chats: readonly IChat[] = []): ISession { const workspaceUri = URI.from({ scheme: 'test', path: '/workspace' }); const workspace: ISessionWorkspace = { uri: workspaceUri, @@ -56,6 +57,7 @@ function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullReque }; return new class extends mock() { override readonly workspace = constObservable(workspace); + override readonly chats = constObservable(chats); }; } @@ -256,6 +258,77 @@ suite('Pull Request Actions', () => { }]); }); + test('Open Pull Request asks which pull request to open across chats and repositories', async () => { + const primaryPullRequestUri = URI.parse('https://github.com/owner/repo/pull/1'); + const peerPullRequestUri = URI.parse('https://github.com/upstream/project/pull/7'); + const peerChatUri = URI.parse('test:/session#peer'); + const peerChat = new class extends mock() { + override readonly resource = peerChatUri; + override readonly title = constObservable('Alternative implementation'); + }; + const session = createSessionWithPullRequest(primaryPullRequestUri, [{ + owner: 'owner', + repo: 'repo', + number: 1, + uri: primaryPullRequestUri, + state: 'open', + }, { + owner: 'upstream', + repo: 'project', + number: 7, + uri: peerPullRequestUri, + title: 'Alternative fix', + state: 'merged', + chat: peerChatUri, + }], [peerChat]); + + const pickedItems: IQuickPickItem[] = []; + const quickInputService = upcastPartial({ + pick: (async (picks: Promise[]> | QuickPickInput[]) => { + const items = await picks as T[]; + pickedItems.push(...items); + const selected = items[1]; + return selected; + }) as IQuickInputService['pick'], + }); + const instantiationService = new TestInstantiationService(); + const openerService = new TestOpenerService(); + instantiationService.stub(IOpenerService, openerService); + instantiationService.stub(IQuickInputService, quickInputService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); + + assert.deepStrictEqual({ + items: pickedItems.map(item => ({ + label: item.label, + description: item.description, + detail: item.detail, + ariaLabel: item.ariaLabel, + })), + opened: openerService.opened, + }, { + items: [{ + label: 'Pull Request #1', + description: 'owner/repo#1', + detail: 'Open', + ariaLabel: 'Pull Request #1, owner/repo#1, Open', + }, { + label: 'Alternative fix', + description: 'upstream/project#7', + detail: 'Merged · Chat: Alternative implementation', + ariaLabel: 'Alternative fix, upstream/project#7, Merged · Chat: Alternative implementation', + }], + opened: [{ + resource: peerPullRequestUri, + openExternal: true, + allowContributedOpeners: true, + }], + }); + }); + test('Copy Pull Request URL uses an explicit contextual pull request', async () => { const secondPullRequestUri = URI.parse('https://github.com/upstream/project/pull/7'); const secondPullRequest = { owner: 'upstream', repo: 'project', number: 7, uri: secondPullRequestUri }; diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index a036ecbc0ec27..b2c3143f8a4ab 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -75,13 +75,19 @@ Agent provider names form logical session-type identifiers. Resource URI schemes - derives capabilities from the advertised agent and live host state; - updates observable state without replacing the facade when identity is stable. -The session working-directory set is authoritative for the folders projected through `ISessionWorkspace`. When an agent creates a peer chat with a different folder, the host first adds that folder to the session and then records the chat's working directories as a subset of the session set. Existing chats that inherited the full set are pinned to the previous set before expansion so they do not silently gain access to the new folder. The session set and per-chat subsets are persisted in the host catalog and restored together after restart. This flow requires the agent's multiple-working-directories capability; omitting a chat subset preserves full-session inheritance. +The session working-directory set is authoritative for the folders projected through `ISessionWorkspace`. When an agent creates a peer chat with a different folder, the host first adds that folder to the session and then records the chat's working directories as a subset of the session set. Existing chats that inherited the full set are pinned to the previous set before expansion so they do not silently gain access to the new folder. When the invoking session uses worktree isolation, a peer chat that adds another project without an explicit isolation choice inherits that policy and reuses a session worktree when that repository is already represented. An explicit `worktree: true` request instead allocates a fresh worktree, including when another chat already owns a worktree from the same repository. Every additional worktree is owned by the parent session across restore, archive, and deletion. The session set and per-chat subsets are persisted in the host catalog and restored together after restart. This flow requires the agent's multiple-working-directories capability; omitting a chat subset preserves full-session inheritance. + +The experiment-backed `chat.agentHost.experimental.createSessionRelationship` setting controls whether the session-creation tool exposes a relationship choice. When disabled, the host omits that property from the advertised schema and authoritatively creates every request as a peer chat in the invoking session, including calls from stale clients that still supply an independent relationship. The setting defaults to enabled for compatibility, and changes are re-advertised to existing sessions without restarting the Agent Host. The provider cache owns adapter identity. Catalog notifications describe membership; adapter observables describe mutable state. Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. -Agent-recorded artifacts and references are persisted with the session and projected together through `ISession.artifacts`, where `isArtifact` distinguishes them. Only artifacts are promoted into the existing GitHub metadata, so a pull request or issue the session produced is polled and shown on the shared GitHub surfaces rather than duplicated; a reference keeps its link identity so anything those surfaces already show is offered exactly once. Customizations used or read by the agent are derived per chat and projected through `IChat.customizations`. +The host projects Branch Changes and Uncommitted Changes onto distinct backend changeset channels for every chat. It derives each channel's trusted working-directory subset from host state, filters the aggregate repository diff to that subset, and publishes operations from the target repository's current Git and GitHub state. The provider subscribes to those chat-owned channels directly rather than suppressing their operations client-side. Session Changes remains an aggregate: repository-mutating changeset operations are omitted when the session spans multiple repositories. Invocation revalidates the chat and working-directory ownership, while configuration, metadata, and durable results continue to use the parent session. + +Agent-recorded artifacts and references are persisted in one session metadata collection and projected together through `ISession.artifacts`, where `isArtifact` distinguishes them. Artifact tools stamp the trusted backend chat URI without changing AHP; the provider validates that URI belongs to the session and maps it to the corresponding default or peer Sessions chat resource. Malformed or cross-session ownership is suppressed rather than exposed as session-wide. Entries without ownership remain session-wide for compatibility and for creation paths that lack chat context. Produced GitHub pull requests are additionally projected into one newest-first collection without filtering them to the session-primary repository; every reference retains its own repository identity and known chat ownership. The legacy singular pull request continues to select the first matching primary-repository entry. + +Chat surfaces filter this aggregate to the active chat before building generic artifact and reference pills. Only artifacts are promoted into the existing GitHub metadata, so pull-request and issue surfaces apply the same ownership filter to promoted links while retaining independently discovered session GitHub state. A reference keeps its link identity so anything those surfaces already show is offered exactly once. Customizations used or read by the agent are derived per chat and projected through `IChat.customizations`. ## Draft and send lifecycle @@ -107,7 +113,7 @@ Startup metadata may seed lightweight session facades before a live connection f External sessions remain provider-owned domain objects. Visibility and interactivity fields determine whether shared Sessions surfaces present them; shared code does not infer visibility from Agent Host URI formats. -Host-owned background activities remain independent of client visibility. Agent Merge monitoring prevents an enabled session from idle eviction while work is active, resumes eligible sessions after host startup, and releases that retention when monitoring ends. +Host-owned background activities remain independent of client visibility. Agent Merge monitoring prevents an enabled session from idle eviction while work is active, resumes eligible sessions after host startup, and releases that retention when monitoring ends. Its persisted target identifies the owning chat and working directory, so restored monitoring, repair turns, notices, and compare-turn changes remain attached to the repository/chat that enabled it rather than the session's default chat. ### Host session catalog diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts index 30fa439e76593..5528503312c7a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts @@ -31,7 +31,7 @@ function parseUri(value: string | undefined): URI | undefined { } } -function toSessionArtifact(artifact: IProtocolSessionArtifact, mapFileUri: (uri: URI) => URI): ISessionArtifact | undefined { +function toSessionArtifact(artifact: IProtocolSessionArtifact, mapFileUri: (uri: URI) => URI, mapChat: ((chat: string) => URI | undefined) | undefined): ISessionArtifact | undefined { const kind = kindByType.get(artifact.type); if (!kind) { return undefined; @@ -40,8 +40,9 @@ function toSessionArtifact(artifact: IProtocolSessionArtifact, mapFileUri: (uri: const link = parseUri(artifact.link); const parsedUri = parseUri(artifact.uri); const uri = parsedUri && artifact.type === SessionArtifactType.File ? mapFileUri(parsedUri) : parsedUri; + const chat = artifact.chat ? (mapChat ? mapChat(artifact.chat) : parseUri(artifact.chat)) : undefined; // An artifact the client cannot act on is not worth surfacing. - if (!link && !uri && !artifact.commitHash) { + if ((!link && !uri && !artifact.commitHash) || (artifact.chat && !chat)) { return undefined; } @@ -50,6 +51,7 @@ function toSessionArtifact(artifact: IProtocolSessionArtifact, mapFileUri: (uri: kind, label: artifact.label, isArtifact: artifact.isArtifact, + ...(chat ? { chat } : {}), ...(link ? { link } : {}), ...(uri ? { uri } : {}), ...(artifact.commitHash ? { commitHash: artifact.commitHash } : {}), @@ -63,6 +65,8 @@ export interface ISessionArtifactPartition { readonly entries: readonly ISessionArtifactEntry[]; /** Pull requests this session produced, most recent first; polled and shown in the pull request pill. */ readonly pullRequestUrls: readonly string[]; + /** Originating chat for each produced pull request, keyed by {@link linkKey}. */ + readonly pullRequestChats: ReadonlyMap; /** * Titles the agent recorded for its pull request artifacts, keyed by * {@link linkKey}. Pull requests discovered from git state have no entry. @@ -96,15 +100,16 @@ function gitHubLink(artifact: IProtocolSessionArtifact): string | undefined { return undefined; } -export function partitionSessionArtifacts(meta: SessionMeta | undefined, mapFileUri: (uri: URI) => URI = uri => uri): ISessionArtifactPartition { +export function partitionSessionArtifacts(meta: SessionMeta | undefined, mapFileUri: (uri: URI) => URI = uri => uri, mapChat?: (chat: string) => URI | undefined): ISessionArtifactPartition { const entries: ISessionArtifactEntry[] = []; const pullRequestUrls: string[] = []; + const pullRequestChats = new Map(); const pullRequestTitles = new Map(); const issueUrls: string[] = []; const issueTitles = new Map(); for (const artifact of readSessionArtifacts(meta)) { - const mapped = toSessionArtifact(artifact, mapFileUri); + const mapped = toSessionArtifact(artifact, mapFileUri, mapChat); if (!mapped) { continue; } @@ -126,6 +131,9 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined, mapFile } pullRequestUrls.push(link); + if (mapped.chat) { + pullRequestChats.set(key, mapped.chat); + } } // Reversed here, after the walk let the first title recorded for a link win. @@ -133,7 +141,7 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined, mapFile pullRequestUrls.reverse(); issueUrls.reverse(); - return { entries, pullRequestUrls, pullRequestTitles, issueUrls, issueTitles }; + return { entries, pullRequestUrls, pullRequestChats, pullRequestTitles, issueUrls, issueTitles }; } /** Case-insensitive de-duplication that keeps the first occurrence's casing. */ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index ff310730a9f80..07164db29ec0a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -12,6 +12,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { readAgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { isMultiRootSession } from '../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; import { AGENT_MERGE_CHANGESET_ID, ChangesetKind, resolveChangesetUriTemplate, selectDefaultChangeset } from '../../../../../platform/agentHost/common/changesetUri.js'; import { isAgentMergeMessage } from '../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; @@ -655,20 +656,20 @@ class AgentHostAgentMergeChangeset extends AbstractAgentHostChangeset { constObservable(sessionUri), ); - const defaultChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { + const targetChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { const sessionState = sessionStateObs.read(reader).read(reader); - return URI.parse( - sessionState && !(sessionState instanceof Error) - ? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri) - : buildDefaultChatUri(sessionUri) - ); + if (!sessionState || sessionState instanceof Error) { + return URI.parse(buildDefaultChatUri(sessionUri).toString()); + } + const targetChatUri = readAgentMergeSessionState(sessionState.config?.values)?.target?.chatUri; + return URI.parse((targetChatUri ?? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri)).toString()); }); const chatStateObs = createActiveSessionSubscriptionObs( options, isActiveSessionObs, StateComponents.Chat, - defaultChatUriObs, + targetChatUriObs, ); this.channelUriObs = derivedOpts({ equalsFn: isEqual }, reader => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 375323e6471af..1f093d2a746a4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -323,15 +323,7 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine return a.owner === b.owner && a.repo === b.repo && - arrayEquals(a.pullRequests ?? [], b.pullRequests ?? [], (x, y) => - x.owner === y.owner && - x.repo === y.repo && - x.number === y.number && - isEqual(x.uri, y.uri) && - x.icon?.id === y.icon?.id && - x.state === y.state && - x.liveState === y.liveState && - x.title === y.title) && + pullRequestRefsEqual(a.pullRequests ?? [], b.pullRequests ?? []) && a.pullRequest?.number === b.pullRequest?.number && a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && a.pullRequest?.state === b.pullRequest?.state && @@ -347,6 +339,20 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine x.title === y.title); } +function pullRequestRefsEqual(a: readonly IGitHubPullRequestRef[], b: readonly IGitHubPullRequestRef[]): boolean { + return arrayEquals(a, b, (x, y) => + x.owner === y.owner && + x.repo === y.repo && + x.number === y.number && + isEqual(x.uri, y.uri) && + isEqual(x.chat, y.chat) && + x.icon?.id === y.icon?.id && + x.state === y.state && + x.liveState === y.liveState && + x.title === y.title && + x.createdByThisSession === y.createdByThisSession); +} + function dateEquals(a: Date | undefined, b: Date | undefined): boolean { return a?.getTime() === b?.getTime(); } @@ -379,17 +385,19 @@ function toGitHubIssueRefs(issueUrls: readonly string[] | undefined, titles: Rea * title. Every pull request published here belongs to the session — it either * produced it or its branch relates to it — so all are marked as such. */ -function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { +function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap, chats: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { const refs: IGitHubPullRequestRef[] = []; for (const url of pullRequestUrls ?? []) { const reference = parseGitHubPullRequestUrl(url); if (reference) { const title = titles.get(linkKey(url)); + const chat = chats.get(linkKey(url)); refs.push({ ...reference, uri: URI.parse(url), state: state?.pullRequestStateUrl && linkKey(state.pullRequestStateUrl) === linkKey(url) ? state.pullRequestState : undefined, ...(title ? { title } : {}), + ...(chat ? { chat } : {}), createdByThisSession: true, }); } @@ -397,36 +405,40 @@ function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullReq return refs.length > 0 ? refs : undefined; } -function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined { +function toSessionGitHubPullRequestRefs(meta: SessionMeta | undefined, mapArtifactChat?: (chat: string) => URI | undefined): readonly IGitHubPullRequestRef[] { + const state = readSessionGitHubState(meta); + const { pullRequestUrls, pullRequestChats, pullRequestTitles } = partitionSessionArtifacts(meta, undefined, mapArtifactChat); + return toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles, pullRequestChats) ?? []; +} + +function toGitHubInfo(meta: SessionMeta | undefined, mapArtifactChat?: (chat: string) => URI | undefined): IGitHubInfo | undefined { const state = readSessionGitHubState(meta); const gitState = readSessionGitState(meta); - const { pullRequestUrls, pullRequestTitles, issueUrls, issueTitles } = partitionSessionArtifacts(meta); + const { issueUrls, issueTitles } = partitionSessionArtifacts(meta, undefined, mapArtifactChat); // Recorded pull requests lead discovered ones, so the first is the newest. - const allPullRequests = toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); - const repository = state?.owner && state.repo - ? { owner: state.owner, repo: state.repo } - : gitState?.githubOwner && gitState.githubRepo - ? { owner: gitState.githubOwner, repo: gitState.githubRepo } - : allPullRequests?.[0]; + const allPullRequests = toSessionGitHubPullRequestRefs(meta, mapArtifactChat); + const repository = gitState?.githubOwner && gitState.githubRepo + ? { owner: gitState.githubOwner, repo: gitState.githubRepo } + : state?.owner && state.repo + ? { owner: state.owner, repo: state.repo } + : allPullRequests[0]; if (!repository) { return undefined; } - // A session carries one repository, so a link from another repository would - // be polled against the wrong coordinates. Leave those in their own pill. const belongsToRepository = (ref: { readonly owner: string; readonly repo: string }) => ref.owner.toLowerCase() === repository.owner.toLowerCase() && ref.repo.toLowerCase() === repository.repo.toLowerCase(); - const pullRequests = allPullRequests?.filter(belongsToRepository); - const pullRequest = pullRequests?.at(0); + const pullRequests = allPullRequests.filter(belongsToRepository); + const pullRequest = pullRequests[0]; const issues = toGitHubIssueRefs(dedupeLinks(issueUrls), issueTitles)?.filter(belongsToRepository); return { owner: repository.owner, repo: repository.repo, - pullRequests: pullRequests?.length ? pullRequests : undefined, + pullRequests: pullRequests.length ? pullRequests : undefined, pullRequest: pullRequest ? { number: pullRequest.number, uri: pullRequest.uri, @@ -839,6 +851,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly description: IObservable; readonly lastTurnEnd: ISettableObservable; readonly gitHubInfo: IObservable; + readonly pullRequests: IObservable; readonly mainChat: IObservable; readonly chats: IObservable; @@ -1048,43 +1061,52 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { }); this.artifacts = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { const meta = this._metaObs.read(reader); - return partitionSessionArtifacts(meta, this._options.mapDiffUri).entries.map(entry => entry.artifact); + return partitionSessionArtifacts(meta, this._options.mapDiffUri, chat => this._mapArtifactChat(chat)).entries.map(entry => entry.artifact); }); + const basePullRequests = derivedOpts({ + owner: this, + equalsFn: pullRequestRefsEqual, + }, reader => toSessionGitHubPullRequestRefs(this._metaObs.read(reader), chat => this._mapArtifactChat(chat))); + this.pullRequests = derived(this, reader => basePullRequests.read(reader).map((pullRequest, index) => ({ + ...pullRequest, + ...computePullRequestRefPresentation( + reader, + this._gitHubService, + this._pullRequestIconCache, + pullRequest, + index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined, + ) + }))); const baseGitHubInfoObs = derivedOpts({ equalsFn: isGitHubInfoEqual }, reader => { - return toGitHubInfo(this._metaObs.read(reader)); + return toGitHubInfo(this._metaObs.read(reader), chat => this._mapArtifactChat(chat)); }); const gitHubInfoWithIcon = derived(this, reader => { const baseGitHubInfo = baseGitHubInfoObs.read(reader); - if (!baseGitHubInfo?.pullRequest) { + const basePullRequests = getGitHubPullRequestRefs(baseGitHubInfo); + if (!baseGitHubInfo || basePullRequests.length === 0) { return baseGitHubInfo; } - const pullRequests = getGitHubPullRequestRefs(baseGitHubInfo).map((pullRequest, index) => ({ - ...pullRequest, - ...computePullRequestRefPresentation( - reader, - this._gitHubService, - this._pullRequestIconCache, - pullRequest, - index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined, - ) - })); - const icon = pullRequests[0].icon; - const liveState = pullRequests[0].liveState; - const title = pullRequests[0].title; + const sessionPullRequests = this.pullRequests.read(reader); + const pullRequests = basePullRequests.map(pullRequest => + sessionPullRequests.find(candidate => isEqual(candidate.uri, pullRequest.uri)) ?? pullRequest); + const basePrimaryPullRequest = baseGitHubInfo.pullRequest; + const primaryPullRequest = basePrimaryPullRequest + ? pullRequests.find(pullRequest => isEqual(pullRequest.uri, basePrimaryPullRequest.uri)) + : undefined; return { ...baseGitHubInfo, pullRequests: baseGitHubInfo.pullRequests ? pullRequests : undefined, - pullRequest: { - ...baseGitHubInfo.pullRequest, - icon, - liveState, - title, - } + pullRequest: basePrimaryPullRequest && primaryPullRequest ? { + ...basePrimaryPullRequest, + icon: primaryPullRequest.icon, + liveState: primaryPullRequest.liveState, + title: primaryPullRequest.title, + } : undefined, }; }); this.gitHubInfo = derivedOpts({ owner: this, equalsFn: isGitHubInfoEqual }, reader => gitHubInfoWithIcon.read(reader)); @@ -1093,8 +1115,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { if (sourceControlState?.latestOutcome === SessionSourceControlOutcome.Merge) { return { ...Codicon.gitMerge, color: themeColorFromId('charts.purple') }; } - const gitHubInfo = this.gitHubInfo.read(reader); - return getHighestPriorityPullRequestIcon(getGitHubPullRequestRefs(gitHubInfo).map(pullRequest => pullRequest.icon)); + return getHighestPriorityPullRequestIcon(this.pullRequests.read(reader).map(pullRequest => pullRequest.icon)); }); const initialWorkspace = this._computeWorkspace(); @@ -1247,6 +1268,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } } + private _mapArtifactChat(chat: string): URI | undefined { + const parsed = parseChatUri(chat); + if (!parsed || !isEqual(URI.parse(parsed.session), this.backendUri)) { + return undefined; + } + return this.resource.with({ fragment: parsed.chatId === DEFAULT_CHAT_ID ? null : parsed.chatId }); + } + private _applyChatCatalog(state: SessionState): void { // The default chat's catalog title drives its independent tab title. // Empty means "inherit the session title"; a non-empty value means it was @@ -1398,26 +1427,29 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return sessionChangesets; } - const branchChangesets = createChangesets( + const repositoryChangesets = createChangesets( backendChatResource, this._options, isActiveChatObs, chatCatalog - .filter(changeset => changeset.changeKind === ChangesetKind.Branch) + .filter(changeset => changeset.changeKind === ChangesetKind.Branch || changeset.changeKind === ChangesetKind.Uncommitted) .map(changeset => ({ ...changeset, workingDirectories, - suppressOperations: true, })), ); if (!sessionChangesets) { - return branchChangesets; + return repositoryChangesets; } - const branchChangeset = branchChangesets[0]; - return sessionChangesets.flatMap(changeset => changeset.id === ChangesetKind.Branch - ? branchChangeset ? [branchChangeset] : [] - : [changeset]); + const repositoryChangesetsByKind = new Map(repositoryChangesets.map(changeset => [changeset.id, changeset])); + return sessionChangesets.flatMap(changeset => { + if (changeset.id !== ChangesetKind.Branch && changeset.id !== ChangesetKind.Uncommitted) { + return [changeset]; + } + const repositoryChangeset = repositoryChangesetsByKind.get(changeset.id); + return repositoryChangeset ? [repositoryChangeset] : []; + }); }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index 79ef48a50f9e5..f19192e1ea3ba 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -24,6 +24,7 @@ import { AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../../platfo import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { AGENT_MERGE_CHANGESET_ID, buildCompareTurnsChangesetUriTemplate, buildUncommittedChangesetUri, ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { createPullRequestDetailsResult, createPullRequestOperationMeta, IPullRequestDetails, PREPARE_PULL_REQUEST_OPERATION_ID } from '../../../../../../platform/agentHost/common/meta/agentPullRequestOperationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; @@ -303,6 +304,7 @@ suite('AgentHostSessionChangesets', () => { test('binds Agent Merge changes to completed repair turns after the last default-chat user turn', () => { const sessionUri = URI.parse('ahp-session:/session-1'); const defaultChatUri = URI.parse('ahp-session:/session-1/chat/default'); + const peerChatUri = URI.parse('ahp-chat://peer/session-1'); const modifiedAt = new Date(0).toISOString(); const chatSummary: ChatSummary = { resource: defaultChatUri.toString(), @@ -310,6 +312,11 @@ suite('AgentHostSessionChangesets', () => { status: SessionStatus.Idle, modifiedAt, }; + const peerChatSummary: ChatSummary = { + ...chatSummary, + resource: peerChatUri.toString(), + title: 'Peer', + }; const sessionState: SessionState = { provider: 'copilot', title: 'Session', @@ -352,12 +359,14 @@ suite('AgentHostSessionChangesets', () => { }); const acquiredChangesets: string[] = []; const releasedChangesets: string[] = []; + const acquiredChats: string[] = []; const connection = new class extends mock() { override getSubscription(component: T, resource: URI): IReference> { switch (component) { case StateComponents.Session: return { object: sessionSubscription.object as IAgentSubscription, dispose: () => { } }; case StateComponents.Chat: + acquiredChats.push(resource.toString()); return { object: chatSubscription.object as IAgentSubscription, dispose: () => { } }; case StateComponents.Changeset: { const key = resource.toString(); @@ -412,21 +421,46 @@ suite('AgentHostSessionChangesets', () => { }); chatSubscription.set({ ...createChatState(chatSummary), turns: [...repairsAfterUser3, user6] }); chatSubscription.set({ ...createChatState(chatSummary), turns: [...repairsAfterUser3, user6, merge7] }); + const user8 = makeTurn('user-8', MessageKind.User); + const merge9 = makeTurn('merge-9', MessageKind.SystemNotification, true); + sessionSubscription.set({ + ...sessionState, + chats: [chatSummary, peerChatSummary], + config: { + schema: { type: 'object', properties: {} }, + values: { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature/peer', + chatUri: peerChatUri.toString(), + workingDirectory: URI.file('/peer').toString(), + enabledAt: modifiedAt, + commentWatermark: modifiedAt, + }, + }, + }, + }, + }); + chatSubscription.set({ ...createChatState(peerChatSummary), turns: [user8, merge9] }); const compareFromUser3 = `ahp-session:/session-1/changeset/compare/user-3/merge-5`; const compareFromUser6 = `ahp-session:/session-1/changeset/compare/user-6/merge-7`; + const compareFromPeerUser = `ahp-session:/session-1/changeset/compare/user-8/merge-9`; assert.deepStrictEqual({ id: changeset.id, enabled: changeset.isEnabled.get(), visibleChangeCount, + acquiredChats, acquiredChangesets, releasedChangesets, }, { id: AGENT_MERGE_CHANGESET_ID, enabled: true, visibleChangeCount: 0, - acquiredChangesets: [compareFromUser3, compareFromUser6], - releasedChangesets: [compareFromUser3], + acquiredChats: [defaultChatUri.toString(), peerChatUri.toString()], + acquiredChangesets: [compareFromUser3, compareFromUser6, compareFromPeerUser], + releasedChangesets: [compareFromUser3, compareFromUser6], }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index dd6d2c40ac04b..d0a8ccc0462b6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -26,7 +26,7 @@ import type { IAgentSubscription } from '../../../../../../platform/agentHost/co import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withMostRecentRelatedSessionPullRequest, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetOperationScope, ChangesetOperationStatus, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withMostRecentRelatedSessionPullRequest, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction, type SessionSummaryChangedParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -5828,7 +5828,31 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('active peer chat exposes branch changes from its workspace only', () => { + test('additional worktrees preserve their source repository identity', () => { + const provider = createProvider(disposables, agentHost); + const primaryDirectory = URI.file('/agent-host-protocol.worktrees/session'); + const peerDirectory = URI.file('/vscode.worktrees/peer-session'); + const session = setupMultiChatSession(provider, 'multi-worktrees', [primaryDirectory, peerDirectory]); + const peerFolder = session.workspace.get()?.folders[1]; + + assert.deepStrictEqual({ + root: peerFolder?.root.toString(), + workingDirectory: peerFolder?.workingDirectory.toString(), + name: peerFolder?.name, + repository: peerFolder?.gitRepository?.uri.toString(), + workTree: peerFolder?.gitRepository?.workTreeUri?.toString(), + isRepository: peerFolder?.gitRepository?.isRepository?.get(), + }, { + root: URI.file('/vscode').toString(), + workingDirectory: peerDirectory.toString(), + name: 'vscode', + repository: URI.file('/vscode').toString(), + workTree: peerDirectory.toString(), + isRepository: true, + }); + }); + + test('active peer chat exposes repository changesets from its workspace only', () => { const primaryDirectory = URI.file('/workspace-primary'); const peerDirectory = URI.file('/workspace-peer'); const activeSession = observableValue('test.activeSession', undefined); @@ -5843,6 +5867,19 @@ suite('LocalAgentHostSessionsProvider', () => { changeKind: 'branch', capabilities: { review: {} }, }; + const peerBranchChangeset = { + ...branchChangeset, + uriTemplate: `${peerChat}/changeset/branch`, + }; + const uncommittedChangeset = { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri}/changeset/uncommitted`, + changeKind: 'uncommitted', + }; + const peerUncommittedChangeset = { + ...uncommittedChangeset, + uriTemplate: `${peerChat}/changeset/uncommitted`, + }; agentHost.setSessionState('multi-chat-changesets', 'copilotcli', { ...makeState([ @@ -5850,7 +5887,7 @@ suite('LocalAgentHostSessionsProvider', () => { makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), ], { defaultChat }), workingDirectories: [primaryDirectory.toString(), peerDirectory.toString()], - changesets: [branchChangeset], + changesets: [branchChangeset, uncommittedChangeset], }); const peer = session.chats.get().find(chat => chat.resource.fragment === 'peer-1'); assert.ok(peer); @@ -5861,12 +5898,12 @@ suite('LocalAgentHostSessionsProvider', () => { } as IActiveSession, undefined); agentHost.setChatState(peerChat, { ...makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), - changesets: [branchChangeset], + changesets: [peerBranchChangeset, peerUncommittedChangeset], turns: [], }); - agentHost.setChangesetState(branchChangeset.uriTemplate, { + agentHost.setChangesetState(peerBranchChangeset.uriTemplate, { status: ChangesetStatus.Ready, - files: [primaryDirectory, peerDirectory].map(directory => { + files: [peerDirectory].map(directory => { const resource = URI.joinPath(directory, 'changed.ts'); return { id: resource.toString(), @@ -5876,17 +5913,52 @@ suite('LocalAgentHostSessionsProvider', () => { }, }; }), + operations: [{ + id: 'create-pr', + label: 'Create PR', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }], + }); + agentHost.setChangesetState(peerUncommittedChangeset.uriTemplate, { + status: ChangesetStatus.Ready, + files: [peerDirectory].map(directory => { + const resource = URI.joinPath(directory, 'uncommitted.ts'); + return { + id: resource.toString(), + edit: { + after: { uri: resource.toString(), content: { uri: resource.toString() } }, + diff: { added: 1, removed: 0 }, + }, + }; + }), + operations: [{ + id: 'commit', + label: 'Commit', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, { + id: 'discard-changes', + label: 'Discard Changes', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }], }); const branch = peer.changesets?.get()?.find(changeset => changeset.id === ChangesetKind.Branch); + const uncommitted = peer.changesets?.get()?.find(changeset => changeset.id === ChangesetKind.Uncommitted); assert.deepStrictEqual({ changes: branch?.changes.get().map(change => isIChatSessionFileChange2(change) ? change.uri.toString() : change.modifiedUri.toString()), - operations: branch?.operations.get(), + operations: branch?.operations.get().map(operation => operation.id), review: branch?.capabilities?.review, + uncommittedChanges: uncommitted?.changes.get().map(change => isIChatSessionFileChange2(change) ? change.uri.toString() : change.modifiedUri.toString()), + uncommittedOperations: uncommitted?.operations.get().map(operation => operation.id), }, { changes: [URI.joinPath(peerDirectory, 'changed.ts').toString()], - operations: [], - review: false, + operations: ['create-pr'], + review: true, + uncommittedChanges: [URI.joinPath(peerDirectory, 'uncommitted.ts').toString()], + uncommittedOperations: ['commit', 'discard-changes'], }); }); @@ -8290,6 +8362,67 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('maps artifact owners to default and peer chat resources and drops invalid owners', async () => { + const id = 'artifact-chat-owners'; + const backendSession = AgentSession.uri('copilotcli', id).toString(); + const gitHubService = new class extends mock() { + private readonly _model = upcastPartial({ pullRequest: constObservable(undefined) }); + override createPullRequestModelReference = () => new ImmortalReference(this._model); + }(); + agentHost.addSession(createSession(id, { summary: 'Artifact owners', project: { uri: URI.file('/repo'), displayName: 'repo' } })); + const provider = createProvider(disposables, agentHost, undefined, { gitHubService }); + await timeout(0); + const session = provider.getSessions().find(candidate => candidate.title.get() === 'Artifact owners'); + assert.ok(session); + + provider.getSessionConfig(session.sessionId); + agentHost.setSessionState(id, 'copilotcli', { + provider: 'copilotcli', title: 'Artifact owners', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + _meta: withSessionArtifacts(withSessionGitHubState(undefined, { owner: 'owner', repo: 'repo' }), [ + { id: 'default', type: SessionArtifactType.Website, label: 'Default', chat: buildDefaultChatUri(backendSession), isArtifact: true, link: 'https://example.com/default' }, + { id: 'peer', type: SessionArtifactType.Website, label: 'Peer', chat: buildChatUri(backendSession, 'peer-1'), isArtifact: true, link: 'https://example.com/peer' }, + { id: 'session', type: SessionArtifactType.Website, label: 'Session', isArtifact: true, link: 'https://example.com/session' }, + { id: 'malformed', type: SessionArtifactType.Website, label: 'Malformed', chat: 'not-a-chat', isArtifact: true, link: 'https://example.com/malformed' }, + { id: 'foreign', type: SessionArtifactType.Website, label: 'Foreign', chat: buildDefaultChatUri('copilot:/other'), isArtifact: true, link: 'https://example.com/foreign' }, + { id: 'default-pr', type: SessionArtifactType.PullRequest, label: 'Default PR', chat: buildDefaultChatUri(backendSession), isArtifact: true, link: 'https://github.com/owner/repo/pull/3', isGitHub: true }, + { id: 'peer-pr', type: SessionArtifactType.PullRequest, label: 'Peer PR', chat: buildChatUri(backendSession, 'peer-1'), isArtifact: true, link: 'https://github.com/upstream/project/pull/4', isGitHub: true }, + { id: 'malformed-pr', type: SessionArtifactType.PullRequest, label: 'Malformed PR', chat: 'not-a-chat', isArtifact: true, link: 'https://github.com/owner/repo/pull/1', isGitHub: true }, + { id: 'foreign-pr', type: SessionArtifactType.PullRequest, label: 'Foreign PR', chat: buildDefaultChatUri('copilot:/other'), isArtifact: true, link: 'https://github.com/owner/repo/pull/2', isGitHub: true }, + ]), + }); + const artifacts = [...session.artifacts!.get()] + .map(artifact => [artifact.id, artifact.chat?.toString()] as const) + .sort(([left], [right]) => left.localeCompare(right)); + + assert.deepStrictEqual({ + artifacts, + pullRequests: session.pullRequests?.get().map(pullRequest => ({ + repository: `${pullRequest.owner}/${pullRequest.repo}`, + number: pullRequest.number, + chat: pullRequest.chat?.toString(), + })), + primaryRepositoryPullRequests: session.workspace.get()?.folders[0].gitRepository?.gitHubInfo.get()?.pullRequests?.map(pullRequest => pullRequest.number), + primaryPullRequest: session.workspace.get()?.folders[0].gitRepository?.gitHubInfo.get()?.pullRequest?.number, + }, { + artifacts: [ + ['default', session.resource.toString()], + ['default-pr', session.resource.toString()], + ['peer', session.resource.with({ fragment: 'peer-1' }).toString()], + ['peer-pr', session.resource.with({ fragment: 'peer-1' }).toString()], + ['session', undefined], + ], + pullRequests: [ + { repository: 'upstream/project', number: 4, chat: session.resource.with({ fragment: 'peer-1' }).toString() }, + { repository: 'owner/repo', number: 3, chat: session.resource.toString() }, + ], + primaryRepositoryPullRequests: [3], + primaryPullRequest: 3, + }); + }); + test('gates artifact removal on the host capability and routes the backend session URI', async () => { agentHost.addSession(createSession('remove-artifact')); const provider = createProvider(disposables, agentHost); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index c47e2094f0a52..edaff4534af45 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -505,6 +505,16 @@ display: flex; } + .monaco-list-row:hover .session-chat-item.has-compact-folder-label:not(.renaming) .session-compact-hover-description, + .monaco-list:focus-within .monaco-list-row.focused .session-chat-item.has-compact-folder-label:not(.renaming) .session-compact-hover-description { + display: flex; + } + + .monaco-list-row:hover .session-chat-item.has-compact-folder-label:not(.renaming) .session-chat-title, + .monaco-list:focus-within .monaco-list-row.focused .session-chat-item.has-compact-folder-label:not(.renaming) .session-chat-title { + flex: 0 1 auto; + } + .monaco-list-row[aria-expanded="true"] .session-item:not(.quick-chat)::after { top: calc(var(--vscode-spacing-size80) + var(--vscode-spacing-size120)); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index cec47af6ac7b4..a2cfc587e92b6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -41,7 +41,7 @@ import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsPro import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, getChatCapabilities, getHighestPriorityPullRequestIcon, getSessionGitHubPullRequestRefs, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { $, append, EventHelper, isMouseEvent, ModifierKeyEmitter, reset } from '../../../../base/browser/dom.js'; @@ -116,8 +116,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const isRead = session.isRead.read(reader); const isArchived = session.isArchived.read(reader); const workspace = session.workspace.read(reader); - const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); - const pullRequestIcon = getHighestPriorityPullRequestIcon(getGitHubPullRequestRefs(gitHubInfo).map(pullRequest => pullRequest.icon)); + const pullRequestIcon = getHighestPriorityPullRequestIcon(getSessionGitHubPullRequestRefs(session, reader).map(pullRequest => pullRequest.icon)); const completedStateIcon = session.completedStateIcon?.read(reader) ?? pullRequestIcon; const icon = sessionsListModelService.getStatusIcon(status, isRead, isArchived, completedStateIcon); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 15429c07d77cd..fc81394ff6cf9 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -50,7 +50,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSectionLabel, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; -import { ChatInteractivity, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatCapabilities, getHighestPriorityPullRequestIcon, getSessionGitHubPullRequestRefs, getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -438,6 +438,7 @@ interface ISessionChatItemTemplate { readonly title: HighlightedLabel; readonly titleContainer: HTMLElement; readonly titleInputContainer: HTMLElement; + readonly compactHoverDescription: HTMLElement; readonly approvalRow: HTMLElement; readonly approvalLabel: HTMLElement; readonly approvalButtonContainer: HTMLElement; @@ -496,6 +497,7 @@ class SessionChatItemRenderer implements ITreeRenderer e.stopPropagation())); } @@ -511,7 +513,7 @@ class SessionChatItemRenderer implements ITreeRenderer, _index: number, template: ISessionChatItemTemplate): void { @@ -536,6 +538,15 @@ class SessionChatItemRenderer implements ITreeRenderer { + const folderLabel = getChatWorkspaceBadgeLabel(element.session.workspace.read(reader), element.chat.workspace.read(reader)); + template.container.classList.toggle('has-compact-folder-label', !!folderLabel); + DOM.clearNode(template.compactHoverDescription); + if (folderLabel) { + const badge = DOM.append(template.compactHoverDescription, $('span.session-badge', undefined, folderLabel)); + reader.store.add(this.hoverService.setupDelayedHover(badge, { content: folderLabel }, { groupId: 'sessions-list' })); + } + })); template.elementDisposables.add(autorun(reader => { const showGuides = this.activeGuideSessionIds.read(reader).has(element.session.sessionId); template.container.classList.toggle('session-hierarchy-guides-visible', showGuides); @@ -1282,9 +1293,8 @@ class SessionItemRenderer implements ITreeRenderer pullRequest.icon)); + ?? getHighestPriorityPullRequestIcon(getSessionGitHubPullRequestRefs(element, reader).map(pullRequest => pullRequest.icon)); if (completedStateIcon && isAgentMergePullRequestIcon(completedStateIcon)) { agentMergeConfiguration ??= getSessionAgentMergeConfigurationObservable(element, this.sessionsProvidersService, this.configurationService); completedStateIcon = getAgentMergeAwarePullRequestIcon(completedStateIcon, agentMergeConfiguration.read(reader)); @@ -1662,6 +1672,18 @@ function getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefine return workspace.label; } +function getChatWorkspaceBadgeLabel(sessionWorkspace: ISessionWorkspace | undefined, chatWorkspace: ISessionWorkspace | undefined): string | undefined { + const firstSessionFolder = sessionWorkspace?.folders[0]; + if ( + !firstSessionFolder || + !sessionWorkspace.folders.some(folder => !isEqual(folder.root, firstSessionFolder.root)) || + chatWorkspace?.folders.length !== 1 + ) { + return undefined; + } + return chatWorkspace.folders[0].name; +} + //#endregion //#region Section Header Renderer @@ -2249,13 +2271,17 @@ class SessionsAccessibilityProvider { getAriaLabel(element: SessionListItem): string | IObservable | null { if (isSessionChatItem(element)) { - return derived(this, reader => localize( - 'sessionChatItemAria', - "{0}, chat, updated {1}, {2}", - getChatTitle(element.chat, reader), - fromNow(element.chat.updatedAt.read(reader), true), - getSessionConversationStatusAriaLabel(element.chat.status.read(reader)), - )); + return derived(this, reader => { + const title = getChatTitle(element.chat, reader); + const updated = fromNow(element.chat.updatedAt.read(reader), true); + const status = getSessionConversationStatusAriaLabel(element.chat.status.read(reader)); + const folderLabel = this.options?.compact?.() + ? getChatWorkspaceBadgeLabel(element.session.workspace.read(reader), element.chat.workspace.read(reader)) + : undefined; + return folderLabel + ? localize('sessionChatItemFolderAria', "{0}, chat in folder {1}, updated {2}, {3}", title, folderLabel, updated, status) + : localize('sessionChatItemAria', "{0}, chat, updated {1}, {2}", title, updated, status); + }); } if (isSessionGroupItem(element)) { return this.getSectionAriaLabel(element.group.name, element.sessions); @@ -2325,6 +2351,21 @@ class SessionsAccessibilityProvider { if (inputNeededMessage) { label = localize('sessionItemInputNeededMessageAria', "{0}, {1}", label, renderAsPlaintext(inputNeededMessage, { omitMarkdownSyntax: true })); } + const pullRequests = getSessionGitHubPullRequestRefs(element, reader); + if (pullRequests.length > 0) { + const states = [ + ['open', localize('sessionItemPullRequestsOpenAria', "{0} open", pullRequests.filter(pullRequest => (pullRequest.liveState ?? pullRequest.state) === 'open').length)], + ['merged', localize('sessionItemPullRequestsMergedAria', "{0} merged", pullRequests.filter(pullRequest => (pullRequest.liveState ?? pullRequest.state) === 'merged').length)], + ['closed', localize('sessionItemPullRequestsClosedAria', "{0} closed", pullRequests.filter(pullRequest => (pullRequest.liveState ?? pullRequest.state) === 'closed').length)], + ].filter(([state]) => pullRequests.some(pullRequest => (pullRequest.liveState ?? pullRequest.state) === state)) + .map(([, stateLabel]) => stateLabel); + const pullRequestSummary = pullRequests.length === 1 + ? localize('sessionItemPullRequestAria', "1 pull request") + : localize('sessionItemPullRequestsAria', "{0} pull requests", pullRequests.length); + label = states.length > 0 + ? localize('sessionItemPullRequestStatesAria', "{0}, {1}: {2}", label, pullRequestSummary, states.join(', ')) + : localize('sessionItemPullRequestCountAria', "{0}, {1}", label, pullRequestSummary); + } const workspace = element.workspace.read(reader); const workspaceLabel = workspace ? getWorkspaceBadgeLabel(workspace) : undefined; if ( @@ -4574,7 +4615,7 @@ export class SessionsList extends Disposable implements ISessionsList { [SessionSupportsRenameContext.key, element.capabilities.get().supportsRename ?? false], [SessionSupportsDeleteContext.key, element.capabilities.get().supportsDelete ?? false], [IsQuickChatSessionContext.key, element.isQuickChat?.get() ?? false], - [SessionHasPullRequestContext.key, !!element.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.pullRequest], + [SessionHasPullRequestContext.key, getSessionGitHubPullRequestRefs(element).length > 0], ]; const disposables = new DisposableStore(); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 65e1f551a8e1e..964732e420ee6 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -2526,6 +2526,7 @@ suite('Sessions - SessionsList', () => { function createChat(title: string, origin?: ChatOriginKind, interactivity = ChatInteractivity.Full, status = SessionStatus.Completed): IChat { return upcastPartial({ resource: URI.parse(`test-chat://${title.replaceAll(' ', '-')}`), + workspace: constObservable(undefined), title: constObservable(title), updatedAt: constObservable(new Date()), status: constObservable(status), @@ -2534,7 +2535,7 @@ suite('Sessions - SessionsList', () => { }); } - function renderSessionChatsList(session: ISession, onChatOpen?: (session: ISession, chat: IChat, preserveFocus: boolean, sideBySide: boolean) => void, enableMotion = false): { readonly container: HTMLElement; readonly list: SessionsList } { + function renderSessionChatsList(session: ISession, onChatOpen?: (session: ISession, chat: IChat, preserveFocus: boolean, sideBySide: boolean) => void, enableMotion = false, compact = false): { readonly container: HTMLElement; readonly list: SessionsList } { const harness = createListHarness(disposables, [session], enableMotion ? instantiationService => instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { override isMotionReduced(): boolean { return false; } @@ -2544,6 +2545,7 @@ suite('Sessions - SessionsList', () => { const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { grouping: () => SessionsGrouping.Date, sorting: () => SessionsSorting.Created, + compact: () => compact, onSessionOpen: () => { }, onChatOpen, })); @@ -2588,6 +2590,106 @@ suite('Sessions - SessionsList', () => { ); }); + test('shows a compact folder badge for chats scoped to one folder of a multi-root session', () => { + const firstRoot = URI.file('/workspace/first'); + const secondRoot = URI.file('/workspace/second'); + const firstFolder = { root: firstRoot, workingDirectory: firstRoot, name: 'first', description: undefined }; + const secondFolder = { root: secondRoot, workingDirectory: secondRoot, name: 'second', description: undefined }; + const base = createTestSession('Session').session; + const sessionWorkspace = { + ...base.workspace.get()!, + label: 'Multi-root workspace', + folders: [firstFolder, secondFolder], + }; + const main = upcastPartial({ + ...createChat('Main chat'), + workspace: constObservable(sessionWorkspace), + }); + const peer = upcastPartial({ + ...createChat('Peer chat', ChatOriginKind.User), + workspace: constObservable({ + ...sessionWorkspace, + uri: secondRoot, + label: secondFolder.name, + folders: [secondFolder], + }), + }); + const session: ISession = { + ...base, + workspace: constObservable(sessionWorkspace), + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + + const { container } = renderSessionChatsList(session, undefined, false, true); + const row = container.querySelector('.session-chat-item'); + + assert.deepStrictEqual({ + compact: container.querySelector('.sessions-list-control')?.classList.contains('compact'), + hasFolderLabel: row?.classList.contains('has-compact-folder-label'), + folder: row?.querySelector('.session-compact-hover-description')?.textContent, + ariaLabel: row?.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, { + compact: true, + hasFolderLabel: true, + folder: 'second', + ariaLabel: 'Peer chat, chat in folder second, updated now, State: Completed', + }); + }); + + test('hides the compact folder badge when session folders are worktrees of the same project', () => { + const projectRoot = URI.file('/repos/project'); + const firstWorkingDirectory = URI.file('/worktrees/project-first'); + const secondWorkingDirectory = URI.file('/worktrees/project-second'); + const firstFolder = { root: projectRoot, workingDirectory: firstWorkingDirectory, name: 'project (first)', description: undefined }; + const secondFolder = { root: projectRoot, workingDirectory: secondWorkingDirectory, name: 'project (second)', description: undefined }; + const base = createTestSession('Session').session; + const sessionWorkspace = { + ...base.workspace.get()!, + label: 'Project worktrees', + folders: [firstFolder, secondFolder], + }; + const main = upcastPartial({ + ...createChat('Main chat'), + workspace: constObservable({ + ...sessionWorkspace, + uri: firstWorkingDirectory, + label: firstFolder.name, + folders: [firstFolder], + }), + }); + const peer = upcastPartial({ + ...createChat('Peer chat', ChatOriginKind.User), + workspace: constObservable({ + ...sessionWorkspace, + uri: secondWorkingDirectory, + label: secondFolder.name, + folders: [secondFolder], + }), + }); + const session: ISession = { + ...base, + workspace: constObservable(sessionWorkspace), + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + + const { container } = renderSessionChatsList(session, undefined, false, true); + const row = container.querySelector('.session-chat-item'); + + assert.deepStrictEqual({ + hasFolderLabel: row?.classList.contains('has-compact-folder-label'), + folder: row?.querySelector('.session-compact-hover-description')?.textContent, + ariaLabel: row?.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, { + hasFolderLabel: false, + folder: '', + ariaLabel: 'Peer chat, chat, updated now, State: Completed', + }); + }); + test('updates nested chat rows when the session chat catalog changes', () => { const main = createChat('Main chat'); const peer = createChat('Peer chat', ChatOriginKind.User); diff --git a/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts b/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts index efaa943d84341..2048d4b13171f 100644 --- a/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts +++ b/src/vs/sessions/contrib/workspace/test/browser/workspaceFolderManagement.test.ts @@ -269,6 +269,32 @@ suite('WorkspaceFolderManagementContribution', () => { }); }); + test('trusts and mounts an added worktree when the active chat changes', async () => { + const { activeSession, workspaceEditing, workspaceTrust } = createContribution(); + const primary = localFolder('/repo-primary'); + const secondary = worktreeFolder('/repo-secondary', '/repo-secondary.worktrees/feature'); + const firstChat: IChat = { ...stubChat, resource: URI.parse('test:///chat-first'), workspace: constObservable(makeWorkspace(primary, true)) }; + const secondChat: IChat = { ...stubChat, resource: URI.parse('test:///chat-second'), workspace: constObservable(makeWorkspace(secondary, true)) }; + const activeChat = observableValue('activeChat', firstChat); + workspaceTrust.trust(primary.workingDirectory); + workspaceTrust.trust(secondary.root); + + activeSession.set({ ...makeActiveSession('a', makeWorkspace([primary, secondary], true)), activeChat }, undefined); + await settle(); + activeChat.set(secondChat, undefined); + await settle(); + + assert.deepStrictEqual({ + granted: workspaceTrust.setUrisTrustCalls, + added: workspaceEditing.addFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + updated: workspaceEditing.updateFoldersCalls.map(call => call.map(entry => entry.uri.toString())), + }, { + granted: [[secondary.workingDirectory.toString()]], + added: [[primary.workingDirectory.toString()]], + updated: [[secondary.workingDirectory.toString()]], + }); + }); + test('replaces the complete workspace folder set when switching sessions', async () => { const { activeSession, workspaceEditing } = createContribution(); const firstPrimary = localFolder('/repo-a'); diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 9b3d0d85dcf07..7a3e83e657edc 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -253,6 +253,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get changesets() { return this._session.changesets; } get changes() { return this._session.changes; } get artifacts() { return this._session.artifacts; } + get pullRequests() { return this._session.pullRequests; } get modelId() { return this._activeChatModelId; } get mode() { return this._activeChatMode; } get loading() { return this._session.loading; } @@ -305,6 +306,7 @@ class ResourceOverrideSession implements ISession { get changes() { return this._session.changes; } get changesets() { return this._session.changesets; } get artifacts() { return this._session.artifacts; } + get pullRequests() { return this._session.pullRequests; } get modelId() { return this._session.modelId; } get mode() { return this._session.mode; } get loading() { return this._session.loading; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 2c251670afff4..2fa51345656f6 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -275,6 +275,8 @@ export interface ISessionArtifact { readonly id: string; readonly kind: SessionArtifactKind; readonly label: string; + /** Chat that recorded this entry. Absent for session-wide entries. */ + readonly chat?: URI; /** * `true` for an artifact — something the session produced — and `false` for * a reference, something it only points the user at. @@ -290,6 +292,11 @@ export interface ISessionArtifact { readonly isGitHub?: boolean; } +/** Returns whether an artifact or reference belongs in a chat-scoped surface. */ +export function isSessionArtifactVisibleInChat(artifact: ISessionArtifact, chat: URI | undefined): boolean { + return artifact.chat === undefined || (chat !== undefined && isEqual(artifact.chat, chat)); +} + /** The kinds of customization a chat can use. */ export const enum SessionCustomizationKind { Agent = 'agent', @@ -367,6 +374,8 @@ export interface IGitHubPullRequestRef { * discovered from git state, which carry no title until they are fetched live. */ readonly title?: string; + /** Chat that created or explicitly associated this pull request, when known. */ + readonly chat?: URI; /** * Whether this pull request originated in or was explicitly associated with the session, as opposed to being * inherited from the checkout it started from or merely referenced by the agent. @@ -394,6 +403,27 @@ export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): r }]; } +/** Returns every pull request associated with a session across all of its repositories. */ +export function getSessionGitHubPullRequestRefs(session: ISession | undefined, reader?: IReader): readonly IGitHubPullRequestRef[] { + const pullRequests = session?.pullRequests?.read(reader); + if (pullRequests) { + return pullRequests; + } + + const result: IGitHubPullRequestRef[] = []; + const seen = new Set(); + for (const folder of session?.workspace.read(reader)?.folders ?? []) { + for (const pullRequest of getGitHubPullRequestRefs(folder.gitRepository?.gitHubInfo.read(reader))) { + const key = `${pullRequest.owner.toLowerCase()}/${pullRequest.repo.toLowerCase()}#${pullRequest.number}`; + if (!seen.has(key)) { + seen.add(key); + result.push(pullRequest); + } + } + } + return result; +} + /** Excludes inherited checkout PRs, while accepting the primary PR from providers without provenance. */ export function getSessionOwnedGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): readonly IGitHubPullRequestRef[] { return gitHubInfo?.pullRequests @@ -793,6 +823,8 @@ export interface ISession { * surfaces only one of them must filter on that field. */ readonly artifacts?: IObservable; + /** Pull requests associated with this session across all repositories, most recent first. */ + readonly pullRequests?: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 7510a636f1655..5c50c9f777c2c 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -35,7 +35,7 @@ import { SessionHasSideChatsContext, SessionHasGitRepositoryContext, } from '../../../common/contextkeys.js'; -import { ChatOriginKind, getChatCapabilities, isActiveSessionStatus, ISession, SessionStatus } from './session.js'; +import { ChatOriginKind, getChatCapabilities, getSessionGitHubPullRequestRefs, isActiveSessionStatus, ISession, SessionStatus } from './session.js'; import { ISessionChangesStatsCache, readSessionChangesStats } from './sessionChangesStatsCache.js'; import { IActiveSession } from './sessionsManagement.js'; @@ -172,8 +172,7 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS const cachedFiles = !changesReported && session ? changesStatsCache?.get(session.sessionId, reader)?.files ?? 0 : 0; keys.hasCachedChanges.set(!worktreePending && cachedFiles > 0); - const pullRequest = session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest; - keys.hasPullRequest.set(!!pullRequest); + keys.hasPullRequest.set(getSessionGitHubPullRequestRefs(session, reader).length > 0); const issues = session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.issues; keys.hasIssues.set(!!issues?.length); diff --git a/src/vs/sessions/services/sessions/test/common/session.test.ts b/src/vs/sessions/services/sessions/test/common/session.test.ts index 7231514697e0b..12a18286ec336 100644 --- a/src/vs/sessions/services/sessions/test/common/session.test.ts +++ b/src/vs/sessions/services/sessions/test/common/session.test.ts @@ -9,9 +9,10 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { getSessionOwnedGitHubPullRequestRefs, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, IGitHubInfo, isActiveSessionStatus, ISessionTurnFileChange, ISessionWorkspace, sessionFileChangesEqual, sessionTurnFileChangesEqual, SessionStatus, SessionWorkspaceKind, sessionWorkspaceEqual } from '../../common/session.js'; +import { getSessionGitHubPullRequestRefs, getSessionOwnedGitHubPullRequestRefs, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, IGitHubInfo, isActiveSessionStatus, ISession, ISessionTurnFileChange, ISessionWorkspace, sessionFileChangesEqual, sessionTurnFileChangesEqual, SessionStatus, SessionWorkspaceKind, sessionWorkspaceEqual } from '../../common/session.js'; suite('getSessionOwnedGitHubPullRequestRefs', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,6 +36,82 @@ suite('getSessionOwnedGitHubPullRequestRefs', () => { }); }); +suite('getSessionGitHubPullRequestRefs', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('collects and deduplicates pull requests across workspace repositories', () => { + const first = { owner: 'owner', repo: 'one', number: 1, uri: URI.parse('https://github.com/owner/one/pull/1') }; + const second = { owner: 'owner', repo: 'two', number: 2, uri: URI.parse('https://github.com/owner/two/pull/2') }; + const workspace: ISessionWorkspace = { + uri: URI.file('/workspace'), + label: 'workspace', + icon: Codicon.folder, + folders: [{ + root: URI.file('/workspace/one'), + workingDirectory: URI.file('/workspace/one'), + name: 'one', + description: undefined, + gitRepository: { + uri: URI.file('/workspace/one'), + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable({ owner: 'owner', repo: 'one', pullRequests: [first] }), + }, + }, { + root: URI.file('/workspace/two'), + workingDirectory: URI.file('/workspace/two'), + name: 'two', + description: undefined, + gitRepository: { + uri: URI.file('/workspace/two'), + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable({ owner: 'owner', repo: 'two', pullRequests: [second, first] }), + }, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; + const session = new class extends mock() { + override readonly workspace = constObservable(workspace); + }; + + assert.deepStrictEqual(getSessionGitHubPullRequestRefs(session).map(pullRequest => pullRequest.uri.toString()), [ + first.uri.toString(), + second.uri.toString(), + ]); + }); + + test('prefers the provider session-level pull request collection', () => { + const sessionPullRequest = { owner: 'owner', repo: 'session', number: 2, uri: URI.parse('https://github.com/owner/session/pull/2') }; + const folderPullRequest = { owner: 'owner', repo: 'folder', number: 1, uri: URI.parse('https://github.com/owner/folder/pull/1') }; + const session = new class extends mock() { + override readonly pullRequests = constObservable([sessionPullRequest]); + override readonly workspace = constObservable({ + uri: URI.file('/workspace'), + label: 'workspace', + icon: Codicon.folder, + folders: [{ + root: URI.file('/workspace/folder'), + workingDirectory: URI.file('/workspace/folder'), + name: 'folder', + description: undefined, + gitRepository: { + uri: URI.file('/workspace/folder'), + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable({ owner: 'owner', repo: 'folder', pullRequests: [folderPullRequest] }), + }, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }); + }; + + assert.deepStrictEqual(getSessionGitHubPullRequestRefs(session), [sessionPullRequest]); + }); +}); + suite('isActiveSessionStatus', () => { ensureNoDisposablesAreLeakedInTestSuite(); From eba589497bb94de5cc701b54800ab50284aeaccf Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 18 Sep 2026 11:46:02 +0200 Subject: [PATCH 3/4] sessions: fix multi-repository CI regressions Restore uncommitted pull request operations, canonicalize discard targets across filesystem aliases, and complete chat workspace test fixtures. Refresh the intentional Agent Host traffic and component screenshot baselines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostDiscardChangesOperationHandler.ts | 18 +++++++++++++++++- .../agentHostPullRequestOperationProvider.ts | 2 +- .../agentHost/node/sessionPermissions.ts | 2 +- ...ude_counts_lines_in_a_file.traffic.ahp.yaml | 3 +++ ..._in_a_new_nested_directory.traffic.ahp.yaml | 3 +++ ...de_creates_a_new_text_file.traffic.ahp.yaml | 3 +++ ...e_deletes_a_workspace_file.traffic.ahp.yaml | 3 +++ ...dits_an_existing_text_file.traffic.ahp.yaml | 3 +++ ...le_without_a_session_error.traffic.ahp.yaml | 3 +++ ...Claude_inspects_git_status.traffic.ahp.yaml | 3 +++ ...de_lists_workspace_entries.traffic.ahp.yaml | 3 +++ ...le_from_a_nested_directory.traffic.ahp.yaml | 3 +++ ...filename_containing_spaces.traffic.ahp.yaml | 3 +++ ...de_reads_a_value_from_JSON.traffic.ahp.yaml | 3 +++ ...eads_an_existing_text_file.traffic.ahp.yaml | 3 +++ ...e_renames_a_workspace_file.traffic.ahp.yaml | 3 +++ ...t_across_consecutive_turns.traffic.ahp.yaml | 3 +++ ...eterministic_shell_command.traffic.ahp.yaml | 3 +++ ..._in_a_new_nested_directory.traffic.ahp.yaml | 3 +++ ...ex_creates_a_new_text_file.traffic.ahp.yaml | 3 +++ ...x_deletes_a_workspace_file.traffic.ahp.yaml | 3 +++ ...dits_an_existing_text_file.traffic.ahp.yaml | 3 +++ ...filename_containing_spaces.traffic.ahp.yaml | 3 +++ ...x_renames_a_workspace_file.traffic.ahp.yaml | 3 +++ ...t_across_consecutive_turns.traffic.ahp.yaml | 3 +++ ..._after_start_and_completes.traffic.ahp.yaml | 3 +++ ...mes_a_failed_turn_in_place.traffic.ahp.yaml | 3 +++ ...emains_a_non-terminal_tool.traffic.ahp.yaml | 3 +++ ...lot_counts_lines_in_a_file.traffic.ahp.yaml | 3 +++ ..._in_a_new_nested_directory.traffic.ahp.yaml | 3 +++ ...ot_creates_a_new_text_file.traffic.ahp.yaml | 3 +++ ...t_deletes_a_workspace_file.traffic.ahp.yaml | 3 +++ ...dits_an_existing_text_file.traffic.ahp.yaml | 3 +++ ...le_without_a_session_error.traffic.ahp.yaml | 3 +++ ...opilot_inspects_git_status.traffic.ahp.yaml | 3 +++ ...ot_lists_workspace_entries.traffic.ahp.yaml | 3 +++ ...le_from_a_nested_directory.traffic.ahp.yaml | 3 +++ ...filename_containing_spaces.traffic.ahp.yaml | 3 +++ ...ot_reads_a_value_from_JSON.traffic.ahp.yaml | 3 +++ ...eads_an_existing_text_file.traffic.ahp.yaml | 3 +++ ...t_renames_a_workspace_file.traffic.ahp.yaml | 3 +++ ...s_repeated_follow-up_turns.traffic.ahp.yaml | 9 +++++++++ ...t_across_consecutive_turns.traffic.ahp.yaml | 3 +++ ...eterministic_shell_command.traffic.ahp.yaml | 3 +++ ...s_before_the_shell_command.traffic.ahp.yaml | 3 +++ .../test/node/e2e/suites/serverToolsSuite.ts | 2 ++ .../browser/sessionsListContextMenu.test.ts | 2 ++ .../test/browser/sessionsListTestUtils.ts | 1 + .../test/browser/sessionsRename.test.ts | 5 +++++ .../sessions/sessionsList.fixture.ts | 2 ++ .../componentFixtures/blocks-ci-screenshots.md | 4 ++-- 51 files changed, 165 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts index 9c35b0d380fe2..05ebd3228dd70 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { realpath } from 'fs/promises'; import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Schemas } from '../../../base/common/network.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; @@ -14,6 +16,7 @@ import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../comm import { type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { resolveRealPathForNonexistent } from './sessionPermissions.js'; export class AgentHostDiscardChangesOperationHandler implements IChangesetOperationHandler { @@ -62,7 +65,20 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat if (!workingDirectories?.length) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } - const workingDirectoryStr = workingDirectories.find(directory => extUriBiasedIgnorePathCase.isEqualOrParent(resource, URI.parse(directory))); + const canonicalResource = resource.scheme === Schemas.file + ? await resolveRealPathForNonexistent(resource, realpath) + : resource; + let workingDirectoryStr: string | undefined; + for (const directory of workingDirectories) { + const workingDirectory = URI.parse(directory); + const canonicalWorkingDirectory = workingDirectory.scheme === Schemas.file + ? await resolveRealPathForNonexistent(workingDirectory, realpath) + : workingDirectory; + if (extUriBiasedIgnorePathCase.isEqualOrParent(canonicalResource, canonicalWorkingDirectory)) { + workingDirectoryStr = directory; + break; + } + } if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Resource is outside the session working directories: ${params.target.resource}`); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 9856906dfe375..beeabcc14f95a 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -106,7 +106,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem } private _computeOperations({ sessionKey, changesetKind, gitState, gitHubState }: IChangesetOperationContext): ChangesetOperation[] | undefined { - if (changesetKind !== ChangesetKind.Branch) { + if (changesetKind !== ChangesetKind.Branch && changesetKind !== ChangesetKind.Uncommitted) { return undefined; } // New Session diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 9488aa52115e6..b5cd1f57e4180 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -147,7 +147,7 @@ function assertPathIsSafe(fsPath: string, _isWindows = isWindows): void { * (or its ancestors) does not yet exist on disk. This ensures a symlink at any * ancestor is followed even for files that are about to be created. */ -async function resolveRealPathForNonexistent(resource: URI, realpath: (fsPath: string) => Promise): Promise { +export async function resolveRealPathForNonexistent(resource: URI, realpath: (fsPath: string) => Promise): Promise { const fsPath = resource.fsPath; try { return URI.file(await realpath(fsPath)); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml index c196f04734f22..8f47ba8e06e80 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index d61ab52e4bdbe..97e3976e010aa 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml index 9e0ecb6b97614..b1922ad9c5931 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml index ab3a41d3f4846..5835f1788191a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml index 8ced5776fed52..b9eb795a0d392 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index a0af75f4ea83c..d7f29f7ed626b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml index e0ea37a10b1a1..d8c0a3484542d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml index 19aa8e50976a8..1036793ab32a9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index 4c3fd7b3bb942..8d2cef378a727 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml index 6f27804c4d51f..2b6cdac949a49 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml index 0d1a7a3e3160c..926ebae7e842e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml index b619a2d9dd4e0..e97c4a7ce4c07 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml index 6fb4a178474b1..31b5481795030 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml index 2ed3fc13e97f7..356a1c25c8680 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml index 9a6332cb96e33..fd790438f7c3f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index b8f4d5c519ac7..987a0a1d59315 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml index e7f81be32ae9c..4b6e8cf19aa2b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml index 5cc6d23682909..6382f7e374a8a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml index b3e732267ae57..34f3cf4cf9681 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml index 89d6d702620b5..201002c9eb175 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml index 2dd8c2a231a7f..ffe9d013a5490 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml index 2ed3fc13e97f7..356a1c25c8680 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml index 867673a88dc38..8fed2a7229084 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml @@ -67,6 +67,9 @@ rounds: - channel: ${session_0} action: type: session/changesetsChanged + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml index a9e3f3da73921..c1a3f323ae42c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml @@ -28,6 +28,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/error diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml index 8619d11e9165d..9c53e7bc796ea 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml @@ -26,6 +26,9 @@ rounds: origin: kind: user - method: root/sessionAdded + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml index 6463830a03444..77799e9fa4917 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 0adc56d65ae6b..930fe2ff1097e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml index e41ef9fb2cff4..ad30e849e2048 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml index 5cf3b9a638bc4..18862e5fefbf1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml index 67455128e310f..2551fd858ac09 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index 5ef31eb1abfae..3292a0c3a430a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml index f968ccdccb0b5..ef7dff67c5174 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml index 3068b0744b2f6..2ad2efb5e0dbd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index d57ffc27b1c80..4a5eee026b5ae 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml index def41982ae4eb..1587ea2806309 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml index e256fbd15aa6a..08e31de0bb419 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml index 74f6dce522cc0..1ad0b35117c9a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml index 6fae6fee08547..7a7ec7de1c9fd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml index 854d344c9babf..4200b319555d1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml @@ -27,6 +27,15 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged + - channel: ${chat_0} + action: + type: chat/toolCallStart + turnId: ${turn_0} + toolCallId: ${toolCall_0} + toolName: task - channel: ${chat_0} action: type: chat/toolCallComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml index 2ed3fc13e97f7..356a1c25c8680 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml index 96c4f27f028fb..592ba760f94a9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -25,6 +25,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml index 5d1677598c59b..47f3554d4b3f9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml @@ -31,6 +31,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${chat_0} + action: + type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 1bd33506c7044..a9f55272a1670 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -339,12 +339,14 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void { type: 'website', label: 'Agent Host guide', + chat: session.chatUri, isArtifact: false, link: 'https://example.com/agent-host', }, { type: 'file', label: 'Agent Host report', + chat: session.chatUri, isArtifact: true, uri: 'file:///agent-host-report.md', }, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts index bbade585dbb16..fcf2da1011054 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts @@ -188,6 +188,7 @@ suite('Sessions list context menus', () => { resource: URI.parse('test-chat:/main'), status: constObservable(SessionStatus.Completed), interactivity: constObservable(ChatInteractivity.Full), + workspace: constObservable(undefined), }); const peerChat = upcastPartial({ resource: URI.parse('test-chat:/peer'), @@ -196,6 +197,7 @@ suite('Sessions list context menus', () => { status: constObservable(SessionStatus.Completed), interactivity: constObservable(ChatInteractivity.Full), capabilities: constObservable({ canRename: true, canDelete: true }), + workspace: constObservable(undefined), }); const chatSession: ISession = { ...createSession('Session with chat').session, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index 5b49fb8ab0894..855cbce30bc57 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -120,6 +120,7 @@ export function createTestSession(title: string, options: ITestSessionOptions = const mainChat = new class extends mock() { override readonly resource = resource.with({ fragment: 'main' }); override readonly status = status; + override readonly workspace = constObservable(undefined); }(); const isArchived = observableValue(`archived-${resourceId}`, options.isArchived ?? false); const isRead = observableValue(`read-${resourceId}`, options.isRead ?? true); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index 9330b7fa033df..e4d67da37d483 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -160,6 +160,7 @@ suite('Sessions rename', () => { override readonly status = constObservable(SessionStatus.Completed); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); const chatSession: ISession = { ...chatSessionBase, @@ -369,6 +370,7 @@ suite('Sessions rename', () => { override readonly status = constObservable(SessionStatus.Completed); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); const chats = observableValue('renameDraftChats', [mainChat, peerChat]); const chatSession: ISession = { ...baseSession, chats, mainChat: constObservable(mainChat) }; @@ -424,6 +426,7 @@ suite('Sessions rename', () => { override readonly status = constObservable(SessionStatus.Completed); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); const session: ISession = { ...baseSession, @@ -606,6 +609,7 @@ suite('Sessions rename', () => { override readonly status = constObservable(options.status ?? SessionStatus.Completed); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: options.canRename ?? true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); const otherPeerChat = new class extends mock() { override readonly resource = URI.parse('test-chat:///other-peer'); @@ -613,6 +617,7 @@ suite('Sessions rename', () => { override readonly status = constObservable(SessionStatus.Completed); override readonly interactivity = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); const chats = observableValue('renameChats', [mainChat, peerChat, otherPeerChat]); const session: ISession = { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 39b13db1ca3f1..86b2ee9a0d741 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -176,6 +176,7 @@ function createChat(sessionId: string, spec: IChatSpec, updatedAt: Date, approva override readonly status: IObservable = constObservable(spec.status ?? SessionStatus.Completed); override readonly interactivity: IObservable = constObservable(ChatInteractivity.Full); override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + override readonly workspace = constObservable(undefined); }(); } @@ -197,6 +198,7 @@ function createSession(spec: ISessionSpec, approvals: Map = constObservable(spec.mainChatStatus ?? spec.status ?? SessionStatus.Completed); override readonly interactivity: IObservable = constObservable(ChatInteractivity.Full); + override readonly workspace = constObservable(undefined); }(); const nestedChats = (spec.chats ?? []).map(chatSpec => createChat(spec.id, chatSpec, updatedAt, approvals)); const chats: readonly IChat[] = [mainChat, ...nestedChats]; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 73902b99e1d4b..bbe7f05cf06ec 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -478,13 +478,13 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2065baa531ad27e1b71e053b384a264859b99fa169fa50b04bb2e679a4abdded) #### sessions/sessionsList/SessionsList_CompactNeedsInput/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a8a00b4654c7581310bebd78710a1e29e9920ddbabbf9e35655bd9cd689e4d9d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0431a69c2294ffa610c8b29c0f12e44fa24e975003237a946e5e31a71b28b52c) #### sessions/sessionsList/SessionsList_CompactNeedsInput/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1af97afcf566429c3f8acb74707c2b8f014060ca0547a79777aa632302ae301e) #### sessions/sessionsList/SessionsList_CompactSessionRename/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/73f2fa43e0a03367d08916d8d27decbe5b76cbdff325549fae73a976d8af3b78) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/649fa74ee5dd64a0187c02b859a0b0483d07ea521e8c3d0d9781091637229774) #### sessions/sessionsList/SessionsList_CompactSessionRename/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b58c784d3624334112da4034d215d88c03aac3c0919af6169d84b3ef5d70a979) From 69613353f45fbab3a97b07c9d39f634bad6ffa13 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 18 Sep 2026 12:58:59 +0200 Subject: [PATCH 4/4] sessions: address multi-repository review feedback Derive chat changesets from the existing session catalog instead of extending AHP. Correct per-repository base branch resolution and session-wide pull request artifact deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../state/protocol/action-origin.generated.ts | 5 +- .../state/protocol/channels-chat/actions.ts | 14 ------ .../state/protocol/channels-chat/reducer.ts | 7 --- .../state/protocol/channels-chat/state.ts | 3 -- .../common/state/protocol/common/actions.ts | 4 +- .../common/state/protocol/version/registry.ts | 1 - .../node/agentHostGitStateService.ts | 5 +- .../agentHost/node/agentHostStateManager.ts | 37 +-------------- .../node/shared/worktreeIsolation.ts | 2 +- .../node/agentHostGitStateService.test.ts | 2 + .../test/node/agentHostStateManager.test.ts | 46 +------------------ .../test/node/artifactServerTools.test.ts | 14 +++--- ...de_counts_lines_in_a_file.traffic.ahp.yaml | 3 -- ...in_a_new_nested_directory.traffic.ahp.yaml | 3 -- ...e_creates_a_new_text_file.traffic.ahp.yaml | 3 -- ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 -- ...its_an_existing_text_file.traffic.ahp.yaml | 3 -- ...e_without_a_session_error.traffic.ahp.yaml | 3 -- ...laude_inspects_git_status.traffic.ahp.yaml | 3 -- ...e_lists_workspace_entries.traffic.ahp.yaml | 3 -- ...e_from_a_nested_directory.traffic.ahp.yaml | 3 -- ...ilename_containing_spaces.traffic.ahp.yaml | 3 -- ...e_reads_a_value_from_JSON.traffic.ahp.yaml | 3 -- ...ads_an_existing_text_file.traffic.ahp.yaml | 3 -- ..._renames_a_workspace_file.traffic.ahp.yaml | 3 -- ..._across_consecutive_turns.traffic.ahp.yaml | 3 -- ...terministic_shell_command.traffic.ahp.yaml | 3 -- ...in_a_new_nested_directory.traffic.ahp.yaml | 3 -- ...x_creates_a_new_text_file.traffic.ahp.yaml | 3 -- ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 -- ...its_an_existing_text_file.traffic.ahp.yaml | 3 -- ...ilename_containing_spaces.traffic.ahp.yaml | 3 -- ..._renames_a_workspace_file.traffic.ahp.yaml | 3 -- ..._across_consecutive_turns.traffic.ahp.yaml | 3 -- ...after_start_and_completes.traffic.ahp.yaml | 3 -- ...es_a_failed_turn_in_place.traffic.ahp.yaml | 3 -- ...mains_a_non-terminal_tool.traffic.ahp.yaml | 3 -- ...ot_counts_lines_in_a_file.traffic.ahp.yaml | 3 -- ...in_a_new_nested_directory.traffic.ahp.yaml | 3 -- ...t_creates_a_new_text_file.traffic.ahp.yaml | 3 -- ..._deletes_a_workspace_file.traffic.ahp.yaml | 3 -- ...its_an_existing_text_file.traffic.ahp.yaml | 3 -- ...e_without_a_session_error.traffic.ahp.yaml | 3 -- ...pilot_inspects_git_status.traffic.ahp.yaml | 3 -- ...t_lists_workspace_entries.traffic.ahp.yaml | 3 -- ...e_from_a_nested_directory.traffic.ahp.yaml | 3 -- ...ilename_containing_spaces.traffic.ahp.yaml | 3 -- ...t_reads_a_value_from_JSON.traffic.ahp.yaml | 3 -- ...ads_an_existing_text_file.traffic.ahp.yaml | 3 -- ..._renames_a_workspace_file.traffic.ahp.yaml | 3 -- ..._repeated_follow-up_turns.traffic.ahp.yaml | 9 ---- ..._across_consecutive_turns.traffic.ahp.yaml | 3 -- ...terministic_shell_command.traffic.ahp.yaml | 3 -- ..._before_the_shell_command.traffic.ahp.yaml | 3 -- .../agentHost/test/node/reducers.test.ts | 31 ------------- .../contrib/chat/browser/sessionArtifacts.ts | 4 +- .../test/browser/sessionArtifacts.test.ts | 31 ++++++++++++- .../browser/baseAgentHostSessionsProvider.ts | 26 +++++++---- .../localAgentHostSessionsProvider.test.ts | 1 - 59 files changed, 65 insertions(+), 300 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 2ad53113d14ed..e1e55844dc100 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatChangesetsChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -123,7 +123,6 @@ export type ChatAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction - | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -169,7 +168,6 @@ export type ServerChatAction = | ChatTurnCompleteAction | ChatErrorAction | ChatActivityChangedAction - | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction @@ -376,7 +374,6 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatActivityChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, - [ActionType.ChatChangesetsChanged]: false, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index c6825c055b610..ba619a4bd39eb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -9,7 +9,6 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; -import type { Changeset } from '../channels-changeset/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -568,18 +567,6 @@ export interface ChatWorkingDirectoryRemovedAction { directory: URI; } -/** - * Replaces the catalogue of changesets exposed for this chat. - * - * @category Chat Actions - * @version 1 - */ -export interface ChatChangesetsChangedAction { - type: ActionType.ChatChangesetsChanged; - /** New catalogue, or `undefined` to clear it. */ - changesets: Changeset[] | undefined; -} - /** * Token usage report for a turn. * @@ -846,7 +833,6 @@ export type ChatAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction - | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index d2c31363a1d2d..5cf707cfc1f3c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -454,13 +454,6 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return { ...state, workingDirectories: updated }; } - case ActionType.ChatChangesetsChanged: { - const { changesets: _omit, ...stateWithoutChangesets } = state; - return action.changesets - ? { ...stateWithoutChangesets, changesets: action.changesets } - : stateWithoutChangesets; - } - // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index cef356eff086f..dbae61c6f3899 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -8,7 +8,6 @@ import type { ModelSelection } from '../channels-root/state.js'; import type { AgentSelection, McpAuthRequirement, SessionStatus } from '../channels-session/state.js'; -import type { Changeset } from '../channels-changeset/state.js'; import type { ContentRef, ErrorInfo, FileEdit, StringOrMarkdown, TextRange, TextSelection, URI, UsageInfo } from '../common/state.js'; // ─── Chat State ────────────────────────────────────────────────────────────── @@ -64,8 +63,6 @@ export interface ChatState { * update the subset on a running chat. */ workingDirectories?: URI[]; - /** Catalogue of changesets the server exposes for this chat. */ - changesets?: Changeset[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index d00523f60b0d4..97ef944059b92 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatChangesetsChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -60,7 +60,6 @@ export const enum ActionType { ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', - ChatChangesetsChanged = 'chat/changesetsChanged', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', @@ -217,7 +216,6 @@ export type StateAction = | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction - | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index e954c67cd0770..86a7dfeb64226 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -130,7 +130,6 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', - [ActionType.ChatChangesetsChanged]: '0.9.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index bcfadf5d82789..27702984b975d 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -92,7 +92,10 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } try { - const baseBranchName = await this.resolveSessionBaseBranchName(sessionKey); + const primaryWorkingDirectory = state.workingDirectories?.[0]; + const baseBranchName = primaryWorkingDirectory && isEqual(URI.parse(primaryWorkingDirectory), workingDirectory) + ? await this.resolveSessionBaseBranchName(sessionKey) + : undefined; const gitState = await this._gitService.getSessionGitState(workingDirectory, baseBranchName); const branchName = gitState?.branchName; const owner = gitState?.githubOwner; diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 066b97948475b..9fbec685db29e 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -16,7 +16,7 @@ import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotations import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { buildBranchChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; import { buildAnnotationsUri, isAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js'; @@ -88,7 +88,6 @@ interface IChatEntry { readonly session: string; summary: ChatSummary; state?: ChatState; - changesets?: readonly Changeset[]; providerData?: string; inheritedTurnId?: string; draft?: Message; @@ -568,7 +567,6 @@ export class AgentHostStateManager extends Disposable { ...createChatState(entry.summary), turns: restored.turns, draft: restored.draft ?? entry.draft, - changesets: entry.changesets ? [...entry.changesets] : undefined, }; entry.resolver = undefined; if (restored.turns.length > 0) { @@ -1117,9 +1115,7 @@ export class AgentHostStateManager extends Disposable { ...createChatState(chatSummary), turns: turns ?? [], draft, - changesets: this._changesetsForChat(chatUri, this._sessionStates.get(sessionKey)?.state.changesets), }, - changesets: this._changesetsForChat(chatUri, this._sessionStates.get(sessionKey)?.state.changesets), valid: true, }); const entry = this._sessionStates.get(sessionKey); @@ -1186,9 +1182,7 @@ export class AgentHostStateManager extends Disposable { state: { ...createChatState(chatSummary), turns: options?.turns ?? [], - changesets: this._changesetsForChat(chatUri, sessionState.changesets), }, - changesets: this._changesetsForChat(chatUri, sessionState.changesets), providerData: options?.providerData, inheritedTurnId: options?.inheritedTurnId, valid: true, @@ -1236,7 +1230,6 @@ export class AgentHostStateManager extends Disposable { this._chatEntries.set(chatUri, { session, summary: chatSummary, - changesets: this._changesetsForChat(chatUri, sessionState.changesets), providerData: options.providerData, inheritedTurnId: options.inheritedTurnId, draft: options.draft, @@ -1560,34 +1553,6 @@ export class AgentHostStateManager extends Disposable { type: ActionType.SessionChangesetsChanged, changesets: next, }); - - for (const chat of entry.state.chats) { - const chatEntry = this._chatEntries.get(chat.resource); - if (!chatEntry) { - continue; - } - const chatChangesets = this._changesetsForChat(chat.resource, next); - chatEntry.changesets = chatChangesets; - if (chatEntry.state) { - this.dispatchServerAction(chat.resource, { - type: ActionType.ChatChangesetsChanged, - changesets: chatChangesets, - }); - } - } - } - - private _changesetsForChat(chat: URI, changesets: readonly Changeset[] | undefined): Changeset[] | undefined { - return changesets?.map(changeset => { - switch (changeset.changeKind) { - case ChangesetKind.Branch: - return { ...changeset, uriTemplate: buildBranchChangesetUri(chat) }; - case ChangesetKind.Uncommitted: - return { ...changeset, uriTemplate: buildUncommittedChangesetUri(chat) }; - default: - return { ...changeset }; - } - }); } /** diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index f487875b9e1b1..4d3fea17360b7 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -1601,7 +1601,7 @@ function projectFromRepositoryRoot(repositoryRoot: URI): IAgentSessionProjectInf } /** - * Builds the repository {@link IAgentSessionProjectInfo} from a persisted + * Builds the repository {@link IAgentSessionProjectInfo} from a persisted * {@link WORKTREE_META_REPOSITORY_ROOT} value (a URI string), or `undefined` * when absent. Lets the host merge the repository project into a session's * catalog entry directly from a metadata batch it already read, without a diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index e4d819ac928c4..83ddace4af119 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -851,11 +851,13 @@ suite('AgentHostGitStateService', () => { assert.deepStrictEqual({ pullRequestUrl, gitCalls: h.gitCalls, + gitBaseBranches: h.gitBaseBranches, gitState: readSessionGitState(h.stateManager.getSessionState(SESSION)?._meta), gitHubState: readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta), }, { pullRequestUrl: 'https://github.com/octo/peer/pull/1', gitCalls: [peerWorkingDirectory.toString()], + gitBaseBranches: [undefined], gitState: primaryGitState, gitHubState: { owner: 'microsoft', repo: 'vscode' }, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index b9f4b49cec025..b6755d087f41d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -14,7 +14,7 @@ import { ActionType, NotificationType, type ActionEnvelope, type INotification } import { ChatInputQuestionKind, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { buildBranchChangesetUri, buildChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; @@ -1090,50 +1090,6 @@ suite('AgentHostStateManager', () => { ); }); - test('session changeset catalog is exposed by existing and newly added chat states', () => { - manager.createSession(makeSessionSummary()); - manager.addChat(sessionUri, peerChat); - const changesets = [{ - label: 'Branch Changes', - uriTemplate: buildChangesetUri(sessionUri, 'branch'), - changeKind: 'branch', - }, { - label: 'Uncommitted Changes', - uriTemplate: buildChangesetUri(sessionUri, 'uncommitted'), - changeKind: 'uncommitted', - }]; - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(envelope => envelopes.push(envelope))); - - manager.setSessionChangesets(sessionUri, changesets); - const peer2 = buildChatUri(sessionUri, 'peer-2'); - manager.addChat(sessionUri, peer2); - - assert.deepStrictEqual({ - defaultChangesets: manager.getDefaultChatState(sessionUri)?.changesets, - peerChangesets: manager.getChatState(peerChat)?.changesets, - newPeerChangesets: manager.getChatState(peer2)?.changesets, - changedChannels: envelopes - .filter(envelope => envelope.action.type === ActionType.ChatChangesetsChanged) - .map(envelope => envelope.channel) - .sort(), - }, { - defaultChangesets: [ - { ...changesets[0], uriTemplate: buildBranchChangesetUri(buildDefaultChatUri(sessionUri)) }, - { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(buildDefaultChatUri(sessionUri)) }, - ], - peerChangesets: [ - { ...changesets[0], uriTemplate: buildBranchChangesetUri(peerChat) }, - { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(peerChat) }, - ], - newPeerChangesets: [ - { ...changesets[0], uriTemplate: buildBranchChangesetUri(peer2) }, - { ...changesets[1], uriTemplate: buildUncommittedChangesetUri(peer2) }, - ], - changedChannels: [buildDefaultChatUri(sessionUri), peerChat].sort(), - }); - }); - test('catalog-only SessionChatAdded does not create chat state', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { diff --git a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts index 1263373b32723..3d42fb6398d55 100644 --- a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -219,7 +219,7 @@ suite('Artifact Server Tools', () => { routableNames: enabled ? [...names, ...legacyNames] : [], }); if (enabled) { - assert.strictEqual(await execute(ArtifactServerToolName.ListArtifactsAndReferences), 'No artifacts or references recorded for this session.'); + assert.strictEqual(await execute(ArtifactServerToolName.ListArtifactsAndReferences), 'No artifacts or references recorded for this chat.'); } else { for (const name of [...names, ...legacyNames]) { await assert.rejects(() => execute(name, {}), /is disabled/); @@ -270,7 +270,7 @@ suite('Artifact Server Tools', () => { }); test('promotes an existing reference in one batch without changing its id or downgrading it', async () => { - const { execute, artifacts, persisted } = createHarness(); + const { execute, artifacts, persisted, sessionUri } = createHarness(); const reference = { type: 'issue', label: 'Investigated issue', isArtifact: false, link: 'https://github.com/microsoft/vscode/issues/1' }; await execute(ArtifactServerToolName.AddArtifactOrReference, { items: [reference] }); const id = artifacts()[0].id; @@ -289,13 +289,13 @@ suite('Artifact Server Tools', () => { }, { result: [`Promoted artifact: ${id}`, `Already recorded: ${id}`, `Already recorded: ${id}`].join('\n'), repeated: `Already recorded: ${id}`, - artifacts: [{ ...artifact, id, isGitHub: true }], + artifacts: [{ ...artifact, id, chat: buildDefaultChatUri(sessionUri), isGitHub: true }], persistCalls: 2, }); }); test('deduplicates and promotes entries within the same atomic batch', async () => { - const { execute, artifacts, persisted } = createHarness(); + const { execute, artifacts, persisted, sessionUri } = createHarness(); const reference = { type: 'file', label: 'Report', isArtifact: false, uri: 'file:///repo/report.md' }; const result = await execute(ArtifactServerToolName.AddArtifactOrReference, { items: [reference, { ...reference, isArtifact: true }, reference], @@ -304,8 +304,8 @@ suite('Artifact Server Tools', () => { assert.deepStrictEqual({ result, artifacts: artifacts(), persisted }, { result: [`Added reference: ${id}`, `Promoted artifact: ${id}`, `Already recorded: ${id}`].join('\n'), - artifacts: [{ ...reference, id, isArtifact: true }], - persisted: [[{ ...reference, id, isArtifact: true }]], + artifacts: [{ ...reference, id, chat: buildDefaultChatUri(sessionUri), isArtifact: true }], + persisted: [[{ ...reference, id, chat: buildDefaultChatUri(sessionUri), isArtifact: true }]], }); }); @@ -397,7 +397,7 @@ suite('Artifact Server Tools', () => { createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), }); - const group = createArtifactServerToolGroup({ isEnabled: () => true, persist: () => { } }); + const group = createArtifactServerToolGroup({ isEnabled: () => true, useCompactPrompts: () => true, persist: () => { } }); const input = { items: [{ type: 'website', label: 'Docs', isArtifact: false, link: 'https://example.com/docs' }], }; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml index 8f47ba8e06e80..c196f04734f22 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_counts_lines_in_a_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 97e3976e010aa..d61ab52e4bdbe 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml index b1922ad9c5931..9e0ecb6b97614 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_creates_a_new_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml index 5835f1788191a..ab3a41d3f4846 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml index b9eb795a0d392..8ced5776fed52 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index d7f29f7ed626b..a0af75f4ea83c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml index d8c0a3484542d..e0ea37a10b1a1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_inspects_git_status.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml index 1036793ab32a9..19aa8e50976a8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_lists_workspace_entries.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index 8d2cef378a727..4c3fd7b3bb942 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml index 2b6cdac949a49..6f27804c4d51f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml index 926ebae7e842e..0d1a7a3e3160c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_a_value_from_JSON.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml index e97c4a7ce4c07..b619a2d9dd4e0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_reads_an_existing_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml index 31b5481795030..6fb4a178474b1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_renames_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c8680..2ed3fc13e97f7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml index fd790438f7c3f..9a6332cb96e33 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Claude_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 987a0a1d59315..b8f4d5c519ac7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml index 4b6e8cf19aa2b..e7f81be32ae9c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_creates_a_new_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml index 6382f7e374a8a..5cc6d23682909 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml index 34f3cf4cf9681..b3e732267ae57 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml index 201002c9eb175..89d6d702620b5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml index ffe9d013a5490..2dd8c2a231a7f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_renames_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c8680..2ed3fc13e97f7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Codex_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml index 8fed2a7229084..867673a88dc38 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml @@ -67,9 +67,6 @@ rounds: - channel: ${session_0} action: type: session/changesetsChanged - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml index c1a3f323ae42c..a9e3f3da73921 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml @@ -28,9 +28,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/error diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml index 9c53e7bc796ea..8619d11e9165d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__shell_read_helper_remains_a_non-terminal_tool.traffic.ahp.yaml @@ -26,9 +26,6 @@ rounds: origin: kind: user - method: root/sessionAdded - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml index 77799e9fa4917..6463830a03444 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_counts_lines_in_a_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml index 930fe2ff1097e..0adc56d65ae6b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_file_in_a_new_nested_directory.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml index ad30e849e2048..e41ef9fb2cff4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_creates_a_new_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml index 18862e5fefbf1..5cf3b9a638bc4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_deletes_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml index 2551fd858ac09..67455128e310f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_edits_an_existing_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml index 3292a0c3a430a..5ef31eb1abfae 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_handles_a_missing_file_without_a_session_error.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml index ef7dff67c5174..f968ccdccb0b5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_inspects_git_status.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml index 2ad2efb5e0dbd..3068b0744b2f6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_lists_workspace_entries.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml index 4a5eee026b5ae..d57ffc27b1c80 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_file_from_a_nested_directory.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml index 1587ea2806309..def41982ae4eb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_filename_containing_spaces.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml index 08e31de0bb419..e256fbd15aa6a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_a_value_from_JSON.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml index 1ad0b35117c9a..74f6dce522cc0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_reads_an_existing_text_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml index 7a7ec7de1c9fd..6fae6fee08547 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_renames_a_workspace_file.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml index 4200b319555d1..854d344c9babf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retained_background_subagent_completes_repeated_follow-up_turns.traffic.ahp.yaml @@ -27,15 +27,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - - channel: ${chat_0} - action: - type: chat/toolCallStart - turnId: ${turn_0} - toolCallId: ${toolCall_0} - toolName: task - channel: ${chat_0} action: type: chat/toolCallComplete diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml index 356a1c25c8680..2ed3fc13e97f7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_retains_context_across_consecutive_turns.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/responsePart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml index 592ba760f94a9..96c4f27f028fb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_runs_a_deterministic_shell_command.traffic.ahp.yaml @@ -25,9 +25,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml index 47f3554d4b3f9..5d1677598c59b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml @@ -31,9 +31,6 @@ rounds: - channel: ${session_0} action: type: session/ready - - channel: ${chat_0} - action: - type: chat/changesetsChanged - channel: ${chat_0} action: type: chat/toolCallStart diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 40660caf279ad..dbb4eecd14284 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -54,37 +54,6 @@ function withActiveTurnAndToolCall(state: ChatState): ChatState { return state; } -suite('chatReducer - changesets', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('replaces and clears the chat changeset catalogue', () => { - const changesets = [{ - label: 'Branch Changes', - uriTemplate: 'ahp-session:/test/changeset/branch', - changeKind: 'branch', - }]; - const populated = chatReducer(makeChat(), { - type: ActionType.ChatChangesetsChanged, - changesets, - }); - const cleared = chatReducer(populated, { - type: ActionType.ChatChangesetsChanged, - changesets: undefined, - }); - - assert.deepStrictEqual({ - populated: populated.changesets, - cleared: cleared.changesets, - hasClearedProperty: Object.hasOwn(cleared, 'changesets'), - }, { - populated: changesets, - cleared: undefined, - hasClearedProperty: false, - }); - }); -}); - suite('chatReducer – summaryStatus with tool call confirmations and input requests', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index a9b92fe5b8cf3..c66da63aa0f41 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -30,7 +30,7 @@ import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/cha import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; import { linkKey } from '../../../common/sessionLinks.js'; -import { getGitHubPullRequestRefs, isSessionArtifactVisibleInChat, SessionArtifactKind, type IChat, type ISessionArtifact } from '../../../services/sessions/common/session.js'; +import { getSessionGitHubPullRequestRefs, isSessionArtifactVisibleInChat, SessionArtifactKind, type IChat, type ISessionArtifact } from '../../../services/sessions/common/session.js'; import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { parseGitHubPullRequestUrl } from '../../github/common/utils.js'; @@ -323,7 +323,7 @@ export class SessionArtifacts extends Disposable { locationFormatting.read(reader); const gitHubInfo = current.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); const surfacedLinks = new Set([ - ...filterSessionArtifactGitHubRefsForChat(getGitHubPullRequestRefs(gitHubInfo), artifacts, SessionArtifactKind.PullRequest, chatResource), + ...filterSessionArtifactGitHubRefsForChat(getSessionGitHubPullRequestRefs(current, reader), artifacts, SessionArtifactKind.PullRequest, chatResource), ...filterSessionArtifactGitHubRefsForChat(gitHubInfo?.issues ?? [], artifacts, SessionArtifactKind.Issue, chatResource), ].map(ref => linkKey(ref.uri.toString()))); return buildSessionArtifactSections( diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts index 3f729d94b1494..496bc16bf8ba8 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts @@ -18,7 +18,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { buildSessionArtifactSections, filterSessionArtifactGitHubRefsForChat, sessionArtifactLocationText, SessionArtifacts, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; -import { type IChat, type IGitHubInfo, type ISessionArtifact, type ISessionWorkspace, SessionArtifactKind } from '../../../../services/sessions/common/session.js'; +import { type IChat, type IGitHubInfo, type IGitHubPullRequestRef, type ISessionArtifact, type ISessionWorkspace, SessionArtifactKind } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; suite('Session Artifacts', () => { @@ -39,7 +39,7 @@ suite('Session Artifacts', () => { }, }; - function createPresentation(entries: readonly ISessionArtifact[], info?: IGitHubInfo) { + function createPresentation(entries: readonly ISessionArtifact[], info?: IGitHubInfo, pullRequests?: readonly IGitHubPullRequestRef[]) { const artifacts = observableValue('artifacts', entries); const gitHubInfo = observableValue('gitHubInfo', info); const chat = observableValue('chat', new class extends mock() { @@ -63,6 +63,7 @@ suite('Session Artifacts', () => { const session = observableValue('session', new class extends mock() { override readonly artifacts = artifacts; override readonly workspace = workspace; + override readonly pullRequests = pullRequests ? constObservable(pullRequests) : undefined; }()); const configurationService = new TestConfigurationService(); disposables.add(configurationService.onDidChangeConfigurationEmitter); @@ -237,6 +238,32 @@ suite('Session Artifacts', () => { }); }); + test('omits a peer-repository pull request promoted through the session collection', () => { + const chat = URI.parse('test-chat:/session#chat-a'); + const link = URI.parse('https://github.com/octo/peer/pull/12'); + const artifact: ISessionArtifact = { + id: 'peer-pr', + kind: SessionArtifactKind.PullRequest, + label: 'Peer PR', + isArtifact: true, + isGitHub: true, + link, + chat, + }; + const { presentation } = createPresentation([artifact], undefined, [{ + owner: 'octo', + repo: 'peer', + number: 12, + uri: link, + chat, + }]); + + assert.deepStrictEqual(visibleEntries(presentation), { + artifacts: [], + references: [], + }); + }); + test('retains non-GitHub and noncanonical entries even when their URLs are surfaced', () => { const pullRequest: ISessionArtifact = { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR', isArtifact: true, isGitHub: true, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 1f093d2a746a4..99fee8165a379 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -27,7 +27,7 @@ import { AgentHostTransportFailureReason } from '../../../../../platform/agentHo import { supportsAgentHostArtifactRemoval } from '../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../platform/agentHost/common/customizationEnablement.js'; import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; -import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { buildBranchChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -837,6 +837,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly completedStateIcon: IObservable; readonly changes: IObservable; readonly changesets: ISettableObservable; + private readonly _changesetMetadata = observableValueOpts({ owner: this, debugName: 'agentHostChangesetMetadata', equalsFn: structuralEquals }, undefined); readonly modelId: ISettableObservable; readonly modelSource: ISettableObservable; modelSelection: ModelSelection | undefined; @@ -1407,15 +1408,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { && isEqual(activeSession.resource, this.resource) && isEqual(activeSession.activeChat.read(reader).resource, clientChatResource); }); - const chatStateObs = createActiveSessionSubscriptionObs( - this._options, - isActiveChatObs, - StateComponents.Chat, - constObservable(backendChatResource), - ); const chatChangesetCatalog = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { - const chatState = chatStateObs.read(reader).read(reader); - return chatState && !(chatState instanceof Error) ? chatState.changesets : undefined; + return this._changesetMetadata.read(reader)?.map(changeset => { + switch (changeset.changeKind) { + case ChangesetKind.Branch: + return { ...changeset, uriTemplate: buildBranchChangesetUri(backendChatResource.toString()) }; + case ChangesetKind.Uncommitted: + return { ...changeset, uriTemplate: buildUncommittedChangesetUri(backendChatResource.toString()) }; + default: + return changeset; + } + }); }); const workingDirectories = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => workspace.read(reader)?.folders.map(folder => folder.workingDirectory.toString())); @@ -1966,7 +1969,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { : undefined, } satisfies IAgentHostChangeset))); - this.changesets.set(changesets, undefined); + transaction(tx => { + this._changesetMetadata.set(changesetsMetadata, tx); + this.changesets.set(changesets, tx); + }); } private _createCurrentTurnChangesObservable(): IObservable { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index d0a8ccc0462b6..1f6041f6189d5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5898,7 +5898,6 @@ suite('LocalAgentHostSessionsProvider', () => { } as IActiveSession, undefined); agentHost.setChatState(peerChat, { ...makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [peerDirectory.toString()]), - changesets: [peerBranchChangeset, peerUncommittedChangeset], turns: [], }); agentHost.setChangesetState(peerBranchChangeset.uriTemplate, {