Adopt AHP 0.7.0 workingDirectories + primaryWorkingDirectory in agent host (#326847)

* Adopt AHP 0.6.0 workingDirectories property in agent host

Sync the generated agent host protocol copy to spec version 0.6.0
(agent-host-protocol @ 11f1a65e), which renames the singular
`workingDirectory` on session/chat state to a `workingDirectories`
array in preparation for multiroot session support.

This change is a pure, behaviour-preserving property adoption across all
consumers: sessions continue to use a single working directory. Reads of
a single directory now use `workingDirectories?.[0]`, field-copy sites
pass the array through unchanged, and writes from a single URI produce a
one-element array. No multiroot client support is added here (no
capability advertising, state actions, multi-folder workspaces, or UI);
those land in a follow-up milestone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- Reject the not-yet-supported multiroot working-directory client actions
  (session|chat/workingDirectorySet|Removed) in the handwritten dispatch path
  so a client cannot mutate the synchronized working-directory set without the
  agent actually reconfiguring its directory access. The protocol declarations
  remain; only the operational dispatch is deferred until multiroot lands.
- Compare workingDirectories by array identity (not just the primary entry) in
  the state manager summary-equality check, matching the immutable reducers and
  SessionSummaryNotifier so secondary-directory changes still dirty the summary.
- Update ISessionWithDefaultChat / mergeSessionWithDefaultChat API docs to
  describe a chat's workingDirectories subset overriding or inheriting the
  session's full set, fixing links to the removed singular members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix agentHost session-filter test for workingDirectories migration

The `sessionAdded notification filters out sessions outside the workspace`
test constructed session summaries with the removed singular `workingDirectory`
field, so the production filter (which now reads `workingDirectories[0]`) saw no
directory and dropped the in-workspace session. Update the two directly-built
summaries to the `workingDirectories` array form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate test createSession wire calls to workingDirectories

The createSession dispatch handler now reads the working directory from the
renamed `CreateSessionParams.workingDirectories` array (AHP 0.6.0), but several
node integration / e2e test call sites still sent the removed singular
`workingDirectory` field. Since the field is silently ignored, sessions were
created without a working directory and fell back to the default chats folder,
failing the Agent Host E2E workspace/fileOperations/hostFeatures suites across
all providers (wrong cwd cascades into file completions, renames, worktree
resolution, and cd-prefix stripping).

Send `workingDirectories: [dir]` from the shared `createProviderSession` helper
and the remaining direct wire call sites so the requested directory reaches the
session state again. The real VS Code client already sent the array form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Adopt AHP 0.7.0 primaryWorkingDirectory + requiresPrimary

Re-sync the generated protocol copy to the multiroot spec at 148c716b (spec
version 0.7.0) and adopt the two follow-up changes:

- Capability flag renamed `MultipleWorkingDirectoriesCapability.immutablePrimary`
  -> `requiresPrimary`, with the new "agent needs one directory designated as
  its primary root" semantics. No consumers referenced the old name.
- New optional `primaryWorkingDirectory` field at both the session level
  (CreateSessionParams / SessionMetadata -> SessionState + SessionSummary) and
  the chat level (CreateChatParams / ChatState + ChatSummary). Mirror it through
  the state<->summary projection layer exactly like `workingDirectories`:
  createSessionState / createChatState / chatSummaryFromState /
  mergeSessionWithDefaultChat, plus the state manager's summary projection,
  field-equality check, SessionSummaryNotifier diff, and markSessionPersisted
  propagation. The generated SessionChatUpdated partial-summary merge is
  field-agnostic, so it carries the new field automatically.

Purely additive optional fields; no client multiroot behavior is added
(consumers still read `workingDirectories[0]` as the single effective root).

Also preserve the VS Code-local `CompletionItem.label` field (added in #326807,
ahead of the spec) which the verbatim re-sync would otherwise drop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reject unsupported working-directory actions via reconciliation path

Address PR review feedback on the multiroot working-directory action gate:

- Instead of silently dropping the four not-yet-supported
  session|chat/workingDirectorySet|Removed client actions (which never echoed
  the origin, leaving the client's optimistic write-ahead action pending until
  reconnect), emit a rejection envelope through the normal reconciliation path.
  Added AgentHostStateManager.rejectClientAction, which emits an ActionEnvelope
  carrying the original ActionOrigin and a rejectionReason without running the
  reducer (no synchronized state change), so the originating client rolls back
  its optimistic action.
- Guard the write-ahead client reconcile (SessionStateSubscription /
  ChatStateSubscription) so a rejected envelope is never applied to confirmed
  state in any branch — this also prevents a broadcast rejection from leaking
  the rejected action into a non-origin client's state.
- Add a table-driven test covering all four action types asserting no dispatch
  and exactly one rejection envelope preserving the original origin.
- Simplify the create-session working-directory read to
  `URI.parse(params.workingDirectories[0])` now the branch proves index 0 exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refine primaryWorkingDirectory to per-chat read-only (AHP ea279d99)

Re-sync the protocol copy to spec ea279d99 (0.7.0) and adopt the refined
primaryWorkingDirectory design:

- Session has NO primary: `primaryWorkingDirectory` is removed from
  SessionState / SessionSummary. The session is just the equal-peer
  `workingDirectories` set. Dropped all session-level primary handling
  (createSessionState, the SessionSummaryNotifier diff, _toSummary,
  _summaryFieldsEqual, and markSessionPersisted propagation).
- Primary is per-chat, read-only, fixed at chat creation: kept the ChatState
  <-> ChatSummary mirroring (createChatState / chatSummaryFromState) and carry
  the chat's own primary through the session+default-chat composite
  (ISessionWithDefaultChat gains its own primaryWorkingDirectory; the merge no
  longer falls back to a session primary). It is not sent via `session/chatUpdated`
  (the state manager only ever puts status/activity/title in those changes), so
  it never mutates post-creation.
- Inputs `CreateSessionParams.primaryWorkingDirectory` (seeds the default chat's
  primary) and `CreateChatParams.primaryWorkingDirectory` are synced; capability
  `requiresPrimary` unchanged.

Also re-preserve the VS Code-local `CompletionItem.label` field (#326807, ahead
of the spec) that the verbatim re-sync drops.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sandeep Somavarapu
2026-07-22 15:19:47 +00:00
committed by GitHub
co-authored by Copilot
parent dd299c41b1
commit 53ebd210b7
58 changed files with 542 additions and 165 deletions
@@ -818,7 +818,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
const promise = this._sendRequest('createSession', {
channel: session.toString(),
provider,
workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined,
workingDirectories: config?.workingDirectory ? [fromAgentHostUri(config.workingDirectory).toString()] : undefined,
config: config?.config,
activeClient: config?.activeClient,
}).then(() => session);
@@ -962,7 +962,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
summary: s.title,
status: s.status,
activity: s.activity,
workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined,
workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined,
isRead: !!(s.status & SessionStatus.IsRead),
isArchived: !!(s.status & SessionStatus.IsArchived),
changes: s.changes,
@@ -313,19 +313,21 @@ export class SessionStateSubscription extends BaseAgentSubscription<SessionState
}
protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
// A rejected envelope must never mutate confirmed state — it only rolls
// back the originating client's matching optimistic action. Guarding all
// apply branches also prevents a broadcast rejection from leaking the
// rejected action into a non-origin client's state.
if (isOwnAction && envelope.origin) {
const idx = this._pendingActions.findIndex(p => p.clientSeq === envelope.origin!.clientSeq);
if (idx !== -1) {
if (envelope.rejectionReason) {
this._pendingActions.splice(idx, 1);
} else {
if (!envelope.rejectionReason) {
this._confirmedApply(envelope.action);
this._pendingActions.splice(idx, 1);
}
} else {
this._pendingActions.splice(idx, 1);
} else if (!envelope.rejectionReason) {
this._confirmedApply(envelope.action);
}
} else {
} else if (!envelope.rejectionReason) {
this._confirmedApply(envelope.action);
}
this._recomputeOptimistic();
@@ -457,19 +459,21 @@ export class ChatStateSubscription extends BaseAgentSubscription<ChatState> {
}
protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
// A rejected envelope must never mutate confirmed state — it only rolls
// back the originating client's matching optimistic action. Guarding all
// apply branches also prevents a broadcast rejection from leaking the
// rejected action into a non-origin client's state.
if (isOwnAction && envelope.origin) {
const idx = this._pendingActions.findIndex(p => p.clientSeq === envelope.origin!.clientSeq);
if (idx !== -1) {
if (envelope.rejectionReason) {
this._pendingActions.splice(idx, 1);
} else {
if (!envelope.rejectionReason) {
this._confirmedApply(envelope.action);
this._pendingActions.splice(idx, 1);
}
} else {
this._pendingActions.splice(idx, 1);
} else if (!envelope.rejectionReason) {
this._confirmedApply(envelope.action);
}
} else {
} else if (!envelope.rejectionReason) {
this._promotePendingTurnStartIfTerminal(envelope.action);
this._confirmedApply(envelope.action);
}
@@ -1 +1 @@
4e554d0e
ea279d99
@@ -9,7 +9,7 @@
// Generated from types/actions.ts — do not edit
// Run `npm run generate` to regenerate.
import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js';
import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js';
// ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ─────────────────
@@ -46,6 +46,8 @@ export type SessionAction =
| SessionServerToolsChangedAction
| SessionActiveClientSetAction
| SessionActiveClientRemovedAction
| SessionWorkingDirectorySetAction
| SessionWorkingDirectoryRemovedAction
| SessionInputNeededSetAction
| SessionInputNeededRemovedAction
| SessionCustomizationsChangedAction
@@ -68,6 +70,8 @@ export type ClientSessionAction =
| SessionTitleChangedAction
| SessionActiveClientSetAction
| SessionActiveClientRemovedAction
| SessionWorkingDirectorySetAction
| SessionWorkingDirectoryRemovedAction
| SessionCustomizationToggledAction
| SessionMcpServerStartRequestedAction
| SessionMcpServerStopRequestedAction
@@ -114,6 +118,8 @@ export type ChatAction =
| ChatTurnCancelledAction
| ChatErrorAction
| ChatActivityChangedAction
| ChatWorkingDirectorySetAction
| ChatWorkingDirectoryRemovedAction
| ChatUsageAction
| ChatReasoningAction
| ChatPendingMessageSetAction
@@ -135,6 +141,8 @@ export type ClientChatAction =
| ChatToolCallResultConfirmedAction
| ChatToolCallContentChangedAction
| ChatTurnCancelledAction
| ChatWorkingDirectorySetAction
| ChatWorkingDirectoryRemovedAction
| ChatPendingMessageSetAction
| ChatPendingMessageRemovedAction
| ChatQueuedMessagesReorderedAction
@@ -283,6 +291,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool
[ActionType.SessionServerToolsChanged]: false,
[ActionType.SessionActiveClientSet]: true,
[ActionType.SessionActiveClientRemoved]: true,
[ActionType.SessionWorkingDirectorySet]: true,
[ActionType.SessionWorkingDirectoryRemoved]: true,
[ActionType.SessionInputNeededSet]: false,
[ActionType.SessionInputNeededRemoved]: false,
[ActionType.SessionCustomizationsChanged]: false,
@@ -314,6 +324,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool
[ActionType.ChatTurnCancelled]: true,
[ActionType.ChatError]: false,
[ActionType.ChatActivityChanged]: false,
[ActionType.ChatWorkingDirectorySet]: true,
[ActionType.ChatWorkingDirectoryRemoved]: true,
[ActionType.ChatUsage]: false,
[ActionType.ChatReasoning]: false,
[ActionType.ChatPendingMessageSet]: true,
@@ -7,7 +7,7 @@
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
import { ActionType } from '../common/actions.js';
import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js';
import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js';
import type { McpAuthRequirement } from '../channels-session/state.js';
import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor, type ToolCallRiskAssessment, type Turn } from './state.js';
@@ -488,6 +488,45 @@ export interface ChatActivityChangedAction {
activity?: string;
}
/**
* A working directory was added to this chat's
* {@link ChatState.workingDirectories} subset.
*
* Membership semantics keyed by the directory URI: the reducer appends
* `directory` when the chat's subset does not already contain it (creating the
* subset if absent) and is a no-op when it is already present. `directory` MUST
* be one of the owning session's {@link SessionState.workingDirectories}; a host
* MUST reject a directory that is not. Only valid when the agent advertises
* {@link AgentCapabilities.multipleWorkingDirectories}.
*
* @category Chat Actions
* @version 1
* @clientDispatchable
*/
export interface ChatWorkingDirectorySetAction {
type: ActionType.ChatWorkingDirectorySet;
/** The working directory to add to this chat's subset. */
directory: URI;
}
/**
* A working directory was removed from this chat's
* {@link ChatState.workingDirectories} subset.
*
* Removes `directory` from the chat's subset; a no-op when it is not present.
* Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the
* chat's subset — the directory remains in the session's set.
*
* @category Chat Actions
* @version 1
* @clientDispatchable
*/
export interface ChatWorkingDirectoryRemovedAction {
type: ActionType.ChatWorkingDirectoryRemoved;
/** The working directory to remove from this chat's subset. */
directory: URI;
}
/**
* Token usage report for a turn.
*
@@ -751,6 +790,8 @@ export type ChatAction =
| ChatTurnCancelledAction
| ChatErrorAction
| ChatActivityChangedAction
| ChatWorkingDirectorySetAction
| ChatWorkingDirectoryRemovedAction
| ChatUsageAction
| ChatReasoningAction
| ChatTruncatedAction
@@ -40,6 +40,29 @@ export interface CreateChatParams extends BaseParams {
initialMessage?: Message;
/** Optional source chat and turn to fork from. */
source?: ChatForkSource;
/**
* Initial working-directory subset for this chat. Every entry MUST be
* present in the owning session's `workingDirectories`; the server MUST
* reject any entry that is not. When absent, the chat inherits the full
* session set. Forked chats (`source`) inherit the source chat's
* `workingDirectories`; this field is ignored for forked chats.
*
* A client MUST NOT supply this field unless the agent advertises
* {@link AgentCapabilities.multipleWorkingDirectories}.
*/
workingDirectories?: URI[];
/**
* The chat's primary working directory — the distinguished root this chat is
* centered on. When set, it MUST be one of the chat's effective working
* directories ({@link workingDirectories}, or the session's set when that is
* omitted). A client SHOULD supply this when the agent advertises
* {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY
* reject creation that omits it, or fall back to the first of the chat's
* directories. Fixed at creation and reported (read-only) on
* {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork
* inherits the source chat's primary).
*/
primaryWorkingDirectory?: URI;
}
// ─── disposeChat ─────────────────────────────────────────────────────────────
@@ -365,6 +365,30 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
case ActionType.ChatActivityChanged:
return { ...state, activity: action.activity };
// ── Working Directories ───────────────────────────────────────────────
case ActionType.ChatWorkingDirectorySet: {
const list = state.workingDirectories ?? [];
if (list.includes(action.directory)) {
return state;
}
return { ...state, workingDirectories: [...list, action.directory] };
}
case ActionType.ChatWorkingDirectoryRemoved: {
const list = state.workingDirectories;
if (!list) {
return state;
}
const idx = list.indexOf(action.directory);
if (idx < 0) {
return state;
}
const updated = list.slice();
updated.splice(idx, 1);
return { ...state, workingDirectories: updated };
}
// ── Tool Call State Machine ───────────────────────────────────────────
case ActionType.ChatToolCallStart:
@@ -50,15 +50,34 @@ export interface ChatState {
*/
interactivity?: ChatInteractivity;
/**
* Optional per-chat working directory.
* The subset of the session's
* {@link SessionState.workingDirectories | `workingDirectories`} that this
* chat's agent has tool access to. Every entry MUST be present in the owning
* session's `workingDirectories`; servers MUST reject a
* `chat/workingDirectorySet` action that violates this constraint.
*
* If absent, the chat inherits
* {@link SessionState.workingDirectory | the session's working directory}.
* Hosts MAY override this for individual chats — for example, to give a
* subordinate chat its own git worktree so multiple chats in a session can
* make independent edits that the orchestrator later merges back.
* When absent, the chat inherits the full session set. When present but empty
* (not recommended), the chat has no working-directory tool access at all.
*
* Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to
* update the subset on a running chat.
*/
workingDirectory?: URI;
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 */
@@ -127,13 +146,15 @@ export interface ChatSummary {
*/
interactivity?: ChatInteractivity;
/**
* Optional per-chat working directory.
*
* If absent, the chat inherits
* {@link SessionSummary.workingDirectory | the session's working directory}.
* See {@link ChatState.workingDirectory} for usage notes.
* The subset of the session's working directories this chat uses.
* See {@link ChatState.workingDirectories} for the full semantics.
*/
workingDirectory?: URI;
workingDirectories?: URI[];
/**
* The chat's primary working directory.
* See {@link ChatState.primaryWorkingDirectory} for the full semantics.
*/
primaryWorkingDirectory?: URI;
}
/**
@@ -108,6 +108,17 @@ export interface AgentCapabilities {
* forking; set {@link MultipleChatsCapability.fork} to also allow forking.
*/
multipleChats?: MultipleChatsCapability;
/**
* The session's agent can be granted tool access to more than one working
* directory. The directories are treated as equal peers except where the
* agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}
* (some backends need one directory designated as a primary root).
*
* When absent, clients MUST NOT mutate a session's or chat's working-directory
* set and MUST NOT set more than one entry in
* {@link CreateSessionParams.workingDirectories}.
*/
multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability;
}
/**
@@ -124,6 +135,29 @@ export interface MultipleChatsCapability {
fork?: boolean;
}
/**
* Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.
*
* @category Root State
*/
export interface MultipleWorkingDirectoriesCapability {
/**
* The agent requires each chat to designate one of its working directories as
* the **primary** a distinguished root the chat is centered on (e.g. the
* agent's process root for that chat, the default location for relative
* paths). Primary is a **per-chat** notion, fixed at chat creation. When
* `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}
* (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the
* session's default chat); a host MAY reject creation that omits it, or fall
* back to the first entry of the chat's working directories. The chosen
* primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.
*
* When absent or `false`, the agent has no primary all directories are
* equal peers and clients need not designate one.
*/
requiresPrimary?: boolean;
}
/**
* @category Root State
*/
@@ -250,6 +250,48 @@ export interface SessionActiveClientRemovedAction {
clientId: string;
}
// ─── Working Directory Actions ───────────────────────────────────────────────
/**
* A working directory was added to the session's
* {@link SessionState.workingDirectories} set.
*
* Membership semantics keyed by the directory URI: the reducer appends
* `directory` when the set does not already contain it (creating the set if
* absent) and is a no-op when it is already present. Only valid when the agent
* advertises {@link AgentCapabilities.multipleWorkingDirectories}.
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
export interface SessionWorkingDirectorySetAction {
type: ActionType.SessionWorkingDirectorySet;
/** The working directory to grant the session's agent tool access to. */
directory: URI;
}
/**
* A working directory was removed from the session's
* {@link SessionState.workingDirectories} set.
*
* Removes `directory` from the set; a no-op when it is not present. There is no
* atomic backend "remove one" primitive a host reconfigures its agent to the
* reduced set so this action is safe to model as idempotent. A host MAY
* decline to apply the removal (e.g. a directory still designated as some
* chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves
* the set unchanged.
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
export interface SessionWorkingDirectoryRemovedAction {
type: ActionType.SessionWorkingDirectoryRemoved;
/** The working directory to revoke the session's agent tool access to. */
directory: URI;
}
// ─── Input Needed Actions ────────────────────────────────────────────────────
/**
@@ -63,8 +63,37 @@ export interface CreateSessionParams extends BaseParams {
channel: URI;
/** Agent provider ID */
provider?: string;
/** Working directory for the session */
workingDirectory?: URI;
/**
* The working directories the session's agent is granted tool access to.
* A session may span multiple directories; they are equal peers except when
* the agent advertises
* {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case
* one of them should be designated the primary via
* {@link primaryWorkingDirectory}.
*
* A client MUST NOT supply more than one entry unless the agent advertises
* {@link AgentCapabilities.multipleWorkingDirectories}; a server without that
* capability treats only the first entry as the session's working directory
* and ignores the rest. Dispatch `session/workingDirectorySet` /
* `session/workingDirectoryRemoved` to change the set after the session has
* started.
*
* Ignored for forked sessions a fork inherits its working directories
* from the source session identified by `fork`.
*/
workingDirectories?: URI[];
/**
* The primary working directory for the session's **default chat** the
* distinguished root that chat is centered on (see
* {@link ChatState.primaryWorkingDirectory}). A session has no primary of its
* own; this seeds the default chat's primary. When set, it MUST be one of
* {@link workingDirectories}. A client SHOULD supply this when the agent
* advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a
* host MAY reject creation that omits it, or fall back to the first entry of
* `workingDirectories`. Ignored for forked sessions (a fork inherits the
* source session's chats and their primaries).
*/
primaryWorkingDirectory?: URI;
/**
* Fork from an existing session. The new session is populated with content
* from the source session up to and including the specified turn's response.
@@ -218,6 +218,30 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?:
return { ...state, activeClients: updated };
}
// ── Working Directories ─────────────────────────────────────────────
case ActionType.SessionWorkingDirectorySet: {
const list = state.workingDirectories ?? [];
if (list.includes(action.directory)) {
return state;
}
return { ...state, workingDirectories: [...list, action.directory] };
}
case ActionType.SessionWorkingDirectoryRemoved: {
const list = state.workingDirectories;
if (!list) {
return state;
}
const idx = list.indexOf(action.directory);
if (idx < 0) {
return state;
}
const updated = list.slice();
updated.splice(idx, 1);
return { ...state, workingDirectories: updated };
}
// ── Input Needed ────────────────────────────────────────────────────
case ActionType.SessionInputNeededSet: {
@@ -73,12 +73,16 @@ export interface SessionMetadata {
/** Server-owned project for this session */
project?: ProjectInfo;
/**
* The default working directory URI for this session. Individual chats
* MAY override via {@link ChatSummary.workingDirectory | their own
* `workingDirectory`}; this field acts as the fallback for any chat that
* does not.
* The working directories the session's agent has tool access to, as
* maintained by the `session/workingDirectorySet` /
* `session/workingDirectoryRemoved` actions. Directories are **equal peers**
* the session has no primary. Individual chats MAY restrict to a subset via
* {@link ChatSummary.workingDirectories | their own `workingDirectories`} and
* designate one of their own directories as primary (see
* {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset
* operates against this full set.
*/
workingDirectory?: URI;
workingDirectories?: URI[];
/**
* Lightweight summary of this session's inline annotations channel
* (`ahp-session:/<uuid>/annotations`). Surfaced so badge UI can render
@@ -181,7 +185,7 @@ export interface SessionState extends SessionMetadata {
*
* Clients MAY look for well-known keys here to provide enhanced UI.
* For example, a `git` key may provide extra git metadata about the session's
* workingDirectory.
* working directories.
*/
_meta?: Record<string, unknown>;
}
@@ -406,9 +410,9 @@ export interface ProjectInfo {
* chat currently driving the promoted status bits when a non-default chat
* wins (e.g. the chat that raised `InputNeeded`).
* - `modifiedAt`: the max of all chats' `modifiedAt`.
* - `workingDirectory`: the session-level **default**. Individual chats MAY
* override via {@link ChatSummary.workingDirectory}; aggregating these up
* is meaningless and SHOULD NOT be attempted.
* - `workingDirectories`: the session-level set. Individual chats MAY restrict
* to a subset via {@link ChatSummary.workingDirectories}; aggregating these
* up is meaningless and SHOULD NOT be attempted.
* - `changes`: optional roll-up across all chats. Producers MAY sum the
* per-chat changeset stats or report the most expensive chat's stats
* whichever is cheaper for the host to compute.
@@ -10,9 +10,9 @@ import type { URI } from './state.js';
import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js';
import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js';
import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js';
import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js';
import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js';
import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js';
@@ -54,12 +54,16 @@ export const enum ActionType {
ChatTurnCancelled = 'chat/turnCancelled',
ChatError = 'chat/error',
ChatActivityChanged = 'chat/activityChanged',
ChatWorkingDirectorySet = 'chat/workingDirectorySet',
ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved',
SessionTitleChanged = 'session/titleChanged',
ChatUsage = 'chat/usage',
ChatReasoning = 'chat/reasoning',
SessionServerToolsChanged = 'session/serverToolsChanged',
SessionActiveClientSet = 'session/activeClientSet',
SessionActiveClientRemoved = 'session/activeClientRemoved',
SessionWorkingDirectorySet = 'session/workingDirectorySet',
SessionWorkingDirectoryRemoved = 'session/workingDirectoryRemoved',
SessionInputNeededSet = 'session/inputNeededSet',
SessionInputNeededRemoved = 'session/inputNeededRemoved',
ChatPendingMessageSet = 'chat/pendingMessageSet',
@@ -161,6 +165,8 @@ export type StateAction =
| SessionServerToolsChangedAction
| SessionActiveClientSetAction
| SessionActiveClientRemovedAction
| SessionWorkingDirectorySetAction
| SessionWorkingDirectoryRemovedAction
| SessionInputNeededSetAction
| SessionInputNeededRemovedAction
| SessionCustomizationsChangedAction
@@ -192,6 +198,8 @@ export type StateAction =
| ChatTurnCancelledAction
| ChatErrorAction
| ChatActivityChangedAction
| ChatWorkingDirectorySetAction
| ChatWorkingDirectoryRemovedAction
| ChatUsageAction
| ChatReasoningAction
| ChatPendingMessageSetAction
@@ -276,7 +276,7 @@ export interface InitializeResult {
* @method ping
* @direction Client Server
* @messageType Request
* @version 0.1.0
* @version 1
*/
export interface PingParams extends BaseParams {
channel: 'ahp-root://';
@@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js';
*
* Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string.
*/
export const PROTOCOL_VERSION = '0.6.0';
export const PROTOCOL_VERSION = '0.7.0';
/**
* Every protocol version a client built from this source tree is willing
@@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.6.0';
* `scripts/verify-release-metadata.ts`.
*/
export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([
'0.7.0',
'0.6.0',
'0.5.2',
'0.5.1',
@@ -90,6 +91,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string
[ActionType.SessionServerToolsChanged]: '0.1.0',
[ActionType.SessionActiveClientSet]: '0.5.0',
[ActionType.SessionActiveClientRemoved]: '0.5.0',
[ActionType.SessionWorkingDirectorySet]: '0.7.0',
[ActionType.SessionWorkingDirectoryRemoved]: '0.7.0',
[ActionType.SessionInputNeededSet]: '0.5.1',
[ActionType.SessionInputNeededRemoved]: '0.5.1',
[ActionType.SessionCustomizationsChanged]: '0.1.0',
@@ -121,6 +124,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string
[ActionType.ChatTurnCancelled]: '0.4.0',
[ActionType.ChatError]: '0.4.0',
[ActionType.ChatActivityChanged]: '0.5.0',
[ActionType.ChatWorkingDirectorySet]: '0.7.0',
[ActionType.ChatWorkingDirectoryRemoved]: '0.7.0',
[ActionType.ChatUsage]: '0.4.0',
[ActionType.ChatReasoning]: '0.4.0',
[ActionType.ChatPendingMessageSet]: '0.4.0',
@@ -617,7 +617,7 @@ export function createSessionState(summary: SessionSummary): SessionState {
};
if (summary.activity !== undefined) { state.activity = summary.activity; }
if (summary.project !== undefined) { state.project = summary.project; }
if (summary.workingDirectory !== undefined) { state.workingDirectory = summary.workingDirectory; }
if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; }
if (summary.annotations !== undefined) { state.annotations = summary.annotations; }
if (summary._meta !== undefined) { state._meta = summary._meta; }
return state;
@@ -637,7 +637,8 @@ export function createChatState(summary: ChatSummary): ChatState {
modifiedAt: summary.modifiedAt,
origin: summary.origin,
interactivity: summary.interactivity,
workingDirectory: summary.workingDirectory,
workingDirectories: summary.workingDirectories,
primaryWorkingDirectory: summary.primaryWorkingDirectory,
turns: [],
activeTurn: undefined,
};
@@ -659,12 +660,14 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto
origin: { kind: ChatOriginKind.User },
};
if (session.activity !== undefined) { summary.activity = session.activity; }
// `workingDirectory` is deliberately NOT copied: per the protocol it is a
// per-chat OVERRIDE and, when absent, the chat inherits the session's
// working directory (see `mergeSessionWithDefaultChat`). Seeding it here
// would denormalize the session default onto every chat as a fake override,
// which then goes stale when the session's working directory is resolved
// later (e.g. a worktree resolved at materialization).
// `workingDirectories` is deliberately NOT copied: per the protocol it is a
// per-chat SUBSET override and, when absent, the chat inherits the session's
// full set of working directories (see `mergeSessionWithDefaultChat`).
// Seeding it here would denormalize the session default onto every chat as a
// fake override, which then goes stale when the session's working
// directories are resolved later (e.g. a worktree resolved at
// materialization). `primaryWorkingDirectory` is per-chat and fixed at chat
// creation (the session has no primary), so it is likewise not seeded here.
return summary;
}
@@ -683,7 +686,8 @@ export function chatSummaryFromState(state: ChatState): ChatSummary {
if (state.activity !== undefined) { summary.activity = state.activity; }
if (state.origin !== undefined) { summary.origin = state.origin; }
if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; }
if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; }
if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; }
if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; }
return summary;
}
@@ -877,19 +881,24 @@ export function isAhpChatChannel(uri: string): boolean {
/**
* A single chat's effective session context: the shared {@link SessionState}
* (working directory, active clients, config, customizations/MCP scope, )
* (working directories, active clients, config, customizations/MCP scope, )
* resolved for one chat and merged with that chat's conversation contents.
*
* The protocol moved turns and pending state off the session and onto a
* per-chat channel, and lets a chat override session defaults (e.g.
* {@link ChatState.workingDirectory}). This composite recombines the session
* per-chat channel, and lets a chat override the session's working directories
* with a subset (e.g. {@link ChatState.workingDirectories}) and carry its own
* read-only {@link ChatState.primaryWorkingDirectory | primary} (fixed at chat
* creation the session has no primary). This composite recombines the session
* with one of its chats default or peer so consumers read the chat's
* effective context and conversation through one object without walking back to
* the session to re-derive shared state. The inherited
* {@link SessionState.workingDirectory} carries the chat's *effective* working
* directory (its own override when present, else the session default).
* the session to re-derive shared state. The {@link ISessionWithDefaultChat.workingDirectories}
* carry the chat's *effective* working directories (its own subset override when
* present, else the session's full set); {@link ISessionWithDefaultChat.primaryWorkingDirectory}
* is the chat's own primary.
*/
export interface ISessionWithDefaultChat extends SessionState {
/** The chat's read-only primary working directory (fixed at chat creation). */
primaryWorkingDirectory?: ProtocolURI;
/** Completed turns of this chat. */
turns: Turn[];
/** Currently in-progress turn of this chat. */
@@ -905,15 +914,17 @@ export interface ISessionWithDefaultChat extends SessionState {
/**
* Projects a {@link SessionState} and one of its {@link ChatState | chats}
* (default or peer) into that chat's {@link ISessionWithDefaultChat | effective
* session context}. Per-chat overrides (currently the working directory) are
* layered over the session defaults, and the conversation fields are taken from
* the chat. When the chat state is absent (e.g. not yet hydrated) the
* conversation fields default to empty and the session defaults apply.
* session context}. Per-chat overrides (the working-directories subset and the
* chat's own primary) are layered over the session defaults, and the
* conversation fields are taken from the chat. When the chat state is absent
* (e.g. not yet hydrated) the conversation fields default to empty and the
* session defaults apply.
*/
export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat {
return {
...session,
workingDirectory: chat?.workingDirectory ?? session.workingDirectory,
workingDirectories: chat?.workingDirectories ?? session.workingDirectories,
primaryWorkingDirectory: chat?.primaryWorkingDirectory,
turns: chat?.turns ?? [],
activeTurn: chat?.activeTurn,
steeringMessage: chat?.steeringMessage,
@@ -211,13 +211,13 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi
}
getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined {
const own = this._stateManager.getSessionState(session)?.workingDirectory;
const own = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
if (own !== undefined) {
return own;
}
const parentInfo = parseSubagentSessionUri(session);
if (parentInfo) {
return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectory;
return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories?.[0];
}
return undefined;
}
@@ -569,7 +569,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
}
private async _computeUncommittedDiffs(session: ProtocolURI): Promise<readonly ISessionFileDiff[] | undefined> {
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
if (!workingDirectory) {
return undefined;
}
@@ -1003,7 +1003,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
* branch git falls back to `HEAD`.
*/
private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise<readonly ISessionFileDiff[] | undefined> {
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
if (!workingDirectory) {
return undefined;
}
@@ -1081,7 +1081,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
* no git working directory (review status is then simply omitted).
*/
private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet<string> } | undefined> {
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
if (!workingDirectory) {
return undefined;
}
@@ -60,7 +60,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
}
const workingDirectoryStr = sessionState.workingDirectory;
const workingDirectoryStr = sessionState.workingDirectories?.[0];
if (!workingDirectoryStr) {
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
}
@@ -57,7 +57,7 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat
`Operation '${AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES}' requires a resource target.`);
}
const workingDirectoryStr = sessionState.workingDirectory;
const workingDirectoryStr = sessionState.workingDirectories?.[0];
if (!workingDirectoryStr) {
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
}
@@ -111,7 +111,7 @@ export class AgentHostFileCompletionProvider implements IAgentHostCompletionItem
) { }
async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise<readonly CompletionItem[]> {
const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectory;
const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectories?.[0];
if (!workingDirectoryStr) {
return [];
}
@@ -100,7 +100,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi
}
if (!workingDirectory) {
const workingDirectoryStr = sessionState?.workingDirectory;
const workingDirectoryStr = sessionState?.workingDirectories?.[0];
if (workingDirectoryStr) {
workingDirectory = URI.parse(workingDirectoryStr);
}
@@ -104,7 +104,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
}
const workingDirectoryStr = sessionState.workingDirectory;
const workingDirectoryStr = sessionState.workingDirectories?.[0];
if (!workingDirectoryStr) {
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
}
@@ -68,7 +68,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi
if (!sessionState) {
throw new Error(`Session not found: ${parsed.sessionUri}`);
}
if (!sessionState.workingDirectory) {
if (!sessionState.workingDirectories?.[0]) {
throw new Error(`Session has no working directory: ${parsed.sessionUri}`);
}
@@ -80,7 +80,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi
databaseRef.dispose();
}
const workingDirectory = URI.parse(sessionState.workingDirectory);
const workingDirectory = URI.parse(sessionState.workingDirectories?.[0]);
const baseBranch = resolveDiffBaseBranchName(persistedBaseBranch, readSessionGitState(sessionState._meta)?.baseBranchName);
await this._sequencer.queue(parsed.sessionUri, async () => {
for (const resource of resources) {
@@ -234,7 +234,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi
}
private async _disposeSessionData(session: ProtocolURI): Promise<void> {
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
if (!workingDirectory) {
// No working directory means we can't resolve the repository root
// (session was never git-backed, or its working directory is gone).
@@ -137,7 +137,7 @@ class SessionSummaryNotifier extends Disposable {
if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; }
if (current.project !== lastNotified.project) { changes.project = current.project; }
if (current.changes !== lastNotified.changes) { changes.changes = current.changes; }
if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; }
if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; }
if (current._meta !== lastNotified._meta) { changes._meta = current._meta; }
this._lastNotified.set(session, current);
@@ -344,7 +344,7 @@ export class AgentHostStateManager extends Disposable {
};
if (state.activity !== undefined) { summary.activity = state.activity; }
if (state.project !== undefined) { summary.project = state.project; }
if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; }
if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; }
if (state.annotations !== undefined) { summary.annotations = state.annotations; }
if (entry.changes !== undefined) { summary.changes = entry.changes; }
if (state._meta !== undefined) { summary._meta = state._meta; }
@@ -361,7 +361,7 @@ export class AgentHostStateManager extends Disposable {
&& a.status === b.status
&& a.activity === b.activity
&& a.project === b.project
&& a.workingDirectory === b.workingDirectory
&& a.workingDirectories === b.workingDirectories
&& a.annotations === b.annotations
&& a._meta === b._meta;
}
@@ -608,7 +608,7 @@ export class AgentHostStateManager extends Disposable {
// directory / project. We don't need to schedule a
// `SessionSummaryChanged` flush because the upcoming `SessionAdded`
// notification carries the complete summary already.
entry.state = { ...entry.state, project: summary.project, workingDirectory: summary.workingDirectory };
entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories };
entry.modifiedAt = summary.modifiedAt;
entry.changes = summary.changes;
const full = this._toSummary(key, entry);
@@ -1131,6 +1131,26 @@ export class AgentHostStateManager extends Disposable {
return this._applyAndEmit(channel, action, origin);
}
/**
* Reject a client-originated action without applying it to state. Emits an
* {@link ActionEnvelope} that carries the original {@link ActionOrigin} and a
* {@link ActionEnvelope.rejectionReason | rejectionReason} so the originating
* client can reconcile (roll back) its optimistic write-ahead action through
* the normal path instead of leaving it pending until reconnect. The reducer
* is deliberately NOT run, so no synchronized state changes.
*/
rejectClientAction(channel: URI, action: StateAction, origin: ActionOrigin, reason: string): void {
const envelope: ActionEnvelope = {
channel,
action,
serverSeq: ++this._serverSeq,
origin,
rejectionReason: reason,
};
this._logService.trace(`[AgentHostStateManager] Emitting rejection envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}, origin=${origin.clientId}:${origin.clientSeq}, reason=${reason}`);
this._onDidEmitEnvelope.fire(envelope);
}
// ---- Internal -----------------------------------------------------------
private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown {
@@ -38,7 +38,7 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
}
const workingDirectoryStr = sessionState.workingDirectory;
const workingDirectoryStr = sessionState.workingDirectories?.[0];
if (!workingDirectoryStr) {
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
}
+13 -10
View File
@@ -488,7 +488,7 @@ export class AgentService extends Disposable implements IAgentService {
resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params),
onTurnComplete: async session => {
// Refresh the git state for the session.
const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectory;
const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
void this._gitStateService.refreshSessionGitState(session, workingDirStr ? URI.parse(workingDirStr) : undefined);
// Check for a GitHub pull request associated with the session's branch.
@@ -847,6 +847,7 @@ export class AgentService extends Disposable implements IAgentService {
const _meta = liveSummary._meta !== undefined || s._meta !== undefined
? { ...s._meta, ...liveSummary._meta }
: undefined;
const liveWorkingDir = liveSummary.workingDirectories?.[0];
return {
...s,
summary: liveSummary.title || s.summary,
@@ -856,8 +857,8 @@ export class AgentService extends Disposable implements IAgentService {
project: liveSummary.project
? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName }
: s.project,
workingDirectory: typeof liveSummary.workingDirectory === 'string'
? URI.parse(liveSummary.workingDirectory)
workingDirectory: typeof liveWorkingDir === 'string'
? URI.parse(liveWorkingDir)
: s.workingDirectory,
changes: liveSummary.changes ?? s.changes,
changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets,
@@ -890,6 +891,7 @@ export class AgentService extends Disposable implements IAgentService {
continue;
}
const summaryWorkingDir = summary.workingDirectories?.[0];
additions.push({
session: URI.parse(summary.resource),
startTime: Date.parse(summary.createdAt),
@@ -897,7 +899,7 @@ export class AgentService extends Disposable implements IAgentService {
summary: summary.title,
status: summary.status,
activity: summary.activity,
workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined,
workingDirectory: typeof summaryWorkingDir === 'string' ? URI.parse(summaryWorkingDir) : undefined,
...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}),
changes: summary.changes,
// This overlay path never opens the session database (unlike the
@@ -1400,6 +1402,7 @@ export class AgentService extends Disposable implements IAgentService {
private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary {
const now = new Date().toISOString();
const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString();
return {
resource: session.toString(),
provider: provider.id,
@@ -1408,7 +1411,7 @@ export class AgentService extends Disposable implements IAgentService {
createdAt: now,
modifiedAt: now,
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(),
workingDirectories: primaryWorkingDir ? [primaryWorkingDir] : undefined,
// Workspace-less is inferred at create from an absent input
// `workingDirectory` (the host assigns a scratch cwd, so it can't be
// re-inferred later) and tagged on the generic `_meta` bag.
@@ -1450,7 +1453,7 @@ export class AgentService extends Disposable implements IAgentService {
const summary: SessionSummary = {
...currentSummary,
...(project ? { project: { uri: project.uri.toString(), displayName: project.displayName } } : {}),
workingDirectory: e.workingDirectory?.toString() ?? currentSummary.workingDirectory,
workingDirectories: e.workingDirectory ? [e.workingDirectory.toString()] : currentSummary.workingDirectories,
modifiedAt: new Date().toISOString(),
};
const configValues = state.config?.values;
@@ -1761,8 +1764,8 @@ export class AgentService extends Disposable implements IAgentService {
// the normal state-update stream.
const sessionState = this._stateManager.getSessionState(resourceStr);
if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) {
const workingDirectory = sessionState.workingDirectory
? URI.parse(sessionState.workingDirectory)
const workingDirectory = sessionState.workingDirectories?.[0]
? URI.parse(sessionState.workingDirectories[0])
: undefined;
void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory);
}
@@ -2408,7 +2411,7 @@ export class AgentService extends Disposable implements IAgentService {
modifiedAt: new Date(meta.modifiedTime).toISOString(),
...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}),
changes: meta.changes ?? changes,
workingDirectory: meta.workingDirectory?.toString(),
workingDirectories: meta.workingDirectory ? [meta.workingDirectory.toString()] : undefined,
_meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined,
};
@@ -3482,7 +3485,7 @@ export class AgentService extends Disposable implements IAgentService {
if (!this._gitService) {
throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);
}
const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectory;
const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectories?.[0];
if (!workingDirectory) {
throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`);
}
@@ -57,7 +57,7 @@ export class BangLocalCommand extends Disposable implements ILocalChatCommand {
const displayName = localize('agentHostBang.terminal', "Terminal");
let terminalCreated = false;
try {
const workingDirStr = ctx.getState(sessionChannel)?.workingDirectory;
const workingDirStr = ctx.getState(sessionChannel)?.workingDirectories?.[0];
const cwd = workingDirStr ? URI.parse(workingDirStr).fsPath : undefined;
const shellPath = await ctx.terminalManager.getDefaultShell();
const shellType = shellTypeForExecutable(shellPath);
@@ -59,6 +59,21 @@ const REPLAY_BUFFER_CAPACITY = 1000;
const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000;
/**
* Client-dispatchable actions that are declared in the protocol but not yet
* operational in this build. The multiroot working-directory mutations
* (`session|chat/workingDirectorySet|Removed`) would mutate the synchronized
* working-directory set without reconfiguring the agent's actual directory
* access, so they are rejected in the dispatch path until capability-backed
* multiroot support lands.
*/
const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet<ActionType> = new Set([
ActionType.SessionWorkingDirectorySet,
ActionType.SessionWorkingDirectoryRemoved,
ActionType.ChatWorkingDirectorySet,
ActionType.ChatWorkingDirectoryRemoved,
]);
/** A client tool call in any of these statuses is still awaiting its result. */
function isPendingToolCallStatus(status: ToolCallStatus): boolean {
return status === ToolCallStatus.Streaming
@@ -442,7 +457,22 @@ export class ProtocolServerHandler extends Disposable {
this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`);
const action = msg.params.action as SessionAction | ChatAction | TerminalAction | IRootConfigChangedAction;
const channel = msg.params.channel;
if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) {
// Multiroot working-directory mutations are declared in the
// protocol but not yet supported: they would mutate the
// synchronized access set without reconfiguring the agent's
// actual directory access. Reject them through the normal
// reconciliation path (preserving the client's origin) so the
// client rolls back its optimistic action instead of leaving
// it pending, until capability-backed multiroot support lands.
if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) {
this._logService.warn(`[ProtocolServer] rejecting unsupported client action: ${action.type}`);
this._stateManager.rejectClientAction(
channel,
action,
{ clientId: client.clientId, clientSeq: msg.params.clientSeq },
`Unsupported action: ${action.type}`,
);
} else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) {
this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq);
}
}
@@ -1141,7 +1171,7 @@ export class ProtocolServerHandler extends Disposable {
try {
createdSession = await this._agentService.createSession({
provider: params.provider,
workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined,
workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories[0]) : undefined,
session: URI.parse(params.channel),
fork,
config: params.config,
@@ -54,7 +54,7 @@ suite('AgentConfigurationService', () => {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///project', displayName: 'Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
};
}
@@ -42,7 +42,7 @@ suite('ChangesetSessionCoordinator', () => {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
}, { emitNotification });
stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session));
stateManager.dispatchServerAction(session, { type: ActionType.SessionReady });
@@ -148,7 +148,7 @@ suite('ChangesetSessionCoordinator', () => {
assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { acquisitions: [], rootLookups: [] });
const summary = environment.stateManager.getSessionSummary(session)!;
environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' });
environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] });
environment.coordinator.onSessionMaterialized(session);
await environment.monitor.waitForAcquisitions(1);
@@ -170,7 +170,7 @@ suite('ChangesetSessionCoordinator', () => {
await tick();
const summary = environment.stateManager.getSessionSummary(session)!;
environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' });
environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] });
environment.coordinator.onSessionMaterialized(session);
await tick();
@@ -76,7 +76,7 @@ suite.skip('AgentHostChangesetService', () => {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
});
stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString()));
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, });
@@ -287,7 +287,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main');
@@ -366,7 +366,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' }));
@@ -402,7 +402,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' }));
@@ -434,7 +434,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
const envelopes: ActionEnvelope[] = [];
@@ -495,7 +495,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
await localChangesets.computeUncommittedChangeset(sessionStr);
@@ -569,7 +569,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
await localChangesets.computeUncommittedChangeset(sessionStr);
@@ -622,7 +622,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
});
localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr));
return sessionStr;
@@ -642,7 +642,7 @@ suite.skip('AgentHostChangesetService', () => {
assert.deepStrictEqual(computes, [], 'nothing computed while the working directory is unknown');
const summary = localStateManager.getSessionSummary(sessionStr)!;
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' });
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] });
service.onWorkingDirectoryAvailable(sessionStr);
await timeout(0);
assert.deepStrictEqual(computes.sort(), ['session', 'session']);
@@ -657,7 +657,7 @@ suite.skip('AgentHostChangesetService', () => {
assert.deepStrictEqual(computes, [], 'uncommitted compute deferred while the working directory is unknown');
const summary = localStateManager.getSessionSummary(sessionStr)!;
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' });
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] });
service.onWorkingDirectoryAvailable(sessionStr);
await timeout(0);
assert.deepStrictEqual(computes, ['uncommitted']);
@@ -673,7 +673,7 @@ suite.skip('AgentHostChangesetService', () => {
subscriptions.delete(buildSessionChangesetUri(sessionStr));
const summary = localStateManager.getSessionSummary(sessionStr)!;
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' });
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] });
service.onWorkingDirectoryAvailable(sessionStr);
await timeout(0);
assert.deepStrictEqual(computes, []);
@@ -694,7 +694,7 @@ suite.skip('AgentHostChangesetService', () => {
service.onSessionDisposed(sessionStr);
const summary = localStateManager.getSessionSummary(sessionStr)!;
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' });
localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] });
service.onWorkingDirectoryAvailable(sessionStr);
await timeout(0);
assert.deepStrictEqual(computes, []);
@@ -1048,7 +1048,7 @@ suite.skip('AgentHostChangesetService', () => {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: 'file:///wd',
workingDirectories: ['file:///wd'],
});
const envelopes: ActionEnvelope[] = [];
@@ -152,7 +152,7 @@ function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitSer
status: SessionStatus.Idle,
createdAt: new Date(1).toISOString(),
modifiedAt: new Date(1).toISOString(),
workingDirectory: URI.file('/repo').toString(),
workingDirectories: [URI.file('/repo').toString()],
});
stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, {
branchName: 'feature/test',
@@ -74,7 +74,7 @@ function setup(disposables: Pick<DisposableStore, 'add'>, opts?: { readonly with
status: SessionStatus.Idle,
createdAt: new Date(1).toISOString(),
modifiedAt: new Date(1).toISOString(),
workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(),
workingDirectories: opts?.withWorkingDirectory === false ? undefined : [URI.file('/repo').toString()],
});
}
const handler = new AgentHostDiscardChangesOperationHandler(
@@ -114,7 +114,7 @@ suite('AgentHostFileCompletionProvider', () => {
createdAt: new Date(0).toISOString(),
modifiedAt: new Date(0).toISOString(),
project: { uri: 'file:///project', displayName: 'Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
};
}
@@ -78,7 +78,7 @@ suite('AgentHostGitStateService', () => {
status: SessionStatus.Idle,
createdAt: new Date(0).toISOString(),
modifiedAt: new Date(0).toISOString(),
workingDirectory: options?.workingDirectory,
workingDirectories: options?.workingDirectory ? [options.workingDirectory] : undefined,
};
// `restoreSession` materializes the session in `ready` lifecycle so the
// persistence path (which skips `creating` sessions) actually runs.
@@ -113,7 +113,7 @@ suite('AgentHostGitStateService', () => {
status: SessionStatus.Idle,
createdAt: new Date(0).toISOString(),
modifiedAt: new Date(0).toISOString(),
workingDirectory: 'file:///original',
workingDirectories: ['file:///original'],
}, { emitNotification: false });
h.setGitResult({ branchName: 'feature' });
@@ -168,7 +168,7 @@ function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitSer
status: SessionStatus.Idle,
createdAt: new Date(1).toISOString(),
modifiedAt: new Date(1).toISOString(),
workingDirectory: URI.file('/repo').toString(),
workingDirectories: [URI.file('/repo').toString()],
});
// Git state and GitHub state now share the single `_meta` bag.
const sessionMeta = withSessionGitHubState(withSessionGitState(undefined, {
@@ -227,12 +227,12 @@ suite('AgentHostStateManager', () => {
// has no per-chat working-directory override, so getSessionState must
// project the RESOLVED session working directory, never the stale
// create-time value that was seeded onto the default chat.
manager.createSession({ ...makeSessionSummary(), workingDirectory: 'file:///provisional' }, { emitNotification: false });
manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectory: 'file:///resolved-worktree' });
manager.createSession({ ...makeSessionSummary(), workingDirectories: ['file:///provisional'] }, { emitNotification: false });
manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectories: ['file:///resolved-worktree'] });
assert.deepStrictEqual({
session: manager.getSessionState(sessionUri)?.workingDirectory,
defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectory,
session: manager.getSessionState(sessionUri)?.workingDirectories?.[0],
defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectories?.[0],
}, {
session: 'file:///resolved-worktree',
defaultChat: 'file:///resolved-worktree',
@@ -1681,7 +1681,7 @@ suite('Subagent URI helpers', () => {
lifecycle: SessionLifecycle.Ready,
activeClients: [],
chats: [],
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
};
}
@@ -1691,7 +1691,7 @@ suite('Subagent URI helpers', () => {
title: 'Peer',
status: SessionStatus.Idle,
modifiedAt: new Date().toISOString(),
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
turns: [],
};
}
@@ -1701,7 +1701,7 @@ suite('Subagent URI helpers', () => {
makeSessionState('file:///session-wd'),
makeChatState('file:///peer-worktree'),
);
assert.strictEqual(merged.workingDirectory, 'file:///peer-worktree');
assert.strictEqual(merged.workingDirectories?.[0], 'file:///peer-worktree');
});
test('falls back to the session working directory when the chat does not override it', () => {
@@ -1709,12 +1709,12 @@ suite('Subagent URI helpers', () => {
makeSessionState('file:///session-wd'),
makeChatState(undefined),
);
assert.strictEqual(merged.workingDirectory, 'file:///session-wd');
assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd');
});
test('falls back to the session working directory when no chat state is hydrated', () => {
const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined);
assert.strictEqual(merged.workingDirectory, 'file:///session-wd');
assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd');
assert.deepStrictEqual(merged.turns, []);
});
});
@@ -1323,7 +1323,7 @@ suite('AgentService (node dispatcher)', () => {
createdAt: new Date(1000).toISOString(),
modifiedAt: new Date(2000).toISOString(),
project: { uri: URI.file('/project').toString(), displayName: 'project' },
workingDirectory: URI.file('/worktree').toString(),
workingDirectories: [URI.file('/worktree').toString()],
}, []);
agent.releaseList.complete();
@@ -4799,7 +4799,7 @@ suite('AgentService (node dispatcher)', () => {
// The state manager should have the worktree path, not the source path
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectory, worktreeDir.toString());
assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString());
});
test('createSession falls back to config working directory when agent does not resolve', async () => {
@@ -4811,7 +4811,7 @@ suite('AgentService (node dispatcher)', () => {
const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir });
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectory, sourceDir.toString());
assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString());
});
test('restoreSession uses agent working directory in state', async () => {
@@ -4830,7 +4830,7 @@ suite('AgentService (node dispatcher)', () => {
await service.restoreSession(session);
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectory, worktreeDir.toString());
assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString());
});
});
@@ -167,7 +167,7 @@ suite('AgentSideEffects', () => {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
});
stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString()));
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, });
@@ -40,12 +40,12 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void {
await context.client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000);
const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri }, 30_000);
await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri] }, 30_000);
createdSessions.push(sessionUri);
const subscribeResult = await context.client.call<SubscribeResult>('subscribe', { channel: sessionUri }, 30_000);
const sessionState = subscribeResult.snapshot!.state as SessionState;
assert.strictEqual(sessionState.workingDirectory, workingDirUri,
assert.strictEqual(sessionState.workingDirectories?.[0], workingDirUri,
`subscribe snapshot summary should carry the requested working directory`);
});
@@ -86,7 +86,7 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void {
const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
await context.client.call('createSession', {
channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri,
channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri],
config: { isolation: 'worktree', branch: defaultBranch },
});
createdSessions.push(sessionUri);
@@ -124,10 +124,11 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void {
);
const addedSummary = (addedNotif.params as SessionAddedParams).summary;
assert.ok(addedSummary.workingDirectory, 'sessionAdded notification should have a workingDirectory');
assert.ok(addedSummary.workingDirectory!.includes('.worktrees'),
`workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectory}`);
const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectory!).fsPath;
const addedWorkingDirectory = addedSummary.workingDirectories?.[0];
assert.ok(addedWorkingDirectory, 'sessionAdded notification should have a workingDirectory');
assert.ok(addedWorkingDirectory.includes('.worktrees'),
`workingDirectory should be under the .worktrees folder, got: ${addedWorkingDirectory}`);
const resolvedWorkingDirectoryPath = URI.parse(addedWorkingDirectory).fsPath;
await context.client.waitForNotification(
n => isActionNotification(n, 'chat/turnComplete') || isActionNotification(n, 'chat/error'),
@@ -98,7 +98,7 @@ suite('Protocol WebSocket - Session Config', function () {
await client.call('createSession', {
channel: nextSessionUri(),
provider: 'mock',
workingDirectory: URI.file('/mock/workspace').toString(),
workingDirectories: [URI.file('/mock/workspace').toString()],
config,
});
@@ -181,7 +181,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio
await client1.call('createSession', {
channel: nextSessionUri(),
provider: 'mock',
workingDirectory: URI.file('/mock/workspace').toString(),
workingDirectories: [URI.file('/mock/workspace').toString()],
config: initialConfig,
});
const addedNotif = await client1.waitForNotification(n =>
@@ -81,7 +81,7 @@ const hasGit = (() => {
await client.call('initialize', { protocolVersions: [PROTOCOL_VERSION], clientId: 'test-git-diffs' });
const workingDirectory = URI.file(tmpRoot).toString();
await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory });
await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: [workingDirectory] });
const addedNotif = await client.waitForNotification(n =>
n.method === 'root/sessionAdded'
@@ -117,7 +117,7 @@ class MockAgentService implements IAgentService {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///created-project', displayName: 'Created Project' },
workingDirectory: config?.workingDirectory?.toString(),
workingDirectories: config?.workingDirectory ? [config.workingDirectory.toString()] : undefined,
});
return session;
}
@@ -488,6 +488,46 @@ suite('ProtocolServerHandler', () => {
assert.strictEqual(envelope.origin.clientSeq, 1);
});
test('unsupported working-directory actions are rejected, not dispatched', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, });
const cases: readonly { readonly type: ActionType; readonly channel: string }[] = [
{ type: ActionType.SessionWorkingDirectorySet, channel: sessionUri },
{ type: ActionType.SessionWorkingDirectoryRemoved, channel: sessionUri },
{ type: ActionType.ChatWorkingDirectorySet, channel: defaultChatUri },
{ type: ActionType.ChatWorkingDirectoryRemoved, channel: defaultChatUri },
];
for (const [index, { type, channel }] of cases.entries()) {
const clientId = `wd-client-${index}`;
const clientSeq = 100 + index;
const transport = connectClient(clientId, [sessionUri, defaultChatUri]);
transport.sent.length = 0;
agentService.handledActions.length = 0;
transport.simulateMessage(notification('dispatchAction', {
channel,
clientSeq,
action: { type, directory: 'file:///tmp/extra-root' },
}));
// No dispatch: the gate intercepts before reaching the agent service,
// so the reducer never runs and synchronized state is untouched.
assert.deepStrictEqual(agentService.handledActions, [], `${type} must not be dispatched`);
// Exactly one rejection envelope, preserving the original origin so the
// client can reconcile its optimistic action.
const actionMsgs = findNotifications(transport.sent, 'action');
assert.strictEqual(actionMsgs.length, 1, `${type} should emit exactly one envelope`);
const envelope = actionMsgs[0].params as unknown as { action: { type: string }; origin: { clientId: string; clientSeq: number }; rejectionReason?: string };
assert.strictEqual(envelope.action.type, type);
assert.ok(envelope.rejectionReason, `${type} envelope should carry a rejectionReason`);
assert.strictEqual(envelope.origin.clientId, clientId);
assert.strictEqual(envelope.origin.clientSeq, clientSeq);
}
});
test('actions are scoped to subscribed sessions', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, });
@@ -32,7 +32,7 @@ export async function createProviderSession(
await client.call('createSession', {
channel: sessionUri,
provider: config.provider,
workingDirectory: workingDirectory.toString(),
workingDirectories: [workingDirectory.toString()],
config: { isolation: 'folder' },
}, 30_000);
trackingList.push(sessionUri);
@@ -859,7 +859,7 @@ export function getActionEnvelope(n: AhpNotification): ActionEnvelope {
export async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise<string> {
await c.call('initialize', { channel: 'ahp-root://', protocolVersions: [PROTOCOL_VERSION], clientId });
await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory });
await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: workingDirectory ? [workingDirectory] : undefined });
const notif = await c.waitForNotification(n =>
n.method === 'root/sessionAdded'
@@ -43,7 +43,7 @@ suite('SessionPermissionManager', () => {
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///project', displayName: 'Project' },
workingDirectory,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
};
}
@@ -2934,7 +2934,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
getWorkingDirectory(sessionId: string): string | undefined {
const sessionState = this._lastSessionStates.get(sessionId);
return sessionState?.workingDirectory;
return sessionState?.workingDirectories?.[0];
}
getMcpServers(sessionId: string): readonly IAgentHostMcpServer[] {
@@ -4224,8 +4224,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
private _handleSessionAdded(summary: SessionSummary): void {
const sessionUri = URI.parse(summary.resource);
const rawId = AgentSession.id(sessionUri);
const workingDir = typeof summary.workingDirectory === 'string'
? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory))
const workingDir = typeof summary.workingDirectories?.[0] === 'string'
? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectories?.[0]))
: undefined;
const meta: IAgentSessionMetadata = {
session: sessionUri,
@@ -436,7 +436,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?:
createdAt: opts?.createdAt ?? new Date().toISOString(),
modifiedAt: opts?.modifiedAt ?? new Date().toISOString(),
project: opts?.project,
workingDirectory: opts?.workingDirectory,
workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined,
changes: opts?.changes,
...(opts?.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}),
},
@@ -280,7 +280,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?:
createdAt: opts?.createdAt ?? new Date().toISOString(),
modifiedAt: opts?.modifiedAt ?? new Date().toISOString(),
project: opts?.project,
workingDirectory: opts?.workingDirectory,
workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined,
},
});
}
@@ -681,7 +681,7 @@ export class AgentHostChatInputPicker extends Disposable {
private _readWorkingDirectory(): URI | undefined {
const state = this._subRef.value?.sub.value;
if (state && !(state instanceof Error)) {
const cwd = state.workingDirectory;
const cwd = state.workingDirectories?.[0];
return typeof cwd === 'string' ? URI.parse(cwd) : cwd;
}
const sessionResource = this._widget.viewModel?.sessionResource;
@@ -454,7 +454,7 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat
const channel = target.backendSession.toString();
return {
customizations: sessionState?.customizations ?? [],
workingDirectory: sessionState?.workingDirectory,
workingDirectory: sessionState?.workingDirectories?.[0],
rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined,
authenticate: request => target.connection.authenticate(request),
setCustomizationEnabled: (rawId, enabled) => {
@@ -140,7 +140,7 @@ export class AgentHostGenericConfigChips extends Disposable {
private _readWorkingDirectory(): URI | undefined {
const state = this._subRef.value?.sub.value;
if (state && !(state instanceof Error)) {
const cwd = state.workingDirectory;
const cwd = state.workingDirectories?.[0];
return typeof cwd === 'string' ? URI.parse(cwd) : cwd;
}
const sessionResource = this._widget.viewModel?.sessionResource;
@@ -4647,7 +4647,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return uri;
}
const backendSession = this._resolveSessionUri(sessionResource);
const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectory;
const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectories?.[0];
const resolvedDir = typeof rawResolvedDir === 'string' ? URI.parse(rawResolvedDir) : rawResolvedDir;
if (!resolvedDir || resolvedDir.scheme !== 'file') {
return uri;
@@ -175,7 +175,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS
}
private _makeItemFromSummary(rawId: string, summary: SessionSummary): IChatSessionItem {
const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory;
const workingDir = typeof summary.workingDirectories?.[0] === 'string' ? URI.parse(summary.workingDirectories?.[0]) : summary.workingDirectories?.[0];
return this._makeItem(rawId, {
title: summary.title,
status: summary.status,
@@ -251,7 +251,7 @@ export class AgentHostSessionListStore extends Disposable {
private _onNotification(notification: INotification): void {
if (notification.type === 'root/sessionAdded') {
if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectory)) {
if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectories?.[0])) {
return;
}
const entry = this._makeEntryFromSummary(notification.summary);
@@ -284,7 +284,7 @@ export class AgentHostSessionListStore extends Disposable {
}
const updatedSummary = { ...cached.summary, ...notification.changes };
if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectory)) {
if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectories?.[0])) {
this.removeSession(provider, rawId);
return;
}
@@ -323,7 +323,7 @@ export class AgentHostSessionListStore extends Disposable {
createdAt: new Date(session.startTime).toISOString(),
modifiedAt: new Date(session.modifiedTime).toISOString(),
changes: session.changes,
workingDirectory: session.workingDirectory?.toString(),
workingDirectories: session.workingDirectory ? [session.workingDirectory.toString()] : undefined,
},
};
}
@@ -225,6 +225,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
// Simulate the server's eager active-client claim: if the caller
// provided activeClient, seed the session state so subscribers see it.
if (config?.activeClient) {
const resolvedWorkingDir = (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString();
const summary: SessionSummary = {
resource: session.toString(),
provider: 'copilot',
@@ -232,7 +233,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(),
workingDirectories: resolvedWorkingDir ? [resolvedWorkingDir] : undefined,
};
const state: SessionState = {
...this._withDefaultChatCatalog(createSessionState(summary), session.toString()),
@@ -503,7 +504,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
status: seeded?.status ?? SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: seeded?.workingDirectory,
workingDirectories: seeded?.workingDirectories,
project: seeded?.project,
};
const chatSummary = createDefaultChatSummary(sessionSummary, chatUriStr);
@@ -529,7 +530,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
status: state.status,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
workingDirectory: state.workingDirectory,
workingDirectories: state.workingDirectories,
project: state.project,
};
const chatUri = buildDefaultChatUri(resource);
@@ -2546,7 +2547,7 @@ suite('AgentHostChatContribution', () => {
status: SessionStatus.Idle,
createdAt: new Date(1000).toISOString(),
modifiedAt: new Date(2000).toISOString(),
workingDirectory: URI.file('/other/workspace').toString(),
workingDirectories: [URI.file('/other/workspace').toString()],
},
} as INotification);
@@ -2563,7 +2564,7 @@ suite('AgentHostChatContribution', () => {
status: SessionStatus.Idle,
createdAt: new Date(1000).toISOString(),
modifiedAt: new Date(2000).toISOString(),
workingDirectory: URI.file('/workspace/root/sub').toString(),
workingDirectories: [URI.file('/workspace/root/sub').toString()],
},
} as INotification);