From e47a24892a373c4d673e23eaa0da2b86da5f6253 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 10:24:12 -0700 Subject: [PATCH 1/7] Adopt AHP 0.6.0 workingDirectories property in agent host Sync the generated agent host protocol copy to spec version 0.6.0 (agent-host-protocol @ 11f1a65e), which renames the singular `workingDirectory` on session/chat state to a `workingDirectories` array in preparation for multiroot session support. This change is a pure, behaviour-preserving property adoption across all consumers: sessions continue to use a single working directory. Reads of a single directory now use `workingDirectories?.[0]`, field-copy sites pass the array through unchanged, and writes from a single URI produce a one-element array. No multiroot client support is added here (no capability advertising, state actions, multi-folder workspaces, or UI); those land in a follow-up milestone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 4 +- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 14 +++++- .../state/protocol/channels-chat/actions.ts | 43 ++++++++++++++++++- .../state/protocol/channels-chat/commands.ts | 11 +++++ .../state/protocol/channels-chat/reducer.ts | 24 +++++++++++ .../state/protocol/channels-chat/state.ts | 27 ++++++------ .../state/protocol/channels-root/state.ts | 32 ++++++++++++++ .../protocol/channels-session/actions.ts | 42 ++++++++++++++++++ .../protocol/channels-session/commands.ts | 20 ++++++++- .../protocol/channels-session/reducer.ts | 24 +++++++++++ .../state/protocol/channels-session/state.ts | 23 ++++++---- .../common/state/protocol/common/actions.ts | 12 +++++- .../common/state/protocol/common/commands.ts | 2 +- .../common/state/protocol/version/registry.ts | 4 ++ .../agentHost/common/state/sessionState.ts | 8 ++-- .../node/agentConfigurationService.ts | 4 +- .../node/agentHostChangesetService.ts | 6 +-- .../node/agentHostCommitOperationHandler.ts | 2 +- ...agentHostDiscardChangesOperationHandler.ts | 2 +- .../node/agentHostFileCompletionProvider.ts | 2 +- .../node/agentHostGitStateService.ts | 2 +- .../agentHostPullRequestOperationHandler.ts | 2 +- .../agentHost/node/agentHostReviewService.ts | 6 +-- .../agentHost/node/agentHostStateManager.ts | 8 ++-- .../node/agentHostSyncOperationHandler.ts | 2 +- .../platform/agentHost/node/agentService.ts | 23 +++++----- .../node/localCommands/bangLocalCommand.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 2 +- .../node/agentConfigurationService.test.ts | 2 +- .../agentHostChangesetCoordinator.test.ts | 6 +-- .../node/agentHostChangesetService.test.ts | 26 +++++------ .../agentHostCommitOperationHandler.test.ts | 2 +- ...HostDiscardChangesOperationHandler.test.ts | 2 +- .../agentHostFileCompletionProvider.test.ts | 2 +- .../node/agentHostGitStateService.test.ts | 4 +- ...entHostPullRequestOperationHandler.test.ts | 2 +- .../test/node/agentHostStateManager.test.ts | 18 ++++---- .../agentHost/test/node/agentService.test.ts | 8 ++-- .../test/node/agentSideEffects.test.ts | 2 +- .../node/protocol/agentHostE2ETestHelpers.ts | 10 ++--- .../test/node/protocolServerHandler.test.ts | 2 +- .../test/node/sessionPermissions.test.ts | 2 +- .../browser/baseAgentHostSessionsProvider.ts | 6 +-- .../localAgentHostSessionsProvider.test.ts | 2 +- .../remoteAgentHostSessionsProvider.test.ts | 2 +- .../agentHost/agentHostChatInputPicker.ts | 2 +- .../agentHostCustomizationService.ts | 2 +- .../agentHost/agentHostGenericConfigChips.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 2 +- .../agentHostSessionListController.ts | 2 +- .../agentHost/agentHostSessionListStore.ts | 6 +-- .../agentHostChatContribution.test.ts | 7 +-- 53 files changed, 350 insertions(+), 126 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 42deaf12f05f9b..ed632feec797a9 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -811,7 +811,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const promise = this._sendRequest('createSession', { channel: session.toString(), provider, - workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined, + workingDirectories: config?.workingDirectory ? [fromAgentHostUri(config.workingDirectory).toString()] : undefined, config: config?.config, activeClient: config?.activeClient, }).then(() => session); @@ -955,7 +955,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC summary: s.title, status: s.status, activity: s.activity, - workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, + workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined, isRead: !!(s.status & SessionStatus.IsRead), isArchived: !!(s.status & SessionStatus.IsArchived), changes: s.changes, diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 20baede03d4a57..e6b2329dc4187d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4e554d0e +11f1a65e 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 96bafcddbcb679..f089bd62481c4a 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 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 ChatActivityChangedAction, 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 } 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 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 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 } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -46,6 +46,8 @@ export type SessionAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -68,6 +70,8 @@ export type ClientSessionAction = | SessionTitleChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionCustomizationToggledAction | SessionMcpServerStartRequestedAction | SessionMcpServerStopRequestedAction @@ -114,6 +118,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -135,6 +141,8 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction @@ -283,6 +291,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionServerToolsChanged]: false, [ActionType.SessionActiveClientSet]: true, [ActionType.SessionActiveClientRemoved]: true, + [ActionType.SessionWorkingDirectorySet]: true, + [ActionType.SessionWorkingDirectoryRemoved]: true, [ActionType.SessionInputNeededSet]: false, [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, @@ -314,6 +324,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, [ActionType.ChatActivityChanged]: false, + [ActionType.ChatWorkingDirectorySet]: true, + [ActionType.ChatWorkingDirectoryRemoved]: true, [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 426b26a2dc108d..3ddcc7d7ef27fe 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 @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; +import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor, type ToolCallRiskAssessment, type Turn } from './state.js'; @@ -488,6 +488,45 @@ export interface ChatActivityChangedAction { activity?: string; } +/** + * A working directory was added to this chat's + * {@link ChatState.workingDirectories} subset. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the chat's subset does not already contain it (creating the + * subset if absent) and is a no-op when it is already present. `directory` MUST + * be one of the owning session's {@link SessionState.workingDirectories}; a host + * MUST reject a directory that is not. Only valid when the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectorySetAction { + type: ActionType.ChatWorkingDirectorySet; + /** The working directory to add to this chat's subset. */ + directory: URI; +} + +/** + * A working directory was removed from this chat's + * {@link ChatState.workingDirectories} subset. + * + * Removes `directory` from the chat's subset; a no-op when it is not present. + * Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the + * chat's subset — the directory remains in the session's set. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectoryRemovedAction { + type: ActionType.ChatWorkingDirectoryRemoved; + /** The working directory to remove from this chat's subset. */ + directory: URI; +} + /** * Token usage report for a turn. * @@ -751,6 +790,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index a727004d1ea2c3..d489b9699a4b4d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -40,6 +40,17 @@ export interface CreateChatParams extends BaseParams { initialMessage?: Message; /** Optional source chat and turn to fork from. */ source?: ChatForkSource; + /** + * Initial working-directory subset for this chat. Every entry MUST be + * present in the owning session's `workingDirectories`; the server MUST + * reject any entry that is not. When absent, the chat inherits the full + * session set. Forked chats (`source`) inherit the source chat's + * `workingDirectories`; this field is ignored for forked chats. + * + * A client MUST NOT supply this field unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + */ + workingDirectories?: URI[]; } // ─── disposeChat ───────────────────────────────────────────────────────────── 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 7ccaeb4d0860b7..231ecbfc5c524a 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 @@ -365,6 +365,30 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; + // ── Working Directories ─────────────────────────────────────────────── + + case ActionType.ChatWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.ChatWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── 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 f24c5526269b83..3b0f2887c2b1a4 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 @@ -50,15 +50,19 @@ export interface ChatState { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. + * The subset of the session's + * {@link SessionState.workingDirectories | `workingDirectories`} that this + * chat's agent has tool access to. Every entry MUST be present in the owning + * session's `workingDirectories`; servers MUST reject a + * `chat/workingDirectorySet` action that violates this constraint. * - * If absent, the chat inherits - * {@link SessionState.workingDirectory | the session's working directory}. - * Hosts MAY override this for individual chats — for example, to give a - * subordinate chat its own git worktree so multiple chats in a session can - * make independent edits that the orchestrator later merges back. + * When absent, the chat inherits the full session set. When present but empty + * (not recommended), the chat has no working-directory tool access at all. + * + * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + * update the subset on a running chat. */ - workingDirectory?: URI; + workingDirectories?: URI[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -127,13 +131,10 @@ export interface ChatSummary { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. - * - * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. - * See {@link ChatState.workingDirectory} for usage notes. + * The subset of the session's working directories this chat uses. + * See {@link ChatState.workingDirectories} for the full semantics. */ - workingDirectory?: URI; + workingDirectories?: URI[]; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index f0d28f53e009f2..6f035435550eff 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -108,6 +108,17 @@ export interface AgentCapabilities { * forking; set {@link MultipleChatsCapability.fork} to also allow forking. */ multipleChats?: MultipleChatsCapability; + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the + * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} + * (some backends pin their first directory as a fixed process root). + * + * When absent, clients MUST NOT mutate a session's or chat's working-directory + * set and MUST NOT set more than one entry in + * {@link CreateSessionParams.workingDirectories}. + */ + multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } /** @@ -124,6 +135,27 @@ export interface MultipleChatsCapability { fork?: boolean; } +/** + * Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. + * + * @category Root State + */ +export interface MultipleWorkingDirectoriesCapability { + /** + * The agent's **first** working directory (index `0` of + * {@link CreateSessionParams.workingDirectories}) is an immutable primary: + * it is fixed for the lifetime of the session — clients MUST NOT remove or + * reorder it. Additional directories after it remain equal peers that can be + * added and removed freely. + * + * Advertised by backends whose agent process is rooted at a single directory + * that cannot change once the session has started (e.g. the SDK's primary + * `workingDirectory`). When absent or `false`, all directories are equal + * peers and any of them may be removed. + */ + immutablePrimary?: boolean; +} + /** * @category Root State */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index aeab330bf8678f..2deedd751d848e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -250,6 +250,48 @@ export interface SessionActiveClientRemovedAction { clientId: string; } +// ─── Working Directory Actions ─────────────────────────────────────────────── + +/** + * A working directory was added to the session's + * {@link SessionState.workingDirectories} set. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the set does not already contain it (creating the set if + * absent) and is a no-op when it is already present. Only valid when the agent + * advertises {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectorySetAction { + type: ActionType.SessionWorkingDirectorySet; + /** The working directory to grant the session's agent tool access to. */ + directory: URI; +} + +/** + * A working directory was removed from the session's + * {@link SessionState.workingDirectories} set. + * + * Removes `directory` from the set; a no-op when it is not present. There is no + * atomic backend "remove one" primitive — a host reconfigures its agent to the + * reduced set — so this action is safe to model as idempotent. A host MAY + * decline to apply the removal (e.g. an immutable primary directory, see + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary}); it then leaves + * the set unchanged. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectoryRemovedAction { + type: ActionType.SessionWorkingDirectoryRemoved; + /** The working directory to revoke the session's agent tool access to. */ + directory: URI; +} + // ─── Input Needed Actions ──────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 0f682bdd58a1ca..bb3acc187c3c39 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -63,8 +63,24 @@ export interface CreateSessionParams extends BaseParams { channel: URI; /** Agent provider ID */ provider?: string; - /** Working directory for the session */ - workingDirectory?: URI; + /** + * The working directories the session's agent is granted tool access to. + * A session may span multiple directories; they are equal peers except when + * the agent advertises + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (in which case + * the first entry is a fixed process root). + * + * A client MUST NOT supply more than one entry unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + * capability treats only the first entry as the session's working directory + * and ignores the rest. Dispatch `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` to change the set after the session has + * started. + * + * Ignored for forked sessions — a fork inherits its working directories + * from the source session identified by `fork`. + */ + workingDirectories?: URI[]; /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index ffb36b09bf08bc..0de727342d8022 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -218,6 +218,30 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, activeClients: updated }; } + // ── Working Directories ───────────────────────────────────────────── + + case ActionType.SessionWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.SessionWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── Input Needed ──────────────────────────────────────────────────── case ActionType.SessionInputNeededSet: { diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index f3341cb999ac80..f31cbd9a956309 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -73,12 +73,17 @@ export interface SessionMetadata { /** Server-owned project for this session */ project?: ProjectInfo; /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are equal peers + * except when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (the first + * entry is then a fixed process root). Individual chats MAY restrict to a + * subset via {@link ChatSummary.workingDirectories | their own + * `workingDirectories`}; a chat that sets none operates against this full + * set. */ - workingDirectory?: URI; + workingDirectories?: URI[]; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -181,7 +186,7 @@ export interface SessionState extends SessionMetadata { * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `git` key may provide extra git metadata about the session's - * workingDirectory. + * working directories. */ _meta?: Record; } @@ -406,9 +411,9 @@ export interface ProjectInfo { * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). * - `modifiedAt`: the max of all chats' `modifiedAt`. - * - `workingDirectory`: the session-level **default**. Individual chats MAY - * override via {@link ChatSummary.workingDirectory}; aggregating these up - * is meaningless and SHOULD NOT be attempted. + * - `workingDirectories`: the session-level set. Individual chats MAY restrict + * to a subset via {@link ChatSummary.workingDirectories}; aggregating these + * up is meaningless and SHOULD NOT be attempted. * - `changes`: optional roll-up across all chats. Producers MAY sum the * per-chat changeset stats or report the most expensive chat's stats — * whichever is cheaper for the host to compute. 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 5a201d07df8e56..7e37f9223a5073 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,9 +10,9 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, 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, ChatActivityChangedAction, 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, 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'; @@ -54,12 +54,16 @@ export const enum ActionType { ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', ChatActivityChanged = 'chat/activityChanged', + ChatWorkingDirectorySet = 'chat/workingDirectorySet', + ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', SessionServerToolsChanged = 'session/serverToolsChanged', SessionActiveClientSet = 'session/activeClientSet', SessionActiveClientRemoved = 'session/activeClientRemoved', + SessionWorkingDirectorySet = 'session/workingDirectorySet', + SessionWorkingDirectoryRemoved = 'session/workingDirectoryRemoved', SessionInputNeededSet = 'session/inputNeededSet', SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', @@ -161,6 +165,8 @@ export type StateAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -192,6 +198,8 @@ export type StateAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index d0bfeab121ab34..2041f2a23a1ba9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -276,7 +276,7 @@ export interface InitializeResult { * @method ping * @direction Client → Server * @messageType Request - * @version 0.1.0 + * @version 1 */ export interface PingParams extends BaseParams { channel: 'ahp-root://'; 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 20ed37b0822c89..3d6f29d3835a5c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -90,6 +90,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionServerToolsChanged]: '0.1.0', [ActionType.SessionActiveClientSet]: '0.5.0', [ActionType.SessionActiveClientRemoved]: '0.5.0', + [ActionType.SessionWorkingDirectorySet]: '0.6.0', + [ActionType.SessionWorkingDirectoryRemoved]: '0.6.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -121,6 +123,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', [ActionType.ChatActivityChanged]: '0.5.0', + [ActionType.ChatWorkingDirectorySet]: '0.6.0', + [ActionType.ChatWorkingDirectoryRemoved]: '0.6.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0302739d5380e6..5d47f54cee376f 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -617,7 +617,7 @@ export function createSessionState(summary: SessionSummary): SessionState { }; if (summary.activity !== undefined) { state.activity = summary.activity; } if (summary.project !== undefined) { state.project = summary.project; } - if (summary.workingDirectory !== undefined) { state.workingDirectory = summary.workingDirectory; } + if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; } if (summary.annotations !== undefined) { state.annotations = summary.annotations; } if (summary._meta !== undefined) { state._meta = summary._meta; } return state; @@ -637,7 +637,7 @@ export function createChatState(summary: ChatSummary): ChatState { modifiedAt: summary.modifiedAt, origin: summary.origin, interactivity: summary.interactivity, - workingDirectory: summary.workingDirectory, + workingDirectories: summary.workingDirectories, turns: [], activeTurn: undefined, }; @@ -683,7 +683,7 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.activity !== undefined) { summary.activity = state.activity; } if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } - if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } + if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } return summary; } @@ -913,7 +913,7 @@ export interface ISessionWithDefaultChat extends SessionState { export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, - workingDirectory: chat?.workingDirectory ?? session.workingDirectory, + workingDirectories: chat?.workingDirectories ?? session.workingDirectories, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 1a6b79e2bf28c6..0f59a1778d5075 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -211,13 +211,13 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { - const own = this._stateManager.getSessionState(session)?.workingDirectory; + const own = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (own !== undefined) { return own; } const parentInfo = parseSubagentSessionUri(session); if (parentInfo) { - return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectory; + return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories?.[0]; } return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index bfb8552c6b1ca3..c2c21a178af9cb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -569,7 +569,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private async _computeUncommittedDiffs(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } @@ -1003,7 +1003,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * branch git falls back to `HEAD`. */ private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } @@ -1081,7 +1081,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * no git working directory (review status is then simply omitted). */ private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet } | undefined> { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index c90b894cb366af..dee479e7a69b5e 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -60,7 +60,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts index e3ff4748437593..3efc282b907e4b 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts @@ -57,7 +57,7 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat `Operation '${AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES}' requires a resource target.`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts index 1b4ca1aaef9328..fe5080e0e214aa 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -111,7 +111,7 @@ export class AgentHostFileCompletionProvider implements IAgentHostCompletionItem ) { } async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise { - const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectory; + const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectories?.[0]; if (!workingDirectoryStr) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 5231950355a467..122619759dc02e 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -100,7 +100,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } if (!workingDirectory) { - const workingDirectoryStr = sessionState?.workingDirectory; + const workingDirectoryStr = sessionState?.workingDirectories?.[0]; if (workingDirectoryStr) { workingDirectory = URI.parse(workingDirectoryStr); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 7946d034b233a9..d3e5cd54f51ec1 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -104,7 +104,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostReviewService.ts b/src/vs/platform/agentHost/node/agentHostReviewService.ts index 24ad761e29536a..73b0c63766ab12 100644 --- a/src/vs/platform/agentHost/node/agentHostReviewService.ts +++ b/src/vs/platform/agentHost/node/agentHostReviewService.ts @@ -68,7 +68,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi if (!sessionState) { throw new Error(`Session not found: ${parsed.sessionUri}`); } - if (!sessionState.workingDirectory) { + if (!sessionState.workingDirectories?.[0]) { throw new Error(`Session has no working directory: ${parsed.sessionUri}`); } @@ -80,7 +80,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi databaseRef.dispose(); } - const workingDirectory = URI.parse(sessionState.workingDirectory); + const workingDirectory = URI.parse(sessionState.workingDirectories?.[0]); const baseBranch = resolveDiffBaseBranchName(persistedBaseBranch, readSessionGitState(sessionState._meta)?.baseBranchName); await this._sequencer.queue(parsed.sessionUri, async () => { for (const resource of resources) { @@ -234,7 +234,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi } private async _disposeSessionData(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { // No working directory means we can't resolve the repository root // (session was never git-backed, or its working directory is gone). diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 53fdd3a1585431..d9d6611c0b7752 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -137,7 +137,7 @@ class SessionSummaryNotifier extends Disposable { if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; } if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } this._lastNotified.set(session, current); @@ -344,7 +344,7 @@ export class AgentHostStateManager extends Disposable { }; if (state.activity !== undefined) { summary.activity = state.activity; } if (state.project !== undefined) { summary.project = state.project; } - if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } + if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } if (state._meta !== undefined) { summary._meta = state._meta; } @@ -361,7 +361,7 @@ export class AgentHostStateManager extends Disposable { && a.status === b.status && a.activity === b.activity && a.project === b.project - && a.workingDirectory === b.workingDirectory + && a.workingDirectories?.[0] === b.workingDirectories?.[0] && a.annotations === b.annotations && a._meta === b._meta; } @@ -608,7 +608,7 @@ export class AgentHostStateManager extends Disposable { // directory / project. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectory: summary.workingDirectory }; + entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index 9ea663d006f23f..bba21e32eb9733 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -38,7 +38,7 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 125523174aca7c..2b9c1e7b15af16 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -484,7 +484,7 @@ export class AgentService extends Disposable implements IAgentService { resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), onTurnComplete: async session => { // Refresh the git state for the session. - const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; void this._gitStateService.refreshSessionGitState(session, workingDirStr ? URI.parse(workingDirStr) : undefined); // Check for a GitHub pull request associated with the session's branch. @@ -843,6 +843,7 @@ export class AgentService extends Disposable implements IAgentService { const _meta = liveSummary._meta !== undefined || s._meta !== undefined ? { ...s._meta, ...liveSummary._meta } : undefined; + const liveWorkingDir = liveSummary.workingDirectories?.[0]; return { ...s, summary: liveSummary.title || s.summary, @@ -852,8 +853,8 @@ export class AgentService extends Disposable implements IAgentService { project: liveSummary.project ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName } : s.project, - workingDirectory: typeof liveSummary.workingDirectory === 'string' - ? URI.parse(liveSummary.workingDirectory) + workingDirectory: typeof liveWorkingDir === 'string' + ? URI.parse(liveWorkingDir) : s.workingDirectory, changes: liveSummary.changes ?? s.changes, changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, @@ -886,6 +887,7 @@ export class AgentService extends Disposable implements IAgentService { continue; } + const summaryWorkingDir = summary.workingDirectories?.[0]; additions.push({ session: URI.parse(summary.resource), startTime: Date.parse(summary.createdAt), @@ -893,7 +895,7 @@ export class AgentService extends Disposable implements IAgentService { summary: summary.title, status: summary.status, activity: summary.activity, - workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined, + workingDirectory: typeof summaryWorkingDir === 'string' ? URI.parse(summaryWorkingDir) : undefined, ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), changes: summary.changes, // This overlay path never opens the session database (unlike the @@ -1396,6 +1398,7 @@ export class AgentService extends Disposable implements IAgentService { private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { const now = new Date().toISOString(); + const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString(); return { resource: session.toString(), provider: provider.id, @@ -1404,7 +1407,7 @@ export class AgentService extends Disposable implements IAgentService { createdAt: now, modifiedAt: now, ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), + workingDirectories: primaryWorkingDir ? [primaryWorkingDir] : undefined, // Workspace-less is inferred at create from an absent input // `workingDirectory` (the host assigns a scratch cwd, so it can't be // re-inferred later) and tagged on the generic `_meta` bag. @@ -1446,7 +1449,7 @@ export class AgentService extends Disposable implements IAgentService { const summary: SessionSummary = { ...currentSummary, ...(project ? { project: { uri: project.uri.toString(), displayName: project.displayName } } : {}), - workingDirectory: e.workingDirectory?.toString() ?? currentSummary.workingDirectory, + workingDirectories: e.workingDirectory ? [e.workingDirectory.toString()] : currentSummary.workingDirectories, modifiedAt: new Date().toISOString(), }; const configValues = state.config?.values; @@ -1757,8 +1760,8 @@ export class AgentService extends Disposable implements IAgentService { // the normal state-update stream. const sessionState = this._stateManager.getSessionState(resourceStr); if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { - const workingDirectory = sessionState.workingDirectory - ? URI.parse(sessionState.workingDirectory) + const workingDirectory = sessionState.workingDirectories?.[0] + ? URI.parse(sessionState.workingDirectories[0]) : undefined; void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); } @@ -2404,7 +2407,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: new Date(meta.modifiedTime).toISOString(), ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), changes: meta.changes ?? changes, - workingDirectory: meta.workingDirectory?.toString(), + workingDirectories: meta.workingDirectory ? [meta.workingDirectory.toString()] : undefined, _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, }; @@ -3413,7 +3416,7 @@ export class AgentService extends Disposable implements IAgentService { if (!this._gitService) { throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`); } - const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectories?.[0]; if (!workingDirectory) { throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts index a0829625375757..78370088964da5 100644 --- a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts @@ -57,7 +57,7 @@ export class BangLocalCommand extends Disposable implements ILocalChatCommand { const displayName = localize('agentHostBang.terminal', "Terminal"); let terminalCreated = false; try { - const workingDirStr = ctx.getState(sessionChannel)?.workingDirectory; + const workingDirStr = ctx.getState(sessionChannel)?.workingDirectories?.[0]; const cwd = workingDirStr ? URI.parse(workingDirStr).fsPath : undefined; const shellPath = await ctx.terminalManager.getDefaultShell(); const shellType = shellTypeForExecutable(shellPath); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 0a2334d416b121..e515c29794f7b5 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1141,7 +1141,7 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, - workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, + workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories?.[0]) : undefined, session: URI.parse(params.channel), fork, config: params.config, diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 1ba04937943d49..a79017a1ba94f5 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -54,7 +54,7 @@ suite('AgentConfigurationService', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 386fef9b90500d..d7c6899ba6282c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -42,7 +42,7 @@ suite('ChangesetSessionCoordinator', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }, { emitNotification }); stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); @@ -148,7 +148,7 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { acquisitions: [], rootLookups: [] }); const summary = environment.stateManager.getSessionSummary(session)!; - environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); + environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] }); environment.coordinator.onSessionMaterialized(session); await environment.monitor.waitForAcquisitions(1); @@ -170,7 +170,7 @@ suite('ChangesetSessionCoordinator', () => { await tick(); const summary = environment.stateManager.getSessionSummary(session)!; - environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); + environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] }); environment.coordinator.onSessionMaterialized(session); await tick(); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index cb581ed2d29c30..7530e629396988 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -76,7 +76,7 @@ suite.skip('AgentHostChangesetService', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); @@ -287,7 +287,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main'); @@ -366,7 +366,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -402,7 +402,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -434,7 +434,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); const envelopes: ActionEnvelope[] = []; @@ -495,7 +495,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await localChangesets.computeUncommittedChangeset(sessionStr); @@ -569,7 +569,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await localChangesets.computeUncommittedChangeset(sessionStr); @@ -622,7 +622,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr)); return sessionStr; @@ -642,7 +642,7 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, [], 'nothing computed while the working directory is unknown'); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes.sort(), ['session', 'session']); @@ -657,7 +657,7 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, [], 'uncommitted compute deferred while the working directory is unknown'); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, ['uncommitted']); @@ -673,7 +673,7 @@ suite.skip('AgentHostChangesetService', () => { subscriptions.delete(buildSessionChangesetUri(sessionStr)); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, []); @@ -694,7 +694,7 @@ suite.skip('AgentHostChangesetService', () => { service.onSessionDisposed(sessionStr); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, []); @@ -1048,7 +1048,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); const envelopes: ActionEnvelope[] = []; diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 1b1d94c5d0e720..a3ba548642c6ba 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -148,7 +148,7 @@ function setup(disposables: Pick, gitService: TestGitSer status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: URI.file('/repo').toString(), + workingDirectories: [URI.file('/repo').toString()], }); stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { branchName: 'feature/test', diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 2135ec16e0cc34..daf13aec2be170 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -70,7 +70,7 @@ function setup(disposables: Pick, opts?: { readonly with status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(), + workingDirectories: opts?.withWorkingDirectory === false ? undefined : [URI.file('/repo').toString()], }); } const handler = new AgentHostDiscardChangesOperationHandler( diff --git a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts index 6563db6c9143e7..aaecb276cf2838 100644 --- a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts @@ -114,7 +114,7 @@ suite('AgentHostFileCompletionProvider', () => { createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 33e09f24fc4870..36c16b322af61c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -78,7 +78,7 @@ suite('AgentHostGitStateService', () => { status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), - workingDirectory: options?.workingDirectory, + workingDirectories: options?.workingDirectory ? [options.workingDirectory] : undefined, }; // `restoreSession` materializes the session in `ready` lifecycle so the // persistence path (which skips `creating` sessions) actually runs. @@ -113,7 +113,7 @@ suite('AgentHostGitStateService', () => { status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), - workingDirectory: 'file:///original', + workingDirectories: ['file:///original'], }, { emitNotification: false }); h.setGitResult({ branchName: 'feature' }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 1a3dbf12b2ec9f..cf055a399f5a98 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -164,7 +164,7 @@ function setup(disposables: Pick, gitService: TestGitSer status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: URI.file('/repo').toString(), + workingDirectories: [URI.file('/repo').toString()], }); // Git state and GitHub state now share the single `_meta` bag. const sessionMeta = withSessionGitHubState(withSessionGitState(undefined, { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 9a82dcb4c4fae6..3f638925868a4d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -227,12 +227,12 @@ suite('AgentHostStateManager', () => { // has no per-chat working-directory override, so getSessionState must // project the RESOLVED session working directory, never the stale // create-time value that was seeded onto the default chat. - manager.createSession({ ...makeSessionSummary(), workingDirectory: 'file:///provisional' }, { emitNotification: false }); - manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectory: 'file:///resolved-worktree' }); + manager.createSession({ ...makeSessionSummary(), workingDirectories: ['file:///provisional'] }, { emitNotification: false }); + manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectories: ['file:///resolved-worktree'] }); assert.deepStrictEqual({ - session: manager.getSessionState(sessionUri)?.workingDirectory, - defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectory, + session: manager.getSessionState(sessionUri)?.workingDirectories?.[0], + defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectories?.[0], }, { session: 'file:///resolved-worktree', defaultChat: 'file:///resolved-worktree', @@ -1681,7 +1681,7 @@ suite('Subagent URI helpers', () => { lifecycle: SessionLifecycle.Ready, activeClients: [], chats: [], - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } @@ -1691,7 +1691,7 @@ suite('Subagent URI helpers', () => { title: 'Peer', status: SessionStatus.Idle, modifiedAt: new Date().toISOString(), - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, turns: [], }; } @@ -1701,7 +1701,7 @@ suite('Subagent URI helpers', () => { makeSessionState('file:///session-wd'), makeChatState('file:///peer-worktree'), ); - assert.strictEqual(merged.workingDirectory, 'file:///peer-worktree'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///peer-worktree'); }); test('falls back to the session working directory when the chat does not override it', () => { @@ -1709,12 +1709,12 @@ suite('Subagent URI helpers', () => { makeSessionState('file:///session-wd'), makeChatState(undefined), ); - assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); }); test('falls back to the session working directory when no chat state is hydrated', () => { const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined); - assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); assert.deepStrictEqual(merged.turns, []); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b88216c31c3ca9..c250eb366cf8f7 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1245,7 +1245,7 @@ suite('AgentService (node dispatcher)', () => { createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), project: { uri: URI.file('/project').toString(), displayName: 'project' }, - workingDirectory: URI.file('/worktree').toString(), + workingDirectories: [URI.file('/worktree').toString()], }, []); agent.releaseList.complete(); @@ -4713,7 +4713,7 @@ suite('AgentService (node dispatcher)', () => { // The state manager should have the worktree path, not the source path const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); test('createSession falls back to config working directory when agent does not resolve', async () => { @@ -4725,7 +4725,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, sourceDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString()); }); test('restoreSession uses agent working directory in state', async () => { @@ -4744,7 +4744,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(session); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 87caa3f29259d2..e5f437ff5ccb19 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -167,7 +167,7 @@ suite('AgentSideEffects', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts index 8f36c6933c8ae2..f78b0e35cf161f 100644 --- a/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts @@ -1049,7 +1049,7 @@ export function defineAgentHostE2ETests(config: IAgentHostE2EProviderConfig): vo const subscribeResult = await client.call('subscribe', { channel: sessionUri }, 30_000); const sessionState = subscribeResult.snapshot!.state as SessionState; - assert.strictEqual(sessionState.workingDirectory, workingDirUri, + assert.strictEqual(sessionState.workingDirectories?.[0], workingDirUri, `subscribe snapshot summary should carry the requested working directory`); }); @@ -1127,10 +1127,10 @@ export function defineAgentHostE2ETests(config: IAgentHostE2EProviderConfig): vo ); const addedSummary = (addedNotif.params as SessionAddedParams).summary; - assert.ok(addedSummary.workingDirectory, 'sessionAdded notification should have a workingDirectory'); - assert.ok(addedSummary.workingDirectory!.includes('.worktrees'), - `workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectory}`); - const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectory!).fsPath; + assert.ok(addedSummary.workingDirectories?.[0], 'sessionAdded notification should have a workingDirectory'); + assert.ok(addedSummary.workingDirectories?.[0]!.includes('.worktrees'), + `workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectories?.[0]}`); + const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectories?.[0]!).fsPath; await client.waitForNotification( n => isActionNotification(n, 'chat/turnComplete') || isActionNotification(n, 'chat/error'), diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6bab185da34173..df9b2b83ac89a9 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -117,7 +117,7 @@ class MockAgentService implements IAgentService { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///created-project', displayName: 'Created Project' }, - workingDirectory: config?.workingDirectory?.toString(), + workingDirectories: config?.workingDirectory ? [config.workingDirectory.toString()] : undefined, }); return session; } diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index ac533239454a72..8c6de10b75f686 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -43,7 +43,7 @@ suite('SessionPermissionManager', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 748a979813043a..cbdcaa3538515b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -2934,7 +2934,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getWorkingDirectory(sessionId: string): string | undefined { const sessionState = this._lastSessionStates.get(sessionId); - return sessionState?.workingDirectory; + return sessionState?.workingDirectories?.[0]; } getMcpServers(sessionId: string): readonly IAgentHostMcpServer[] { @@ -4224,8 +4224,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionAdded(summary: SessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); - const workingDir = typeof summary.workingDirectory === 'string' - ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory)) + const workingDir = typeof summary.workingDirectories?.[0] === 'string' + ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectories?.[0])) : undefined; const meta: IAgentSessionMetadata = { session: sessionUri, 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 10761a964d9b40..891b8770a46ec8 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 @@ -436,7 +436,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined, changes: opts?.changes, ...(opts?.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 9295326918f890..0454b3bac17596 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -280,7 +280,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined, }, }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index e1b37d9a1ff213..c9bb50cab44871 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -681,7 +681,7 @@ export class AgentHostChatInputPicker extends Disposable { private _readWorkingDirectory(): URI | undefined { const state = this._subRef.value?.sub.value; if (state && !(state instanceof Error)) { - const cwd = state.workingDirectory; + const cwd = state.workingDirectories?.[0]; return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index c0a662ec20eda1..acdc6b36774086 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -454,7 +454,7 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat const channel = target.backendSession.toString(); return { customizations: sessionState?.customizations ?? [], - workingDirectory: sessionState?.workingDirectory, + workingDirectory: sessionState?.workingDirectories?.[0], rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, authenticate: request => target.connection.authenticate(request), setCustomizationEnabled: (rawId, enabled) => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index a0bf40b87b4a36..7130daf9d29b60 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -140,7 +140,7 @@ export class AgentHostGenericConfigChips extends Disposable { private _readWorkingDirectory(): URI | undefined { const state = this._subRef.value?.sub.value; if (state && !(state instanceof Error)) { - const cwd = state.workingDirectory; + const cwd = state.workingDirectories?.[0]; return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 30cb64ecea2d92..f0449878f5f6bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4496,7 +4496,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return uri; } const backendSession = this._resolveSessionUri(sessionResource); - const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectory; + const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectories?.[0]; const resolvedDir = typeof rawResolvedDir === 'string' ? URI.parse(rawResolvedDir) : rawResolvedDir; if (!resolvedDir || resolvedDir.scheme !== 'file') { return uri; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 8bd0a37b38d756..c5afb96d21f0fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -175,7 +175,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS } private _makeItemFromSummary(rawId: string, summary: SessionSummary): IChatSessionItem { - const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory; + const workingDir = typeof summary.workingDirectories?.[0] === 'string' ? URI.parse(summary.workingDirectories?.[0]) : summary.workingDirectories?.[0]; return this._makeItem(rawId, { title: summary.title, status: summary.status, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 41b12a8d17454f..6007edb6a972f0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -251,7 +251,7 @@ export class AgentHostSessionListStore extends Disposable { private _onNotification(notification: INotification): void { if (notification.type === 'root/sessionAdded') { - if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectory)) { + if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectories?.[0])) { return; } const entry = this._makeEntryFromSummary(notification.summary); @@ -284,7 +284,7 @@ export class AgentHostSessionListStore extends Disposable { } const updatedSummary = { ...cached.summary, ...notification.changes }; - if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectory)) { + if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectories?.[0])) { this.removeSession(provider, rawId); return; } @@ -323,7 +323,7 @@ export class AgentHostSessionListStore extends Disposable { createdAt: new Date(session.startTime).toISOString(), modifiedAt: new Date(session.modifiedTime).toISOString(), changes: session.changes, - workingDirectory: session.workingDirectory?.toString(), + workingDirectories: session.workingDirectory ? [session.workingDirectory.toString()] : undefined, }, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 6aab080b1f42ed..61f417c89ae1ed 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -219,6 +219,7 @@ class MockAgentHostService extends mock() { // Simulate the server's eager active-client claim: if the caller // provided activeClient, seed the session state so subscribers see it. if (config?.activeClient) { + const resolvedWorkingDir = (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(); const summary: SessionSummary = { resource: session.toString(), provider: 'copilot', @@ -226,7 +227,7 @@ class MockAgentHostService extends mock() { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(), + workingDirectories: resolvedWorkingDir ? [resolvedWorkingDir] : undefined, }; const state: SessionState = { ...this._withDefaultChatCatalog(createSessionState(summary), session.toString()), @@ -484,7 +485,7 @@ class MockAgentHostService extends mock() { status: seeded?.status ?? SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: seeded?.workingDirectory, + workingDirectories: seeded?.workingDirectories, project: seeded?.project, }; const chatSummary = createDefaultChatSummary(sessionSummary, chatUriStr); @@ -509,7 +510,7 @@ class MockAgentHostService extends mock() { status: state.status, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: state.workingDirectory, + workingDirectories: state.workingDirectories, project: state.project, }; const chatUri = buildDefaultChatUri(resource); From ac8c8f9f5167e791390bb52ef01ebf60e29c1953 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 11:01:41 -0700 Subject: [PATCH 2/7] Address PR review feedback - Reject the not-yet-supported multiroot working-directory client actions (session|chat/workingDirectorySet|Removed) in the handwritten dispatch path so a client cannot mutate the synchronized working-directory set without the agent actually reconfiguring its directory access. The protocol declarations remain; only the operational dispatch is deferred until multiroot lands. - Compare workingDirectories by array identity (not just the primary entry) in the state manager summary-equality check, matching the immutable reducers and SessionSummaryNotifier so secondary-directory changes still dirty the summary. - Update ISessionWithDefaultChat / mergeSessionWithDefaultChat API docs to describe a chat's workingDirectories subset overriding or inheriting the session's full set, fixing links to the removed singular members. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionState.ts | 32 ++++++++++--------- .../agentHost/node/agentHostStateManager.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 24 +++++++++++++- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 5d47f54cee376f..bd911d676d5dc5 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -659,12 +659,13 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - // `workingDirectory` is deliberately NOT copied: per the protocol it is a - // per-chat OVERRIDE and, when absent, the chat inherits the session's - // working directory (see `mergeSessionWithDefaultChat`). Seeding it here - // would denormalize the session default onto every chat as a fake override, - // which then goes stale when the session's working directory is resolved - // later (e.g. a worktree resolved at materialization). + // `workingDirectories` is deliberately NOT copied: per the protocol it is a + // per-chat SUBSET override and, when absent, the chat inherits the session's + // full set of working directories (see `mergeSessionWithDefaultChat`). + // Seeding it here would denormalize the session default onto every chat as a + // fake override, which then goes stale when the session's working + // directories are resolved later (e.g. a worktree resolved at + // materialization). return summary; } @@ -877,17 +878,18 @@ export function isAhpChatChannel(uri: string): boolean { /** * A single chat's effective session context: the shared {@link SessionState} - * (working directory, active clients, config, customizations/MCP scope, …) + * (working directories, active clients, config, customizations/MCP scope, …) * resolved for one chat and merged with that chat's conversation contents. * * The protocol moved turns and pending state off the session and onto a - * per-chat channel, and lets a chat override session defaults (e.g. - * {@link ChatState.workingDirectory}). This composite recombines the session - * with one of its chats — default or peer — so consumers read the chat's - * effective context and conversation through one object without walking back to - * the session to re-derive shared state. The inherited - * {@link SessionState.workingDirectory} carries the chat's *effective* working - * directory (its own override when present, else the session default). + * per-chat channel, and lets a chat override the session's working directories + * with a subset (e.g. {@link ChatState.workingDirectories}). This composite + * recombines the session with one of its chats — default or peer — so consumers + * read the chat's effective context and conversation through one object without + * walking back to the session to re-derive shared state. The inherited + * {@link SessionState.workingDirectories} carries the chat's *effective* + * working directories (its own subset override when present, else the session's + * full set). */ export interface ISessionWithDefaultChat extends SessionState { /** Completed turns of this chat. */ @@ -905,7 +907,7 @@ export interface ISessionWithDefaultChat extends SessionState { /** * Projects a {@link SessionState} and one of its {@link ChatState | chats} * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective - * session context}. Per-chat overrides (currently the working directory) are + * session context}. Per-chat overrides (currently the working directories) are * layered over the session defaults, and the conversation fields are taken from * the chat. When the chat state is absent (e.g. not yet hydrated) the * conversation fields default to empty and the session defaults apply. diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index d9d6611c0b7752..550c4ea1d03d96 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -361,7 +361,7 @@ export class AgentHostStateManager extends Disposable { && a.status === b.status && a.activity === b.activity && a.project === b.project - && a.workingDirectories?.[0] === b.workingDirectories?.[0] + && a.workingDirectories === b.workingDirectories && a.annotations === b.annotations && a._meta === b._meta; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index b247ccde40448f..b4d91bc77e0151 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -59,6 +59,21 @@ const REPLAY_BUFFER_CAPACITY = 1000; const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; +/** + * Client-dispatchable actions that are declared in the protocol but not yet + * operational in this build. The multiroot working-directory mutations + * (`session|chat/workingDirectorySet|Removed`) would mutate the synchronized + * working-directory set without reconfiguring the agent's actual directory + * access, so they are rejected in the dispatch path until capability-backed + * multiroot support lands. + */ +const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet = new Set([ + ActionType.SessionWorkingDirectorySet, + ActionType.SessionWorkingDirectoryRemoved, + ActionType.ChatWorkingDirectorySet, + ActionType.ChatWorkingDirectoryRemoved, +]); + /** A client tool call in any of these statuses is still awaiting its result. */ function isPendingToolCallStatus(status: ToolCallStatus): boolean { return status === ToolCallStatus.Streaming @@ -442,7 +457,14 @@ export class ProtocolServerHandler extends Disposable { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); const action = msg.params.action as SessionAction | ChatAction | TerminalAction | IRootConfigChangedAction; const channel = msg.params.channel; - if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { + // Multiroot working-directory mutations are declared in the + // protocol but not yet supported: they would mutate the + // synchronized access set without reconfiguring the agent's + // actual directory access. Reject them until capability-backed + // multiroot support lands. + if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { + this._logService.warn(`[ProtocolServer] ignoring unsupported client action: ${action.type}`); + } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq); } } From 828ce1d47ec486dfad3fef8271f2a219d1e3fbb7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 11:37:44 -0700 Subject: [PATCH 3/7] Fix agentHost session-filter test for workingDirectories migration The `sessionAdded notification filters out sessions outside the workspace` test constructed session summaries with the removed singular `workingDirectory` field, so the production filter (which now reads `workingDirectories[0]`) saw no directory and dropped the in-workspace session. Update the two directly-built summaries to the `workingDirectories` array form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentSessions/agentHostChatContribution.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 319044ac8a3243..d69b4a9f67cac9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -2544,7 +2544,7 @@ suite('AgentHostChatContribution', () => { status: SessionStatus.Idle, createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), - workingDirectory: URI.file('/other/workspace').toString(), + workingDirectories: [URI.file('/other/workspace').toString()], }, } as INotification); @@ -2561,7 +2561,7 @@ suite('AgentHostChatContribution', () => { status: SessionStatus.Idle, createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), - workingDirectory: URI.file('/workspace/root/sub').toString(), + workingDirectories: [URI.file('/workspace/root/sub').toString()], }, } as INotification); From 14e541bf9f62d5e3dbb9819c6fcb831f6ffda64d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 16:36:15 -0700 Subject: [PATCH 4/7] Migrate test createSession wire calls to workingDirectories The createSession dispatch handler now reads the working directory from the renamed `CreateSessionParams.workingDirectories` array (AHP 0.6.0), but several node integration / e2e test call sites still sent the removed singular `workingDirectory` field. Since the field is silently ignored, sessions were created without a working directory and fell back to the default chats folder, failing the Agent Host E2E workspace/fileOperations/hostFeatures suites across all providers (wrong cwd cascades into file completions, renames, worktree resolution, and cd-prefix stripping). Send `workingDirectories: [dir]` from the shared `createProviderSession` helper and the remaining direct wire call sites so the requested directory reaches the session state again. The real VS Code client already sent the array form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/test/node/e2e/suites/workspaceSuite.ts | 4 ++-- .../test/node/protocol/sessionConfig.integrationTest.ts | 4 ++-- .../test/node/protocol/sessionDiffs.integrationTest.ts | 2 +- .../agentHost/test/node/providerIntegrationTestHelpers.ts | 2 +- .../agentHost/test/node/serverIntegrationTestHelpers.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts index ac55305e4336a7..d98abf9da51b41 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts @@ -40,7 +40,7 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { await context.client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); - await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri }, 30_000); + await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri] }, 30_000); createdSessions.push(sessionUri); const subscribeResult = await context.client.call('subscribe', { channel: sessionUri }, 30_000); @@ -86,7 +86,7 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); await context.client.call('createSession', { - channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri, + channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri], config: { isolation: 'worktree', branch: defaultBranch }, }); createdSessions.push(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts index fb12db0046f81f..93ac3840796936 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts @@ -98,7 +98,7 @@ suite('Protocol WebSocket - Session Config', function () { await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), + workingDirectories: [URI.file('/mock/workspace').toString()], config, }); @@ -181,7 +181,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio await client1.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), + workingDirectories: [URI.file('/mock/workspace').toString()], config: initialConfig, }); const addedNotif = await client1.waitForNotification(n => diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index 96a07a844bc2e9..3d0ad44bc062c0 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -81,7 +81,7 @@ const hasGit = (() => { await client.call('initialize', { protocolVersions: [PROTOCOL_VERSION], clientId: 'test-git-diffs' }); const workingDirectory = URI.file(tmpRoot).toString(); - await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory }); + await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: [workingDirectory] }); const addedNotif = await client.waitForNotification(n => n.method === 'root/sessionAdded' diff --git a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts index ec862ed8790c54..92b860ef7cb90c 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts @@ -32,7 +32,7 @@ export async function createProviderSession( await client.call('createSession', { channel: sessionUri, provider: config.provider, - workingDirectory: workingDirectory.toString(), + workingDirectories: [workingDirectory.toString()], config: { isolation: 'folder' }, }, 30_000); trackingList.push(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 62194565d241ce..a2a964a75c5598 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -859,7 +859,7 @@ export function getActionEnvelope(n: AhpNotification): ActionEnvelope { export async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise { await c.call('initialize', { channel: 'ahp-root://', protocolVersions: [PROTOCOL_VERSION], clientId }); - await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory }); + await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: workingDirectory ? [workingDirectory] : undefined }); const notif = await c.waitForNotification(n => n.method === 'root/sessionAdded' From 540b4975b50ccd0a8a0deb94bdc447b5ea92e296 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 21:53:39 -0700 Subject: [PATCH 5/7] Adopt AHP 0.7.0 primaryWorkingDirectory + requiresPrimary Re-sync the generated protocol copy to the multiroot spec at 148c716b (spec version 0.7.0) and adopt the two follow-up changes: - Capability flag renamed `MultipleWorkingDirectoriesCapability.immutablePrimary` -> `requiresPrimary`, with the new "agent needs one directory designated as its primary root" semantics. No consumers referenced the old name. - New optional `primaryWorkingDirectory` field at both the session level (CreateSessionParams / SessionMetadata -> SessionState + SessionSummary) and the chat level (CreateChatParams / ChatState + ChatSummary). Mirror it through the state<->summary projection layer exactly like `workingDirectories`: createSessionState / createChatState / chatSummaryFromState / mergeSessionWithDefaultChat, plus the state manager's summary projection, field-equality check, SessionSummaryNotifier diff, and markSessionPersisted propagation. The generated SessionChatUpdated partial-summary merge is field-agnostic, so it carries the new field automatically. Purely additive optional fields; no client multiroot behavior is added (consumers still read `workingDirectories[0]` as the single effective root). Also preserve the VS Code-local `CompletionItem.label` field (added in #326807, ahead of the spec) which the verbatim re-sync would otherwise drop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-chat/commands.ts | 10 +++++++ .../state/protocol/channels-chat/state.ts | 15 +++++++++++ .../state/protocol/channels-root/state.ts | 26 +++++++++--------- .../protocol/channels-session/actions.ts | 6 ++--- .../protocol/channels-session/commands.ts | 15 +++++++++-- .../state/protocol/channels-session/state.ts | 18 +++++++++---- .../common/state/protocol/version/registry.ts | 11 ++++---- .../agentHost/common/state/sessionState.ts | 27 +++++++++++-------- .../agentHost/node/agentHostStateManager.ts | 5 +++- 10 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index e6b2329dc4187d..58486624928a96 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -11f1a65e +148c716b diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index d489b9699a4b4d..fe25d5d364d574 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -51,6 +51,16 @@ export interface CreateChatParams extends BaseParams { * {@link AgentCapabilities.multipleWorkingDirectories}. */ workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat + * centers on. When set, it MUST be one of the chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * omitted). A client SHOULD supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary} and the chat + * narrows to a subset that excludes the session's primary; when absent, the + * chat inherits the session's primary. Ignored for forked chats. + */ + primaryWorkingDirectory?: URI; } // ─── disposeChat ───────────────────────────────────────────────────────────── 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 3b0f2887c2b1a4..0948f78ff680a9 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 @@ -63,6 +63,16 @@ export interface ChatState { * update the subset on a running chat. */ workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat + * centers on. When set, it MUST be one of this chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * absent). Present when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; when absent, + * the chat inherits {@link SessionState.primaryWorkingDirectory | the + * session's primary}. + */ + primaryWorkingDirectory?: URI; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -135,6 +145,11 @@ export interface ChatSummary { * See {@link ChatState.workingDirectories} for the full semantics. */ workingDirectories?: URI[]; + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. + */ + primaryWorkingDirectory?: URI; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 6f035435550eff..c29945dfa232ab 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -111,8 +111,8 @@ export interface AgentCapabilities { /** * The session's agent can be granted tool access to more than one working * directory. The directories are treated as equal peers except where the - * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} - * (some backends pin their first directory as a fixed process root). + * agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + * (some backends need one directory designated as a primary root). * * When absent, clients MUST NOT mutate a session's or chat's working-directory * set and MUST NOT set more than one entry in @@ -142,18 +142,20 @@ export interface MultipleChatsCapability { */ export interface MultipleWorkingDirectoriesCapability { /** - * The agent's **first** working directory (index `0` of - * {@link CreateSessionParams.workingDirectories}) is an immutable primary: - * it is fixed for the lifetime of the session — clients MUST NOT remove or - * reorder it. Additional directories after it remain equal peers that can be - * added and removed freely. + * The agent requires exactly one of its working directories to be designated + * the **primary** — a distinguished root the agent centers on (e.g. its + * process root, the default location for relative paths). When `true`, a + * client SHOULD supply {@link CreateSessionParams.primaryWorkingDirectory} + * (and {@link CreateChatParams.primaryWorkingDirectory} for a chat that + * narrows the set); a host MAY reject creation that omits it, or fall back to + * the first entry of `workingDirectories`. The chosen primary is reported on + * {@link SessionState.primaryWorkingDirectory} / + * {@link ChatState.primaryWorkingDirectory}. * - * Advertised by backends whose agent process is rooted at a single directory - * that cannot change once the session has started (e.g. the SDK's primary - * `workingDirectory`). When absent or `false`, all directories are equal - * peers and any of them may be removed. + * When absent or `false`, the agent has no primary — all directories are + * equal peers and clients need not designate one. */ - immutablePrimary?: boolean; + requiresPrimary?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 2deedd751d848e..93800e2eeaf2d5 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -278,9 +278,9 @@ export interface SessionWorkingDirectorySetAction { * Removes `directory` from the set; a no-op when it is not present. There is no * atomic backend "remove one" primitive — a host reconfigures its agent to the * reduced set — so this action is safe to model as idempotent. A host MAY - * decline to apply the removal (e.g. an immutable primary directory, see - * {@link MultipleWorkingDirectoriesCapability.immutablePrimary}); it then leaves - * the set unchanged. + * decline to apply the removal (e.g. the current primary directory of an agent + * that {@link MultipleWorkingDirectoriesCapability.requiresPrimary | requires a + * primary}); it then leaves the set unchanged. * * @category Session Actions * @version 1 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 0c6906b9b3b334..f70b18ab905fb4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -67,8 +67,9 @@ export interface CreateSessionParams extends BaseParams { * The working directories the session's agent is granted tool access to. * A session may span multiple directories; they are equal peers except when * the agent advertises - * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (in which case - * the first entry is a fixed process root). + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + * one of them should be designated the primary via + * {@link primaryWorkingDirectory}. * * A client MUST NOT supply more than one entry unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that @@ -81,6 +82,16 @@ export interface CreateSessionParams extends BaseParams { * from the source session identified by `fork`. */ workingDirectories?: URI[]; + /** + * The session's primary working directory — the distinguished root the agent + * centers on. When set, it MUST be one of {@link workingDirectories}. A client + * SHOULD supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first entry of + * `workingDirectories`. Ignored for forked sessions (a fork inherits the + * source session's primary). + */ + primaryWorkingDirectory?: URI; /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index f31cbd9a956309..bc6bb7d4250d14 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -77,13 +77,21 @@ export interface SessionMetadata { * maintained by the `session/workingDirectorySet` / * `session/workingDirectoryRemoved` actions. Directories are equal peers * except when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (the first - * entry is then a fixed process root). Individual chats MAY restrict to a - * subset via {@link ChatSummary.workingDirectories | their own - * `workingDirectories`}; a chat that sets none operates against this full - * set. + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + * one of them is designated the primary (see {@link primaryWorkingDirectory}). + * Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a + * chat that sets none operates against this full set. */ workingDirectories?: URI[]; + /** + * The session's primary working directory — the distinguished root the agent + * centers on. When set, it MUST be one of {@link workingDirectories}. Present + * when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; absent when + * the agent has no primary (all directories equal peers). + */ + primaryWorkingDirectory?: URI; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render 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 3d6f29d3835a5c..22e3b1fa702849 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.6.0'; +export const PROTOCOL_VERSION = '0.7.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.6.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.7.0', '0.6.0', '0.5.2', '0.5.1', @@ -90,8 +91,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionServerToolsChanged]: '0.1.0', [ActionType.SessionActiveClientSet]: '0.5.0', [ActionType.SessionActiveClientRemoved]: '0.5.0', - [ActionType.SessionWorkingDirectorySet]: '0.6.0', - [ActionType.SessionWorkingDirectoryRemoved]: '0.6.0', + [ActionType.SessionWorkingDirectorySet]: '0.7.0', + [ActionType.SessionWorkingDirectoryRemoved]: '0.7.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -123,8 +124,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', [ActionType.ChatActivityChanged]: '0.5.0', - [ActionType.ChatWorkingDirectorySet]: '0.6.0', - [ActionType.ChatWorkingDirectoryRemoved]: '0.6.0', + [ActionType.ChatWorkingDirectorySet]: '0.7.0', + [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index bd911d676d5dc5..7dfc281851233f 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -618,6 +618,7 @@ export function createSessionState(summary: SessionSummary): SessionState { if (summary.activity !== undefined) { state.activity = summary.activity; } if (summary.project !== undefined) { state.project = summary.project; } if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; } + if (summary.primaryWorkingDirectory !== undefined) { state.primaryWorkingDirectory = summary.primaryWorkingDirectory; } if (summary.annotations !== undefined) { state.annotations = summary.annotations; } if (summary._meta !== undefined) { state._meta = summary._meta; } return state; @@ -638,6 +639,7 @@ export function createChatState(summary: ChatSummary): ChatState { origin: summary.origin, interactivity: summary.interactivity, workingDirectories: summary.workingDirectories, + primaryWorkingDirectory: summary.primaryWorkingDirectory, turns: [], activeTurn: undefined, }; @@ -659,13 +661,13 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - // `workingDirectories` is deliberately NOT copied: per the protocol it is a - // per-chat SUBSET override and, when absent, the chat inherits the session's - // full set of working directories (see `mergeSessionWithDefaultChat`). - // Seeding it here would denormalize the session default onto every chat as a - // fake override, which then goes stale when the session's working - // directories are resolved later (e.g. a worktree resolved at - // materialization). + // `workingDirectories` (and `primaryWorkingDirectory`) is deliberately NOT + // copied: per the protocol it is a per-chat SUBSET override and, when absent, + // the chat inherits the session's full set of working directories and its + // primary (see `mergeSessionWithDefaultChat`). Seeding it here would + // denormalize the session default onto every chat as a fake override, which + // then goes stale when the session's working directories are resolved later + // (e.g. a worktree resolved at materialization). return summary; } @@ -685,6 +687,7 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } + if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } return summary; } @@ -883,13 +886,14 @@ export function isAhpChatChannel(uri: string): boolean { * * The protocol moved turns and pending state off the session and onto a * per-chat channel, and lets a chat override the session's working directories - * with a subset (e.g. {@link ChatState.workingDirectories}). This composite + * with a subset (e.g. {@link ChatState.workingDirectories}) and pick its own + * {@link ChatState.primaryWorkingDirectory | primary}. This composite * recombines the session with one of its chats — default or peer — so consumers * read the chat's effective context and conversation through one object without * walking back to the session to re-derive shared state. The inherited - * {@link SessionState.workingDirectories} carries the chat's *effective* - * working directories (its own subset override when present, else the session's - * full set). + * {@link SessionState.workingDirectories} / {@link SessionState.primaryWorkingDirectory} + * carry the chat's *effective* working directories and primary (its own + * override when present, else the session's). */ export interface ISessionWithDefaultChat extends SessionState { /** Completed turns of this chat. */ @@ -916,6 +920,7 @@ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatSta return { ...session, workingDirectories: chat?.workingDirectories ?? session.workingDirectories, + primaryWorkingDirectory: chat?.primaryWorkingDirectory ?? session.primaryWorkingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 550c4ea1d03d96..c2f15a11e7c5c7 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -138,6 +138,7 @@ class SessionSummaryNotifier extends Disposable { if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; } + if (current.primaryWorkingDirectory !== lastNotified.primaryWorkingDirectory) { changes.primaryWorkingDirectory = current.primaryWorkingDirectory; } if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } this._lastNotified.set(session, current); @@ -345,6 +346,7 @@ export class AgentHostStateManager extends Disposable { if (state.activity !== undefined) { summary.activity = state.activity; } if (state.project !== undefined) { summary.project = state.project; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } + if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } if (state._meta !== undefined) { summary._meta = state._meta; } @@ -362,6 +364,7 @@ export class AgentHostStateManager extends Disposable { && a.activity === b.activity && a.project === b.project && a.workingDirectories === b.workingDirectories + && a.primaryWorkingDirectory === b.primaryWorkingDirectory && a.annotations === b.annotations && a._meta === b._meta; } @@ -608,7 +611,7 @@ export class AgentHostStateManager extends Disposable { // directory / project. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; + entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories, primaryWorkingDirectory: summary.primaryWorkingDirectory }; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); From 2a1afa4017fb6a74bdd9c276e4996cebd51a9652 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 22:07:56 -0700 Subject: [PATCH 6/7] Reject unsupported working-directory actions via reconciliation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback on the multiroot working-directory action gate: - Instead of silently dropping the four not-yet-supported session|chat/workingDirectorySet|Removed client actions (which never echoed the origin, leaving the client's optimistic write-ahead action pending until reconnect), emit a rejection envelope through the normal reconciliation path. Added AgentHostStateManager.rejectClientAction, which emits an ActionEnvelope carrying the original ActionOrigin and a rejectionReason without running the reducer (no synchronized state change), so the originating client rolls back its optimistic action. - Guard the write-ahead client reconcile (SessionStateSubscription / ChatStateSubscription) so a rejected envelope is never applied to confirmed state in any branch — this also prevents a broadcast rejection from leaking the rejected action into a non-origin client's state. - Add a table-driven test covering all four action types asserting no dispatch and exactly one rejection envelope preserving the original origin. - Simplify the create-session working-directory read to `URI.parse(params.workingDirectories[0])` now the branch proves index 0 exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/state/agentSubscription.ts | 28 +++++++------ .../agentHost/node/agentHostStateManager.ts | 20 ++++++++++ .../agentHost/node/protocolServerHandler.ts | 16 ++++++-- .../test/node/protocolServerHandler.test.ts | 40 +++++++++++++++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 95e40f34b75f4b..cdb52bc7bb4bbd 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -313,19 +313,21 @@ export class SessionStateSubscription extends BaseAgentSubscription p.clientSeq === envelope.origin!.clientSeq); if (idx !== -1) { - if (envelope.rejectionReason) { - this._pendingActions.splice(idx, 1); - } else { + if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); - this._pendingActions.splice(idx, 1); } - } else { + this._pendingActions.splice(idx, 1); + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } - } else { + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } this._recomputeOptimistic(); @@ -457,19 +459,21 @@ export class ChatStateSubscription extends BaseAgentSubscription { } protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void { + // A rejected envelope must never mutate confirmed state — it only rolls + // back the originating client's matching optimistic action. Guarding all + // apply branches also prevents a broadcast rejection from leaking the + // rejected action into a non-origin client's state. if (isOwnAction && envelope.origin) { const idx = this._pendingActions.findIndex(p => p.clientSeq === envelope.origin!.clientSeq); if (idx !== -1) { - if (envelope.rejectionReason) { - this._pendingActions.splice(idx, 1); - } else { + if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); - this._pendingActions.splice(idx, 1); } - } else { + this._pendingActions.splice(idx, 1); + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } - } else { + } else if (!envelope.rejectionReason) { this._promotePendingTurnStartIfTerminal(envelope.action); this._confirmedApply(envelope.action); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index c2f15a11e7c5c7..baa55ec7b40ef6 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -1134,6 +1134,26 @@ export class AgentHostStateManager extends Disposable { return this._applyAndEmit(channel, action, origin); } + /** + * Reject a client-originated action without applying it to state. Emits an + * {@link ActionEnvelope} that carries the original {@link ActionOrigin} and a + * {@link ActionEnvelope.rejectionReason | rejectionReason} so the originating + * client can reconcile (roll back) its optimistic write-ahead action through + * the normal path instead of leaving it pending until reconnect. The reducer + * is deliberately NOT run, so no synchronized state changes. + */ + rejectClientAction(channel: URI, action: StateAction, origin: ActionOrigin, reason: string): void { + const envelope: ActionEnvelope = { + channel, + action, + serverSeq: ++this._serverSeq, + origin, + rejectionReason: reason, + }; + this._logService.trace(`[AgentHostStateManager] Emitting rejection envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}, origin=${origin.clientId}:${origin.clientSeq}, reason=${reason}`); + this._onDidEmitEnvelope.fire(envelope); + } + // ---- Internal ----------------------------------------------------------- private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index b4d91bc77e0151..e3d13e6c738bcf 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -460,10 +460,18 @@ export class ProtocolServerHandler extends Disposable { // Multiroot working-directory mutations are declared in the // protocol but not yet supported: they would mutate the // synchronized access set without reconfiguring the agent's - // actual directory access. Reject them until capability-backed - // multiroot support lands. + // actual directory access. Reject them through the normal + // reconciliation path (preserving the client's origin) so the + // client rolls back its optimistic action instead of leaving + // it pending, until capability-backed multiroot support lands. if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { - this._logService.warn(`[ProtocolServer] ignoring unsupported client action: ${action.type}`); + this._logService.warn(`[ProtocolServer] rejecting unsupported client action: ${action.type}`); + this._stateManager.rejectClientAction( + channel, + action, + { clientId: client.clientId, clientSeq: msg.params.clientSeq }, + `Unsupported action: ${action.type}`, + ); } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq); } @@ -1163,7 +1171,7 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, - workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories?.[0]) : undefined, + workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories[0]) : undefined, session: URI.parse(params.channel), fork, config: params.config, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 1ddf1c89be5bf6..07b236cc8f5fed 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -488,6 +488,46 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(envelope.origin.clientSeq, 1); }); + test('unsupported working-directory actions are rejected, not dispatched', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + + const cases: readonly { readonly type: ActionType; readonly channel: string }[] = [ + { type: ActionType.SessionWorkingDirectorySet, channel: sessionUri }, + { type: ActionType.SessionWorkingDirectoryRemoved, channel: sessionUri }, + { type: ActionType.ChatWorkingDirectorySet, channel: defaultChatUri }, + { type: ActionType.ChatWorkingDirectoryRemoved, channel: defaultChatUri }, + ]; + + for (const [index, { type, channel }] of cases.entries()) { + const clientId = `wd-client-${index}`; + const clientSeq = 100 + index; + const transport = connectClient(clientId, [sessionUri, defaultChatUri]); + transport.sent.length = 0; + agentService.handledActions.length = 0; + + transport.simulateMessage(notification('dispatchAction', { + channel, + clientSeq, + action: { type, directory: 'file:///tmp/extra-root' }, + })); + + // No dispatch: the gate intercepts before reaching the agent service, + // so the reducer never runs and synchronized state is untouched. + assert.deepStrictEqual(agentService.handledActions, [], `${type} must not be dispatched`); + + // Exactly one rejection envelope, preserving the original origin so the + // client can reconcile its optimistic action. + const actionMsgs = findNotifications(transport.sent, 'action'); + assert.strictEqual(actionMsgs.length, 1, `${type} should emit exactly one envelope`); + const envelope = actionMsgs[0].params as unknown as { action: { type: string }; origin: { clientId: string; clientSeq: number }; rejectionReason?: string }; + assert.strictEqual(envelope.action.type, type); + assert.ok(envelope.rejectionReason, `${type} envelope should carry a rejectionReason`); + assert.strictEqual(envelope.origin.clientId, clientId); + assert.strictEqual(envelope.origin.clientSeq, clientSeq); + } + }); + test('actions are scoped to subscribed sessions', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); From 6cb4041f6ccbaaa784829886e2ae4eb752908960 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 21 Jul 2026 23:18:53 -0700 Subject: [PATCH 7/7] Refine primaryWorkingDirectory to per-chat read-only (AHP ea279d99) Re-sync the protocol copy to spec ea279d99 (0.7.0) and adopt the refined primaryWorkingDirectory design: - Session has NO primary: `primaryWorkingDirectory` is removed from SessionState / SessionSummary. The session is just the equal-peer `workingDirectories` set. Dropped all session-level primary handling (createSessionState, the SessionSummaryNotifier diff, _toSummary, _summaryFieldsEqual, and markSessionPersisted propagation). - Primary is per-chat, read-only, fixed at chat creation: kept the ChatState <-> ChatSummary mirroring (createChatState / chatSummaryFromState) and carry the chat's own primary through the session+default-chat composite (ISessionWithDefaultChat gains its own primaryWorkingDirectory; the merge no longer falls back to a session primary). It is not sent via `session/chatUpdated` (the state manager only ever puts status/activity/title in those changes), so it never mutates post-creation. - Inputs `CreateSessionParams.primaryWorkingDirectory` (seeds the default chat's primary) and `CreateChatParams.primaryWorkingDirectory` are synced; capability `requiresPrimary` unchanged. Also re-preserve the VS Code-local `CompletionItem.label` field (#326807, ahead of the spec) that the verbatim re-sync drops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-chat/commands.ts | 12 +++-- .../state/protocol/channels-chat/state.ts | 15 ++++-- .../state/protocol/channels-root/state.ts | 18 ++++---- .../protocol/channels-session/actions.ts | 6 +-- .../protocol/channels-session/commands.ts | 14 +++--- .../state/protocol/channels-session/state.ts | 21 +++------ .../agentHost/common/state/sessionState.ts | 46 ++++++++++--------- .../agentHost/node/agentHostStateManager.ts | 5 +- 9 files changed, 70 insertions(+), 69 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 58486624928a96..4076cdf14429b4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -148c716b +ea279d99 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index fe25d5d364d574..296f0efe8f05a2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -52,13 +52,15 @@ export interface CreateChatParams extends BaseParams { */ workingDirectories?: URI[]; /** - * The chat's primary working directory — the distinguished root this chat - * centers on. When set, it MUST be one of the chat's effective working + * The chat's primary working directory — the distinguished root this chat is + * centered on. When set, it MUST be one of the chat's effective working * directories ({@link workingDirectories}, or the session's set when that is * omitted). A client SHOULD supply this when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary} and the chat - * narrows to a subset that excludes the session's primary; when absent, the - * chat inherits the session's primary. Ignored for forked chats. + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first of the chat's + * directories. Fixed at creation and reported (read-only) on + * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + * inherits the source chat's primary). */ primaryWorkingDirectory?: URI; } 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 0948f78ff680a9..d39a4a88c3d982 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 @@ -64,13 +64,18 @@ export interface ChatState { */ workingDirectories?: URI[]; /** - * The chat's primary working directory — the distinguished root this chat - * centers on. When set, it MUST be one of this chat's effective working + * The chat's primary working directory — the distinguished root this chat is + * centered on (e.g. the agent's process root for this chat, the default + * location for relative paths). MUST be one of this chat's effective working * directories ({@link workingDirectories}, or the session's set when that is * absent). Present when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; when absent, - * the chat inherits {@link SessionState.primaryWorkingDirectory | the - * session's primary}. + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + * + * **Read-only and fixed at creation.** It is set from + * {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + * default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + * not change over the chat's lifetime — there is no action to mutate it, and + * it does not participate in `session/chatUpdated`. */ primaryWorkingDirectory?: URI; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index c29945dfa232ab..f9aafad9a759b7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -142,15 +142,15 @@ export interface MultipleChatsCapability { */ export interface MultipleWorkingDirectoriesCapability { /** - * The agent requires exactly one of its working directories to be designated - * the **primary** — a distinguished root the agent centers on (e.g. its - * process root, the default location for relative paths). When `true`, a - * client SHOULD supply {@link CreateSessionParams.primaryWorkingDirectory} - * (and {@link CreateChatParams.primaryWorkingDirectory} for a chat that - * narrows the set); a host MAY reject creation that omits it, or fall back to - * the first entry of `workingDirectories`. The chosen primary is reported on - * {@link SessionState.primaryWorkingDirectory} / - * {@link ChatState.primaryWorkingDirectory}. + * The agent requires each chat to designate one of its working directories as + * the **primary** — a distinguished root the chat is centered on (e.g. the + * agent's process root for that chat, the default location for relative + * paths). Primary is a **per-chat** notion, fixed at chat creation. When + * `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + * (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + * session's default chat); a host MAY reject creation that omits it, or fall + * back to the first entry of the chat's working directories. The chosen + * primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. * * When absent or `false`, the agent has no primary — all directories are * equal peers and clients need not designate one. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 93800e2eeaf2d5..aa4a813f04cc48 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -278,9 +278,9 @@ export interface SessionWorkingDirectorySetAction { * Removes `directory` from the set; a no-op when it is not present. There is no * atomic backend "remove one" primitive — a host reconfigures its agent to the * reduced set — so this action is safe to model as idempotent. A host MAY - * decline to apply the removal (e.g. the current primary directory of an agent - * that {@link MultipleWorkingDirectoriesCapability.requiresPrimary | requires a - * primary}); it then leaves the set unchanged. + * decline to apply the removal (e.g. a directory still designated as some + * chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves + * the set unchanged. * * @category Session Actions * @version 1 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index f70b18ab905fb4..ff7d4a69595f02 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -83,13 +83,15 @@ export interface CreateSessionParams extends BaseParams { */ workingDirectories?: URI[]; /** - * The session's primary working directory — the distinguished root the agent - * centers on. When set, it MUST be one of {@link workingDirectories}. A client - * SHOULD supply this when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY - * reject creation that omits it, or fall back to the first entry of + * The primary working directory for the session's **default chat** — the + * distinguished root that chat is centered on (see + * {@link ChatState.primaryWorkingDirectory}). A session has no primary of its + * own; this seeds the default chat's primary. When set, it MUST be one of + * {@link workingDirectories}. A client SHOULD supply this when the agent + * advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a + * host MAY reject creation that omits it, or fall back to the first entry of * `workingDirectories`. Ignored for forked sessions (a fork inherits the - * source session's primary). + * source session's chats and their primaries). */ primaryWorkingDirectory?: URI; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index bc6bb7d4250d14..56783cadb3af4b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -75,23 +75,14 @@ export interface SessionMetadata { /** * The working directories the session's agent has tool access to, as * maintained by the `session/workingDirectorySet` / - * `session/workingDirectoryRemoved` actions. Directories are equal peers - * except when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case - * one of them is designated the primary (see {@link primaryWorkingDirectory}). - * Individual chats MAY restrict to a subset via - * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a - * chat that sets none operates against this full set. + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. */ workingDirectories?: URI[]; - /** - * The session's primary working directory — the distinguished root the agent - * centers on. When set, it MUST be one of {@link workingDirectories}. Present - * when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; absent when - * the agent has no primary (all directories equal peers). - */ - primaryWorkingDirectory?: URI; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 7dfc281851233f..a8033ad07f04f1 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -618,7 +618,6 @@ export function createSessionState(summary: SessionSummary): SessionState { if (summary.activity !== undefined) { state.activity = summary.activity; } if (summary.project !== undefined) { state.project = summary.project; } if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; } - if (summary.primaryWorkingDirectory !== undefined) { state.primaryWorkingDirectory = summary.primaryWorkingDirectory; } if (summary.annotations !== undefined) { state.annotations = summary.annotations; } if (summary._meta !== undefined) { state._meta = summary._meta; } return state; @@ -661,13 +660,14 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - // `workingDirectories` (and `primaryWorkingDirectory`) is deliberately NOT - // copied: per the protocol it is a per-chat SUBSET override and, when absent, - // the chat inherits the session's full set of working directories and its - // primary (see `mergeSessionWithDefaultChat`). Seeding it here would - // denormalize the session default onto every chat as a fake override, which - // then goes stale when the session's working directories are resolved later - // (e.g. a worktree resolved at materialization). + // `workingDirectories` is deliberately NOT copied: per the protocol it is a + // per-chat SUBSET override and, when absent, the chat inherits the session's + // full set of working directories (see `mergeSessionWithDefaultChat`). + // Seeding it here would denormalize the session default onto every chat as a + // fake override, which then goes stale when the session's working + // directories are resolved later (e.g. a worktree resolved at + // materialization). `primaryWorkingDirectory` is per-chat and fixed at chat + // creation (the session has no primary), so it is likewise not seeded here. return summary; } @@ -886,16 +886,19 @@ export function isAhpChatChannel(uri: string): boolean { * * The protocol moved turns and pending state off the session and onto a * per-chat channel, and lets a chat override the session's working directories - * with a subset (e.g. {@link ChatState.workingDirectories}) and pick its own - * {@link ChatState.primaryWorkingDirectory | primary}. This composite - * recombines the session with one of its chats — default or peer — so consumers - * read the chat's effective context and conversation through one object without - * walking back to the session to re-derive shared state. The inherited - * {@link SessionState.workingDirectories} / {@link SessionState.primaryWorkingDirectory} - * carry the chat's *effective* working directories and primary (its own - * override when present, else the session's). + * with a subset (e.g. {@link ChatState.workingDirectories}) and carry its own + * read-only {@link ChatState.primaryWorkingDirectory | primary} (fixed at chat + * creation — the session has no primary). This composite recombines the session + * with one of its chats — default or peer — so consumers read the chat's + * effective context and conversation through one object without walking back to + * the session to re-derive shared state. The {@link ISessionWithDefaultChat.workingDirectories} + * carry the chat's *effective* working directories (its own subset override when + * present, else the session's full set); {@link ISessionWithDefaultChat.primaryWorkingDirectory} + * is the chat's own primary. */ export interface ISessionWithDefaultChat extends SessionState { + /** The chat's read-only primary working directory (fixed at chat creation). */ + primaryWorkingDirectory?: ProtocolURI; /** Completed turns of this chat. */ turns: Turn[]; /** Currently in-progress turn of this chat. */ @@ -911,16 +914,17 @@ export interface ISessionWithDefaultChat extends SessionState { /** * Projects a {@link SessionState} and one of its {@link ChatState | chats} * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective - * session context}. Per-chat overrides (currently the working directories) are - * layered over the session defaults, and the conversation fields are taken from - * the chat. When the chat state is absent (e.g. not yet hydrated) the - * conversation fields default to empty and the session defaults apply. + * session context}. Per-chat overrides (the working-directories subset and the + * chat's own primary) are layered over the session defaults, and the + * conversation fields are taken from the chat. When the chat state is absent + * (e.g. not yet hydrated) the conversation fields default to empty and the + * session defaults apply. */ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, workingDirectories: chat?.workingDirectories ?? session.workingDirectories, - primaryWorkingDirectory: chat?.primaryWorkingDirectory ?? session.primaryWorkingDirectory, + primaryWorkingDirectory: chat?.primaryWorkingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index baa55ec7b40ef6..8cfc183e098a8a 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -138,7 +138,6 @@ class SessionSummaryNotifier extends Disposable { if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; } - if (current.primaryWorkingDirectory !== lastNotified.primaryWorkingDirectory) { changes.primaryWorkingDirectory = current.primaryWorkingDirectory; } if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } this._lastNotified.set(session, current); @@ -346,7 +345,6 @@ export class AgentHostStateManager extends Disposable { if (state.activity !== undefined) { summary.activity = state.activity; } if (state.project !== undefined) { summary.project = state.project; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } - if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } if (state._meta !== undefined) { summary._meta = state._meta; } @@ -364,7 +362,6 @@ export class AgentHostStateManager extends Disposable { && a.activity === b.activity && a.project === b.project && a.workingDirectories === b.workingDirectories - && a.primaryWorkingDirectory === b.primaryWorkingDirectory && a.annotations === b.annotations && a._meta === b._meta; } @@ -611,7 +608,7 @@ export class AgentHostStateManager extends Disposable { // directory / project. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories, primaryWorkingDirectory: summary.primaryWorkingDirectory }; + entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry);