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/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/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 20baede03d4a57..4076cdf14429b4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4e554d0e +ea279d99 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..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 @@ -40,6 +40,29 @@ 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[]; + /** + * 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}; 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; } // ─── 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..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 @@ -50,15 +50,34 @@ 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. + */ + workingDirectories?: URI[]; + /** + * 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}. + * + * **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`. */ - workingDirectory?: URI; + primaryWorkingDirectory?: URI; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -127,13 +146,15 @@ 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. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. */ - workingDirectory?: URI; + 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 f0d28f53e009f2..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 @@ -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.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 + * {@link CreateSessionParams.workingDirectories}. + */ + multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } /** @@ -124,6 +135,29 @@ export interface MultipleChatsCapability { fork?: boolean; } +/** + * Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. + * + * @category Root State + */ +export interface MultipleWorkingDirectoriesCapability { + /** + * 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. + */ + requiresPrimary?: 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..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 @@ -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. a directory still designated as some + * chat's {@link ChatState.primaryWorkingDirectory | primary}); 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 f70d6af3a30c3c..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 @@ -63,8 +63,37 @@ 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.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 + * 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[]; + /** + * 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 chats and their primaries). + */ + 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/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..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 @@ -73,12 +73,16 @@ 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** — + * 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. */ - workingDirectory?: URI; + workingDirectories?: URI[]; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -181,7 +185,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 +410,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..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,6 +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.7.0', + [ActionType.SessionWorkingDirectoryRemoved]: '0.7.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -121,6 +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.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 0302739d5380e6..a8033ad07f04f1 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,8 @@ export function createChatState(summary: ChatSummary): ChatState { modifiedAt: summary.modifiedAt, origin: summary.origin, interactivity: summary.interactivity, - workingDirectory: summary.workingDirectory, + workingDirectories: summary.workingDirectories, + primaryWorkingDirectory: summary.primaryWorkingDirectory, turns: [], activeTurn: undefined, }; @@ -659,12 +660,14 @@ 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). `primaryWorkingDirectory` is per-chat and fixed at chat + // creation (the session has no primary), so it is likewise not seeded here. return summary; } @@ -683,7 +686,8 @@ 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; } + if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } return summary; } @@ -877,19 +881,24 @@ 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 + * per-chat channel, and lets a chat override the session's working directories + * 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 inherited - * {@link SessionState.workingDirectory} carries the chat's *effective* working - * directory (its own override when present, else the session default). + * 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. */ @@ -905,15 +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 directory) 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, - workingDirectory: chat?.workingDirectory ?? session.workingDirectory, + workingDirectories: chat?.workingDirectories ?? session.workingDirectories, + primaryWorkingDirectory: chat?.primaryWorkingDirectory, 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 22f79de0dfb6e3..ca486da98fdd1b 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..8cfc183e098a8a 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 === b.workingDirectories && 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); @@ -1131,6 +1131,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/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 26d488aee5b7bf..705b523267ec6c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -488,7 +488,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. @@ -847,6 +847,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, @@ -856,8 +857,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, @@ -890,6 +891,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), @@ -897,7 +899,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 @@ -1400,6 +1402,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, @@ -1408,7 +1411,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. @@ -1450,7 +1453,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; @@ -1761,8 +1764,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); } @@ -2408,7 +2411,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, }; @@ -3482,7 +3485,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 abacd8d88dda6e..e3d13e6c738bcf 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,22 @@ 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 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] 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); } } @@ -1141,7 +1171,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 40adca4c2322a3..e52e393d114a7c 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 58fc27a88c396d..ae3ad951ad0326 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 93cd35ae2cb69c..fb64053345e035 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 10f46904a0c4ec..bb0a2bf20e2609 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 7ce02247a49d1b..5d31ed21c6eaa5 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/e2e/suites/workspaceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts index 8f316ad58a8054..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,12 +40,12 @@ 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); 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`); }); @@ -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); @@ -124,10 +124,11 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { ); 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; + const addedWorkingDirectory = addedSummary.workingDirectories?.[0]; + assert.ok(addedWorkingDirectory, 'sessionAdded notification should have a workingDirectory'); + assert.ok(addedWorkingDirectory.includes('.worktrees'), + `workingDirectory should be under the .worktrees folder, got: ${addedWorkingDirectory}`); + const resolvedWorkingDirectoryPath = URI.parse(addedWorkingDirectory).fsPath; await context.client.waitForNotification( n => isActionNotification(n, 'chat/turnComplete') || isActionNotification(n, 'chat/error'), 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/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index eb7f1ad08cd787..07b236cc8f5fed 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; } @@ -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, }); 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' 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 e72261072cf551..e06981547f6fe6 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 91a5ae46e4a37d..8e750e2323f440 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 c3409b810fc494..b90c6f622ae3bd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4619,7 +4619,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 9b94311a59e0c3..fbf5f0dc9fa65a 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 @@ -225,6 +225,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', @@ -232,7 +233,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()), @@ -503,7 +504,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); @@ -529,7 +530,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); @@ -2546,7 +2547,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); @@ -2563,7 +2564,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);