From 9916eda551c82d658c4005c388d9c480315231ec Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Sat, 25 Jul 2026 22:15:10 +1000 Subject: [PATCH 1/5] Update protocol version and refine working directory handling in chat and session states --- .../state/protocol/channels-chat/commands.ts | 12 ------- .../state/protocol/channels-chat/state.ts | 20 ----------- .../state/protocol/channels-root/state.ts | 26 +++++++-------- .../protocol/channels-session/actions.ts | 4 +-- .../protocol/channels-session/commands.ts | 25 ++------------ .../state/protocol/channels-session/state.ts | 13 ++++---- .../agentHost/common/state/sessionState.ts | 33 +++++++------------ .../platform/agentHost/node/claude/CONTEXT.md | 3 +- 8 files changed, 36 insertions(+), 100 deletions(-) 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 04692343647897..aafbf7f78a37f8 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 @@ -119,18 +119,6 @@ export interface CreateChatParams extends BaseParams { * {@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 forks (a chat whose - * `source.kind` is `"fork"` inherits the source chat's primary). - */ - primaryWorkingDirectory?: URI; } // ─── disposeChat ───────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 0b3be55a5be8c5..8120846639fb28 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -63,21 +63,6 @@ export interface ChatState { * 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`. - */ - primaryWorkingDirectory?: URI; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -150,11 +135,6 @@ export interface ChatSummary { * See {@link ChatState.workingDirectories} for the full semantics. */ workingDirectories?: URI[]; - /** - * The chat's primary working directory. - * See {@link ChatState.primaryWorkingDirectory} for the full semantics. - */ - primaryWorkingDirectory?: URI; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index c2ea12ca4f5b52..1dcd635f796b9a 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 @@ -112,8 +112,8 @@ export interface AgentCapabilities { /** * The session's agent can be granted tool access to more than one working * directory. The directories are treated as equal peers except where the - * agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} - * (some backends need one directory designated as a primary root). + * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} + * (some backends pin their first directory as a fixed process root). * * When absent, clients MUST NOT mutate a session's or chat's working-directory * set and MUST NOT set more than one entry in @@ -157,20 +157,18 @@ export interface MultipleChatsCapability { */ 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}. + * The agent's **first** working directory (index `0` of + * {@link CreateSessionParams.workingDirectories}) is an immutable primary: + * it is fixed for the lifetime of the session — clients MUST NOT remove or + * reorder it. Additional directories after it remain equal peers that can be + * added and removed freely. * - * When absent or `false`, the agent has no primary — all directories are - * equal peers and clients need not designate one. + * Advertised by backends whose agent process is rooted at a single directory + * that cannot change once the session has started (e.g. the SDK's primary + * `workingDirectory`). When absent or `false`, all directories are equal + * peers and any of them may be removed. */ - requiresPrimary?: boolean; + immutablePrimary?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index aa4a813f04cc48..2deedd751d848e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -278,8 +278,8 @@ export interface SessionWorkingDirectorySetAction { * Removes `directory` from the set; a no-op when it is not present. There is no * atomic backend "remove one" primitive — a host reconfigures its agent to the * reduced set — so this action is safe to model as idempotent. A host MAY - * decline to apply the removal (e.g. a directory still designated as some - * chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves + * decline to apply the removal (e.g. an immutable primary directory, see + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary}); it then leaves * the set unchanged. * * @category Session 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 156ff423986987..bb3acc187c3c39 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -67,9 +67,8 @@ export interface CreateSessionParams extends BaseParams { * The working directories the session's agent is granted tool access to. * A session may span multiple directories; they are equal peers except when * the agent advertises - * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case - * one of them should be designated the primary via - * {@link primaryWorkingDirectory}. + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (in which case + * the first entry is a fixed process root). * * A client MUST NOT supply more than one entry unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that @@ -82,26 +81,6 @@ export interface CreateSessionParams extends BaseParams { * from the source session identified by `fork`. */ workingDirectories?: URI[]; - /** - * The primary working directory for the session's **default chat**. - * - * A session has no primary of its own — primary is a per-chat notion (see - * {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly - * creates the session's default chat, and there is no separate `createChat` - * call to carry that chat's create-time fields. This field is therefore the - * only place a client can designate the **default chat's** primary at birth; - * it is copied into that chat's read-only `primaryWorkingDirectory`. For any - * non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} - * instead. - * - * 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/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 56783cadb3af4b..f31cbd9a956309 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -75,12 +75,13 @@ export interface SessionMetadata { /** * The working directories the session's agent has tool access to, as * maintained by the `session/workingDirectorySet` / - * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — - * 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. + * `session/workingDirectoryRemoved` actions. Directories are equal peers + * except when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (the first + * entry is then a fixed process root). Individual chats MAY restrict to a + * subset via {@link ChatSummary.workingDirectories | their own + * `workingDirectories`}; a chat that sets none operates against this full + * set. */ workingDirectories?: URI[]; /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 98068c9d3a0db7..4e9aa29382b656 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -642,7 +642,6 @@ export function createChatState(summary: ChatSummary): ChatState { origin: summary.origin, interactivity: summary.interactivity, workingDirectories: summary.workingDirectories, - primaryWorkingDirectory: summary.primaryWorkingDirectory, turns: [], activeTurn: undefined, }; @@ -670,8 +669,7 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto // 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. + // materialization). return summary; } @@ -744,7 +742,6 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } - if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } return summary; } @@ -943,19 +940,15 @@ export function isAhpChatChannel(uri: string): boolean { * * The protocol moved turns and pending state off the session and onto a * per-chat channel, and lets a chat override the session's working directories - * with a subset (e.g. {@link ChatState.workingDirectories}) and carry its own - * read-only {@link ChatState.primaryWorkingDirectory | primary} (fixed at chat - * creation — the session has no primary). This composite recombines the session - * with one of its chats — default or peer — so consumers read the chat's - * effective context and conversation through one object without walking back to - * the session to re-derive shared state. The {@link ISessionWithDefaultChat.workingDirectories} - * carry the chat's *effective* working directories (its own subset override when - * present, else the session's full set); {@link ISessionWithDefaultChat.primaryWorkingDirectory} - * is the chat's own primary. + * with a subset (e.g. {@link ChatState.workingDirectories}). This composite + * recombines the session with one of its chats — default or peer — so consumers + * read the chat's effective context and conversation through one object without + * walking back to the session to re-derive shared state. The + * {@link ISessionWithDefaultChat.workingDirectories} carry the chat's *effective* + * working directories (its own subset override when present, else the session's + * full set). */ 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. */ @@ -971,17 +964,15 @@ 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 (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. + * session context}. Per-chat overrides (the working-directories subset) are + * layered over the session defaults, and the conversation fields are taken from + * the chat. When the chat state is absent (e.g. not yet hydrated) the + * conversation fields default to empty and the session defaults apply. */ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, workingDirectories: chat?.workingDirectories ?? session.workingDirectories, - primaryWorkingDirectory: chat?.primaryWorkingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index 9303c990c8f708..f550571b5f2527 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -2081,7 +2081,7 @@ available on each `CCAModel` and should flow through verbatim. |---|---| | Returns | `IAgentSessionMetadata[]` ([agentService.ts:100-124](../../common/agentService.ts#L100-L124)) | | Required fields | `session: URI`, `startTime: number`, `modifiedTime: number` | -| Optional fields | `project`, `summary`, `status`, `activity`, `model`, `workingDirectory`, `customizationDirectory`, `isRead`, `isArchived`, `diffs`, `_meta` | +| Optional fields | `project`, `summary`, `status`, `activity`, `model`, `workingDirectory`, `isRead`, `isArchived`, `diffs`, `_meta` | | Claude SDK source | **Top-level** `listSessions(options?): Promise` ([sdk.d.ts:729](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L729)) — *not* a `Query` method | | `SDKSessionInfo` shape | `{ sessionId, summary, lastModified, customTitle?, firstPrompt?, gitBranch?, cwd?, tag?, createdAt }` ([sdk.d.ts:2782-2825](../../../../../../extensions/copilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L2782-L2825)) | @@ -2137,7 +2137,6 @@ the two SDKs disagree on which fields they carry: | `workingDirectory` | sidecar | SDK (`cwd`) — sidecar redundant | | `model` | sidecar | sidecar (SDK doesn't carry it) | | `project` | resolved from `cwd` | resolved from `cwd` | -| `customizationDirectory` | sidecar | sidecar | | `_meta.git` | not populated by `listSessions` | not populated by `listSessions` | | `isArchived` | host-side archive store, not from SDK | host-side archive store, not from SDK | | `status` | not populated by `listSessions` | not populated by `listSessions` | From de26e0a93db757cabfcd89fd41b9d4da3073198a Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Mon, 27 Jul 2026 16:56:56 +1000 Subject: [PATCH 2/5] Refactor agent session handling to support multiple working directories - Updated interfaces and implementations across various modules to replace single working directory references with arrays of working directories. - Modified session creation and metadata handling to accommodate multiple working directories. - Adjusted tests to reflect changes in session structure and ensure compatibility with the new working directory model. - Enhanced workspace building functions to properly utilize multiple directories for session management. --- .../browser/remoteAgentHostProtocolClient.ts | 3 +- .../platform/agentHost/common/agentService.ts | 49 +++- .../node/agentConfigurationService.ts | 23 ++ .../platform/agentHost/node/agentService.ts | 121 ++++++--- .../agentHost/node/agentSideEffects.ts | 21 +- .../agentHost/node/claude/claudeAgent.ts | 39 +-- .../node/claude/claudeSessionMetadataStore.ts | 2 +- .../agentHost/node/codex/codexAgent.ts | 47 +++- .../agentHost/node/copilot/copilotAgent.ts | 40 +-- .../agentHost/node/protocolServerHandler.ts | 4 +- .../agentHost/node/sessionPermissions.ts | 76 ++++-- .../node/shared/sessionServerTools.ts | 29 ++- .../node/agentConfigurationService.test.ts | 30 ++- .../agentHost/test/node/agentService.test.ts | 78 ++++-- .../test/node/claudeAgent.integrationTest.ts | 6 +- .../agentHost/test/node/claudeAgent.test.ts | 244 +++++++++--------- .../node/claudeSessionMetadataStore.test.ts | 4 +- .../node/codex/codexPrewarmEviction.test.ts | 4 +- .../agentHost/test/node/copilotAgent.test.ts | 87 ++++--- .../test/node/copilotAgentSession.test.ts | 1 + .../test/node/copilotShellTools.test.ts | 1 + .../platform/agentHost/test/node/mockAgent.ts | 6 +- .../test/node/protocolServerHandler.test.ts | 2 +- .../test/node/sessionPermissions.test.ts | 68 ++++- .../test/node/sessionServerTools.test.ts | 8 +- .../common/agentHostSessionWorkspace.ts | 67 +++-- .../browser/baseAgentHostSessionsProvider.ts | 24 +- .../browser/localAgentHostSessionsProvider.ts | 13 +- .../localAgentHostSessionsProvider.test.ts | 4 +- .../remoteAgentHostSessionsProvider.ts | 13 +- .../remoteAgentHostSessionsProvider.test.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 4 +- .../agentHost/agentHostSessionListStore.ts | 4 +- ...ntHostUntitledProvisionalSessionService.ts | 6 +- .../agentHostChatContribution.test.ts | 20 +- ...tUntitledProvisionalSessionService.test.ts | 2 +- 36 files changed, 740 insertions(+), 412 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index e3250fad28007b..d650039d5b5c9c 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -880,7 +880,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const promise = this._sendRequest('createSession', { channel: session.toString(), provider, - workingDirectories: config?.workingDirectory ? [fromAgentHostUri(config.workingDirectory).toString()] : undefined, + workingDirectories: config?.workingDirectories?.map(d => fromAgentHostUri(d).toString()), fork: config?.fork ? { session: fromAgentHostUri(config.fork.session).toString(), turnId: config.fork.turnId } : undefined, config: config?.config, activeClient: config?.activeClient, @@ -1051,6 +1051,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC status: s.status, activity: s.activity, workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined, + workingDirectories: s.workingDirectories?.map(d => toAgentHostUri(URI.parse(d), this._connectionAuthority)), isRead: !!(s.status & SessionStatus.IsRead), isArchived: !!(s.status & SessionStatus.IsArchived), changes: s.changes, diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index b2152eb681c6b4..4bc4909707b381 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -729,7 +729,8 @@ export interface IAgentSessionMetadata { readonly status?: SessionStatus; /** Human-readable description of what the session is currently doing. */ readonly activity?: string; - readonly workingDirectory?: URI; + /** All working directories available to the session (index 0 = primary). */ + readonly workingDirectories?: readonly URI[]; readonly isRead?: boolean; readonly isArchived?: boolean; /** @@ -768,8 +769,19 @@ export interface IAgentSessionProjectInfo { export interface IAgentCreateSessionResult { readonly session: URI; readonly project?: IAgentSessionProjectInfo; - /** The resolved working directory, which may differ from the requested one (e.g. worktree). */ - readonly workingDirectory?: URI; + /** + * The single working directory the provider resolved for this session — its + * process root. This may differ from the requested primary (e.g. a + * workspace-less session runs in a provider-assigned scratch dir). It is NOT + * the full multi-root set: a provider only resolves the one directory its + * subprocess launches in. The host assembles the session's set by replacing + * the requested primary (index 0) with this value while keeping the requested + * tail. Worktree remaps and the fully-resolved set land later, on the first + * send, via {@link IAgentMaterializeSessionEvent.workingDirectories}. + * `undefined` means the provider did not resolve a directory (the host keeps + * the requested set as-is). + */ + readonly resolvedWorkingDirectory?: URI; /** * `true` when the agent only allocated an in-memory placeholder for this * session (no SDK session, no worktree, no on-disk state). Materialization @@ -789,7 +801,14 @@ export interface IAgentCreateSessionResult { */ export interface IAgentMaterializeSessionEvent { readonly session: URI; - readonly workingDirectory: URI | undefined; + /** + * The complete resolved working-directory set (index 0 = the resolved process + * root, e.g. a worktree). The host replaces index 0 of the current session set + * with this set's index 0 while preserving the rest of the current set — the + * resume path can only report the single process cwd, so its tail is owned + * by the restored session state. + */ + readonly workingDirectories: readonly URI[] | undefined; readonly project: IAgentSessionProjectInfo | undefined; } @@ -907,7 +926,20 @@ export interface IAgentCreateSessionConfig { */ readonly agent?: AgentSelection; readonly session?: URI; - readonly workingDirectory?: URI; + /** + * The working directories the session's agent is granted tool access to, + * ordered so that index 0 is the intended process root (the "primary"). + * + * Distinct values: + * - `undefined` — no directories requested (workspace-less inference applies); + * - `[]` — explicitly no directories; + * - `[dir, …]` — the ordered set (index 0 = primary/process root). + * + * A client MUST NOT supply more than one entry unless the agent advertises + * the `multipleWorkingDirectories` capability (not advertised yet). During + * the compatibility phase callers supply exactly one directory (`[dir]`). + */ + readonly workingDirectories?: readonly URI[]; readonly config?: Record; /** * Eagerly claim the active client role for the new session. When provided, @@ -1184,9 +1216,12 @@ export interface IAgentChats { /** * Send a user message into `chat`; on first send, the host passes the resolved - * working directory (or `undefined` for workspace-less sessions). + * working directories (index 0 = the process root / resolved worktree, followed + * by any additional roots). `undefined` for workspace-less sessions. Providers + * launch their subprocess in index 0; the full set is recorded in the + * materialization receipt. */ - sendMessage(chat: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise; + sendMessage(chat: URI, prompt: string, workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise; /** Abort the in-flight turn for `chat`. */ abort(chat: URI): Promise; diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 1edc8c06b15ab5..a251e301282fb3 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -76,9 +76,20 @@ export interface IAgentConfigurationService { * to the parent (subagent) session's working directory when the * session itself does not have one set. The host layer does not carry * a working directory. + * @deprecated Use {@link getEffectiveWorkingDirectories} instead, which preserves every root instead of collapsing to the primary. */ getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined; + /** + * Returns the full ordered set of effective working directories for a + * session (index 0 = primary), falling back to the parent (subagent) + * session's set when the session itself does not have one set. Mirrors + * {@link getEffectiveWorkingDirectory} but preserves every root instead + * of collapsing to the primary. The host layer does not carry a working + * directory. + */ + getEffectiveWorkingDirectories(session: ProtocolURI): string[] | undefined; + /** * Whether a fresh worktree-isolation session's worktree has not yet been * created. Agents consult this to defer prewarming (and any other eager @@ -230,6 +241,18 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi return undefined; } + getEffectiveWorkingDirectories(session: ProtocolURI): string[] | undefined { + const own = this._stateManager.getSessionState(session)?.workingDirectories; + if (own !== undefined) { + return own; + } + const parentInfo = parseSubagentSessionUri(session); + if (parentInfo) { + return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories; + } + return undefined; + } + isWorkingDirectoryPending(session: ProtocolURI): boolean { return this._worktree?.isWorkingDirectoryPending(AgentSession.id(session)) ?? false; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6ada812ffdb0ff..184941d36d002e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -174,6 +174,29 @@ interface IPersistedPeerChat { readonly origin?: ChatOrigin; } +/** + * Reconcile a session's working-directory set from a create-result / + * materialization receipt. The resolved receipt is authoritative for the roots + * it reports (index 0 = the resolved process root, e.g. a worktree); any + * additional requested/current roots *beyond* the resolved set's length are + * preserved. This is what lets a receipt that reports only the process root — + * the resume path reads a single cwd from disk — keep the rest of the known set + * instead of collapsing `[A, B, C]` to `[dir]`, while a receipt that carries the + * full resolved set (the send/create path) is trusted verbatim (including a + * remapped tail). A missing resolved set keeps the requested value as-is, + * preserving the `undefined` (workspace-less / inherit) vs `[]` (explicitly none) + * distinction. + * + * Returns the protocol form (`string[]`), since protocol URIs are strings. + */ +function reconcileWorkingDirectories(requested: readonly URI[] | undefined, resolved: readonly URI[] | undefined): string[] | undefined { + if (resolved === undefined) { + return requested?.map(d => d.toString()); + } + const tail = (requested ?? []).slice(resolved.length); + return [...resolved, ...tail].map(d => d.toString()); +} + /** * The agent service implementation that runs inside the agent-host utility * process. Dispatches to registered {@link IAgent} instances based @@ -543,30 +566,36 @@ export class AgentService extends Disposable implements IAgentService { /** * Host-owned first-send hook (invoked by {@link AgentSideEffects} before the - * agent locks its subprocess cwd). Resolves the working directory the session - * will actually run in and hands it to the agent at send time: - * - `worktree` isolation: the isolated worktree, created here on the first - * send (see {@link _resolveWorktreeBeforeSend}); - * - `folder` isolation: the picked folder; - * - workspace-less: `undefined` (the agent runs in its own scratch dir). + * agent locks its subprocess cwd). Resolves the working directories the session + * will actually run in and hands them to the agent at send time: + * - index 0 is the process root: for `worktree` isolation the isolated + * worktree (created here on the first send, see + * {@link _resolveWorktreeBeforeSend}); for `folder` isolation the picked + * folder; `undefined` (whole result) for workspace-less sessions. + * - the tail carries any additional session roots as-is (only index 0 is + * worktree-remapped; additional roots are passed through unchanged). */ - private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise { + private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise { const sessionId = AgentSession.id(params.session); - const pickedFolder = this._configurationService.getEffectiveWorkingDirectory(params.session); - const pickedFolderUri = pickedFolder ? URI.parse(pickedFolder) : undefined; + const pickedFolders = this._configurationService.getEffectiveWorkingDirectories(params.session); + const pickedFolderUri = pickedFolders?.[0] ? URI.parse(pickedFolders[0]) : undefined; + const tail = (pickedFolders ?? []).slice(1).map(d => URI.parse(d)); // Only worktree-isolation sessions defer directory resolution to the first // send (so the prompt can name the branch); folder / workspace-less // sessions run directly in the picked folder. if (!this._worktree?.isWorkingDirectoryPending(sessionId)) { - return pickedFolderUri - ? this._configurationService.resolveWorkingDirectoryForResume(params.session, pickedFolderUri) - : undefined; + if (!pickedFolderUri) { + return undefined; + } + const resolved = await this._configurationService.resolveWorkingDirectoryForResume(params.session, pickedFolderUri); + return [resolved, ...tail]; } // Fall back to the picked folder when worktree creation failed so the // session still materializes in the user's folder rather than nowhere. - return await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri; + const resolved = await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri; + return resolved ? [resolved, ...tail] : undefined; } private async _resolveChatAttachmentTurns(resource: string): Promise { @@ -886,7 +915,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]; + const liveWorkingDirs = liveSummary.workingDirectories; return { ...s, summary: liveSummary.title || s.summary, @@ -896,9 +925,9 @@ export class AgentService extends Disposable implements IAgentService { project: liveSummary.project ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName } : s.project, - workingDirectory: typeof liveWorkingDir === 'string' - ? URI.parse(liveWorkingDir) - : s.workingDirectory, + workingDirectories: liveWorkingDirs !== undefined + ? liveWorkingDirs.map(d => URI.parse(d)) + : s.workingDirectories, changes: liveSummary.changes ?? s.changes, changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, ...(_meta !== undefined ? { _meta } : {}), @@ -930,7 +959,7 @@ export class AgentService extends Disposable implements IAgentService { continue; } - const summaryWorkingDir = summary.workingDirectories?.[0]; + const summaryWorkingDirs = summary.workingDirectories; additions.push({ session: URI.parse(summary.resource), startTime: Date.parse(summary.createdAt), @@ -938,7 +967,7 @@ export class AgentService extends Disposable implements IAgentService { summary: summary.title, status: summary.status, activity: summary.activity, - workingDirectory: typeof summaryWorkingDir === 'string' ? URI.parse(summaryWorkingDir) : undefined, + workingDirectories: summaryWorkingDirs?.map(d => URI.parse(d)), ...(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 @@ -963,6 +992,15 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); } + // Capability guard: no provider advertises `multipleWorkingDirectories` + // yet, so reject a request for more than one working directory rather + // than silently launching in only the first. Callers supply exactly one + // directory during the compatibility phase; this is a defensive backstop + // against the newly-plural plumbing forwarding an unsupported set. + if (config?.workingDirectories && config.workingDirectories.length > 1) { + throw new Error(`Provider '${providerId}' does not support multiple working directories (received ${config.workingDirectories.length}).`); + } + // When forking, build the old→new turn ID mapping before creating the // session so the agent can use it to remap per-turn data. If the // source has no turns to copy (e.g. a still-provisional session), a @@ -1168,8 +1206,8 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady }); } - // Refresh the git state for the session. - const workingDirectory = created.workingDirectory ?? config?.workingDirectory; + // Refresh the git state for the session's process root. + const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); return session; @@ -1529,9 +1567,8 @@ export class AgentService extends Disposable implements IAgentService { return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText; } - private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { + private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; resolvedWorkingDirectory?: URI }, title: string): SessionSummary { const now = new Date().toISOString(); - const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString(); return { resource: session.toString(), provider: provider.id, @@ -1540,11 +1577,18 @@ export class AgentService extends Disposable implements IAgentService { createdAt: now, modifiedAt: now, ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - workingDirectories: primaryWorkingDir ? [primaryWorkingDir] : undefined, + // The provider resolved only its process root (index 0), which may + // differ from the requested primary (e.g. a workspace-less scratch dir). + // Assemble the session set by overriding the requested primary with it + // and keeping the requested tail; the fully-resolved multi-root set + // arrives later via the materialization receipt. + workingDirectories: reconcileWorkingDirectories(config?.workingDirectories, created.resolvedWorkingDirectory ? [created.resolvedWorkingDirectory] : 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. - ...(config && !config.fork && !config.workingDirectory ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + // `workingDirectories` (the host assigns a scratch cwd, so it can't be + // re-inferred later) and tagged on the generic `_meta` bag. Use + // `=== undefined` so an explicit empty set (`[]`) is NOT treated as + // workspace-less. + ...(config && !config.fork && config.workingDirectories === undefined ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }; } @@ -1579,10 +1623,15 @@ export class AgentService extends Disposable implements IAgentService { // (created in the first-send hook) wins for worktree-isolated sessions, and // falls back to whatever the agent reported for folder sessions. const project = this._worktree?.createdWorktreeProject(AgentSession.id(e.session)) ?? e.project; + const currentSet = currentSummary.workingDirectories?.map(d => URI.parse(d)); const summary: SessionSummary = { ...currentSummary, ...(project ? { project: { uri: project.uri.toString(), displayName: project.displayName } } : {}), - workingDirectories: e.workingDirectory ? [e.workingDirectory.toString()] : currentSummary.workingDirectories, + // The materialize receipt is authoritative for the roots it reports + // (index 0 = the resolved process root, e.g. a worktree). A send-path + // receipt carries the full resolved set; a resume-path receipt reports + // only the process root, so the rest of the current set is preserved. + workingDirectories: reconcileWorkingDirectories(currentSet, e.workingDirectories), modifiedAt: new Date().toISOString(), }; const configValues = state.config?.values; @@ -1598,8 +1647,8 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.markSessionPersisted(sessionKey, summary); this._stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); - // Attach git state for the working directory (if present) - void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory); + // Attach git state for the resolved process root (index 0), if present. + void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectories?.[0]); // If a client subscribed to this session's uncommitted changeset // before the working directory was known, the coordinator drains @@ -1686,12 +1735,14 @@ export class AgentService extends Disposable implements IAgentService { } private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { - if (!config?.config && !config?.workingDirectory) { + if (!config?.config && config?.workingDirectories === undefined) { return undefined; } const params: IAgentResolveSessionConfigParams = { provider: provider.id, - workingDirectory: config.workingDirectory, + // `resolveSessionConfig` is a pre-session, single-context API: + // resolve against the session's primary (index 0). + workingDirectory: config.workingDirectories?.[0], config: config.config, }; try { @@ -2543,7 +2594,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, - workingDirectories: meta.workingDirectory ? [meta.workingDirectory.toString()] : undefined, + workingDirectories: meta.workingDirectories?.map(d => d.toString()), _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, }; @@ -2604,7 +2655,7 @@ export class AgentService extends Disposable implements IAgentService { // `SessionConfigChanged`) on top of the provider's resolved defaults. const [restoredConfig, restoredCustomizations] = await Promise.all([ this._resolveCreatedSessionConfig(agent, { - workingDirectory: meta.workingDirectory, + workingDirectories: meta.workingDirectories, config: persistedConfigValues, }), agent.getSessionCustomizations @@ -2628,7 +2679,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); // Refresh the git state for the session. - void this._gitStateService.refreshSessionGitState(sessionStr, meta.workingDirectory); + void this._gitStateService.refreshSessionGitState(sessionStr, meta.workingDirectories?.[0]); // Check for a GitHub pull request associated with the session's branch. void this._gitStateService.attachSessionGitHubPullRequest(sessionStr); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 34d5bf022cf58f..b711e469ecd679 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -92,11 +92,11 @@ export interface IAgentSideEffectsOptions { * Host-owned working-directory resolution hook, awaited before the agent's * first send so the session's working directory (an isolated worktree created * on the first send, or the picked folder) is resolved before the agent - * materializes and its cwd is locked. Resolves to the working directory to - * hand the agent, or `undefined` for workspace-less sessions. Provided by - * {@link AgentService}. + * materializes and its cwd is locked. Resolves to the working directories to + * hand the agent (index 0 = process root), or `undefined` for workspace-less + * sessions. Provided by {@link AgentService}. */ - readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise; + readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise; /** Resolves a referenced chat's turns, hydrating its owning session when needed. */ readonly resolveChatAttachmentTurns?: (resource: ProtocolURI) => Promise; /** @@ -1647,11 +1647,12 @@ export class AgentSideEffects extends Disposable { let failureStage: AgentHostTurnFailureStage = 'workingDirectory'; try { // Host-owned working-directory resolution: resolve the session's working - // directory before the agent materializes, so the agent runs in it - // without ever knowing how it was derived. Returns the created worktree - // for worktree sessions (created here on the first send) or the picked - // folder for folder sessions; undefined for workspace-less sessions. - const resolvedWorkingDirectory = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text }); + // directories before the agent materializes, so the agent runs in + // index 0 (the process root) without ever knowing how it was derived. + // Index 0 is the created worktree for worktree sessions (created here on + // the first send) or the picked folder for folder sessions; undefined for + // workspace-less sessions. Any additional roots follow index 0. + const resolvedWorkingDirectories = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text }); const selectionUpdates: Promise[] = []; if (message.model) { @@ -1666,7 +1667,7 @@ export class AgentSideEffects extends Disposable { failureStage = 'sendMessage'; const resolvedAttachments = await this._resolveChatAttachments(sessionChannel, message.attachments); - await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, resolvedAttachments, turnId, senderClientId, clientType); + await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectories, resolvedAttachments, turnId, senderClientId, clientType); } catch (err) { const failure = buildTurnFailure(failureStage, err); const error = failure.error; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 9e8fdfc9c21df9..21c03a1e6c79d2 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -732,24 +732,25 @@ export class ClaudeAgent extends Disposable implements IAgent { if (!existing.isPipelineReady) { return { session: existing.sessionUri, - workingDirectory: existing.workingDirectory, + resolvedWorkingDirectory: existing.workingDirectory, provisional: true, ...(existing.project ? { project: existing.project } : {}), }; } - return { session: sessionUri, workingDirectory: config.workingDirectory }; + return { session: sessionUri, resolvedWorkingDirectory: config.workingDirectories?.[0] }; } - // A workspace-less session (no `workingDirectory` supplied, and not a + // A workspace-less session (no `workingDirectories` supplied, and not a // fork) runs in a stable per-session scratch dir shared with the Copilot // agent; without a cwd Claude throws at materialize. The workspace-less // marker itself is owned/persisted centrally by the AH service. - const workingDirectory = config.workingDirectory ?? await ensureWorkspacelessScratchDir(this._environmentService.userHome, sessionId); + const requestedWorkingDirectory = config.workingDirectories?.[0]; + const workingDirectory = requestedWorkingDirectory ?? await ensureWorkspacelessScratchDir(this._environmentService.userHome, sessionId); // Only probe for a project when the caller supplied a real folder; a // scratch dir is never a code project. - const project = config.workingDirectory - ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) + const project = requestedWorkingDirectory + ? await projectFromCopilotContext({ cwd: requestedWorkingDirectory.fsPath }, this._gitService) : undefined; const permissionMode = this._resolvePermissionMode(config.config); @@ -773,7 +774,7 @@ export class ClaudeAgent extends Disposable implements IAgent { return { session: sessionUri, - workingDirectory, + resolvedWorkingDirectory: workingDirectory, provisional: true, ...(project ? { project } : {}), }; @@ -877,7 +878,7 @@ export class ClaudeAgent extends Disposable implements IAgent { await this.createSession({ session, - workingDirectory, + workingDirectories: [workingDirectory], ...(overlay.model ? { model: overlay.model } : {}), ...(overlay.agent ? { agent: overlay.agent } : {}), ...(overlay.permissionMode ? { config: { [ClaudeSessionConfigKey.PermissionMode]: overlay.permissionMode } } : {}), @@ -913,8 +914,8 @@ export class ClaudeAgent extends Disposable implements IAgent { const { session, chat } = this._resolveChatTarget(chatUri); return this._disposeChat(session, chat); }, - sendMessage: (chatUri, prompt, workingDirectory, attachments, turnId, senderClientId) => { - return this._sendMessage(chatUri, prompt, workingDirectory, attachments, turnId, senderClientId); + sendMessage: (chatUri, prompt, workingDirectories, attachments, turnId, senderClientId) => { + return this._sendMessage(chatUri, prompt, workingDirectories, attachments, turnId, senderClientId); }, abort: chatUri => { return this._abortSession(chatUri); @@ -992,7 +993,7 @@ export class ClaudeAgent extends Disposable implements IAgent { // fast (rather than at the first `sendMessage` when `_resumeSession` // requires a cwd). The Query itself starts lazily — see the JSDoc. const sdkInfo = await this._sdkService.getSessionInfo(newSessionId); - const workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : config.workingDirectory; + const workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : config.workingDirectories?.[0]; if (!workingDirectory) { throw new Error(`Cannot fork session ${sourceSessionId}: forked session ${newSessionId} has no working directory (SDK cwd missing and none supplied)`); } @@ -1004,7 +1005,7 @@ export class ClaudeAgent extends Disposable implements IAgent { } return { session: newSessionUri, - workingDirectory, + resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}), }; }); @@ -1055,7 +1056,7 @@ export class ClaudeAgent extends Disposable implements IAgent { * inside `materialize` throws so we never expose a live pipeline * for a session the caller has already torn down. */ - private async _materializeProvisional(sessionId: string, workingDirectory?: URI): Promise { + private async _materializeProvisional(sessionId: string, workingDirectories?: readonly URI[]): Promise { const session = this._findAnySession(sessionId); if (!session) { throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); @@ -1065,16 +1066,18 @@ export class ClaudeAgent extends Disposable implements IAgent { const canUseTool = this._makeCanUseTool(sessionId); const onElicitation = this._makeOnElicitation(sessionId); try { - await session.materialize({ transport, canUseTool, onElicitation, isResume: false, workingDirectory, serverToolHost: this._serverToolHost }); + await session.materialize({ transport, canUseTool, onElicitation, isResume: false, workingDirectory: workingDirectories?.[0], serverToolHost: this._serverToolHost }); } catch (err) { this._sessions.deleteAndDispose(sessionId); throw err; } + // Emit the resolved set (index 0 = process root); the host preserves the + // session set's tail via an index-0 replacement. this._onDidMaterializeSession.fire({ session: session.sessionUri, - workingDirectory: session.workingDirectory, project: session.project, + workingDirectories: workingDirectories ?? (session.workingDirectory ? [session.workingDirectory] : undefined), }); return session; @@ -1148,8 +1151,8 @@ export class ClaudeAgent extends Disposable implements IAgent { this._onDidMaterializeSession.fire({ session: sessionUri, - workingDirectory, project, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); return session; @@ -1907,7 +1910,7 @@ export class ClaudeAgent extends Disposable implements IAgent { })(); } - private async _sendMessage(chat: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise { + private async _sendMessage(chat: URI, prompt: string, workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise { // `IAgent.sendMessage` declares `turnId?` but every production caller in // `AgentSideEffects` supplies one. Generate a fallback so the // session-side `QueuedRequest.turnId: string` invariant holds even if a @@ -1944,7 +1947,7 @@ export class ClaudeAgent extends Disposable implements IAgent { if (existing?.isPipelineReady) { session = existing; } else if (existing) { - session = await this._materializeProvisional(context.sessionId, workingDirectory); + session = await this._materializeProvisional(context.sessionId, workingDirectories); } else { session = await this._resumeSession(context.sessionId, context.session); } diff --git a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts index 27a501e83cefeb..efe4a87cbe74dc 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts @@ -153,7 +153,7 @@ export class ClaudeSessionMetadataStore { startTime: entry.createdAt ?? entry.lastModified, modifiedTime: entry.lastModified, summary: entry.customTitle ?? entry.summary, - workingDirectory: entry.cwd ? URI.file(entry.cwd) : undefined, + workingDirectories: entry.cwd ? [URI.file(entry.cwd)] : undefined, }; } } diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 94ad2a420f7cb2..c87803f88bcfd0 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -395,6 +395,15 @@ interface ICodexSession { * `createSession`. */ workingDirectory: URI | undefined; + /** + * The full resolved working-directory set handed to the first send (index 0 + * = the process root, mirrored in {@link workingDirectory}; the tail carries + * any additional session roots). Populated only when the host-owned send hook + * supplied a set, so the materialization receipt can record the lossless set; + * `undefined` on the resume/restore path (the receipt then emits the singular + * `workingDirectory`). + */ + workingDirectories?: readonly URI[]; /** * Set to the temp folder created for this session when no working * directory was supplied, so {@link CodexAgent.disposeSession} can remove @@ -2654,8 +2663,8 @@ export class CodexAgent extends Disposable implements IAgent { // default chat lives and dies with its session. return Promise.resolve(); }, - sendMessage: (chat: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { - return this._sendMessage(chat, prompt, attachments, turnId, workingDirectory); + sendMessage: (chat: URI, prompt: string, workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { + return this._sendMessage(chat, prompt, attachments, turnId, workingDirectories); }, abort: (chat: URI): Promise => { return this._abort(chat); @@ -2673,7 +2682,7 @@ export class CodexAgent extends Disposable implements IAgent { }; async createSession(config: IAgentCreateSessionConfig = {}): Promise { - this._logService.info(`[Codex DEBUG] createSession usageSource=${this._usageSource} accountStatus=${codexAccountStateForUsageSource(this._usageSource, this._openAIAccountState).status} session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectory?.toString() ?? '(none)'}`); + this._logService.info(`[Codex DEBUG] createSession usageSource=${this._usageSource} accountStatus=${codexAccountStateForUsageSource(this._usageSource, this._openAIAccountState).status} session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectories?.[0]?.toString() ?? '(none)'}`); let validation = this._usageSourceValidation; await validation; while (validation !== this._usageSourceValidation) { @@ -2706,9 +2715,10 @@ export class CodexAgent extends Disposable implements IAgent { const existing = this._sessions.get(sessionId); if (existing) { existing.model = effectiveModel ?? existing.model; + const cwd = existing.workingDirectory ?? config.workingDirectories?.[0]; return { session: sessionUri, - workingDirectory: existing.workingDirectory ?? config.workingDirectory, + resolvedWorkingDirectory: cwd, provisional: existing.threadId === undefined, }; } @@ -2718,7 +2728,7 @@ export class CodexAgent extends Disposable implements IAgent { sessionId, threadId: undefined, sessionUri, - workingDirectory: config.workingDirectory, + workingDirectory: config.workingDirectories?.[0], managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), @@ -2753,7 +2763,7 @@ export class CodexAgent extends Disposable implements IAgent { this._schedulePrewarm(session); return { session: sessionUri, - workingDirectory: config.workingDirectory, + resolvedWorkingDirectory: config.workingDirectories?.[0], provisional: true, }; } @@ -2889,7 +2899,7 @@ export class CodexAgent extends Disposable implements IAgent { const newSessionUri = AgentSession.uri(this.id, newThreadId); const workingDirectory = forkResult.cwd ? URI.file(forkResult.cwd) - : (sourceRead.thread.cwd ? URI.file(sourceRead.thread.cwd) : config.workingDirectory); + : (sourceRead.thread.cwd ? URI.file(sourceRead.thread.cwd) : config.workingDirectories?.[0]); const session = this._createResumedSessionEntry(newThreadId, newThreadId, newSessionUri, workingDirectory, model); this._sessions.set(newThreadId, session); @@ -2931,7 +2941,7 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.info(`[Codex] forked session ${sourceThreadId} → ${newThreadId} (kept ${sourceTurns.length - numTurnsToDrop}/${sourceTurns.length} turns)`); return { session: newSessionUri, - workingDirectory, + resolvedWorkingDirectory: workingDirectory, provisional: false, }; } @@ -3068,10 +3078,12 @@ export class CodexAgent extends Disposable implements IAgent { return; } session.materializedEventFired = true; + // Emit the resolved set (index 0 = process root); the host preserves the + // session set's tail via an index-0 replacement. this._onDidMaterializeSession.fire({ session: session.sessionUri, - workingDirectory: session.workingDirectory, project: undefined, + workingDirectories: session.workingDirectories ?? (session.workingDirectory ? [session.workingDirectory] : undefined), }); } @@ -3195,7 +3207,7 @@ export class CodexAgent extends Disposable implements IAgent { return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; } - private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, workingDirectory?: URI): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, workingDirectories?: readonly URI[]): Promise { const sessionUri = this._sessionUriFromChat(chat); this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); const sessionId = AgentSession.id(sessionUri); @@ -3203,7 +3215,18 @@ export class CodexAgent extends Disposable implements IAgent { if (!session) { throw new Error(`Codex session not found: ${sessionUri.toString()}`); } - await this._adoptWorkingDirectoryBeforeSend(session, workingDirectory); + // The host hands us the resolved working directories (index 0 = the process + // root) on the first send; adopt index 0 as the codex subprocess cwd before + // materialize locks it. The agent stays unaware of worktrees. + await this._adoptWorkingDirectoryBeforeSend(session, workingDirectories?.[0]); + // Record the full set for the materialization receipt OUTSIDE the adoption + // path: a prewarm may have already materialized the thread, yet the receipt + // is fired on this first send and must still carry the resolved set. Only + // assign when the send supplied one, so the resume path keeps emitting the + // singular working directory. + if (workingDirectories) { + session.workingDirectories = workingDirectories; + } const conn = await this._ensureConnection(); const effectiveTurnId = turnId ?? generateUuid(); @@ -3741,7 +3764,7 @@ export class CodexAgent extends Disposable implements IAgent { startTime: (thread.createdAt ?? 0) * 1000, modifiedTime: (thread.updatedAt ?? thread.createdAt ?? 0) * 1000, summary: thread.name ?? thread.preview ?? undefined, - workingDirectory: thread.cwd ? URI.file(thread.cwd) : undefined, + workingDirectories: thread.cwd ? [URI.file(thread.cwd)] : undefined, }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 1febb790e778c9..b824a0d09aee81 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1495,7 +1495,7 @@ export class CopilotAgent extends Disposable implements IAgent { modifiedTime: s.modifiedTime.getTime(), project, summary: s.summary, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; return result; })); @@ -1531,7 +1531,7 @@ export class CopilotAgent extends Disposable implements IAgent { modifiedTime: sessionMetadata?.modifiedTime.getTime() ?? Date.now(), project, summary: sessionMetadata?.summary, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } @@ -1577,7 +1577,7 @@ export class CopilotAgent extends Disposable implements IAgent { * (no `workingDirectory` supplied) — a stable per-session scratch directory. */ private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string, isWorkspaceless: boolean): Promise { - const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; + const existing = sessionConfig.workingDirectories?.[0] ?? this._provisionalSessions.get(sessionId)?.workingDirectory; if (existing) { return existing; } @@ -1723,8 +1723,8 @@ export class CopilotAgent extends Disposable implements IAgent { const { session, chat } = this._resolveChatTarget(chatUri); return this._disposeChat(session, chat); }, - sendMessage: (chatUri: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise => { - return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectory); + sendMessage: (chatUri: URI, prompt: string, workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise => { + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectories); }, abort: (chatUri: URI): Promise => { return this._abortSession(chatUri); @@ -1752,7 +1752,7 @@ export class CopilotAgent extends Disposable implements IAgent { // pick the workspace-less system prompt. Forks always inherit the source // session's context, so they are never inferred workspace-less even when no // `workingDirectory` is passed. - const isWorkspaceless = !sessionConfig.fork && !sessionConfig.workingDirectory; + const isWorkspaceless = !sessionConfig.fork && sessionConfig.workingDirectories === undefined; const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId, isWorkspaceless); const client = await this._ensureClient(); // When forking, use the SDK's sessions.fork RPC. Forking from a source @@ -1827,7 +1827,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (sessionConfig.agent !== undefined) { await this._storeSessionAgentMetadata(session, sessionConfig.agent); } - return { session, workingDirectory, ...(project ? { project } : {}) }; + return { session, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; }); } @@ -1852,7 +1852,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._findAnySession(sessionId)) { this._logService.info(`[Copilot] createSession is a no-op: session already materialized: ${sessionUri.toString()}`); const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); - return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; + return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; } // Idempotent: a duplicate `createSession` for a still-provisional URI @@ -1900,7 +1900,7 @@ export class CopilotAgent extends Disposable implements IAgent { } this._logService.info(`[Copilot] Session created (provisional): ${sessionUri.toString()}`); - return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) }; + return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, provisional: true, ...(project ? { project } : {}) }; } /** @@ -1944,7 +1944,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Resume so the SDK loads the seeded history as editable turns. await this._resumeSession(sessionId); this._logService.info(`[Copilot] Imported session created: ${sessionUri.toString()}`); - return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; + return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; }); } @@ -1968,7 +1968,7 @@ export class CopilotAgent extends Disposable implements IAgent { * `SessionConfigChanged` actions that arrived after `createSession` are * honoured without bespoke forwarding. */ - private async _materializeProvisional(sessionId: string, resolvedWorkingDirectory?: URI): Promise { + private async _materializeProvisional(sessionId: string, resolvedWorkingDirectories?: readonly URI[]): Promise { const provisional = this._provisionalSessions.get(sessionId); if (!provisional) { throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); @@ -1976,11 +1976,11 @@ export class CopilotAgent extends Disposable implements IAgent { const client = await this._ensureClient(); const sessionUri = provisional.sessionUri; - // The host hands us the resolved working directory (an isolated worktree for - // worktree isolation) on the first send; use it so the SDK subprocess spawns - // in the worktree. Falls back to the folder / scratch dir captured at create - // time for folder / workspace-less sessions. - const workingDirectory = resolvedWorkingDirectory ?? provisional.workingDirectory; + // The host hands us the resolved working directories (an isolated worktree for + // worktree isolation) on the first send; use index 0 (the process root) so the + // SDK subprocess spawns in it. Falls back to the folder / scratch dir captured + // at create time for folder / workspace-less sessions. + const workingDirectory = resolvedWorkingDirectories?.[0] ?? provisional.workingDirectory; // The customization anchor follows the working directory: once a worktree // is created the agent must discover skills/instructions/agents from the // worktree (not the user-picked folder) so the model reads and edits files @@ -2043,7 +2043,9 @@ export class CopilotAgent extends Disposable implements IAgent { }); this._logService.info(`[Copilot] Session materialized: ${sessionUri.toString()}`); - this._onDidMaterializeSession.fire({ session: sessionUri, workingDirectory, project }); + // Emit the resolved working-directory set (index 0 = process root). The host + // replaces index 0 of the session set with it, preserving the tail. + this._onDidMaterializeSession.fire({ session: sessionUri, project, workingDirectories: resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined) }); return agentSession; } @@ -2150,7 +2152,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectory?: URI): Promise { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[]): Promise { const context = this._getChatContext(chat); // Additional (non-default) chats are backed by their own SDK // chat hosted on the owning session entry, keyed by the chat URI. @@ -2177,7 +2179,7 @@ export class CopilotAgent extends Disposable implements IAgent { // its branch-name hint from the user's first message. let entry: CopilotAgentSession | undefined; if (this._provisionalSessions.has(context.sessionId)) { - entry = await this._materializeProvisional(context.sessionId, workingDirectory); + entry = await this._materializeProvisional(context.sessionId, workingDirectories); } else { entry = this._getChatContext(chat).target; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index bc9ed80d76362f..6173e651704821 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1190,7 +1190,7 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, - workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories[0]) : undefined, + workingDirectories: params.workingDirectories?.map(d => URI.parse(d)), session: URI.parse(params.channel), fork, config: params.config, @@ -1287,7 +1287,7 @@ export class ProtocolServerHandler extends Disposable { createdAt: new Date(s.startTime).toISOString(), modifiedAt: new Date(s.modifiedTime).toISOString(), ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), - workingDirectories: s.workingDirectory ? [s.workingDirectory.toString()] : undefined, + workingDirectories: s.workingDirectories?.map(d => d.toString()), changes: s.changes, } satisfies ListSessionsResult['items'][number]; }); diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index eb531e16c3a269..cd79ea714f994c 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -6,6 +6,7 @@ import { realpath as fsRealpath } from 'fs'; import { homedir } from 'os'; import { promisify } from 'util'; +import { firstParallel } from '../../../base/common/async.js'; import { match as globMatch } from '../../../base/common/glob.js'; import { untildify } from '../../../base/common/labels.js'; import { Disposable } from '../../../base/common/lifecycle.js'; @@ -251,8 +252,15 @@ export class SessionPermissionManager extends Disposable { * 6. Shell command rules (tree-sitter parsed, default allow/deny) */ async getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): Promise { - const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); - const workingDirectory = workDir ? URI.parse(workDir) : undefined; + // `sessionKey` is the chat channel URI (see `_handleToolReady`), so the + // state manager returns that chat's *effective* working-directory set + // (its own subset override when present, else the session's full set — + // peer chats inherit). A read/write/shell destination auto-approves when + // contained by *any* root. Today the set has exactly one entry (the + // create-time length guard), so this is behaviour-identical to the + // previous single-directory logic. + const workDirs = this._configService.getEffectiveWorkingDirectories(sessionKey); + const workingDirectories = workDirs?.map(d => URI.parse(d)); // 0. Sandbox bypass: a shell command that opted out of the // sandbox (`requestSandboxBypass`) escapes the sandbox's @@ -278,7 +286,7 @@ export class SessionPermissionManager extends Disposable { // 4. Read auto-approval if (e.permissionKind === 'read' && e.permissionPath) { - if (await this._isReadAutoApproved(URI.file(e.permissionPath), workingDirectory)) { + if (await this._isReadAutoApproved(URI.file(e.permissionPath), workingDirectories)) { this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`); return ToolCallConfirmationReason.NotNeeded; } @@ -287,7 +295,7 @@ export class SessionPermissionManager extends Disposable { // 5. Write auto-approval if (e.permissionKind === 'write' && e.permissionPath) { - if (await this._isEditAutoApproved(URI.file(e.permissionPath), workingDirectory)) { + if (await this._isEditAutoApproved(URI.file(e.permissionPath), workingDirectories)) { this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); return ToolCallConfirmationReason.NotNeeded; } @@ -301,7 +309,7 @@ export class SessionPermissionManager extends Disposable { } const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput, { autoApproveRules: this._configService.getRootValue(platformRootSchema, AgentHostTerminalAutoApproveRulesConfigKey), - isWriteDestApproved: dest => this._isShellWriteDestApproved(dest, workingDirectory), + isWriteDestApproved: dest => this._isShellWriteDestApproved(dest, workingDirectories), }); if (result === 'approved') { this._logService.trace('[SessionPermissionManager] Auto-approving shell command'); @@ -394,15 +402,32 @@ export class SessionPermissionManager extends Disposable { // ---- Internal helpers --------------------------------------------------- - private async _isReadAutoApproved(resource: URI, workingDirectory: URI | undefined): Promise { - if (!workingDirectory) { + /** + * Whether a read of `resource` auto-approves against the session's working + * directories: it must be contained by **at least one** root. The read's + * symlink-resolved real path is compared too, so a symlink that crosses + * from one root into another is *not* auto-approved (fail-closed). With a + * single root this is identical to the previous behaviour. + */ + private async _isReadAutoApproved(resource: URI, workingDirectories: readonly URI[] | undefined): Promise { + if (!workingDirectories || workingDirectories.length === 0) { return false; } + // Resolve the read target once (literal + symlink real path); a denied + // resolution requires confirmation. + const resourcesToCheck = this._resolveResourcesForApproval(resource); + // Resolve each root's real path in parallel and stop at the first root + // that contains the target. + const match = await firstParallel( + workingDirectories.map(directory => this._isReadContainedByRoot(resourcesToCheck, directory)), + approved => approved, + ); + return match === true; + } - const [resourcesToCheck, workingDirectories] = await Promise.all([ - this._resolveResourcesForApproval(resource), - this._resolveResourcesForApproval(workingDirectory), - ]); + /** Whether every resolved read candidate is contained by `workingDirectory` (or its real path). */ + private async _isReadContainedByRoot(resourcesToCheckPromise: Promise, workingDirectory: URI): Promise { + const [resourcesToCheck, workingDirectories] = await Promise.all([resourcesToCheckPromise, this._resolveResourcesForApproval(workingDirectory)]); return resourcesToCheck !== undefined && workingDirectories !== undefined && resourcesToCheck.every(candidate => workingDirectories.some(directory => this._isResourceInDirectory(candidate, directory))); @@ -422,12 +447,18 @@ export class SessionPermissionManager extends Disposable { * rules that govern write tool calls: the destination must resolve to a * path inside the working directory and must not match a denied glob. */ - private _isShellWriteDestApproved(dest: string, workingDirectory: URI | undefined): boolean { - const resource = this._resolveShellRedirectResource(dest, workingDirectory); + private _isShellWriteDestApproved(dest: string, workingDirectories: readonly URI[] | undefined): boolean { + // A shell command runs in exactly one process cwd = the primary root + // (index 0), so a *relative* redirect can only resolve against that cwd. + const resource = this._resolveShellRedirectResource(dest, workingDirectories?.[0]); if (!resource) { return false; } - return this._checkWriteResource(resource, workingDirectory); + // The resolved (absolute) destination auto-approves when contained by + // any root — the same "any root" rule as read/write. Unlike read/write, + // this path is synchronous and does not resolve symlinks on the + // destination (pre-existing behaviour, unchanged here). + return (workingDirectories ?? []).some(workingDirectory => this._checkWriteResource(resource, workingDirectory)); } /** @@ -464,9 +495,22 @@ export class SessionPermissionManager extends Disposable { * `~/Library`, `%APPDATA%`, ...). * 5. The path must match the edit auto-approve glob rules. */ - private async _isEditAutoApproved(resource: URI, workingDirectory: URI | undefined): Promise { + private async _isEditAutoApproved(resource: URI, workingDirectories: readonly URI[] | undefined): Promise { + if (!workingDirectories || workingDirectories.length === 0) { + // A write is never auto-approved without a working directory to + // contain it (matches the previous behaviour). + return false; + } + // Resolve the write target once (literal + symlink real path); a denied + // resolution requires confirmation. const resourcesToCheck = await this._resolveResourcesForApproval(resource); - return resourcesToCheck !== undefined && resourcesToCheck.every(candidate => this._checkWriteResource(candidate, workingDirectory)); + if (resourcesToCheck === undefined) { + return false; + } + // Approve if ANY root clears the write checks for every resource + // candidate. `_checkWriteResource` is synchronous, so a plain `.some` + // already short-circuits — there is no per-root async work to parallelize. + return workingDirectories.some(workingDirectory => resourcesToCheck.every(candidate => this._checkWriteResource(candidate, workingDirectory))); } /** diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 6b8ecd28964580..1c6c88bf480aab 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -294,10 +294,11 @@ function parseWorkspaceUri(workspace: string): URI | undefined { } function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMetadata[]): URI { - const matchingSession = sessions.find(session => - session.workingDirectory?.toString() === workspace || session.workingDirectory?.fsPath === workspace); - if (matchingSession?.workingDirectory) { - return matchingSession.workingDirectory; + for (const session of sessions) { + const match = session.workingDirectories?.find(d => d.toString() === workspace || d.fsPath === workspace); + if (match) { + return match; + } } const parsed = parseWorkspaceUri(workspace); if (!parsed) { @@ -456,17 +457,19 @@ function sessionIsArchived(session: IAgentSessionMetadata): boolean { return session.isArchived === true || (session.status !== undefined && (session.status & SessionStatus.IsArchived) !== 0); } -/** Whether a session's working directory matches the given folder (absolute path or URI). */ +/** Whether any of a session's working directories matches the given folder (absolute path or URI). */ function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: string): boolean { - const dir = session.workingDirectory; - if (!dir) { + const dirs = session.workingDirectories; + if (!dirs || dirs.length === 0) { return false; } - if (dir.toString() === workspace || dir.fsPath === workspace) { - return true; - } const parsed = parseWorkspaceUri(workspace); - return !!parsed && parsed.toString() === dir.toString(); + // Any-root membership: a session matches when the folder is any of its + // working directories, not only the primary. + return dirs.some(dir => + dir.toString() === workspace + || dir.fsPath === workspace + || (!!parsed && parsed.toString() === dir.toString())); } /** Applies the {@link IListSessionsArgs} filters to a set of sessions. */ @@ -547,7 +550,7 @@ function serializeSession(session: IAgentSessionMetadata): ISerializedSession { ...(session.summary !== undefined ? { title: session.summary } : {}), ...(status !== undefined ? { status } : {}), ...(session.activity !== undefined ? { activity: session.activity } : {}), - ...(session.workingDirectory !== undefined ? { workingDirectory: session.workingDirectory.toString() } : {}), + ...(session.workingDirectories?.[0] !== undefined ? { workingDirectory: session.workingDirectories[0].toString() } : {}), ...(session.project !== undefined ? { project: session.project.displayName } : {}), ...(session.isRead === false ? { unread: true } : {}), ...(session.startTime > 0 ? { createdAt: new Date(session.startTime).toISOString() } : {}), @@ -592,7 +595,7 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso const sessions = await accessor.listSessions(); const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels()); const config: IAgentCreateSessionConfig = { - workingDirectory: args.workspace, + workingDirectories: args.workspace ? [args.workspace] : undefined, ...(args.model !== undefined ? { provider: args.model.provider, model: { id: args.model.id } } : {}), }; const session = await accessor.createSession(config); diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index d5323393afced1..86dd0fa75c98df 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -49,7 +49,7 @@ suite('AgentConfigurationService', () => { }; } - function makeSummary(resource: string, workingDirectory?: string): SessionSummary { + function makeSummary(resource: string, ...workingDirectories: string[]): SessionSummary { return { resource, provider: 'copilot', @@ -58,7 +58,7 @@ suite('AgentConfigurationService', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories: workingDirectories.length > 0 ? workingDirectories : undefined, }; } @@ -156,7 +156,31 @@ suite('AgentConfigurationService', () => { }); }); - // ---- updateSessionConfig ---------------------------------------------- + // ---- getEffectiveWorkingDirectories ----------------------------------- + + suite('getEffectiveWorkingDirectories', () => { + + test('returns the full ordered session set when set', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri, 'file:///work', 'file:///work-2')); + assert.deepStrictEqual(service.getEffectiveWorkingDirectories(uri), ['file:///work', 'file:///work-2']); + }); + + test('falls back to the parent session set for subagents', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent, 'file:///work/parent', 'file:///work/parent-2')); + + const child = buildSubagentSessionUri(parent, 'tc-3'); + manager.createSession(makeSummary(child)); + assert.deepStrictEqual(service.getEffectiveWorkingDirectories(child), ['file:///work/parent', 'file:///work/parent-2']); + }); + + test('returns undefined when neither layer has a working directory', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + assert.strictEqual(service.getEffectiveWorkingDirectories(uri), undefined); + }); + }); suite('updateSessionConfig', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 79542044d5d1ed..209de82da708c4 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -469,7 +469,7 @@ suite('AgentService (node dispatcher)', () => { await localService.createSession({ provider: 'codex', session, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, }); const failedSession = AgentSession.uri('codex', 'failed-before-create'); @@ -477,7 +477,7 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(localService.createSession({ provider: 'codex', session: failedSession, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, }), /create failed/); @@ -522,12 +522,12 @@ suite('AgentService (node dispatcher)', () => { const creatingSession = await localService.createSession({ provider: 'codex', - workingDirectory: URI.file('/workspace/repo'), + workingDirectories: [URI.file('/workspace/repo')], config: { [SessionConfigKey.Isolation]: 'folder' }, }); const readySession = await localService.createSession({ provider: 'copilot', - workingDirectory: URI.file('/workspace/repo'), + workingDirectories: [URI.file('/workspace/repo')], config: { [SessionConfigKey.Isolation]: 'folder' }, }); const creatingInitially = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); @@ -1398,7 +1398,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ modifiedTime: listed?.modifiedTime, project: listed?.project && { uri: listed.project.uri.path, displayName: listed.project.displayName }, - workingDirectory: listed?.workingDirectory?.path, + workingDirectory: listed?.workingDirectories?.[0]?.path, }, { modifiedTime: 2000, project: { uri: '/project', displayName: 'project' }, @@ -1781,12 +1781,12 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); // A normal session passes an input workingDirectory, so it is not // inferred workspace-less; `_meta` carries only the git overlay. - const session = await localService.createSession({ provider: 'copilot', workingDirectory }); + const session = await localService.createSession({ provider: 'copilot', workingDirectories: workingDirectory ? [workingDirectory] : undefined }); // _attachGitState is fire-and-forget; drain microtasks until the // git service's promise has resolved and setSessionMeta has run. @@ -1828,7 +1828,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -1911,7 +1911,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -1942,7 +1942,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -1976,7 +1976,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -2021,7 +2021,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; + agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; localService.registerProvider(agent); // Seed a session and clear its _meta so subscribe must lazily @@ -4652,7 +4652,7 @@ suite('AgentService (node dispatcher)', () => { test('addSubscriber for /changeset/uncommitted triggers the first git diff refresh', async () => { const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-refresh' }); copilotAgent.resolvedWorkingDirectory = workingDirectory; - copilotAgent.sessionMetadataOverrides = { workingDirectory }; + copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; // Recording git service: a call to `computeSessionFileDiffs` // with `baseBranch=undefined` is the signature of the uncommitted @@ -4696,7 +4696,7 @@ suite('AgentService (node dispatcher)', () => { // URI from any other client must also fire its own refresh. const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-refresh-2' }); copilotAgent.resolvedWorkingDirectory = workingDirectory; - copilotAgent.sessionMetadataOverrides = { workingDirectory }; + copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; const computeCalls: { wd: string; baseBranch: string | undefined }[] = []; const gitService = createNoopGitService(); @@ -4737,7 +4737,7 @@ suite('AgentService (node dispatcher)', () => { // from disk, and MUST drain the pending refresh. const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-restore-drain' }); copilotAgent.resolvedWorkingDirectory = workingDirectory; - copilotAgent.sessionMetadataOverrides = { workingDirectory }; + copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined }; const computeCalls: { wd: string; baseBranch: string | undefined }[] = []; const gitService = createNoopGitService(); @@ -5152,7 +5152,7 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(copilotAgent); const sourceDir = URI.file('/source/repo'); - const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); + const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] }); // The state manager should have the worktree path, not the source path const state = service.stateManager.getSessionState(session.toString()); @@ -5165,7 +5165,7 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(copilotAgent); const sourceDir = URI.file('/source/repo'); - const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); + const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] }); const state = service.stateManager.getSessionState(session.toString()); assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString()); @@ -5174,7 +5174,7 @@ suite('AgentService (node dispatcher)', () => { test('restoreSession uses agent working directory in state', async () => { // Agent returns the worktree path through listSessions const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz'); - copilotAgent.sessionMetadataOverrides = { workingDirectory: worktreeDir }; + copilotAgent.sessionMetadataOverrides = { workingDirectories: worktreeDir ? [worktreeDir] : undefined }; service.registerProvider(copilotAgent); const session = await service.createSession({ provider: 'copilot' }); @@ -5189,6 +5189,42 @@ suite('AgentService (node dispatcher)', () => { const state = service.stateManager.getSessionState(session.toString()); assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); + + test('_resolveWorkingDirectoryBeforeSend returns the full set (index 0 + tail), or undefined when unset', async () => { + const resolver = service as unknown as { + _resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise; + }; + const resolve = (resource: string) => resolver._resolveWorkingDirectoryBeforeSend({ session: resource, chat: `${resource}/chat`, turnId: 't', prompt: 'hi' }); + const inject = (resource: string, dirs?: readonly URI[]) => service.stateManager.restoreSession({ + resource, + provider: 'copilot', + title: 't', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + project: undefined, + workingDirectories: dirs?.map(d => d.toString()), + }, []); + + const a = URI.file('/roots/a'); + const b = URI.file('/roots/b'); + const c = URI.file('/roots/c'); + const multi = URI.from({ scheme: 'copilot', path: '/multi' }).toString(); + const single = URI.from({ scheme: 'copilot', path: '/single' }).toString(); + const none = URI.from({ scheme: 'copilot', path: '/none' }).toString(); + inject(multi, [a, b, c]); + inject(single, [a]); + inject(none, undefined); + + // No worktree isolation is configured, so index 0 resolves to itself and + // the additional roots are preserved as-is; a session with no roots + // resolves to `undefined` (the agent runs in its own scratch dir). + const toStrings = (r: readonly URI[] | undefined) => r?.map(d => d.toString()); + assert.deepStrictEqual( + [toStrings(await resolve(multi)), toStrings(await resolve(single)), toStrings(await resolve(none))], + [[a, b, c].map(d => d.toString()), [a.toString()], undefined], + ); + }); }); test('provisional workspace session advertises Uncommitted Changes before materialization', async () => { @@ -5222,7 +5258,7 @@ suite('AgentService (node dispatcher)', () => { const workspaceSession = await localService.createSession({ provider: provisionalAgent.id, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); const uncommittedUri = buildUncommittedChangesetUri(workspaceSession.toString()); localService.addSubscriber(URI.parse(uncommittedUri), 'client-1'); @@ -5353,14 +5389,14 @@ suite('AgentService (node dispatcher)', () => { // "session created in-memory now, persisted on first sendMessage" // flow that Copilot CLI / Claude actually use in production. class ProvisionalMockAgent extends MockAgent { - private readonly _onDidMaterialize = new Emitter<{ session: URI; workingDirectory: URI | undefined; project: { uri: URI; displayName: string } | undefined }>(); + private readonly _onDidMaterialize = new Emitter<{ session: URI; workingDirectories: readonly URI[] | undefined; project: { uri: URI; displayName: string } | undefined }>(); readonly onDidMaterializeSession = this._onDidMaterialize.event; override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise { const result = await super.createSession(config); return { ...result, provisional: true }; } materialize(session: URI, workingDirectory?: URI): void { - this._onDidMaterialize.fire({ session, workingDirectory, project: undefined }); + this._onDidMaterialize.fire({ session, workingDirectories: workingDirectory ? [workingDirectory] : undefined, project: undefined }); } } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 801d19997a884d..f6952ff13d8870 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -668,7 +668,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { assert.strictEqual(accepted, true); // Create a provisional session — no SDK contact yet. - const created = await agent.createSession({ workingDirectory: URI.file('/integration-cwd') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/integration-cwd')] }); assert.strictEqual(sdk.capturedStartupOptions.length, 0, 'createSession does not touch the SDK'); // Stage a transcript on the SDK so `sendMessage` resolves. @@ -796,7 +796,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'gh-int-test-token'); - const created = await agent.createSession({ workingDirectory: URI.file('/integration-cwd') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/integration-cwd')] }); const sessionId = created.session.path.replace(/^\//, ''); sdk.queryMessages = [ makeSystemInitMessage(sessionId), @@ -872,7 +872,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'gh-int-test-token'); - const created = await agent.createSession({ workingDirectory: URI.file('/integration-cwd') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/integration-cwd')] }); const sessionId = created.session.path.replace(/^\//, ''); // Canned turn: assistant says "reading", calls `Read`, the SDK diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index fa6baff5121480..7af3d3e20d8f76 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1157,7 +1157,7 @@ suite('ClaudeAgent', () => { const { agent } = createTestContext(disposables); await assert.rejects( - () => agent.createSession({ workingDirectory: URI.file('/workspace') }), + () => agent.createSession({ workingDirectories: [URI.file('/workspace')] }), (err: Error) => err instanceof ProtocolError && err.code === AHP_AUTH_REQUIRED && @@ -1594,13 +1594,13 @@ suite('ClaudeAgent', () => { const { agent, sdk, sessionData } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const result = await agent.createSession({ workingDirectory: URI.parse('file:///workspace') }); + const result = await agent.createSession({ workingDirectories: [URI.parse('file:///workspace')] }); assert.deepStrictEqual({ scheme: result.session.scheme, provider: AgentSession.provider(result.session), isUuid: isUUID(AgentSession.id(result.session)), - workingDirectory: result.workingDirectory?.toString(), + workingDirectory: result.resolvedWorkingDirectory?.toString(), provisional: result.provisional, openDatabaseCalls: sessionData.openDatabaseCallCount, tryOpenDatabaseCalls: sessionData.tryOpenDatabaseCallCount, @@ -1631,7 +1631,7 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({}); const sessionId = AgentSession.id(created.session); const expected = URI.joinPath(userHome, '.copilot', 'chats', sessionId); - assert.strictEqual(created.workingDirectory?.fsPath, expected.fsPath); + assert.strictEqual(created.resolvedWorkingDirectory?.fsPath, expected.fsPath); await fs.access(expected.fsPath); // Drive materialize via the first send; before the fix this rejected @@ -1713,7 +1713,7 @@ suite('ClaudeAgent', () => { // Phase 1: fresh materialize so the overlay is seeded with the // session's initial model. const initialModel = { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }; - const created = await agent.createSession({ workingDirectory: URI.file('/work-resume'), model: initialModel }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work-resume')], model: initialModel }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1'); @@ -1755,7 +1755,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const expected = AgentSession.uri('claude', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); - const result = await agent.createSession({ session: expected, workingDirectory: URI.file('/work') }); + const result = await agent.createSession({ session: expected, workingDirectories: [URI.file('/work')] }); assert.deepStrictEqual({ session: result.session.toString(), @@ -1792,7 +1792,7 @@ suite('ClaudeAgent', () => { materializeCount: events.length, startupCount: sdk.capturedStartupOptions.length, resultSession: result.session.toString(), - resultCwd: result.workingDirectory?.fsPath, + resultCwd: result.resolvedWorkingDirectory?.fsPath, provisional: result.provisional, }; @@ -1845,7 +1845,7 @@ suite('ClaudeAgent', () => { const database = new TestSessionDatabase(); const { agent, sdk } = createTestContext(disposables, { database }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [ @@ -1877,7 +1877,7 @@ suite('ClaudeAgent', () => { test('truncateSession cold-resumes an unloaded session, then applies the anchor on the next turn', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -1907,7 +1907,7 @@ suite('ClaudeAgent', () => { test('truncateSession throws when the turn is not in the transcript', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -1919,7 +1919,7 @@ suite('ClaudeAgent', () => { test('truncateSession on a provisional session is a no-op', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); await agent.truncateSession(created.session, 'u1'); @@ -1936,7 +1936,7 @@ suite('ClaudeAgent', () => { const database = new TestSessionDatabase(); const { agent, sdk } = createTestContext(disposables, { database }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), @@ -1969,7 +1969,7 @@ suite('ClaudeAgent', () => { test('truncateSession() with no turnId awaits the live query teardown (subprocess exit) before deleteSession', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), @@ -1997,7 +1997,7 @@ suite('ClaudeAgent', () => { const database = new TestSessionDatabase(); const { agent, sdk, instantiationService } = createTestContext(disposables, { database }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -2140,7 +2140,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); // A plain createSession is provisional until the first sendMessage. - const provisional = await agent.createSession({ workingDirectory: URI.file('/src') }); + const provisional = await agent.createSession({ workingDirectories: [URI.file('/src')] }); await assert.rejects( agent.createSession({ fork: { session: provisional.session, turnIndex: 0, turnId: 'u1' } }), @@ -2167,7 +2167,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); assert.strictEqual(proxy.startCalls.length, 1, 'proxy started by authenticate'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); assert.strictEqual(sdk.startupCallCount, 0, 'createSession does not touch the SDK'); const events: IAgentMaterializeSessionEvent[] = []; @@ -2186,7 +2186,7 @@ suite('ClaudeAgent', () => { startupCallCount: sdk.startupCallCount, materializeEventCount: events.length, eventSession: events[0]?.session.toString(), - eventCwd: events[0]?.workingDirectory?.fsPath, + eventCwd: events[0]?.workingDirectories?.[0]?.fsPath, startupOptionsCwd: sdk.capturedStartupOptions[0]?.cwd, startupOptionsSessionId: sdk.capturedStartupOptions[0]?.sessionId, }, { @@ -2205,14 +2205,14 @@ suite('ClaudeAgent', () => { const workspace = URI.file('/workspace'); const worktree = URI.file('/workspace.worktrees/session'); - const created = await agent.createSession({ workingDirectory: workspace }); + const created = await agent.createSession({ workingDirectories: [workspace] }); const sessionId = AgentSession.id(created.session); sdk.supportedCommandsResult = [{ name: 'worktree-skill', description: 'Worktree skill', argumentHint: '' }]; sdk.supportedAgentsResult = []; sdk.mcpServerStatusResult = []; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', worktree, undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', [worktree], undefined, 'turn-1'); let sessionChanges = 0; let agentChanges = 0; @@ -2252,7 +2252,7 @@ suite('ClaudeAgent', () => { const agentFile = URI.file('/mock-home/.claude/agents/foo.md'); await fileService.writeFile(agentFile, VSBuffer.fromString('---\nname: my-real-agent\ndescription: A real agent\n---\nbody')); - const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: agentFile.toString() } }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')], agent: { uri: agentFile.toString() } }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -2269,7 +2269,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work'), agent: { uri: 'claude-internal:/agent/Explore' } }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')], agent: { uri: 'claude-internal:/agent/Explore' } }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -2290,7 +2290,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const cwd = URI.file('/payload-shape'); - const created = await agent.createSession({ workingDirectory: cwd }); + const created = await agent.createSession({ workingDirectories: [cwd] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -2304,14 +2304,14 @@ suite('ClaudeAgent', () => { const ev = events[0]; assert.deepStrictEqual({ session: ev.session.toString(), - workingDirectory: ev.workingDirectory?.toString(), + workingDirectory: ev.workingDirectories?.[0]?.toString(), project: ev.project, keys: Object.keys(ev).sort(), }, { session: created.session.toString(), workingDirectory: cwd.toString(), project: undefined, - keys: ['project', 'session', 'workingDirectory'], + keys: ['project', 'session', 'workingDirectories'], }); }); @@ -2329,7 +2329,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], model: { id: 'claude-sonnet-4.6' }, config: { permissionMode: 'plan' }, }); @@ -2365,7 +2365,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], model: { id: 'claude-opus-4.6', config: { thinkingLevel: 'high' } }, }); const sessionId = AgentSession.id(created.session); @@ -2396,7 +2396,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Stage two turns. Park the iterator at index 2 (right after the @@ -2468,7 +2468,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), @@ -2537,7 +2537,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), @@ -2607,7 +2607,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const result = makeResultSuccess(sessionId); // Override the zero-default usage with values the mapper must @@ -2677,7 +2677,7 @@ suite('ClaudeAgent', () => { const { agent, proxy, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const result = makeResultSuccess(sessionId); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), result]; @@ -2716,7 +2716,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), @@ -2784,7 +2784,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables, { logService }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), @@ -2824,7 +2824,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const PARENT = 'toolu_parent_sa'; @@ -2896,7 +2896,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), @@ -2946,7 +2946,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -2969,7 +2969,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Park the iterator at index 0 so `_processMessages` is @@ -3076,7 +3076,7 @@ suite('ClaudeAgent', () => { const agent: ClaudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -3135,7 +3135,7 @@ suite('ClaudeAgent', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); await agent.disposeSession(created.session); @@ -3197,7 +3197,7 @@ suite('ClaudeAgent', () => { startupCallCount: sdk.startupCallCount, materializeEventCount: events.length, eventSession: events[0]?.session.toString(), - eventCwd: events[0]?.workingDirectory?.fsPath, + eventCwd: events[0]?.workingDirectories?.[0]?.fsPath, startupOptionsCwd: sdk.capturedStartupOptions[0]?.cwd, // In resume mode the SDK gets `Options.resume = ` and // MUST NOT get `Options.sessionId`. @@ -3307,7 +3307,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], config: { permissionMode: 'default' }, }); const sessionId = AgentSession.id(created.session); @@ -3360,7 +3360,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); // Materialize one session by running a turn end-to-end. - const matCreated = await agent.createSession({ workingDirectory: URI.file('/work-mat') }); + const matCreated = await agent.createSession({ workingDirectories: [URI.file('/work-mat')] }); sdk.nextQueryMessages = [ makeSystemInitMessage(AgentSession.id(matCreated.session)), makeResultSuccess(AgentSession.id(matCreated.session)), @@ -3368,7 +3368,7 @@ suite('ClaudeAgent', () => { await agent.chats.sendMessage(defaultChatUri(matCreated.session), 'hi', undefined, undefined, 'turn-1'); // Leave a second session provisional. - const provCreated = await agent.createSession({ workingDirectory: URI.file('/work-prov') }); + const provCreated = await agent.createSession({ workingDirectories: [URI.file('/work-prov')] }); const provAborter = (() => { // The provisional's controller isn't directly observable from the // public surface; capture it indirectly via the `capturedStartupOptions` @@ -3425,7 +3425,7 @@ suite('ClaudeAgent', () => { // 3) the result message still completes the deferred. const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const sessionUri = created.session; @@ -3485,7 +3485,7 @@ suite('ClaudeAgent', () => { // being our turn id, NOT a fresh SDK-generated uuid. const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ @@ -3516,7 +3516,7 @@ suite('ClaudeAgent', () => { // at the protocol → agent boundary). const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ @@ -3645,8 +3645,8 @@ suite('ClaudeAgent', () => { // key, and the agent does not surface a double-dispose error. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const r1 = await agent.createSession({ workingDirectory: URI.file('/work') }); - await agent.createSession({ workingDirectory: URI.file('/work') }); + const r1 = await agent.createSession({ workingDirectories: [URI.file('/work')] }); + await agent.createSession({ workingDirectories: [URI.file('/work')] }); const p1 = agent.disposeSession(r1.session); const p2 = agent.shutdown(); @@ -3673,7 +3673,7 @@ suite('ClaudeAgent', () => { // into SDK-side or DB-side deletion. const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); // Make the SDK report the just-created session as if its // metadata had been written by an earlier `query()` turn — // that's the steady state once Phase 6 sendMessage lands. @@ -3920,14 +3920,14 @@ suite('ClaudeAgent', () => { summary: sidecar?.summary, startTime: sidecar?.startTime, modifiedTime: sidecar?.modifiedTime, - workingDirectory: sidecar?.workingDirectory?.toString(), + workingDirectory: sidecar?.workingDirectories?.[0]?.toString(), }, external: { session: external?.session.toString(), summary: external?.summary, startTime: external?.startTime, modifiedTime: external?.modifiedTime, - workingDirectory: external?.workingDirectory?.toString(), + workingDirectory: external?.workingDirectories?.[0]?.toString(), }, unknown, sdkLookups: sdk.getSessionInfoCalls.slice().sort(), @@ -4014,8 +4014,8 @@ suite('ClaudeAgent', () => { // Mirror of `CopilotAgent.shutdown()` at copilotAgent.ts:1246. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - await agent.createSession({ workingDirectory: URI.file('/work') }); - await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectories: [URI.file('/work')] }); + await agent.createSession({ workingDirectories: [URI.file('/work')] }); const first = agent.shutdown(); const second = agent.shutdown(); @@ -4275,7 +4275,7 @@ suite('ClaudeAgent', () => { const agent = instantiationService.createInstance(ClaudeAgent); await agent.authenticate('https://api.github.com', 'tok'); - await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectories: [URI.file('/work')] }); agent.dispose(); assert.strictEqual(proxyDisposed, true); @@ -4331,7 +4331,7 @@ suite('ClaudeAgent', () => { const agent: ClaudeAgent = instantiationService.createInstance(ClaudeAgent); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -4378,7 +4378,7 @@ suite('ClaudeAgent', () => { test('setClientTools registers tools that flow into Options.mcpServers on first materialize', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const tools: ToolDefinition[] = [{ name: 'echo', description: 'Echo back', inputSchema: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] } }]; @@ -4401,7 +4401,7 @@ suite('ClaudeAgent', () => { test('setClientTools after materialize triggers yield-restart on next sendMessage with the new tool set', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Pause the iterator after the first result so the pipeline doesn't @@ -4436,7 +4436,7 @@ suite('ClaudeAgent', () => { test('a pending truncation anchor reaches the next rebuild as Options.resumeSessionAt, consumed once', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Pause after the first result so the pipeline doesn't auto-rebind on its own. @@ -4471,7 +4471,7 @@ suite('ClaudeAgent', () => { test('the truncation anchor is applied exactly once and not leaked to later rebuilds', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), @@ -4499,7 +4499,7 @@ suite('ClaudeAgent', () => { test('a rebuild that fails after reading the anchor keeps it staged so the next send retries the truncation', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), @@ -4524,7 +4524,7 @@ suite('ClaudeAgent', () => { const database = new TestSessionDatabase(); const { agent, sdk } = createTestContext(disposables, { database }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1'); @@ -4542,7 +4542,7 @@ suite('ClaudeAgent', () => { test('setClientTools with an equal snapshot does NOT restart', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const advance = new DeferredPromise(); @@ -4574,7 +4574,7 @@ suite('ClaudeAgent', () => { test('onClientToolCallComplete resolves the parked deferred keyed by tool_use_id', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -4610,7 +4610,7 @@ suite('ClaudeAgent', () => { // does not throw when there are no parked calls (the common case). const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -4621,7 +4621,7 @@ suite('ClaudeAgent', () => { test('FakeQuery.setMcpServers stays unmodeled (Phase 10 never calls Query.setMcpServers for client tools)', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -4637,7 +4637,7 @@ suite('ClaudeAgent', () => { test('setClientTools landing during the materialize gap is re-synced into the live session', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Initial snapshot before materialize starts. @@ -4723,7 +4723,7 @@ suite('ClaudeAgent', () => { test('rebind failure leaves the client-tool diff dirty so the next send retries', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Pause the iterator after the first result so the pipeline doesn't @@ -4857,7 +4857,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { }> { const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const state = ctx.stateManager.createSession({ @@ -5131,7 +5131,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { }> { const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const state = ctx.stateManager.createSession({ @@ -5337,7 +5337,7 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = // via `Options.permissionMode`). const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Seed state.config so `updateSessionConfig` (which dispatches @@ -5400,7 +5400,7 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = // exercises that path. const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const state = ctx.stateManager.createSession({ @@ -5446,7 +5446,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { }> { const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); const inputRequests: ChatInputRequest[] = []; @@ -5582,7 +5582,7 @@ suite('ClaudeAgent (Phase 8 — file edit tracking via SDK message stream)', () async function materialize(): Promise<{ ctx: ITestContext; sessionId: string; sessionUri: URI }> { const ctx = createTestContext(disposables); await ctx.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1'); @@ -5651,7 +5651,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables, { logService: opts?.logService }); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sessionId = AgentSession.id(created.session); const advance = new DeferredPromise(); ctx.sdk.queryAdvance = async (i: number) => { if (i === 2) { await advance.p; } }; @@ -5671,7 +5671,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); const created = await ctx.agent.createSession({ - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' }, }); @@ -5801,7 +5801,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sid = AgentSession.id(created.session); // Block the FakeQuery at index 0 so the first turn never completes. @@ -5837,7 +5837,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sid = AgentSession.id(created.session); // Materialize the session by driving one full turn so canUseTool is wired into Options. @@ -5862,7 +5862,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sid = AgentSession.id(created.session); // First turn: yield system_init then throw mid-stream (subprocess crash). @@ -5921,7 +5921,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sid = AgentSession.id(created.session); // Stage: system_init, then PARK at index 1 so the original turn @@ -5984,7 +5984,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const ctx = createTestContext(disposables); await ctx.agent.authenticate('https://api.github.com', 'tok'); await tick(); - const created = await ctx.agent.createSession({ workingDirectory: URI.file('/workspace'), model: { id: 'claude-opus-4.6' } }); + const created = await ctx.agent.createSession({ workingDirectories: [URI.file('/workspace')], model: { id: 'claude-opus-4.6' } }); const sid = AgentSession.id(created.session); // Park BOTH advance gates so we can release results one at a time. @@ -6096,7 +6096,7 @@ suite('ClaudeAgent (Phase 13 — getSessionMessages)', () => { test('getSessionMessages on provisional session returns [] with no SDK call', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/workspace') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/workspace')] }); const turns = await agent.getSessionMessages(created.session); @@ -6214,7 +6214,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const customizations = [makeClientCustomization('https://bundle', 'Synced')]; await agent.createSession({ session: AgentSession.uri('claude', 'eager'), - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], activeClient: { clientId: 'client-1', tools: [], customizations }, }); @@ -6233,7 +6233,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.createSession({ session: AgentSession.uri('claude', 'no-eager'), - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], }); assert.deepStrictEqual(pm.syncCalls, []); @@ -6249,7 +6249,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { URI.joinPath(workspace, '.mcp.json'), VSBuffer.fromString(JSON.stringify({ slack: { type: 'http', url: 'https://mcp.slack.com/mcp' } })), ); - const created = await agent.createSession({ workingDirectory: workspace }); + const created = await agent.createSession({ workingDirectories: [workspace] }); const before = await agent.getSessionCustomizations(created.session); const server = before.find(customization => customization.type === CustomizationType.McpServer && customization.name === 'slack'); assert.ok(server); @@ -6320,7 +6320,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const customizations = [makeClientCustomization('https://bundle', 'Synced')]; const cfg = { session: AgentSession.uri('claude', 'reconnect'), - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], activeClient: { clientId: 'client-1', tools: [], customizations }, }; await agent.createSession(cfg); @@ -6349,7 +6349,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.createSession({ session: AgentSession.uri('claude', 'quiet'), - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], activeClient: { clientId: 'client-1', tools: [], customizations: [makeClientCustomization('https://bundle', 'Synced')] }, }); @@ -6364,7 +6364,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const updates: { uri: string }[] = []; disposables.add(agent.onDidSessionProgress(s => { @@ -6388,8 +6388,8 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, stateManager } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'a'), workingDirectory: URI.file('/work') }); - const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'b'), workingDirectory: URI.file('/work') }); + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'a'), workingDirectories: [URI.file('/work')] }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'b'), workingDirectories: [URI.file('/work')] }); pm.syncResult = [makeSyncedRef('https://shared', '/p/shared')]; await agent.syncClientCustomizations(s1.session, 'c', [makeClientCustomization('https://shared', 'S')]); @@ -6425,8 +6425,8 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'one'), workingDirectory: URI.file('/work') }); - const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'two'), workingDirectory: URI.file('/work') }); + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'one'), workingDirectories: [URI.file('/work')] }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'two'), workingDirectories: [URI.file('/work')] }); pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://a', '/p/a')]; await agent.syncClientCustomizations(s1.session, 'c', []); @@ -6445,7 +6445,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; const { agent } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); assert.strictEqual(created.provisional, true); await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); @@ -6463,7 +6463,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; const { agent, stateManager } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const initial = await agent.getSessionCustomizations!(created.session); @@ -6483,7 +6483,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const ctx = buildCtxWith(pm); const { agent, sdk } = ctx; await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Stage 2 turns and park the iterator after turn 1's `result` so @@ -6521,7 +6521,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const ctx = buildCtxWith(pm); const { agent, sdk, stateManager } = ctx; await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Materialize, then drain the dirty bit from a customization @@ -6583,7 +6583,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; const { agent, sdk } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -6605,7 +6605,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const pm = new FakeAgentPluginManager(); const { agent, sdk } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // A successful snapshot: one SDK-only command, no agents/MCP. (No disk @@ -6642,7 +6642,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const pm = new FakeAgentPluginManager(); const { agent, sdk, fileService } = buildCtxWith(pm); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); // Seed an enabled native plugin under the mock user home cache. @@ -6672,7 +6672,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const ctx = buildCtxWith(pm); const { agent, sdk } = ctx; await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); await agent.chats.changeAgent(defaultChatUri(created.session), { uri: 'file:///foo/agents/code-reviewer.md' }); @@ -6689,7 +6689,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const ctx = buildCtxWith(pm); const { agent, sdk } = ctx; await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [ @@ -6714,7 +6714,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = ctx; await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const created = await agent.createSession({ - workingDirectory: URI.file('/work'), + workingDirectories: [URI.file('/work')], agent: { uri: 'file:///foo/agents/planner.md' }, }); const sessionId = AgentSession.id(created.session); @@ -6739,7 +6739,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); await agent.chats.createChat(chatUri); @@ -6763,7 +6763,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const defaultChat = URI.parse(buildChatUri(created.session.toString(), 'default')); await agent.chats.createChat(defaultChat); @@ -6777,7 +6777,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); // Parent session with a two-turn transcript; fork the peer chat at u1. - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); sdk.forkSessionResult = { sessionId: 'forked-1' }; @@ -6806,7 +6806,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { test('createChat({ sideChat }) forks hidden context and filters inherited turns', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); sdk.forkSessionResult = { sessionId: 'side-1' }; @@ -6868,7 +6868,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { test('createChat({ sideChat }) falls back to injected source context when the source transcript is unavailable', async () => { const { agent, sdk, stateManager } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); stateManager.restoreSession({ resource: created.session.toString(), provider: 'claude', @@ -6904,7 +6904,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { test('createChat({ sideChat }) preserves a local source turn id while forking from the concrete provider anchor', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); sdk.forkSessionResult = { sessionId: 'side-local-1' }; @@ -6958,7 +6958,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -6978,7 +6978,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.forkSessionResult = { sessionId: 'forked-1' }; sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -7007,7 +7007,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.forkSessionResult = { sessionId: 'forked-1' }; sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -7030,7 +7030,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); await agent.chats.createChat(chatUri); @@ -7048,7 +7048,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables, { logService }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.forkSessionResult = { sessionId: 'forked-1' }; sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -7076,7 +7076,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.forkSessionResult = { sessionId: 'forked-1' }; sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -7100,7 +7100,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); @@ -7147,7 +7147,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { // persist. --- const ctxA = createTestContext(disposables, { database }); await ctxA.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await ctxA.agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await ctxA.agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); ctxA.sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); ctxA.sdk.forkSessionResult = { sessionId: 'forked-1' }; @@ -7199,7 +7199,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); const createResult = await agent.chats.createChat(chatUri); const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; @@ -7222,7 +7222,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); // Tearing the session down must not throw and must be idempotent. await agent.disposeSession(created.session); await agent.disposeSession(created.session); @@ -7240,7 +7240,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); await agent.chats!.createChat(chatUri); @@ -7259,7 +7259,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); sdk.forkSessionResult = { sessionId: 'forked-1' }; @@ -7288,7 +7288,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; @@ -7313,7 +7313,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); const createResult = await agent.chats!.createChat(chatUri); const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; @@ -7332,7 +7332,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const created = await agent.createSession({ workingDirectories: [URI.file('/work')] }); const parentId = AgentSession.id(created.session); sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); sdk.forkSessionResult = { sessionId: 'forked-1' }; diff --git a/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts b/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts index 5243d18c34cdc6..856d74c7532cf5 100644 --- a/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSessionMetadataStore.test.ts @@ -130,7 +130,7 @@ suite('ClaudeSessionMetadataStore', () => { startTime: projected.startTime, modifiedTime: projected.modifiedTime, summary: projected.summary, - workingDirectory: projected.workingDirectory?.toString(), + workingDirectory: projected.workingDirectories?.[0]?.toString(), }, { session: 'claude:/abc', startTime: 1000, @@ -148,7 +148,7 @@ suite('ClaudeSessionMetadataStore', () => { assert.deepStrictEqual({ summary: projected.summary, - workingDirectory: projected.workingDirectory, + workingDirectory: projected.workingDirectories?.[0], }, { summary: 'fallback', workingDirectory: undefined, diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index db5003763cd524..91a48a964edd61 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -138,7 +138,7 @@ async function assertPrewarmEvictedOnSend(disposables: Pick { const session = AgentSession.uri('copilotcli', 'unauth-create'); const workingDirectory = URI.file('/workspace'); try { - const result = await agent.createSession({ session, workingDirectory }); - assert.ok(result.workingDirectory); + const result = await agent.createSession({ session, workingDirectories: workingDirectory ? [workingDirectory] : undefined }); + assert.ok(result.resolvedWorkingDirectory); assert.deepStrictEqual({ session: result.session.toString(), - workingDirectory: result.workingDirectory.toString(), + workingDirectory: result.resolvedWorkingDirectory.toString(), provisional: result.provisional, starts: client.startCallCount, stops: client.stopCallCount, @@ -1427,11 +1427,12 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - assert.ok(result.workingDirectory); + const resultWorkingDirectory = result.resolvedWorkingDirectory; + assert.ok(resultWorkingDirectory); const expected = URI.joinPath(userHome, '.copilot', 'chats', 'temp-fallback'); - assert.strictEqual(result.workingDirectory.scheme, Schemas.file); - assert.strictEqual(result.workingDirectory.fsPath, expected.fsPath); - assert.deepStrictEqual(await fs.readdir(result.workingDirectory.fsPath), []); + assert.strictEqual(resultWorkingDirectory.scheme, Schemas.file); + assert.strictEqual(resultWorkingDirectory.fsPath, expected.fsPath); + assert.deepStrictEqual(await fs.readdir(resultWorkingDirectory.fsPath), []); // Tagged workspace-less purely from inference (no input flag). const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get('temp-fallback'); assert.strictEqual(provisional?.workspaceless, true); @@ -2198,7 +2199,7 @@ suite('CopilotAgent', () => { await waitForState(agent.models, m => m.length > 0); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'ctx-session'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], ...(model ? { model } : {}), }); await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); @@ -2585,7 +2586,7 @@ suite('CopilotAgent', () => { startTime: 1000, modifiedTime: 2000, summary: 'SDK legacy', - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }]); } finally { await disposeAgent(agent); @@ -2613,7 +2614,7 @@ suite('CopilotAgent', () => { startTime: 1000, modifiedTime: 2000, summary: 'SDK quick', - workingDirectory: URI.file('/scratch/quick'), + workingDirectories: [URI.file('/scratch/quick')], }]); } finally { await disposeAgent(agent); @@ -2639,7 +2640,7 @@ suite('CopilotAgent', () => { startTime: 1000, modifiedTime: 2000, summary: 'SDK target', - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); assert.deepStrictEqual(client.getSessionMetadataCalls, ['target']); assert.strictEqual(client.listSessionCallCount, 0); @@ -2705,7 +2706,7 @@ suite('CopilotAgent', () => { const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }]; const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'test-session'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], activeClient: { clientId: 'client-1', tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], @@ -2732,7 +2733,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'test-session-2'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); assert.strictEqual(result.provisional, true); @@ -2900,7 +2901,7 @@ suite('CopilotAgent', () => { const session = AgentSession.uri('copilotcli', 'session-discovery-directories'); await agent.createSession({ session, - workingDirectory: workspace, + workingDirectories: [workspace], }); const customizations = await agent.getSessionCustomizations(session); @@ -2993,7 +2994,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'session-discovery-immediate'); - await agent.createSession({ session, workingDirectory: workspace }); + await agent.createSession({ session, workingDirectories: [workspace] }); provider.trackStats = true; const customizations = agent.getSessionCustomizations(session); @@ -3026,7 +3027,7 @@ suite('CopilotAgent', () => { const session = AgentSession.uri('copilotcli', 'session-discovery-cleared'); await agent.createSession({ session, - workingDirectory: workspace, + workingDirectories: [workspace], }); const before = await agent.getSessionCustomizations(session); @@ -3091,7 +3092,7 @@ suite('CopilotAgent', () => { const session = AgentSession.uri('copilotcli', 'session-discovery-neutral-watcher-change'); await agent.createSession({ session, - workingDirectory: workspace, + workingDirectories: [workspace], }); await agent.getSessionCustomizations(session); @@ -3170,7 +3171,7 @@ suite('CopilotAgent', () => { const session = AgentSession.uri('copilotcli', 'session-discovery-burst-watcher-change'); await agent.createSession({ session, - workingDirectory: workspace, + workingDirectories: [workspace], }); await agent.getSessionCustomizations(session); @@ -3225,7 +3226,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'prov-1'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], config: { isolation: 'worktree', branch: 'main' }, }); @@ -3250,7 +3251,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'prov-default-chat'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); @@ -3275,7 +3276,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'prov-2'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); await agent.disposeSession(result.session); @@ -3312,7 +3313,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'prov-3'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); await agent.disposeSession(result.session); @@ -3358,7 +3359,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'system-message-session'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); assert.strictEqual(result.provisional, true); @@ -3398,7 +3399,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'session-level-token'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); assert.strictEqual(result.provisional, true); @@ -3423,7 +3424,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'gh-token-abc'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'failed-session-token'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); await assert.rejects(agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined), /create failed/); @@ -3448,7 +3449,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); assert.strictEqual(result.provisional, true); @@ -3645,7 +3646,7 @@ suite('CopilotAgent', () => { const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'parent-with-peers'), - workingDirectory: URI.file('/workspace'), + workingDirectories: [URI.file('/workspace')], }); const chatUri = URI.parse(buildChatUri(result.session, 'peer-x')); const chat = installStubChat(agent, chatUri); @@ -3761,7 +3762,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'create-peer'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const chatUri = URI.parse(buildChatUri(session, 'peer-a')); const internals = agent as unknown as ChatInternals; @@ -3836,7 +3837,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'fork-peer'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const internals = agent as unknown as ChatInternals; // Install the default chat as the fork source so resolution stays @@ -3892,7 +3893,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'side-peer'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const sourceTurn: Turn = { id: 't1', state: TurnState.Complete, @@ -3974,7 +3975,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'side-local-peer'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const sourceTurn: Turn = { id: 't1', state: TurnState.Complete, @@ -4147,7 +4148,7 @@ suite('CopilotAgent', () => { const agent1 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); try { await agent1.authenticate('https://api.github.com', 'token'); - await agent1.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent1.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const internals1 = agent1 as unknown as ChatInternals; internals1._createAgentSession = (launchPlan, _dir, _ac, identity) => { if (identity) { @@ -4172,7 +4173,7 @@ suite('CopilotAgent', () => { // The orchestrator re-creates the (provisional) parent session on // restore; this seeds the working directory the peer-chat resume // path needs. - await agent2.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent2.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const internals2 = agent2 as unknown as ChatInternals; const peerA = URI.parse(buildChatUri(session, 'peer-a')); @@ -4347,7 +4348,7 @@ suite('CopilotAgent', () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); try { const session = AgentSession.uri('copilotcli', 'scope-create'); - const result = await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const result = await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const internals = agent as unknown as ConvInternals; assert.deepStrictEqual({ session: result.session.toString(), @@ -4365,7 +4366,7 @@ suite('CopilotAgent', () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); try { const session = AgentSession.uri('copilotcli', 'scope-dispose'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const internals = agent as unknown as ConvInternals; assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); @@ -4383,7 +4384,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'conv-create'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); const chatUri = URI.parse(buildChatUri(session, 'peer-a')); stubBackingSession(agent); @@ -4409,7 +4410,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'conv-fork'); - await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); installFake(agent, AgentSession.id(session), 'session', session); const forkArgs: { turnId: string }[] = []; @@ -5009,9 +5010,9 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectory: originalFolder }); + const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectories: [originalFolder] }); assert.strictEqual(result.provisional, true); - await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', resolvedWorkingDirectory, undefined, undefined, undefined); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', resolvedWorkingDirectory ? [resolvedWorkingDirectory] : undefined, undefined, undefined, undefined); } finally { await disposeAgent(agent); } @@ -5065,11 +5066,11 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'wt-dirs-session'), - workingDirectory: originalFolder, + workingDirectories: [originalFolder], activeClient: { clientId: 'c1', tools: [] }, }); assert.strictEqual(result.provisional, true); - await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', worktree, undefined, undefined, undefined); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', [worktree], undefined, undefined, undefined); } finally { await disposeAgent(agent); } @@ -5222,11 +5223,11 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'agent-translate'), - workingDirectory: repoFolder, + workingDirectories: [repoFolder], agent: { uri: repoAgentFile.toString() }, }); assert.strictEqual(result.provisional, true); - await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', worktreeFolder, undefined, undefined, undefined); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', [worktreeFolder], undefined, undefined, undefined); // `_readSessionMetadata` reads back the exact agent field the // resume path consumes, so asserting it stands in for restore. diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4980e51713dad2..3013fd846e5e1f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -502,6 +502,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { // session class will read. getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'], getEffectiveWorkingDirectory: () => undefined, + getEffectiveWorkingDirectories: () => undefined, isWorkingDirectoryPending: () => false, resolveWorkingDirectoryForResume: async (_session, workingDirectory) => workingDirectory, getSessionConfigValues: () => undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 62710ac19e414e..5dcbe7df861480 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -126,6 +126,7 @@ suite('CopilotShellTools', () => { onDidSessionConfigChange: Event.None, getEffectiveValue: () => undefined, getEffectiveWorkingDirectory: () => undefined, + getEffectiveWorkingDirectories: () => undefined, isWorkingDirectoryPending: () => false, resolveWorkingDirectoryForResume: async (_session, workingDirectory) => workingDirectory, getSessionConfigValues: () => undefined, diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 0cb7a240416c92..02cb1db2ca9322 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -132,7 +132,7 @@ export class MockAgent implements IAgent { const session = config?.session ?? AgentSession.uri(this.id, `${this.id}-session-${this._nextId++}`); const rawId = AgentSession.id(session); this._sessions.set(rawId, session); - return { session, project: mockProject(this.id), workingDirectory: this.resolvedWorkingDirectory }; + return { session, project: mockProject(this.id), resolvedWorkingDirectory: this.resolvedWorkingDirectory }; } async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { @@ -250,7 +250,7 @@ export class MockAgent implements IAgent { const { session, chat } = this._resolveChatTarget(chatUri); return this.disposeChat(session, chat); }, - sendMessage: (chatUri: URI, prompt: string, _workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise => { + sendMessage: (chatUri: URI, prompt: string, _workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise => { const { session, chat } = this._resolveChatTarget(chatUri); return this.sendMessage(session, chat, prompt, attachments, turnId, senderClientId, clientType); }, @@ -929,7 +929,7 @@ export class ScriptedMockAgent implements IAgent { disposeChat: (_chat: URI): Promise => { return Promise.resolve(); }, - sendMessage: (chatUri: URI, prompt: string, _workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { + sendMessage: (chatUri: URI, prompt: string, _workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise => { const { session, chat } = this._resolveChatTarget(chatUri); return this.sendMessage(session, chat, prompt, attachments, turnId); }, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index b806a841ea9e5f..bb732743d20068 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -124,7 +124,7 @@ class MockAgentService implements IAgentService { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///created-project', displayName: 'Created Project' }, - workingDirectories: config?.workingDirectory ? [config.workingDirectory.toString()] : undefined, + workingDirectories: config?.workingDirectories?.[0] ? [config.workingDirectories?.[0].toString()] : undefined, }); return session; } diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index 8019e724eac07f..4bd0fe1435c86b 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -30,11 +30,12 @@ suite('SessionPermissionManager', () => { // Real (symlink-resolved) temp directories so that the symlink-resolution // checks compare like-for-like (e.g. macOS `/var` -> `/private/var`). let workDir: string; + let workDir2: string; let outsideDir: string; const sessionUri = URI.from({ scheme: 'copilot', path: '/s' }).toString(); const directoryLinkType = isWindows ? 'junction' : 'dir'; - function makeSummary(resource: string, workingDirectory?: string): SessionSummary { + function makeSummary(resource: string, ...workingDirectories: string[]): SessionSummary { return { resource, provider: 'copilot', @@ -43,7 +44,7 @@ suite('SessionPermissionManager', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories: workingDirectories.length > 0 ? workingDirectories : undefined, }; } @@ -71,6 +72,7 @@ suite('SessionPermissionManager', () => { // checks compare like-for-like. const baseTmp = process.env.AGENT_TEMPDIRECTORY || process.env.RUNNER_TEMP || tmpdir(); workDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-work-'))); + workDir2 = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-work2-'))); outsideDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-out-'))); manager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -84,6 +86,7 @@ suite('SessionPermissionManager', () => { teardown(() => { disposables.clear(); rmSync(workDir, { recursive: true, force: true }); + rmSync(workDir2, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); @@ -308,4 +311,65 @@ suite('SessionPermissionManager', () => { assert.strictEqual(permissions.isGlobalAutoApproveEnabled(), true); assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); }); + + // ---- Multi-root auto-approval ------------------------------------------ + // A session with multiple working directories auto-approves a read/write/ + // shell destination when it is contained by *any* root (index 0 = primary). + // The multi-root path is otherwise dormant today (the create-time length + // guard keeps sessions single-root), so these tests synthesize a two-root + // session state directly. + suite('multi-root', () => { + const multiUri = URI.from({ scheme: 'copilot', path: '/multi' }).toString(); + + setup(() => { + // Index 0 (`workDir`) is the primary/process cwd; `workDir2` is a peer root. + manager.createSession(makeSummary(multiUri, URI.file(workDir).toString(), URI.file(workDir2).toString())); + }); + + test('auto-approves reads and writes under any root, confirms outside all roots', async () => { + const results = [ + await permissions.getAutoApproval(readEvent(join(workDir, 'a.txt'), multiUri), multiUri), + await permissions.getAutoApproval(readEvent(join(workDir2, 'a.txt'), multiUri), multiUri), + await permissions.getAutoApproval(readEvent(join(outsideDir, 'a.txt'), multiUri), multiUri), + await permissions.getAutoApproval(writeEvent(join(workDir, 'x.txt')), multiUri), + await permissions.getAutoApproval(writeEvent(join(workDir2, 'x.txt')), multiUri), + await permissions.getAutoApproval(writeEvent(join(outsideDir, 'x.txt')), multiUri), + ]; + assert.deepStrictEqual(results, [ + ToolCallConfirmationReason.NotNeeded, + ToolCallConfirmationReason.NotNeeded, + undefined, + ToolCallConfirmationReason.NotNeeded, + ToolCallConfirmationReason.NotNeeded, + undefined, + ]); + }); + + test('a relative shell redirect resolves against the primary root (index 0)', async () => { + // `out.txt` resolves against the single process cwd = `workDir`, so it + // is contained by the primary root and auto-approves. + const result = await permissions.getAutoApproval(shellEvent('echo hi > out.txt'), multiUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + (isWindows ? test.skip : test)('an absolute shell redirect auto-approves under a non-primary root but confirms outside', async () => { + // POSIX-only: embedding absolute paths in the command string avoids + // Windows backslash/drive-colon parsing pitfalls. The containment rule + // itself is platform-agnostic and covered by the read/write test above. + const intoPeer = await permissions.getAutoApproval(shellEvent(`echo hi > ${join(workDir2, 'out.txt')}`), multiUri); + const outside = await permissions.getAutoApproval(shellEvent(`echo hi > ${join(outsideDir, 'out.txt')}`), multiUri); + assert.deepStrictEqual([intoPeer, outside], [ToolCallConfirmationReason.NotNeeded, undefined]); + }); + + test('requires confirmation for a symlink that crosses from one root into another (fail-closed)', async () => { + // A symlink inside `workDir` pointing at the peer root `workDir2`: the + // literal path is under root A and the real path under root B, so no + // single root contains both the literal and resolved path. Fail-closed + // ⇒ confirmation for both read and write. + symlinkSync(workDir2, join(workDir, 'cross-link'), directoryLinkType); + const read = await permissions.getAutoApproval(readEvent(join(workDir, 'cross-link', 'note.txt'), multiUri), multiUri); + const write = await permissions.getAutoApproval(writeEvent(join(workDir, 'cross-link', 'note.txt')), multiUri); + assert.deepStrictEqual([read, write], [undefined, undefined]); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 0a3b4ed1d246bd..7e70324bcd760b 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -47,7 +47,7 @@ suite('SessionServerTools', () => { const model: IAgentModelInfo = { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', supportsVision: false }; function sessionMeta(id: string, status: SessionStatus, dir: URI): IAgentSessionMetadata { - return { session: URI.parse(`copilot:/${id}`), startTime: 0, modifiedTime: 0, status, workingDirectory: dir, summary: `title-${id}` }; + return { session: URI.parse(`copilot:/${id}`), startTime: 0, modifiedTime: 0, status, workingDirectories: dir ? [dir] : undefined, summary: `title-${id}` }; } function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (session: URI, chat: URI, prompt: string) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: IAgentModelInfo }) => void; onDelete?: (session: URI) => void; depths?: Map }): ISessionServerToolAccessor { @@ -97,7 +97,7 @@ suite('SessionServerTools', () => { modifiedTime: 1700000000000, status: SessionStatus.InProgress, activity: 'Running tests', - workingDirectory: workspace, + workingDirectories: workspace ? [workspace] : undefined, project: { uri: workspace, displayName: 'app' }, isRead: false, summary: 'Rich session', @@ -124,7 +124,7 @@ suite('SessionServerTools', () => { test('serializeSessions reports archived status for metadata-only archives', () => { const metadataOnly: IAgentSessionMetadata = { ...sessionMeta('archived', SessionStatus.Idle, workspace), isArchived: true }; const bitOnly: IAgentSessionMetadata = { ...sessionMeta('bitArchived', SessionStatus.Idle | SessionStatus.IsArchived, workspace) }; - const noStatus: IAgentSessionMetadata = { session: URI.parse('copilot:/noStatus'), startTime: 0, modifiedTime: 0, isArchived: true, workingDirectory: workspace }; + const noStatus: IAgentSessionMetadata = { session: URI.parse('copilot:/noStatus'), startTime: 0, modifiedTime: 0, isArchived: true, workingDirectories: workspace ? [workspace] : undefined }; assert.deepStrictEqual(JSON.parse(serializeSessions([metadataOnly, bitOnly, noStatus])).sessions.map((s: { session: string; status?: string }) => ({ session: s.session, status: s.status })), [ { session: 'copilot:/archived', status: 'idle,archived' }, { session: 'copilot:/bitArchived', status: 'idle,archived' }, @@ -355,7 +355,7 @@ suite('SessionServerTools', () => { const md = (content: string): ResponsePart => ({ kind: ResponsePartKind.Markdown, id: 'm', content }); const toolPart = (tc: ToolCallState): ResponsePart => ({ kind: ResponsePartKind.ToolCall, toolCall: tc }); const turn = (id: string, user: string, parts: ResponsePart[], state = TurnState.Complete): Turn => - ({ id, message: { text: user, origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined, state }); + ({ id, message: { text: user, origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined, state }); const snapshot: IChatContextSnapshot = { turns: [ diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index d799d3878b8949..d26e4d197d8ed7 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from '../../base/common/themables.js'; import { URI } from '../../base/common/uri.js'; import type { ISessionGitState } from '../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; -import { IGitHubInfo, ISessionWorkspace } from '../services/sessions/common/session.js'; +import { IGitHubInfo, ISessionFolder, ISessionWorkspace } from '../services/sessions/common/session.js'; export interface IAgentHostSessionProjectSummary { readonly uri: URI; @@ -75,23 +75,27 @@ export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | unde if (!workspace || !folder) { return undefined; } - const repo = folder.gitRepository; - return [ - workspace.label, - extUri.getComparisonKey(folder.root), - folder.workingDirectory ? extUri.getComparisonKey(folder.workingDirectory) : '', - repo?.branchName ?? '', - repo?.baseBranchName ?? '', - String(repo?.baseBranchProtected ?? ''), - String(repo?.hasGitHubRemote ?? ''), - repo?.upstreamBranchName ?? '', - String(repo?.incomingChanges ?? ''), - String(repo?.outgoingChanges ?? ''), - String(repo?.uncommittedChanges ?? ''), - ].join('\n'); + // Hash every folder so a change to any peer directory (added/removed/reordered) + // invalidates the key, not just the primary. + const folderKeys = workspace.folders.map(f => { + const repo = f.gitRepository; + return [ + extUri.getComparisonKey(f.root), + f.workingDirectory ? extUri.getComparisonKey(f.workingDirectory) : '', + repo?.branchName ?? '', + repo?.baseBranchName ?? '', + String(repo?.baseBranchProtected ?? ''), + String(repo?.hasGitHubRemote ?? ''), + repo?.upstreamBranchName ?? '', + String(repo?.incomingChanges ?? ''), + String(repo?.outgoingChanges ?? ''), + String(repo?.uncommittedChanges ?? ''), + ].join('\u0001'); + }); + return [workspace.label, ...folderKeys].join('\n'); } -export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectory: URI | undefined, options: IAgentHostSessionWorkspaceOptions, gitHubInfo: IObservable, gitState?: ISessionGitState): ISessionWorkspace | undefined { +export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectories: readonly URI[] | undefined, options: IAgentHostSessionWorkspaceOptions, gitHubInfo: IObservable, gitState?: ISessionGitState): ISessionWorkspace | undefined { const baseBranchName = gitState?.baseBranchName; const baseBranchProtected = baseBranchName !== undefined ? matchesAnyBranchProtectionPattern(baseBranchName, options.branchProtectionPatterns) @@ -103,8 +107,19 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject const uncommittedChanges = gitState?.uncommittedChanges; const branchName = gitState?.branchName; const gitFields = { branchName, baseBranchName, baseBranchProtected, hasGitHubRemote, upstreamBranchName, incomingChanges, outgoingChanges, uncommittedChanges }; + + // The primary (index 0) is the session's process root; it carries the git + // state / project association. Additional directories are emitted as plain + // peer folders — per-folder git state is owned by the deferred git track and + // is not populated here. + const primary = workingDirectories?.[0]; + const additionalFolders: ISessionFolder[] = (workingDirectories ?? []).slice(1).map(dir => { + const name = basename(dir) || dir.path; + return { root: dir, workingDirectory: dir, name, description: options.description }; + }); + if (project) { - const workTreeUri = extUri.isEqual(workingDirectory, project.uri) ? undefined : workingDirectory; + const workTreeUri = extUri.isEqual(primary, project.uri) ? undefined : primary; const label = options.providerLabel ? `${project.displayName} [${options.providerLabel}]` : project.displayName; return { uri: project.uri, @@ -114,35 +129,35 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject group: options.group, folders: [{ root: project.uri, - workingDirectory: workingDirectory ?? project.uri, + workingDirectory: primary ?? project.uri, name: project.displayName, description: options.description, gitRepository: { uri: project.uri, workTreeUri, gitHubInfo, ...gitFields }, - }], + }, ...additionalFolders], requiresWorkspaceTrust: options.requiresWorkspaceTrust, isVirtualWorkspace: false, }; } - if (!workingDirectory) { + if (!primary) { return undefined; } - const folderName = basename(workingDirectory) || workingDirectory.path; + const folderName = basename(primary) || primary.path; const label = options.providerLabel ? `${folderName} [${options.providerLabel}]` : folderName; return { - uri: workingDirectory, + uri: primary, label, description: options.description, icon: options.fallbackIcon, group: options.group, folders: [{ - root: workingDirectory, - workingDirectory, + root: primary, + workingDirectory: primary, name: folderName, description: options.description, - gitRepository: { uri: workingDirectory, workTreeUri: undefined, gitHubInfo, ...gitFields }, - }], + gitRepository: { uri: primary, workTreeUri: undefined, gitHubInfo, ...gitFields }, + }, ...additionalFolders], requiresWorkspaceTrust: options.requiresWorkspaceTrust, isVirtualWorkspace: false, }; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index f0de3426973c2a..a43f5637e6ee70 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -99,7 +99,7 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad startTime: meta.startTime, modifiedTime: meta.modifiedTime, summary: meta.summary, - workingDirectory: meta.workingDirectory?.toString(), + workingDirectory: meta.workingDirectories?.[0]?.toString(), isRead: meta.isRead, isArchived: meta.isArchived, project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, @@ -114,7 +114,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta startTime: raw.startTime, modifiedTime: raw.modifiedTime, summary: raw.summary, - workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, + workingDirectories: raw.workingDirectory ? [URI.parse(raw.workingDirectory)] : undefined, isRead: raw.isRead, isArchived: raw.isArchived ?? raw.isDone, project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, @@ -233,7 +233,7 @@ export interface IAgentHostAdapterOptions { /** Loading observable wired to the provider's authentication-pending state. */ readonly loading: IObservable; /** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */ - readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => ISessionWorkspace | undefined; + readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => ISessionWorkspace | undefined; /** Optional URI mapping for diff entries (remote uses `toAgentHostUri`; local uses identity). */ readonly mapDiffUri?: (uri: URI) => URI; /** @@ -490,7 +490,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // a `SessionMetaChanged` action dispatched on session open (without a full // list refresh). See `_applySessionMetaFromState` / `setMeta`. private _project: IAgentSessionMetadata['project']; - private _workingDirectory: URI | undefined; + private _workingDirectories: readonly URI[] | undefined; // The directory that the current `mode` custom-agent URI is rooted at. Used to // compute the agent's repo-relative path so the selection can be rebased onto // its worktree twin when the session relocates into an isolated worktree (see @@ -574,7 +574,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this._activity = observableValue('activity', metadata.activity); this._project = metadata.project; - this._workingDirectory = metadata.workingDirectory; + this._workingDirectories = metadata.workingDirectories; this._meta = metadata._meta; this._metaObs = observableValue('agentHostSessionMeta', this._meta); @@ -898,7 +898,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.mode.set(agent ? { id: agent.uri, kind: AGENT_MODE_KIND } : undefined, undefined); // Remember which working directory the agent URI is rooted at so the // selection can be rebased if the session later relocates into a worktree. - this._agentBaseDir = agent ? this._workingDirectory : undefined; + this._agentBaseDir = agent ? this._workingDirectories?.[0] : undefined; } } @@ -1103,7 +1103,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } this._project = metadata.project; - this._workingDirectory = metadata.workingDirectory; + this._workingDirectories = metadata.workingDirectories; // Only update `_meta` when the source actually provides one — an // undefined value means "not included" (e.g. a summary path that // omits it), not "cleared". The authoritative git-state `_meta` @@ -1185,7 +1185,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * assigned; workspace sessions build from project/git metadata. */ private _computeWorkspace(): ISessionWorkspace | undefined { - return this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, readSessionGitState(this._meta))); + return this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectories, this.gitHubInfo, readSessionGitState(this._meta))); } updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) { @@ -1626,7 +1626,7 @@ class NewSession extends Disposable { await connection.createSession({ provider: this.agentProvider, session: backendUri, - workingDirectory: this.workspaceUri, + workingDirectories: this.workspaceUri ? [this.workspaceUri] : undefined, config: this._config?.values, // MCP-style opt-in: offer to receive `progress` for any // long-running bring-up (chiefly the lazy first-use SDK @@ -4383,9 +4383,7 @@ 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.workingDirectories?.[0] === 'string' - ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectories?.[0])) - : undefined; + const workingDirs = summary.workingDirectories?.map(d => this.mapWorkingDirectoryUri(URI.parse(d))); const meta: IAgentSessionMetadata = { session: sessionUri, startTime: Date.parse(summary.createdAt), @@ -4399,7 +4397,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement uri: this.mapProjectUri(URI.parse(summary.project.uri)) } } : {}), - workingDirectory: workingDir, + workingDirectories: workingDirs, changes: summary.changes, isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived), isRead: !!(summary.status & ProtocolSessionStatus.IsRead), diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index f573c450fad4b2..edfab075ec423e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -185,11 +185,12 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide protected _adapterOptions() { return { - buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { - const uriForDescription = project?.uri ?? workingDirectory; + buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { + const primary = workingDirectories?.[0]; + const uriForDescription = project?.uri ?? primary; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; - const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, workingDirectory ?? project?.uri); - return LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, gitHubInfo, gitState, description, branchProtectionPatterns); + const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, primary ?? project?.uri); + return LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectories, gitHubInfo, gitState, description, branchProtectionPatterns); }, }; } @@ -204,13 +205,13 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { // Intentionally pass `undefined` for `providerLabel` so the workspace // label matches the one produced by `resolveWorkspace` (and by other // providers serving the same folder). Sessions list grouping uses // `workspace.label` as the group key — divergent labels would surface // the same folder as multiple groups. - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: undefined, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_LOCAL }, gitHubInfo, gitState); + return buildAgentHostSessionWorkspace(project, workingDirectories, { providerLabel: undefined, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_LOCAL }, gitHubInfo, gitState); } resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined { 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 98563288a709b8..69e616382cef9c 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 @@ -163,7 +163,7 @@ class MockAgentHostService extends mock() { public wireOps: string[] = []; override async createSession(config?: IAgentCreateSessionConfig): Promise { const uri = config?.session ?? URI.parse('copilotcli:///auto-' + this._nextSeq); - this.createSessionConfigs.push({ config: config?.config, workingDirectory: config?.workingDirectory }); + this.createSessionConfigs.push({ config: config?.config, workingDirectory: config?.workingDirectories?.[0] }); this.wireOps.push(`createSession:${uri.toString()}`); this.createdSessionUris.push(uri); const hook = this.onCreateSession; @@ -300,7 +300,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, _meta: opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined, }; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 1c696ee9e71fa9..4ab689bac2a369 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -194,11 +194,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected _adapterOptions() { const web = this.isWebPlatform; return { - buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { - const uriForDescription = project?.uri ?? workingDirectory; + buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { + const primary = workingDirectories?.[0]; + const uriForDescription = project?.uri ?? primary; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; - const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, workingDirectory ?? project?.uri); - return RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, web ? undefined : this.label, gitHubInfo, gitState, description, branchProtectionPatterns); + const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, primary ?? project?.uri); + return RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectories, web ? undefined : this.label, gitHubInfo, gitState, description, branchProtectionPatterns); }, }; } @@ -390,8 +391,8 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_REMOTE }, gitHubInfo, gitState); + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectories: readonly URI[] | undefined, providerLabel: string | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { + return buildAgentHostSessionWorkspace(project, workingDirectories, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_REMOTE }, gitHubInfo, gitState); } private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { 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 0454b3bac17596..42db95ee3c46cc 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 @@ -187,7 +187,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, }; } 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 a3b21f3800552b..563ebe800cb248 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4037,7 +4037,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: requestedSession, model, provider: this._config.provider, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, fork, config, importConversation, @@ -4056,7 +4056,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: requestedSession, model, provider: this._config.provider, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, fork, config, importConversation, 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 6007edb6a972f0..9532b5225fed8e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -213,7 +213,7 @@ export class AgentHostSessionListStore extends Disposable { const nextEntries: IAgentHostSessionListEntry[] = []; for (const session of sessions) { - if (!this._isWorkingDirectoryInWorkspace(session.workingDirectory)) { + if (!this._isWorkingDirectoryInWorkspace(session.workingDirectories?.[0])) { continue; } const entry = this._makeEntryFromMetadata(session); @@ -323,7 +323,7 @@ export class AgentHostSessionListStore extends Disposable { createdAt: new Date(session.startTime).toISOString(), modifiedAt: new Date(session.modifiedTime).toISOString(), changes: session.changes, - workingDirectories: session.workingDirectory ? [session.workingDirectory.toString()] : undefined, + workingDirectories: session.workingDirectories?.map(d => d.toString()), }, }; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index cb06efb98b6e51..2ffbb9e3811d6e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -318,7 +318,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple const created = await this._agentHostService.createSession({ provider, session: backendSession, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, config: initialConfig, progressToken: generateUuid(), }); @@ -397,7 +397,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider, session: newBackendSession, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, config, ...(imported ? { model: imported.model, importConversation: { turns: imported.turns, model: imported.model } } : {}), progressToken: generateUuid(), @@ -461,7 +461,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider, session: entry.backendSession, - workingDirectory: newWorkingDirectory, + workingDirectories: [newWorkingDirectory], config, progressToken: generateUuid(), }); 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 6ed876875319f0..83f1b5b108efa1 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 @@ -231,7 +231,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 resolvedWorkingDir = (this.nextResolvedWorkingDirectory ?? config.workingDirectories?.[0])?.toString(); const summary: SessionSummary = { resource: session.toString(), provider: 'copilot', @@ -2539,8 +2539,8 @@ suite('AgentHostChatContribution', () => { onDidChangeWorkspaceFolders: Event.None, }); - agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectory: URI.file('/workspace/root/sub') }); - agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectory: URI.file('/other/place') }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectories: [URI.file('/other/place')] }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'no-wd'), startTime: 1000, modifiedTime: 2000, summary: 'No working directory' }); const listController = createSessionListController(disposables, instantiationService, agentHostService); @@ -2553,7 +2553,7 @@ suite('AgentHostChatContribution', () => { test('refresh does not filter when no workspace folders are open', async () => { const { listController, agentHostService } = createContribution(disposables); - agentHostService.addSession({ session: AgentSession.uri('copilot', 'a'), startTime: 1000, modifiedTime: 2000, summary: 'A', workingDirectory: URI.file('/any/path') }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'a'), startTime: 1000, modifiedTime: 2000, summary: 'A', workingDirectories: [URI.file('/any/path')] }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'b'), startTime: 1000, modifiedTime: 2000, summary: 'B' }); await listController.refresh(CancellationToken.None); @@ -2573,8 +2573,8 @@ suite('AgentHostChatContribution', () => { onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, }); - agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectory: URI.file('/workspace/root/sub') }); - agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectory: URI.file('/other/place') }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectories: [URI.file('/other/place')] }); const listController = createSessionListController(disposables, instantiationService, agentHostService); @@ -7496,7 +7496,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectory?.toString(), URI.file('/custom/working/dir').toString()); + assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectories?.[0]?.toString(), URI.file('/custom/working/dir').toString()); })); test('handler forwards request session config to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -7891,7 +7891,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectory?.toString(), resolvedWorkingDirectory.toString()); + assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectories?.[0]?.toString(), resolvedWorkingDirectory.toString()); })); test('handler passes vscode-agent-host URI as-is to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -7924,7 +7924,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectory?.toString(), agentHostUri.toString()); + assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectories?.[0]?.toString(), agentHostUri.toString()); })); test('list controller includes description in items', async () => { @@ -7962,7 +7962,7 @@ suite('AgentHostChatContribution', () => { startTime: 1000, modifiedTime: 2000, summary: 'With git', - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); await controller.refresh(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 3473656f390d0c..d7cc884544b31a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -434,7 +434,7 @@ suite('AgentHostUntitledProvisionalSessionService', () => { createCount: agentHost.createCalls.length, disposedOld: agentHost.disposed.some(d => d.toString() === expectedBackendUri('cwd1').toString()), recreatedSession: recreate.session?.toString(), - recreatedCwd: recreate.workingDirectory?.toString(), + recreatedCwd: recreate.workingDirectories?.[0]?.toString(), recreatedConfig: recreate.config?.['isolation'], }, { createCount: 2, From 773bc10c028c99fab8a4da4fadbe4a7b9aee7f60 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Mon, 27 Jul 2026 17:03:41 +1000 Subject: [PATCH 3/5] fix: correct indentation in turn function for better readability --- src/vs/platform/agentHost/test/node/sessionServerTools.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 7e70324bcd760b..13efeaaf000042 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -355,7 +355,7 @@ suite('SessionServerTools', () => { const md = (content: string): ResponsePart => ({ kind: ResponsePartKind.Markdown, id: 'm', content }); const toolPart = (tc: ToolCallState): ResponsePart => ({ kind: ResponsePartKind.ToolCall, toolCall: tc }); const turn = (id: string, user: string, parts: ResponsePart[], state = TurnState.Complete): Turn => - ({ id, message: { text: user, origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined, state }); + ({ id, message: { text: user, origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined, state }); const snapshot: IChatContextSnapshot = { turns: [ From 30eeb3f1913a4e602d935b300a25a172c6b96cb2 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 28 Jul 2026 11:00:33 +1000 Subject: [PATCH 4/5] Update workingDirectory to workingDirectories in ProtocolServerHandler tests --- .../platform/agentHost/test/node/protocolServerHandler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6bfa16a9e13578..b7c2463a770538 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -750,7 +750,7 @@ suite('ProtocolServerHandler', () => { startTime: 1000, modifiedTime: 2000, summary: 'Quick Chat', - workingDirectory: URI.file('/home/user/.copilot/chats/session-1'), + workingDirectories: [URI.file('/home/user/.copilot/chats/session-1')], _meta: withSessionWorkspaceless(undefined, true), }); From 1dbfe4c10ce06106168d4e681bbea2412a5e8292 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 28 Jul 2026 11:36:45 +1000 Subject: [PATCH 5/5] Enhance working directory handling across agent services - Updated AgentService to truncate working directories for providers that do not support multipleWorkingDirectories. - Modified CopilotAgent to handle multiple working directories and persist them correctly. - Adjusted session launcher and session handler to support additional working directories. - Added tests to verify working directory truncation and persistence behavior. - Implemented computeWorkingDirectories function to derive the ordered set of working directories based on provider capabilities. - Updated related tests to ensure correct behavior for multi-root and single-root workspaces. --- .../platform/agentHost/node/agentService.ts | 19 ++-- .../agentHost/node/copilot/copilotAgent.ts | 71 +++++++++++---- .../node/copilot/copilotSessionLauncher.ts | 10 +++ .../agentHost/test/node/agentService.test.ts | 37 ++++++++ .../agentHost/test/node/copilotAgent.test.ts | 62 +++++++++++++ .../test/node/sessionServerTools.test.ts | 2 +- .../agentHostNewSessionFolderService.ts | 25 ++++++ .../agentHost/agentHostSessionHandler.ts | 13 ++- ...ntHostUntitledProvisionalSessionService.ts | 14 ++- .../agentHostNewSessionFolderService.test.ts | 66 +++++++++++++- ...tUntitledProvisionalSessionService.test.ts | 86 +++++++++++++++++++ 11 files changed, 374 insertions(+), 31 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 47e564862ffe36..29d660d97f53b8 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -992,13 +992,20 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); } - // Capability guard: no provider advertises `multipleWorkingDirectories` - // yet, so reject a request for more than one working directory rather - // than silently launching in only the first. Callers supply exactly one - // directory during the compatibility phase; this is a defensive backstop - // against the newly-plural plumbing forwarding an unsupported set. + // Capability gate: only a provider that advertises + // `multipleWorkingDirectories` accepts more than one working directory. + // For a provider that does not, keep the primary (index 0 = the process + // root) and drop the rest so the plural plumbing cannot forward an + // unsupported set — the agent still launches in the user's chosen folder. + // This is a create-time-only grant: runtime add/remove of directories is + // still rejected in the dispatch path, so a provider that opts in accepts + // the set at creation but its members remain fixed for the session. if (config?.workingDirectories && config.workingDirectories.length > 1) { - throw new Error(`Provider '${providerId}' does not support multiple working directories (received ${config.workingDirectories.length}).`); + const supportsMultiple = !!provider.getDescriptor().capabilities?.multipleWorkingDirectories; + if (!supportsMultiple) { + this._logService.warn(`[AgentService] Provider '${providerId}' does not advertise multipleWorkingDirectories; truncating ${config.workingDirectories.length} working directories to 1.`); + config = { ...config, workingDirectories: [config.workingDirectories[0]] }; + } } // When forking, build the old→new turn ID mapping before creating the diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index ec9cd41777e364..e3c14edc53397b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1488,14 +1488,14 @@ export class CopilotAgent extends Disposable implements IAgent { project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext); void this._storeSessionProjectResolution(session, project); } - const workingDirectory = metadata.workingDirectory ?? (typeof s.context?.workingDirectory === 'string' ? URI.file(s.context.workingDirectory) : undefined); + const workingDirectories = metadata.workingDirectories ?? (typeof s.context?.workingDirectory === 'string' ? [URI.file(s.context.workingDirectory)] : undefined); const result: IAgentSessionMetadata = { session, startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), project, summary: s.summary, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories, }; return result; })); @@ -1524,14 +1524,14 @@ export class CopilotAgent extends Disposable implements IAgent { void this._storeSessionProjectResolution(session, project); } - const workingDirectory = storedMetadata?.workingDirectory ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? URI.file(sessionMetadata.context.workingDirectory) : undefined); + const workingDirectories = storedMetadata?.workingDirectories ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? [URI.file(sessionMetadata.context.workingDirectory)] : undefined); return { session, startTime: sessionMetadata?.startTime.getTime() ?? Date.now(), modifiedTime: sessionMetadata?.modifiedTime.getTime() ?? Date.now(), project, summary: sessionMetadata?.summary, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories, }; } @@ -1823,7 +1823,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); - await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true); + await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, sessionConfig.workingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), workingDirectory, project, true); if (sessionConfig.agent !== undefined) { await this._storeSessionAgentMetadata(session, sessionConfig.agent); } @@ -1936,7 +1936,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Persist metadata before resume so `_resumeSession` can resolve the // working directory and model. const project = await projectPromise; - await this._storeSessionMetadata(sessionUri, model, workingDirectory, workingDirectory, project); + await this._storeSessionMetadata(sessionUri, model, workingDirectory, sessionConfig.workingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), workingDirectory, project); if (sessionConfig.agent !== undefined) { await this._storeSessionAgentMetadata(sessionUri, sessionConfig.agent); } @@ -2006,6 +2006,7 @@ export class CopilotAgent extends Disposable implements IAgent { client, sessionId, workingDirectory, + additionalDirectories: resolvedWorkingDirectories?.slice(1), resolvedAgentName: resolvedAgent?.name, snapshot, activeClientToolSet: activeClient.toolSet, @@ -2027,7 +2028,7 @@ export class CopilotAgent extends Disposable implements IAgent { const project = await projectFromCopilotContext({ cwd: workingDirectory?.fsPath }, this._gitService); this._provisionalSessions.delete(sessionId); - await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, customizationDirectory, project, true); + await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), customizationDirectory, project, true); if (agent !== undefined) { await this._storeSessionAgentMetadata(sessionUri, agent); } @@ -2883,7 +2884,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (entry) { await entry.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); } - await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined); + await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined, undefined); } private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise { @@ -3254,6 +3255,7 @@ export class CopilotAgent extends Disposable implements IAgent { client, sessionId, workingDirectory: resolvedWorkingDirectory, + additionalDirectories: storedMetadata.workingDirectories?.slice(1), resolvedAgentName, snapshot, activeClientToolSet: activeClient.toolSet, @@ -3284,6 +3286,8 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_MODEL = 'copilot.model'; private static readonly _META_AGENT = 'copilot.agent'; private static readonly _META_CWD = 'copilot.workingDirectory'; + /** Persisted ordered working-directory set (JSON array of URI strings; index 0 = primary). */ + private static readonly _META_CWDS = 'copilot.workingDirectories'; private static readonly _META_CUSTOMIZATION_DIRECTORY = 'copilot.customizationDirectory'; private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; private static readonly _META_PROJECT_URI = 'copilot.project.uri'; @@ -3335,7 +3339,7 @@ export class CopilotAgent extends Disposable implements IAgent { } - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -3346,6 +3350,14 @@ export class CopilotAgent extends Disposable implements IAgent { if (workingDirectory) { work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); } + // Persist the ordered set alongside the legacy single cwd so a + // multi-root session restores every directory on reload. Reads prefer + // this key; `_META_CWD` remains the fallback for sessions persisted + // before this key existed. Written together with `_META_CWD` from the + // same source so index 0 stays consistent across both keys. + if (workingDirectories) { + work.push(db.setMetadata(CopilotAgent._META_CWDS, JSON.stringify(workingDirectories.map(d => d.toString())))); + } if (customizationDirectory) { work.push(db.setMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY, customizationDirectory.toString())); } @@ -3362,23 +3374,50 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; workspaceless?: boolean }> { + /** + * Parses the persisted ordered working-directory set. Prefers the JSON + * `_META_CWDS` array when present and valid, otherwise falls back to the + * single legacy `_META_CWD` value. A malformed blob (the metadata store is + * client-influenced and may be corrupt) is ignored in favour of the legacy + * fallback so it can never reject the caller. + */ + private _parseWorkingDirectories(rawSet: string | undefined, fallback: URI | undefined): readonly URI[] | undefined { + if (rawSet) { + try { + const parsed = JSON.parse(rawSet); + if (Array.isArray(parsed)) { + const dirs = parsed.filter((d): d is string => typeof d === 'string' && d.length > 0).map(d => URI.parse(d)); + if (dirs.length > 0) { + return dirs; + } + } + } catch { + // Malformed metadata blob: fall through to the legacy fallback. + } + } + return fallback ? [fallback] : undefined; + } + + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; workspaceless?: boolean }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; } try { - const [model, agent, cwd, customizationDirectory, workspaceless] = await Promise.all([ + const [model, agent, cwd, cwds, customizationDirectory, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), + ref.object.getMetadata(CopilotAgent._META_CWDS), ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), ]); + const workingDirectory = cwd ? URI.parse(cwd) : undefined; return { model: this._parseModelSelection(model), agent: this._parseAgentSelection(agent), - workingDirectory: cwd ? URI.parse(cwd) : undefined, + workingDirectory, + workingDirectories: this._parseWorkingDirectories(cwds, workingDirectory), customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, workspaceless: workspaceless === 'true', }; @@ -3387,16 +3426,17 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; } try { - const [model, agent, cwd, customizationDirectory, resolved, uri, displayName, workspaceless] = await Promise.all([ + const [model, agent, cwd, cwds, customizationDirectory, resolved, uri, displayName, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), + ref.object.getMetadata(CopilotAgent._META_CWDS), ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), @@ -3409,6 +3449,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: this._parseModelSelection(model), agent: this._parseAgentSelection(agent), workingDirectory, + workingDirectories: this._parseWorkingDirectories(cwds, workingDirectory), customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, project, resolved: resolved === 'true' || project !== undefined, @@ -3438,7 +3479,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): Promise { - await this._storeSessionMetadata(session, undefined, undefined, undefined, project, true); + await this._storeSessionMetadata(session, undefined, undefined, undefined, undefined, project, true); } private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter, projectByContext: Map>): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index af552d560db0a7..442640397bf49f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -124,6 +124,16 @@ interface ICopilotSessionLaunchBase { readonly client: CopilotSessionClient; readonly sessionId: string; readonly workingDirectory: URI | undefined; + /** + * The additional working directories beyond the primary process root + * ({@link workingDirectory} = index 0). These are the peer roots of a + * multi-root session's ordered set — the directories the agent should be + * granted tool access to in addition to its process cwd. Empty (or absent) + * for a single-root session. Passed through so the SDK can register them as + * extra accessible roots once that surface is available; the process still + * launches in {@link workingDirectory}. + */ + readonly additionalDirectories?: readonly URI[]; readonly resolvedAgentName: string | undefined; readonly snapshot: IActiveClientSnapshot; /** diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ae02367d54ecfd..0c43d2303139c0 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1093,6 +1093,43 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(AgentSession.provider(session), 'copilot'); }); + test('truncates working directories for a provider without multipleWorkingDirectories', async () => { + class CapturingAgent extends MockAgent { + lastConfig: IAgentCreateSessionConfig | undefined; + constructor(id: string, private readonly _caps: import('../../common/agentService.js').IAgentCapabilities | undefined) { + super(id); + } + override getDescriptor() { + return { ...super.getDescriptor(), capabilities: this._caps }; + } + override async createSession(config?: IAgentCreateSessionConfig): Promise { + this.lastConfig = config; + return super.createSession(config); + } + } + + const single = new CapturingAgent('single', undefined); + const multi = new CapturingAgent('multi', { multipleWorkingDirectories: { immutablePrimary: true } }); + disposables.add(toDisposable(() => single.dispose())); + disposables.add(toDisposable(() => multi.dispose())); + service.registerProvider(single); + service.registerProvider(multi); + + const dirs = [URI.file('/repoA'), URI.file('/repoB'), URI.file('/repoC')]; + await service.createSession({ provider: 'single', workingDirectories: dirs }); + await service.createSession({ provider: 'multi', workingDirectories: dirs }); + + // A provider that does not advertise the capability keeps only the + // primary (index 0); one that advertises it receives the full set. + assert.deepStrictEqual({ + single: single.lastConfig?.workingDirectories?.map(d => d.toString()), + multi: multi.lastConfig?.workingDirectories?.map(d => d.toString()), + }, { + single: [dirs[0].toString()], + multi: dirs.map(d => d.toString()), + }); + }); + test('honors requested session URI', async () => { service.registerProvider(copilotAgent); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 9dc709af86e51e..546e5d107f2ed8 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1487,6 +1487,68 @@ suite('CopilotAgent', () => { }).timeout(30_000); }); + suite('working-directory persistence', () => { + const repoA = URI.file('/repoA'); + const repoB = URI.file('/repoB'); + const repoC = URI.file('/repoC'); + + async function restore(seed: (db: ReturnType) => Promise, cwd?: string): Promise<{ list: string[] | undefined; meta: string[] | undefined }> { + const sessionId = 'wd-persist'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + // Mark the project resolved so the restore path does not probe git. + await db.object.setMetadata('copilot.project.resolved', 'true'); + await seed(db); + db.dispose(); + const client = new TestCopilotClient([sdkSession(sessionId, cwd)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + const listed = (await agent.listSessions()).find(s => AgentSession.id(s.session) === sessionId); + const meta = await agent.getSessionMetadata(session); + return { + list: listed?.workingDirectories?.map(d => d.toString()), + meta: meta?.workingDirectories?.map(d => d.toString()), + }; + } finally { + await disposeAgent(agent); + } + } + + test('restores the persisted ordered set from copilot.workingDirectories', async () => { + const result = await restore(async db => { + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([repoA, repoB, repoC].map(d => d.toString()))); + await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); + }); + assert.deepStrictEqual(result, { + list: [repoA.toString(), repoB.toString(), repoC.toString()], + meta: [repoA.toString(), repoB.toString(), repoC.toString()], + }); + }); + + test('falls back to the legacy single working directory when the set is absent', async () => { + const result = await restore(async db => { + await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); + }); + assert.deepStrictEqual(result, { + list: [repoA.toString()], + meta: [repoA.toString()], + }); + }); + + test('falls back to the legacy single working directory when the set is malformed', async () => { + const result = await restore(async db => { + await db.object.setMetadata('copilot.workingDirectories', 'not-json'); + await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); + }); + assert.deepStrictEqual(result, { + list: [repoA.toString()], + meta: [repoA.toString()], + }); + }); + }); + suite('restart on startup config change', () => { class StopCountingClient extends TestCopilotClient { diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 13efeaaf000042..fd088a207604f6 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -165,7 +165,7 @@ suite('SessionServerTools', () => { const text = await group.execute(stateManager, 'copilot:/caller', createSessionToolName, { workspace: workspace.toString(), prompt: 'do it', model: 'gpt-4o' }); - assert.deepStrictEqual(created, { workingDirectory: workspace, provider: 'copilot', model: { id: 'gpt-4o' } }); + assert.deepStrictEqual(created, { workingDirectories: [workspace], provider: 'copilot', model: { id: 'gpt-4o' } }); assert.strictEqual(prompted?.prompt, 'do it'); assert.strictEqual(prompted?.chat.toString(), buildDefaultChatUri(URI.parse('copilot:/new'))); assert.ok(text.includes('agent-host-session://copilot/new'), 'result carries the open-session link for the pill'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts index dd8357f226041b..2f75ed93b4dd0f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts @@ -11,10 +11,35 @@ import { URI } from '../../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatService } from '../../../common/chatService/chatService.js'; export const IAgentHostNewSessionFolderService = createDecorator('agentHostNewSessionFolderService'); +/** + * Computes the ordered working-directory set for a new agent-host session: + * `[primary, …otherWorkspaceFolders]`, with the primary at index 0. The other + * workspace folders are included only when the primary is itself a workspace + * folder and `provider` advertises the `multipleWorkingDirectories` capability + * in `rootState`; otherwise (single-folder workspace, a standalone primary, + * unadvertised capability, or an unavailable root state) just `[primary]` is + * returned. Support is opt-in per the {@link AgentCapabilities} convention, and + * the node-side guard remains the authoritative backstop. Returns `undefined` + * when no primary was chosen. + */ +export function computeWorkingDirectories(primary: URI | undefined, workspaceFolders: readonly URI[], rootState: RootState | Error | undefined, provider: string): readonly URI[] | undefined { + if (!primary) { + return undefined; + } + const agent = (rootState && !(rootState instanceof Error)) ? rootState.agents.find(a => a.provider === provider) : undefined; + const supportsMultiple = !!agent?.capabilities?.multipleWorkingDirectories; + if (!supportsMultiple || !workspaceFolders.some(folder => extUriBiasedIgnorePathCase.isEqual(folder, primary))) { + return [primary]; + } + const rest = workspaceFolders.filter(folder => !extUriBiasedIgnorePathCase.isEqual(folder, primary)); + return [primary, ...rest]; +} + /** * Per-window store of the working directory a user picked for a not-yet-started * agent-host session, keyed by the chat session resource it was picked against 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 96a3262507df91..9876af40468a32 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -87,7 +87,7 @@ import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; -import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; +import { IAgentHostNewSessionFolderService, computeWorkingDirectories } from './agentHostNewSessionFolderService.js'; import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { AgentHostResponseFileChangesProvider } from './agentHostResponseFileChanges.js'; import type { AgentHostPromptCacheNotification } from './agentHostPromptCacheNotification.js'; @@ -4015,7 +4015,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Creates a new backend session and subscribes to its state. */ private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, config?: Record, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise { - const workingDirectory = this._resolveRequestedWorkingDirectory(sessionResource); + const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); const requestedSession = fork ? undefined : this._resolveSessionUri(sessionResource); this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); @@ -4038,7 +4038,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: requestedSession, model, provider: this._config.provider, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories, fork, config, importConversation, @@ -4057,7 +4057,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: requestedSession, model, provider: this._config.provider, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories, fork, config, importConversation, @@ -4385,6 +4385,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; } + private _resolveRequestedWorkingDirectories(sessionResource: URI): readonly URI[] | undefined { + const primary = this._resolveRequestedWorkingDirectory(sessionResource); + return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._getRootState(), this._config.provider); + } + /** * Ensures the workspace/folder the agent will run in is trusted before a * session is spawned. Returns `false` if the user declines. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index 2ffbb9e3811d6e..6b6aee027304b8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -64,11 +64,12 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatConfiguration, getChatPermissionLevelFromDefaultConfiguration, type IChatDefaultConfiguration } from '../../../common/constants.js'; import { IChatService } from '../../../common/chatService/chatService.js'; -import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; +import { IAgentHostNewSessionFolderService, computeWorkingDirectories } from './agentHostNewSessionFolderService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; export const IAgentHostUntitledProvisionalSessionService = @@ -228,6 +229,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple @IConfigurationService private readonly _configurationService: IConfigurationService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, ) { @@ -268,6 +270,10 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return this._entries.get(sessionResource)?.backendSession; } + private _computeWorkingDirectories(primary: URI | undefined, provider: string): readonly URI[] | undefined { + return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._agentHostService.rootState.value, provider); + } + getInitialSessionConfig(): Record | undefined { return this._getInitialConfig(); } @@ -318,7 +324,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple const created = await this._agentHostService.createSession({ provider, session: backendSession, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories: this._computeWorkingDirectories(workingDirectory, provider), config: initialConfig, progressToken: generateUuid(), }); @@ -397,7 +403,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider, session: newBackendSession, - workingDirectories: workingDirectory ? [workingDirectory] : undefined, + workingDirectories: this._computeWorkingDirectories(workingDirectory, provider), config, ...(imported ? { model: imported.model, importConversation: { turns: imported.turns, model: imported.model } } : {}), progressToken: generateUuid(), @@ -461,7 +467,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple created = await this._agentHostService.createSession({ provider, session: entry.backendSession, - workingDirectories: [newWorkingDirectory], + workingDirectories: this._computeWorkingDirectories(newWorkingDirectory, provider), config, progressToken: generateUuid(), }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts index 90587cf5215f3a..e6cafa79664b42 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts @@ -10,8 +10,9 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace } from '../../../../../../platform/workspace/common/workspace.js'; +import type { AgentInfo, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatService } from '../../../common/chatService/chatService.js'; -import { AgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { AgentHostNewSessionFolderService, computeWorkingDirectories } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; suite('AgentHostNewSessionFolderService', () => { const ds = ensureNoDisposablesAreLeakedInTestSuite(); @@ -118,3 +119,66 @@ suite('AgentHostNewSessionFolderService', () => { }); }); }); + +suite('computeWorkingDirectories', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const folderA = URI.file('/repoA'); + const folderB = URI.file('/repoB'); + const folderC = URI.file('/repoC'); + + const toStrings = (dirs: readonly URI[] | undefined) => dirs?.map(d => d.toString()); + + function rootStateWith(provider: string, multipleWorkingDirectories: boolean): RootState { + const agent = { + provider, + displayName: provider, + description: '', + models: [], + capabilities: multipleWorkingDirectories ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}, + } as AgentInfo; + return { agents: [agent] } as unknown as RootState; + } + + const advertised = rootStateWith('copilot', true); + + test('derives the ordered set from the chosen primary (capability advertised)', () => { + assert.deepStrictEqual({ + noPrimary: toStrings(computeWorkingDirectories(undefined, [folderA, folderB, folderC], advertised, 'copilot')), + singleFolder: toStrings(computeWorkingDirectories(folderA, [folderA], advertised, 'copilot')), + primaryFirst: toStrings(computeWorkingDirectories(folderA, [folderA, folderB, folderC], advertised, 'copilot')), + primaryMiddle: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], advertised, 'copilot')), + primaryOutsideWorkspace: toStrings(computeWorkingDirectories(folderC, [folderA, folderB], advertised, 'copilot')), + noWorkspaceFolders: toStrings(computeWorkingDirectories(folderA, [], advertised, 'copilot')), + }, { + noPrimary: undefined, + singleFolder: [folderA.toString()], + primaryFirst: [folderA.toString(), folderB.toString(), folderC.toString()], + primaryMiddle: [folderB.toString(), folderA.toString(), folderC.toString()], + primaryOutsideWorkspace: [folderC.toString()], + noWorkspaceFolders: [folderA.toString()], + }); + }); + + test('restricts to the primary unless the provider advertises the capability', () => { + const notAdvertised = rootStateWith('other', false); + assert.deepStrictEqual({ + // Provider present and does NOT advertise → restrict to primary. + restricted: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], notAdvertised, 'other')), + // Provider present and advertises → full ordered set. + advertised: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], advertised, 'copilot')), + // Provider absent from the root state → unsupported → primary only. + providerAbsent: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], advertised, 'missing')), + // Root state unavailable → unsupported → primary only. + rootStateUndefined: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], undefined, 'copilot')), + // Root state errored → unsupported → primary only. + rootStateError: toStrings(computeWorkingDirectories(folderB, [folderA, folderB, folderC], new Error('boom'), 'copilot')), + }, { + restricted: [folderB.toString()], + advertised: [folderB.toString(), folderA.toString(), folderC.toString()], + providerAbsent: [folderB.toString()], + rootStateUndefined: [folderB.toString()], + rootStateError: [folderB.toString()], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index d7cc884544b31a..b1edb15417c425 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -19,6 +19,9 @@ import { ActionType } from '../../../../../../platform/agentHost/common/state/pr import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ConfigSchema } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { AgentInfo, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { AgentHostUntitledProvisionalSessionService, IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; @@ -41,6 +44,19 @@ class MockAgentHostService extends mock() { readonly dispatched: IDispatchedAction[] = []; readonly resolveCalls: IAgentResolveSessionConfigParams[] = []; + /** Agents advertised by the (stubbed) root state; drives capability gating. */ + rootStateAgents: AgentInfo[] = []; + override readonly rootState: IAgentSubscription = (() => { + const self = this; + return { + get value(): RootState { return { agents: self.rootStateAgents } as unknown as RootState; }, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as unknown as IAgentSubscription; + })(); + /** * Each entry is consumed in order by the next `resolveSessionConfig` call. * Callers may push deferred promises (for race tests) or resolved values. @@ -117,17 +133,24 @@ suite('AgentHostUntitledProvisionalSessionService', () => { let cleanup: DisposableStore; let workspaceTrusted: boolean; let untrustedFolders: Set; + let workspaceFolders: URI[]; setup(async () => { agentHost = new MockAgentHostService(); workspaceTrusted = true; untrustedFolders = new Set(); + workspaceFolders = []; const insta = ds.add(new TestInstantiationService()); insta.stub(IAgentHostService, agentHost); insta.stub(ILogService, new NullLogService()); insta.stub(IChatService, new MockChatService()); insta.stub(IConfigurationService, new TestConfigurationService()); insta.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); + insta.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspace(): IWorkspace { + return { folders: workspaceFolders.map(uri => ({ uri } as IWorkspaceFolder)) } as IWorkspace; + } + }); insta.stub(IWorkspaceTrustManagementService, new class extends mock() { override isWorkspaceTrusted(): boolean { return workspaceTrusted; } override async getUriTrustInfo(uri: URI) { return { uri, trusted: !untrustedFolders.has(uri.toString()) }; } @@ -466,4 +489,67 @@ suite('AgentHostUntitledProvisionalSessionService', () => { assert.strictEqual(agentHost.createCalls.length, 0); assert.strictEqual(provisional.get(ui), undefined); }); + + test('derives the ordered working-directory set from the picked primary', async () => { + const folderA = URI.file('/repoA'); + const folderB = URI.file('/repoB'); + const folderC = URI.file('/repoC'); + workspaceFolders = [folderA, folderB, folderC]; + // The provider advertises multi-root support, so the client sends the set. + agentHost.rootStateAgents = [agentInfo('copilot', true)]; + + const multiRoot = untitledChatUri('multi'); + await provisional.getOrCreate(multiRoot, 'copilot', folderB); + + // A single-folder workspace keeps just the primary (byte-identical to the + // previous single-directory behaviour). + workspaceFolders = [folderA]; + const singleRoot = untitledChatUri('single'); + await provisional.getOrCreate(singleRoot, 'copilot', folderA); + + assert.deepStrictEqual({ + multiRoot: agentHost.createCalls.find(c => c.session?.toString() === expectedBackendUri('multi').toString())?.workingDirectories?.map(d => d.toString()), + singleRoot: agentHost.createCalls.find(c => c.session?.toString() === expectedBackendUri('single').toString())?.workingDirectories?.map(d => d.toString()), + }, { + multiRoot: [folderB.toString(), folderA.toString(), folderC.toString()], + singleRoot: [folderA.toString()], + }); + }); + + test('sends only the primary when the provider does not advertise multiple working directories', async () => { + const folderA = URI.file('/repoA'); + const folderB = URI.file('/repoB'); + const folderC = URI.file('/repoC'); + workspaceFolders = [folderA, folderB, folderC]; + + // The same provider gets the full ordered set while it advertises the + // capability, and only the primary once it does not — the client mirrors + // the node-side guard instead of relying on it alone. + agentHost.rootStateAgents = [agentInfo('copilot', true)]; + const multi = untitledChatUri('cap-multi'); + await provisional.getOrCreate(multi, 'copilot', folderB); + + agentHost.rootStateAgents = [agentInfo('copilot', false)]; + const single = untitledChatUri('cap-single'); + await provisional.getOrCreate(single, 'copilot', folderB); + + assert.deepStrictEqual({ + advertising: agentHost.createCalls.find(c => c.session?.toString() === expectedBackendUri('cap-multi').toString())?.workingDirectories?.map(d => d.toString()), + nonAdvertising: agentHost.createCalls.find(c => c.session?.toString() === expectedBackendUri('cap-single').toString())?.workingDirectories?.map(d => d.toString()), + }, { + advertising: [folderB.toString(), folderA.toString(), folderC.toString()], + nonAdvertising: [folderB.toString()], + }); + }); }); + +/** Minimal {@link AgentInfo} for capability-gating tests. */ +function agentInfo(provider: string, multipleWorkingDirectories: boolean): AgentInfo { + return { + provider, + displayName: provider, + description: '', + models: [], + capabilities: multipleWorkingDirectories ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}, + } as AgentInfo; +}